diff options
| author | A Farzat <a@farzat.xyz> | 2026-06-21 21:35:56 +0300 |
|---|---|---|
| committer | A Farzat <a@farzat.xyz> | 2026-06-22 05:19:58 +0300 |
| commit | 06b52872961427ed4eb32e60f33936fa0a309d86 (patch) | |
| tree | 471f1a02e8ca45597bfe3196a5aa12bcabfdef74 /src/generator/mod.rs | |
| parent | d460ef157a5822f6326958cf7ab2309560eba2a4 (diff) | |
| download | repo2markdown-06b52872961427ed4eb32e60f33936fa0a309d86.tar.gz repo2markdown-06b52872961427ed4eb32e60f33936fa0a309d86.zip | |
Refactor markdown generators into a single struct
Diffstat (limited to 'src/generator/mod.rs')
| -rw-r--r-- | src/generator/mod.rs | 383 |
1 files changed, 383 insertions, 0 deletions
diff --git a/src/generator/mod.rs b/src/generator/mod.rs new file mode 100644 index 0000000..5c0b84a --- /dev/null +++ b/src/generator/mod.rs @@ -0,0 +1,383 @@ +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<W: Write>(self, output: W) -> Result<(), Box<dyn std::error::Error>> { + 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<W: Write>( + self, + mut output: W, + ) -> Result<(), Box<dyn std::error::Error>> { + 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<String, Box<dyn std::error::Error>> { + 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<String, Box<dyn std::error::Error>> { + 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`````")); + } +} |
