summaryrefslogtreecommitdiff
path: root/src/generator/mod.rs
diff options
context:
space:
mode:
authorA Farzat <a@farzat.xyz>2026-06-21 21:35:56 +0300
committerA Farzat <a@farzat.xyz>2026-06-22 05:19:58 +0300
commit06b52872961427ed4eb32e60f33936fa0a309d86 (patch)
tree471f1a02e8ca45597bfe3196a5aa12bcabfdef74 /src/generator/mod.rs
parentd460ef157a5822f6326958cf7ab2309560eba2a4 (diff)
downloadrepo2markdown-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.rs383
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`````"));
+ }
+}