mod generation; use std::{io::Write, path::Path}; use crate::{ generator::generation::MarkdownGeneration, logger::Logger, renderer::RenderOptions, util::fence::generate_outer_backticks, }; const DEFAULT_PROJECT_NAME: &str = "Project Outline"; pub struct MarkdownGenerator<'a> { 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 { 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_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, logger: &Logger, ) -> Result<(), Box> { MarkdownGeneration::new(self, output, logger)?.run() } pub fn generate_fenced( self, mut output: W, logger: &Logger, ) -> Result<(), Box> { let mut md_output = Vec::new(); self.generate(&mut md_output, logger)?; 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::Verbosity, renderer::RenderOptions, util::test_support::{logger_with_shared_log, quiet_logger}, }; use super::MarkdownGenerator; fn generate_to_string( generator: MarkdownGenerator, ) -> Result> { let mut output = Vec::new(); let logger = quiet_logger(); generator.generate(&mut output, &logger)?; Ok(String::from_utf8(output).unwrap()) } fn generate_fenced_to_string( generator: MarkdownGenerator, ) -> Result> { let mut output = Vec::new(); let logger = quiet_logger(); generator.generate_fenced(&mut output, &logger)?; 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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(&root) .with_origin(temp_dir.path()) .with_project_title(None); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(&origin) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(&root) .with_origin(&origin) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_to_string(generator).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 render_options = RenderOptions { max_file_size: 5, placeholder_for_binary_files: false, placeholder_for_large_files: true, }; let generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .with_render_options(render_options); let output = generate_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_fenced_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_fenced_to_string(generator).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 generator = MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")); let output = generate_fenced_to_string(generator).unwrap(); assert!(output.starts_with("``````markdown\n")); assert!(output.ends_with("\n``````\n")); assert!(output.contains("`````rust\n````\n`````")); } #[test] fn normal_verbosity_logs_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 mut output = Vec::new(); let (logger, logs) = logger_with_shared_log(Verbosity::Normal); MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .generate(&mut output, &logger) .unwrap(); assert_eq!(logs.contents(), "Warning: skipping duplicate file: a.rs\n"); } #[test] fn quiet_verbosity_suppresses_duplicate_path_warning() { 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 mut output = Vec::new(); let (logger, logs) = logger_with_shared_log(Verbosity::Quiet); MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .generate(&mut output, &logger) .unwrap(); assert_eq!(logs.contents(), ""); } #[test] fn normal_verbosity_logs_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 mut output = Vec::new(); let (logger, logs) = logger_with_shared_log(Verbosity::Normal); MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .generate(&mut output, &logger) .unwrap(); assert_eq!(logs.contents(), "Warning: skipping duplicate file: a.rs\n"); } #[test] fn verbose_verbosity_logs_rendered_files_and_duplicates_in_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("a.rs"), Path::new("b.rs")]; let mut output = Vec::new(); let (logger, logs) = logger_with_shared_log(Verbosity::Verbose); MarkdownGenerator::new(&paths) .with_root(temp_dir.path()) .with_origin(temp_dir.path()) .with_project_title(Some("Project name")) .generate(&mut output, &logger) .unwrap(); assert_eq!( logs.contents(), "Info: rendering file: a.rs\n\ Warning: skipping duplicate file: a.rs\n\ Info: rendering file: b.rs\n" ); } }