use std::{collections::HashSet, io::Write, path::Path}; use crate::{ logger::Logger, normalizer::Normalizer, renderer::{RenderOptions, Renderer}, util::{fence::generate_outer_backticks, path_display::display_path}, }; const DEFAULT_PROJECT_NAME: &str = "Project Outline"; pub struct MarkdownGenerator<'a> { logger: Logger, origin_base: &'a Path, paths: &'a [&'a Path], project_title: Option<&'a str>, render_options: RenderOptions, root: &'a Path, } impl<'a> MarkdownGenerator<'a> { pub fn new(paths: &'a [&'a Path]) -> Self { MarkdownGenerator { logger: Logger::default(), origin_base: Path::new("."), paths, project_title: None, render_options: RenderOptions::default(), root: Path::new("."), } } pub fn with_root(mut self, root: &'a Path) -> Self { self.root = root; self } pub fn with_origin(mut self, origin: &'a Path) -> Self { self.origin_base = origin; self } pub fn with_project_title(mut self, project_title: Option<&'a str>) -> Self { self.project_title = project_title; self } pub fn with_logger(mut self, logger: Logger) -> Self { self.logger = logger; self } pub fn with_render_options(mut self, render_options: RenderOptions) -> Self { self.render_options = render_options; self } fn get_or_derive_project_title(&self) -> &str { if let Some(project_title) = &self.project_title { project_title } else if let Some(os_str_name) = self.root.file_name() && let Some(name) = os_str_name.to_str() { name } else { DEFAULT_PROJECT_NAME } } pub fn generate(self, output: W) -> Result<(), Box> { let normalizer = Normalizer::new(self.root, self.origin_base)?; let mut renderer = Renderer::new(output, self.render_options).with_logger(self.logger); renderer.render_header(self.get_or_derive_project_title())?; let mut seen_paths = HashSet::new(); for path in self.paths { let normalized_path = normalizer.normalize(path)?; if !seen_paths.insert(normalized_path.clone()) { self.logger.warn(format!( "skipping duplicate file: {}", display_path(&normalized_path.root_relative) )); continue; } renderer.render_path(&normalized_path)?; } Ok(()) } pub fn generate_fenced( self, mut output: W, ) -> Result<(), Box> { let mut md_output = Vec::new(); self.generate(&mut md_output)?; let fence = generate_outer_backticks(&md_output); writeln!(output, "{}markdown", fence)?; output.write_all(&md_output)?; writeln!(output, "{}", fence)?; Ok(()) } } #[cfg(test)] mod tests { use std::{fs, path::Path}; use tempfile::tempdir; use crate::{logger::Logger, renderer::RenderOptions}; use super::MarkdownGenerator; fn generate_to_string( paths: &[&Path], root: &Path, origin: &Path, project_title: Option<&str>, ) -> Result> { let mut output = Vec::new(); MarkdownGenerator::new(paths) .with_root(root) .with_origin(origin) .with_project_title(project_title) .generate(&mut output)?; Ok(String::from_utf8(output).unwrap()) } fn generate_fenced_to_string( paths: &[&Path], root: &Path, origin: &Path, project_title: Option<&str>, ) -> Result> { let mut output = Vec::new(); MarkdownGenerator::new(paths) .with_root(root) .with_origin(origin) .with_project_title(project_title) .generate_fenced(&mut output)?; Ok(String::from_utf8(output).unwrap()) } #[test] fn empty_path_list_renders_header_with_specified_project_title() { let temp_dir = tempdir().unwrap(); let paths = []; let output = generate_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert_eq!(output, "# Project name\n"); } #[test] fn project_title_is_derived_from_root_when_not_specified() { let temp_dir = tempdir().unwrap(); let paths = []; let root = temp_dir.path().join("repo2markdown"); let output = generate_to_string(&paths, &root, temp_dir.path(), None).unwrap(); assert_eq!(output, "# repo2markdown\n"); } #[test] fn renders_single_file() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); let paths = [Path::new("main.rs")]; let output = generate_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert!(output.contains("# Project name\n")); assert!(output.contains("## File: main.rs")); assert!(output.contains("```rust\nfn main() {}\n```")); } #[test] fn renders_multiple_files_in_input_order() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); fs::write(temp_dir.path().join("b.rs"), "B").unwrap(); let paths = [Path::new("a.rs"), Path::new("b.rs")]; let output = generate_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); let a_pos = output.find("## File: a.rs").unwrap(); let b_pos = output.find("## File: b.rs").unwrap(); assert!(a_pos < b_pos); assert!(output.contains("```rust\nA\n```")); assert!(output.contains("```rust\nB\n```")); } #[test] fn resolves_relative_paths_from_origin() { let temp_dir = tempdir().unwrap(); let origin = temp_dir.path().join("src"); fs::create_dir_all(&origin).unwrap(); fs::write(origin.join("main.rs"), "fn main() {}").unwrap(); let paths = [Path::new("main.rs")]; let output = generate_to_string(&paths, temp_dir.path(), &origin, Some("Project name")).unwrap(); assert!(output.contains("## File: src/main.rs")); assert!(output.contains("fn main() {}")); } #[test] fn displays_paths_relative_to_root() { let temp_dir = tempdir().unwrap(); let root = temp_dir.path().join("project"); let origin = root.join("src"); fs::create_dir_all(&origin).unwrap(); fs::write(origin.join("main.rs"), "fn main() {}").unwrap(); let paths = [Path::new("main.rs")]; let output = generate_to_string(&paths, &root, &origin, Some("Project name")).unwrap(); assert!(output.contains("## File: src/main.rs")); assert!(output.contains("fn main() {}")); } #[test] fn skips_duplicate_paths() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); let paths = [Path::new("a.rs"), Path::new("a.rs")]; let output = generate_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert_eq!(output.matches("## File: a.rs").count(), 1); } #[test] fn skips_lexically_equivalent_duplicate_paths() { let temp_dir = tempdir().unwrap(); fs::create_dir_all(temp_dir.path().join("src")).unwrap(); fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); let paths = [Path::new("a.rs"), Path::new("src/../a.rs")]; let output = generate_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert_eq!(output.matches("## File: a.rs").count(), 1); } #[test] fn uses_render_options_for_large_file_placeholders() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("big.txt"), "A".repeat(10)).unwrap(); let paths = [Path::new("big.txt")]; let mut output = Vec::new(); let render_options = RenderOptions { max_file_size: 5, placeholder_for_binary_files: false, placeholder_for_large_files: true, }; MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .with_render_options(render_options) .with_logger(Logger::default()) .generate(&mut output) .unwrap(); let output = String::from_utf8(output).unwrap(); assert!(output.contains("## File: big.txt")); assert!(output.contains("[FILE TOO LARGE]")); } #[test] fn fenced_generation_wraps_empty_markdown_output() { let temp_dir = tempdir().unwrap(); let paths: [&Path; 0] = []; let output = generate_fenced_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert_eq!(output, "```markdown\n# Project name\n```\n"); } #[test] fn fenced_generation_wraps_file_output() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("main.rs"), "fn main() {}").unwrap(); let paths = [Path::new("main.rs")]; let output = generate_fenced_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert!(output.starts_with("````markdown\n")); assert!(output.ends_with("\n````\n")); assert!(output.contains("# Project name")); assert!(output.contains("## File: main.rs")); assert!(output.contains("```rust\nfn main() {}\n```")); } #[test] fn fenced_generation_expands_outer_fence_when_inner_markdown_contains_backticks() { let temp_dir = tempdir().unwrap(); fs::write(temp_dir.path().join("backticks.rs"), "````").unwrap(); let paths = [Path::new("backticks.rs")]; let output = generate_fenced_to_string( &paths, temp_dir.path(), temp_dir.path(), Some("Project name"), ) .unwrap(); assert!(output.starts_with("``````markdown\n")); assert!(output.ends_with("\n``````\n")); assert!(output.contains("`````rust\n````\n`````")); } }