diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/fenced_md_generator.rs | 48 | ||||
| -rw-r--r-- | src/md_generator.rs | 113 | ||||
| -rw-r--r-- | src/run.rs | 115 |
3 files changed, 179 insertions, 97 deletions
diff --git a/src/fenced_md_generator.rs b/src/fenced_md_generator.rs index 7cc5e37..bf44e3a 100644 --- a/src/fenced_md_generator.rs +++ b/src/fenced_md_generator.rs @@ -3,25 +3,22 @@ //! This module is useful when the generated project Markdown needs to be embedded inside another //! document or a prompt. -use std::{ - io::{Read, Write}, - path::Path, -}; +use std::{io::Write, path::Path}; use crate::{ logger::Logger, md_generator::generate_markdown_from_paths, renderer::RenderOptions, util::fence::generate_outer_backticks, }; -/// Generates Markdown from a null-separated path list and wraps the full output in a fence. +/// Generates Markdown from `paths` and wraps the full output in a fence. /// /// The inner Markdown is produced by [`generate_markdown_from_paths`]. The outer fence length is /// chosen so that any backtick runs inside the generated Markdown cannot close the outer fence. /// /// `render_options` controls file-size limits and placeholder behavior. `root` controls displayed /// relative paths, while `origin_base` controls how relative input paths are resolved. -pub fn generate_fenced_markdown<R: Read, W: Write>( - input: R, +pub fn generate_fenced_markdown<W: Write>( + paths: &[&Path], mut output: W, render_options: RenderOptions, root: &Path, @@ -31,7 +28,7 @@ pub fn generate_fenced_markdown<R: Read, W: Write>( ) -> Result<(), Box<dyn std::error::Error>> { let mut md_output = Vec::new(); generate_markdown_from_paths( - input, + paths, &mut md_output, render_options, root, @@ -49,7 +46,8 @@ pub fn generate_fenced_markdown<R: Read, W: Write>( #[cfg(test)] mod tests { use std::fs; - use std::io::{Cursor, Read, Write}; + use std::io::Write; + use std::path::Path; use tempfile::tempdir; use crate::logger::Logger; @@ -57,8 +55,8 @@ mod tests { use super::generate_fenced_markdown; - fn wrap_with_defaults<R: Read, W: Write>( - input: R, + fn generate_fenced_with_defaults<W: Write>( + paths: &[&Path], output: W, root: &std::path::Path, origin_base: &std::path::Path, @@ -67,7 +65,7 @@ mod tests { let logger = Logger::default(); let render_options = RenderOptions::default(); generate_fenced_markdown( - input, + paths, output, render_options, root, @@ -78,14 +76,15 @@ mod tests { } #[test] - fn empty_input_produces_wrapped_header() { + fn empty_path_list_produces_wrapped_header() { let temp_dir = tempdir().unwrap(); - let input = Cursor::new(b""); + let paths = []; let mut output = Vec::new(); let root = temp_dir.path(); let origin_base = temp_dir.path(); - wrap_with_defaults(input, &mut output, root, origin_base, Some("Project name")).unwrap(); + generate_fenced_with_defaults(&paths, &mut output, root, origin_base, Some("Project name")) + .unwrap(); assert_eq!( String::from_utf8(output).unwrap(), @@ -97,13 +96,13 @@ mod tests { fn single_file_is_wrapped() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"test_main.rs\0"); + 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(); - wrap_with_defaults(input, &mut output, root, origin_base, None).unwrap(); + generate_fenced_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); // Must contain outer fence @@ -115,17 +114,17 @@ mod tests { } #[test] - fn multiple_files_are_enveloped_using_only_one_outer_fence_while_each_having_its_own_fence() { + fn multiple_files_are_wrapped_in_one_outer_fence() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"a.rs\0b.rs\0"); + 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(); - wrap_with_defaults(input, &mut output, root, origin_base, None).unwrap(); + generate_fenced_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); // Each inner file must have its own languageāspecific fence @@ -140,14 +139,14 @@ mod tests { fn inner_file_with_four_backticks_causes_outer_fence_of_six_backticks() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"backticks.rs\0"); + let paths = [Path::new("backticks.rs")]; let mut output = Vec::new(); let root = temp_dir.path(); let content = "````"; // 4 backticks fs::write(origin_base.join("backticks.rs"), content).unwrap(); - wrap_with_defaults(input, &mut output, root, origin_base, None).unwrap(); + generate_fenced_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); // Inner fence should be 5 backticks because inner content contains 4 backticks @@ -160,13 +159,14 @@ mod tests { #[test] fn project_title_with_three_backticks_causes_outer_fence_of_four_backticks() { let temp_dir = tempdir().unwrap(); - let input = Cursor::new(b""); + let paths = []; let mut output = Vec::new(); let root = temp_dir.path(); let origin_base = temp_dir.path(); let project_title = "Project ``` name"; - wrap_with_defaults(input, &mut output, root, origin_base, Some(project_title)).unwrap(); + generate_fenced_with_defaults(&paths, &mut output, root, origin_base, Some(project_title)) + .unwrap(); let output_str = String::from_utf8(output).unwrap(); // Outer fence must be 4 backticks because the header contains 3 backticks diff --git a/src/md_generator.rs b/src/md_generator.rs index 48f066c..7740247 100644 --- a/src/md_generator.rs +++ b/src/md_generator.rs @@ -1,15 +1,8 @@ -//! Generate Markdown from null-separated path lists. +//! Generate Markdown from path lists. //! -//! This module reads a stream of NUL-separated file paths, normalizes each path, skips duplicates, -//! and renders selected files into Markdown. +//! This module normalizes paths, skips duplicates, and renders selected files into Markdown. -use std::{ - collections::HashSet, - ffi::OsStr, - io::{Read, Write}, - os::unix::ffi::OsStrExt, - path::Path, -}; +use std::{collections::HashSet, io::Write, path::Path}; use crate::{ logger::Logger, @@ -20,20 +13,17 @@ use crate::{ const DEFAULT_PROJECT_NAME: &str = "Project Outline"; -/// Generates Markdown for the files listed in `input`. -/// -/// `input` is expected to contain a NUL-separated sequence of paths, such as output from -/// `find -print0` or `git ls-files -z`. +/// Generates Markdown for `paths`. /// -/// Relative input paths are resolved against `origin_base`. File headings use paths made relative -/// to `root`. +/// 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<R: Read, W: Write>( - mut input: R, +pub fn generate_markdown_from_paths<W: Write>( + paths: &[&Path], output: W, render_options: RenderOptions, root: &Path, @@ -41,9 +31,6 @@ pub fn generate_markdown_from_paths<R: Read, W: Write>( project_title: Option<&str>, logger: Logger, ) -> Result<(), Box<dyn std::error::Error>> { - let mut buf = Vec::new(); - input.read_to_end(&mut buf)?; - let normalizer = Normalizer::new(root, origin_base)?; let mut renderer = Renderer::new(output, render_options).with_logger(logger); @@ -51,12 +38,7 @@ pub fn generate_markdown_from_paths<R: Read, W: Write>( renderer.render_header(project_title)?; let mut seen_paths = HashSet::new(); - for segment in buf.split(|b| *b == 0) { - if segment.is_empty() { - continue; - } - - let path = Path::new(OsStr::from_bytes(segment)); + for path in paths { let normalized_path = normalizer.normalize(path)?; if !seen_paths.insert(normalized_path.clone()) { logger.warn(format!( @@ -84,7 +66,7 @@ fn derive_project_title(root: &Path) -> &str { mod tests { use std::ffi::OsStr; use std::fs; - use std::io::{Cursor, Read, Write}; + use std::io::Write; use std::os::unix::ffi::OsStrExt; use std::path::Path; @@ -94,17 +76,8 @@ mod tests { use super::{DEFAULT_PROJECT_NAME, derive_project_title, generate_markdown_from_paths}; - fn paths_to_null_sep_bytes(file_paths: &[&Path]) -> Vec<u8> { - let mut output = Vec::new(); - for path in file_paths { - output.extend(path.as_os_str().as_encoded_bytes()); - output.push(0); - } - output - } - - fn generate_with_defaults<R: Read, W: Write>( - input: R, + fn generate_with_defaults<W: Write>( + paths: &[&Path], output: W, root: &Path, origin_base: &Path, @@ -113,7 +86,7 @@ mod tests { let logger = Logger::default(); let render_options = RenderOptions::default(); generate_markdown_from_paths( - input, + paths, output, render_options, root, @@ -124,30 +97,30 @@ mod tests { } #[test] - fn cli_with_empty_input_produces_empty_project_with_specified_project_title() { + fn empty_path_list_produces_header_with_specified_project_title() { let temp_dir = tempdir().unwrap(); - let input = Cursor::new(b""); + let paths = []; let mut output = Vec::new(); let root = temp_dir.path(); let origin_base = temp_dir.path(); - generate_with_defaults(input, &mut output, root, origin_base, Some("Project name")) + 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 cli_reads_single_file_from_stdin() { + fn renders_single_file_from_path_list() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"test_main.rs\0"); + 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(input, &mut output, root, origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); @@ -156,17 +129,17 @@ mod tests { } #[test] - fn cli_reads_multiple_files_in_order() { + fn renders_multiple_files_in_order() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"a.rs\0b.rs\0"); + 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(input, &mut output, root, origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); @@ -177,10 +150,10 @@ mod tests { } #[test] - fn cli_normalizes_paths_before_rendering() { + fn normalizes_paths_before_rendering() { let temp_dir = tempdir().unwrap(); let origin_base = temp_dir.path(); - let input = Cursor::new(b"test/./main.rs\0"); + let paths = [Path::new("test/./main.rs")]; let mut output = Vec::new(); let root = temp_dir.path(); @@ -188,7 +161,7 @@ mod tests { fs::create_dir_all(&write_dir).unwrap(); fs::write(write_dir.join("main.rs"), "fn main() {}").unwrap(); - generate_with_defaults(input, &mut output, root, origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); @@ -196,17 +169,17 @@ mod tests { } #[test] - fn cli_reads_from_origin_but_outputs_relative_to_root() { + 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 input = Cursor::new(b"main.rs\0"); + 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(input, &mut output, &root, &origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, &root, &origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); @@ -218,17 +191,17 @@ mod tests { } #[test] - fn cli_ignores_origin_when_input_path_is_absolute() { + 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 input = Cursor::new(paths_to_null_sep_bytes(&[&filepath])); + let paths = [filepath.as_ref()]; let mut output = Vec::new(); let root = temp_dir2.path(); fs::write(&filepath, "fn main() {}").unwrap(); - generate_with_defaults(input, &mut output, root, origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap(); let output = String::from_utf8(output).unwrap(); @@ -244,10 +217,10 @@ mod tests { fs::write(origin.join("a.rs"), "A").unwrap(); - let input = Cursor::new(b"a.rs\0a.rs\0"); + let paths = [Path::new("a.rs"), Path::new("a.rs")]; let mut output = Vec::new(); - generate_with_defaults(input, &mut output, root, origin, None).unwrap(); + generate_with_defaults(&paths, &mut output, root, origin, None).unwrap(); let output = String::from_utf8(output).unwrap(); @@ -263,10 +236,10 @@ mod tests { fs::write(origin.join("a.rs"), "A").unwrap(); fs::write(origin.join("b.rs"), "B").unwrap(); - let input = Cursor::new(b"a.rs\0b.rs\0a.rs\0"); + let paths = [Path::new("a.rs"), Path::new("b.rs"), Path::new("a.rs")]; let mut output = Vec::new(); - generate_with_defaults(input, &mut output, root, origin, None).unwrap(); + 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); @@ -286,10 +259,14 @@ mod tests { fs::write(origin.join("a.rs"), "A").unwrap(); fs::write(origin.join("b.rs"), "B").unwrap(); - let input = Cursor::new(b"a.rs\0b.rs\0bla/../a.rs\0"); + let paths = [ + Path::new("a.rs"), + Path::new("b.rs"), + Path::new("bla/../a.rs"), + ]; let mut output = Vec::new(); - generate_with_defaults(input, &mut output, root, origin, None).unwrap(); + 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); @@ -299,11 +276,11 @@ mod tests { 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 input = Cursor::new(b""); + let paths = []; let mut output = Vec::new(); let root = temp_dir.path().join("repo2markdown"); - generate_with_defaults(input, &mut output, &root, origin_base, None).unwrap(); + generate_with_defaults(&paths, &mut output, &root, origin_base, None).unwrap(); let output_str = String::from_utf8(output).unwrap(); @@ -336,7 +313,7 @@ mod tests { let file_path = origin_base.join("big.txt"); fs::write(&file_path, "A".repeat(10)).unwrap(); - let input = Cursor::new(b"big.txt\0"); + let paths = [Path::new("big.txt")]; let mut output = Vec::new(); let config = RenderOptions { @@ -346,7 +323,7 @@ mod tests { }; generate_markdown_from_paths( - input, + &paths, &mut output, config, root, @@ -1,4 +1,9 @@ -use std::io::{Read, Write}; +use std::{ + ffi::OsStr, + io::{Read, Write}, + os::unix::ffi::OsStrExt, + path::Path, +}; use repo2markdown::{ fenced_md_generator::generate_fenced_markdown, logger::Logger, @@ -9,14 +14,22 @@ use crate::cli::Cli; pub fn run<R: Read, W: Write>( cli: &Cli, - input: R, + mut input: R, output: W, ) -> Result<(), Box<dyn std::error::Error>> { let logger = Logger::new(cli.verbosity()); + let mut input_buf = Vec::new(); + input.read_to_end(&mut input_buf)?; + let paths: Vec<&Path> = input_buf + .split(|b| *b == 0) + .filter(|bytes| !bytes.is_empty()) + .map(|bytes| Path::new(OsStr::from_bytes(bytes))) + .collect(); + if cli.fenced { generate_fenced_markdown( - input, + &paths, output, cli.render_options(), &cli.root, @@ -26,7 +39,7 @@ pub fn run<R: Read, W: Write>( ) } else { generate_markdown_from_paths( - input, + &paths, output, cli.render_options(), &cli.root, @@ -39,9 +52,10 @@ pub fn run<R: Read, W: Write>( #[cfg(test)] mod tests { - use std::io::Cursor; + use std::{fs, io::Cursor}; use clap::Parser; + use tempfile::tempdir; use crate::cli::Cli; @@ -74,4 +88,95 @@ mod tests { "```markdown\n# Project name\n```\n" ); } + + #[test] + fn run_reads_null_separated_paths_from_stdin() { + let temp_dir = tempdir().unwrap(); + fs::write(temp_dir.path().join("b.rs"), "B").unwrap(); + fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); + + let cli = Cli::try_parse_from([ + "repo2markdown", + "--origin", + temp_dir.path().to_str().unwrap(), + "--root", + temp_dir.path().to_str().unwrap(), + "--name", + "Project name", + ]) + .unwrap(); + + let input = Cursor::new(b"a.rs\0b.rs\0"); + let mut output = Vec::new(); + + run(&cli, input, &mut output).unwrap(); + + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("## File: a.rs")); + assert!(output.contains("## File: b.rs")); + assert!(output.contains("```rust\nA\n```")); + assert!(output.contains("```rust\nB\n```")); + + let a_pos = output.find("a.rs").unwrap(); + let b_pos = output.find("b.rs").unwrap(); + assert!(a_pos < b_pos); + } + + #[test] + fn run_ignores_empty_path_segments() { + let temp_dir = tempdir().unwrap(); + + fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); + + let cli = Cli::try_parse_from([ + "repo2markdown", + "--origin", + temp_dir.path().to_str().unwrap(), + "--root", + temp_dir.path().to_str().unwrap(), + "--name", + "Project name", + ]) + .unwrap(); + + let input = Cursor::new(b"\0a.rs\0\0"); + let mut output = Vec::new(); + + run(&cli, input, &mut output).unwrap(); + + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("## File: a.rs")); + assert!(output.contains("```rust\nA\n```")); + assert_eq!(output.matches("## File: a.rs").count(), 1); + } + + #[test] + fn run_accepts_final_path_without_trailing_nul() { + let temp_dir = tempdir().unwrap(); + + fs::write(temp_dir.path().join("a.rs"), "A").unwrap(); + + let cli = Cli::try_parse_from([ + "repo2markdown", + "--origin", + temp_dir.path().to_str().unwrap(), + "--root", + temp_dir.path().to_str().unwrap(), + "--name", + "Project name", + ]) + .unwrap(); + + let input = Cursor::new(b"a.rs"); + let mut output = Vec::new(); + + run(&cli, input, &mut output).unwrap(); + + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("## File: a.rs")); + assert!(output.contains("```rust\nA\n```")); + } } |
