//! Generate Markdown from path lists. //! //! This module normalizes paths, skips duplicates, and renders selected files into Markdown. use std::{collections::HashSet, io::Write, path::Path}; use crate::{ logger::Logger, normalizer::Normalizer, renderer::{RenderOptions, Renderer}, util::path_display::display_path, }; const DEFAULT_PROJECT_NAME: &str = "Project Outline"; /// Generates Markdown for `paths`. /// /// Relative paths are resolved against `origin_base`. File headings use paths made relative to /// `root`. /// /// Duplicate files are skipped after normalization, while preserving the first occurrence order. /// /// Rendering behavior such as file-size limits and placeholder output is controlled by /// `render_options`. pub fn generate_markdown_from_paths( paths: &[&Path], output: W, render_options: RenderOptions, root: &Path, origin_base: &Path, project_title: Option<&str>, logger: Logger, ) -> Result<(), Box> { let normalizer = Normalizer::new(root, origin_base)?; let mut renderer = Renderer::new(output, render_options).with_logger(logger); let project_title = project_title.unwrap_or_else(|| derive_project_title(root)); renderer.render_header(project_title)?; let mut seen_paths = HashSet::new(); for path in paths { let normalized_path = normalizer.normalize(path)?; if !seen_paths.insert(normalized_path.clone()) { logger.warn(format!( "skipping duplicate file: {}", display_path(&normalized_path.root_relative) )); continue; } renderer.render_path(&normalized_path)?; } Ok(()) } fn derive_project_title(root: &Path) -> &str { if let Some(os_str_name) = root.file_name() && let Some(name) = os_str_name.to_str() { name } else { DEFAULT_PROJECT_NAME } } #[cfg(test)] mod tests { use std::ffi::OsStr; use std::fs; use std::io::Write; use std::os::unix::ffi::OsStrExt; use std::path::Path; use crate::logger::Logger; use crate::renderer::RenderOptions; use tempfile::tempdir; use super::{DEFAULT_PROJECT_NAME, derive_project_title, generate_markdown_from_paths}; fn generate_with_defaults( paths: &[&Path], output: W, root: &Path, origin_base: &Path, project_title: Option<&str>, ) -> Result<(), Box> { let logger = Logger::default(); let render_options = RenderOptions::default(); generate_markdown_from_paths( paths, output, render_options, root, origin_base, project_title, logger, ) } #[test] fn empty_path_list_produces_header_with_specified_project_title() { let temp_dir = tempdir().unwrap(); let paths = []; let mut output = Vec::new(); let root = temp_dir.path(); let origin_base = temp_dir.path(); generate_with_defaults(&paths, &mut output, root, origin_base, Some("Project name")) .unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "# Project name\n"); } #[test] fn renders_single_file_from_path_list() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); let paths = [Path::new("test_main.rs")]; let mut output = Vec::new(); let root = temp_dir.path(); fs::write(origin_base.join("test_main.rs"), "fn main() {}").unwrap(); generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); assert!(output_str.contains("## File: test_main.rs")); assert!(output_str.contains("fn main() {}")); } #[test] fn renders_multiple_files_in_order() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); let paths = [Path::new("a.rs"), Path::new("b.rs")]; let mut output = Vec::new(); let root = temp_dir.path(); fs::write(origin_base.join("a.rs"), "A").unwrap(); fs::write(origin_base.join("b.rs"), "B").unwrap(); generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); let a_pos = output.find("a.rs").unwrap(); let b_pos = output.find("b.rs").unwrap(); assert!(a_pos < b_pos); } #[test] fn normalizes_paths_before_rendering() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); let paths = [Path::new("test/./main.rs")]; let mut output = Vec::new(); let root = temp_dir.path(); let write_dir = temp_dir.path().join("test"); fs::create_dir_all(&write_dir).unwrap(); fs::write(write_dir.join("main.rs"), "fn main() {}").unwrap(); generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); assert!(output.contains("## File: test/main.rs")); } #[test] fn resolves_paths_from_origin_but_outputs_root_relative_paths() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path().join("sandbox/src"); let paths = [Path::new("main.rs")]; let mut output = Vec::new(); let root = temp_dir.path().join("project"); fs::create_dir_all(&origin_base).unwrap(); fs::write(origin_base.join("main.rs"), "fn main() {}").unwrap(); generate_with_defaults(&paths, &mut output, &root, &origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); // Must contain file content → proves correct reading assert!(output.contains("fn main() {}")); // Must contain normalized path → proves normalization applied assert!(output.contains("sandbox/src/main.rs")); } #[test] fn absolute_paths_are_not_resolved_against_origin() { let temp_dir1 = tempdir().unwrap(); let temp_dir2 = tempdir().unwrap(); let origin_base = temp_dir2.path(); let filepath = temp_dir1.path().join("test_main.rs"); let paths = [filepath.as_ref()]; let mut output = Vec::new(); let root = temp_dir2.path(); fs::write(&filepath, "fn main() {}").unwrap(); generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); // Must contain file content → proves correct reading assert!(output.contains("fn main() {}")); } #[test] fn duplicate_files_in_sequence_are_skipped() { let temp_dir = tempdir().unwrap(); let origin = temp_dir.path(); let root = temp_dir.path(); fs::write(origin.join("a.rs"), "A").unwrap(); let paths = [Path::new("a.rs"), Path::new("a.rs")]; let mut output = Vec::new(); generate_with_defaults(&paths, &mut output, root, origin, None).unwrap(); let output = String::from_utf8(output).unwrap(); assert_eq!(output.matches("## File: a.rs").count(), 1); } #[test] fn duplicate_files_are_skipped_with_preserved_display_order_even_if_not_adjacent() { let temp_dir = tempdir().unwrap(); let origin = temp_dir.path(); let root = temp_dir.path(); fs::write(origin.join("a.rs"), "A").unwrap(); fs::write(origin.join("b.rs"), "B").unwrap(); let paths = [Path::new("a.rs"), Path::new("b.rs"), Path::new("a.rs")]; let mut output = Vec::new(); generate_with_defaults(&paths, &mut output, root, origin, None).unwrap(); let output = String::from_utf8(output).unwrap(); assert_eq!(output.matches("## File: a.rs").count(), 1); assert_eq!(output.matches("## File: b.rs").count(), 1); let a_pos = output.find("a.rs").unwrap(); let b_pos = output.find("b.rs").unwrap(); assert!(a_pos < b_pos); } #[test] fn lexically_equivalent_paths_are_detected_as_duplicates() { let temp_dir = tempdir().unwrap(); let origin = temp_dir.path(); let root = temp_dir.path(); fs::create_dir_all(origin.join("bla")).unwrap(); fs::write(origin.join("a.rs"), "A").unwrap(); fs::write(origin.join("b.rs"), "B").unwrap(); let paths = [ Path::new("a.rs"), Path::new("b.rs"), Path::new("bla/../a.rs"), ]; let mut output = Vec::new(); generate_with_defaults(&paths, &mut output, root, origin, None).unwrap(); let output = String::from_utf8(output).unwrap(); assert_eq!(output.matches("## File: a.rs").count(), 1); } #[test] fn project_title_is_derived_from_root_by_default_even_if_directory_does_not_exist() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); let paths = []; let mut output = Vec::new(); let root = temp_dir.path().join("repo2markdown"); generate_with_defaults(&paths, &mut output, &root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); assert_eq!(output_str, "# repo2markdown\n"); } #[test] fn project_title_fallsback_to_default_if_root_is_filesystem_root() { assert_eq!(derive_project_title(Path::new("/")), DEFAULT_PROJECT_NAME); } #[test] fn project_title_fallsback_if_root_ending_is_not_utf8() { let root = Path::new(OsStr::from_bytes(b"/root/fd\xC3")); assert_eq!(derive_project_title(root), DEFAULT_PROJECT_NAME); } #[test] fn deriving_project_title_from_root_ignores_trailing_slash() { let root = Path::new("/root/repo2markdown/"); assert_eq!(derive_project_title(root), "repo2markdown"); } #[test] fn generator_uses_render_options_for_large_file_placeholders() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); let root = temp_dir.path(); let file_path = origin_base.join("big.txt"); fs::write(&file_path, "A".repeat(10)).unwrap(); let paths = [Path::new("big.txt")]; let mut output = Vec::new(); let config = RenderOptions { placeholder_for_binary_files: false, placeholder_for_large_files: true, max_file_size: 5, }; generate_markdown_from_paths( &paths, &mut output, config, root, origin_base, None, Logger::default(), ) .unwrap(); let output = String::from_utf8(output).unwrap(); assert!(output.contains("## File: big.txt")); assert!(output.contains("[FILE TOO LARGE]")); } }