summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/fenced_md_generator.rs48
-rw-r--r--src/md_generator.rs113
-rw-r--r--src/run.rs115
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,
diff --git a/src/run.rs b/src/run.rs
index e9a32c2..5b1a790 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -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```"));
+ }
}