From 1293ea92bdbf3eb2086ec117e355714b6ee54ae9 Mon Sep 17 00:00:00 2001 From: A Farzat Date: Mon, 22 Jun 2026 19:56:40 +0300 Subject: Make Logger's output destination customizable This aids in testing and might allow logging to a file at a later point. --- src/generator/generation.rs | 20 ++-- src/generator/mod.rs | 268 +++++++++++++++++++++++++++----------------- src/logger.rs | 119 ++++++++++++++++++-- src/renderer.rs | 229 ++++++++++++++++++++++++++++--------- src/run.rs | 5 +- src/util/mod.rs | 3 + src/util/test_support.rs | 40 +++++++ 7 files changed, 505 insertions(+), 179 deletions(-) create mode 100644 src/util/test_support.rs (limited to 'src') diff --git a/src/generator/generation.rs b/src/generator/generation.rs index 4235cd2..738373b 100644 --- a/src/generator/generation.rs +++ b/src/generator/generation.rs @@ -9,25 +9,27 @@ use crate::{ use super::MarkdownGenerator; -pub(super) struct MarkdownGeneration<'a, W: Write> { - logger: Logger, +pub(super) struct MarkdownGeneration<'a, W: Write, LW: Write> { + logger: &'a Logger, normalizer: Normalizer, paths: &'a [&'a Path], project_title: String, - renderer: Renderer, + renderer: Renderer<'a, W, LW>, seen_paths: HashSet, } -impl<'a, W: Write> MarkdownGeneration<'a, W> { - pub fn new(generator: MarkdownGenerator<'a>, output: W) -> Result { - let renderer = - Renderer::new(output, generator.render_options).with_logger(generator.logger); +impl<'a, W: Write, LW: Write> MarkdownGeneration<'a, W, LW> { + pub fn new( + generator: MarkdownGenerator<'a>, + output: W, + logger: &'a Logger, + ) -> Result { Ok(Self { - logger: generator.logger, + logger, normalizer: Normalizer::new(generator.root, generator.origin_base)?, paths: generator.paths, project_title: generator.get_or_derive_project_title().to_string(), - renderer, + renderer: Renderer::new(output, logger, generator.render_options), seen_paths: HashSet::new(), }) } diff --git a/src/generator/mod.rs b/src/generator/mod.rs index 07bdf1f..81e2420 100644 --- a/src/generator/mod.rs +++ b/src/generator/mod.rs @@ -10,7 +10,6 @@ use crate::{ 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>, @@ -21,7 +20,6 @@ pub struct MarkdownGenerator<'a> { impl<'a> MarkdownGenerator<'a> { pub fn new(paths: &'a [&'a Path]) -> Self { MarkdownGenerator { - logger: Logger::default(), origin_base: Path::new("."), paths, project_title: None, @@ -45,11 +43,6 @@ impl<'a> MarkdownGenerator<'a> { 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 @@ -67,16 +60,21 @@ impl<'a> MarkdownGenerator<'a> { } } - pub fn generate(self, output: W) -> Result<(), Box> { - MarkdownGeneration::new(self, output)?.run() + pub fn generate( + self, + output: W, + logger: &Logger, + ) -> Result<(), Box> { + MarkdownGeneration::new(self, output, logger)?.run() } - pub fn generate_fenced( + pub fn generate_fenced( self, mut output: W, + logger: &Logger, ) -> Result<(), Box> { let mut md_output = Vec::new(); - self.generate(&mut md_output)?; + self.generate(&mut md_output, logger)?; let fence = generate_outer_backticks(&md_output); writeln!(output, "{}markdown", fence)?; @@ -92,40 +90,32 @@ mod tests { use tempfile::tempdir; - use crate::{logger::Logger, renderer::RenderOptions}; + use crate::{ + logger::Verbosity, + renderer::RenderOptions, + util::test_support::{logger_with_shared_log, quiet_logger}, + }; use super::MarkdownGenerator; fn generate_to_string( - paths: &[&Path], - root: &Path, - origin: &Path, - project_title: Option<&str>, + generator: MarkdownGenerator, ) -> Result> { let mut output = Vec::new(); + let logger = quiet_logger(); - MarkdownGenerator::new(paths) - .with_root(root) - .with_origin(origin) - .with_project_title(project_title) - .generate(&mut output)?; + generator.generate(&mut output, &logger)?; Ok(String::from_utf8(output).unwrap()) } fn generate_fenced_to_string( - paths: &[&Path], - root: &Path, - origin: &Path, - project_title: Option<&str>, + generator: MarkdownGenerator, ) -> Result> { let mut output = Vec::new(); + let logger = quiet_logger(); - MarkdownGenerator::new(paths) - .with_root(root) - .with_origin(origin) - .with_project_title(project_title) - .generate_fenced(&mut output)?; + generator.generate_fenced(&mut output, &logger)?; Ok(String::from_utf8(output).unwrap()) } @@ -135,13 +125,11 @@ mod tests { let temp_dir = tempdir().unwrap(); let paths = []; - let output = generate_to_string( - &paths, - temp_dir.path(), - temp_dir.path(), - Some("Project name"), - ) - .unwrap(); + 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"); } @@ -152,7 +140,11 @@ mod tests { let paths = []; let root = temp_dir.path().join("repo2markdown"); - let output = generate_to_string(&paths, &root, temp_dir.path(), None).unwrap(); + 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"); } @@ -164,13 +156,11 @@ mod tests { let paths = [Path::new("main.rs")]; - let output = generate_to_string( - &paths, - temp_dir.path(), - temp_dir.path(), - Some("Project name"), - ) - .unwrap(); + 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")); @@ -186,13 +176,11 @@ mod tests { 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 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(); @@ -212,8 +200,11 @@ mod tests { let paths = [Path::new("main.rs")]; - let output = - generate_to_string(&paths, temp_dir.path(), &origin, Some("Project name")).unwrap(); + 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() {}")); @@ -230,7 +221,11 @@ mod tests { let paths = [Path::new("main.rs")]; - let output = generate_to_string(&paths, &root, &origin, Some("Project name")).unwrap(); + 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() {}")); @@ -244,13 +239,11 @@ mod tests { 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(); + 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); } @@ -264,13 +257,11 @@ mod tests { 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(); + 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); } @@ -278,29 +269,21 @@ mod tests { #[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) + 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) - .with_logger(Logger::default()) - .generate(&mut output) - .unwrap(); - - let output = String::from_utf8(output).unwrap(); + .with_render_options(render_options); + let output = generate_to_string(generator).unwrap(); assert!(output.contains("## File: big.txt")); assert!(output.contains("[FILE TOO LARGE]")); @@ -311,13 +294,11 @@ mod tests { 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(); + 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"); } @@ -330,13 +311,11 @@ mod tests { let paths = [Path::new("main.rs")]; - let output = generate_fenced_to_string( - &paths, - temp_dir.path(), - temp_dir.path(), - Some("Project name"), - ) - .unwrap(); + 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")); @@ -353,16 +332,103 @@ mod tests { let paths = [Path::new("backticks.rs")]; - let output = generate_fenced_to_string( - &paths, - temp_dir.path(), - temp_dir.path(), - Some("Project name"), - ) - .unwrap(); + 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" + ); + } } diff --git a/src/logger.rs b/src/logger.rs index 0352d61..0a0bdc2 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1,5 +1,10 @@ //! Minimal stderr logging utilities. +use std::{ + cell::RefCell, + io::{self, Write}, +}; + /// Logging verbosity level. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Verbosity { @@ -12,36 +17,130 @@ pub enum Verbosity { } /// Lightweight logger used by renderers and generators. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub struct Logger { +#[derive(Debug)] +pub struct Logger { verbosity: Verbosity, + writer: RefCell, } -impl Logger { +impl Logger { /// Creates a logger with the given verbosity. pub fn new(verbosity: Verbosity) -> Self { - Self { verbosity } + Self { + verbosity, + writer: RefCell::new(io::stderr()), + } + } +} + +impl Logger { + pub fn with_writer(verbosity: Verbosity, writer: W) -> Self { + Self { + verbosity, + writer: RefCell::new(writer), + } } /// Emits a warning unless verbosity is [`Verbosity::Quiet`]. pub fn warn(&self, message: impl AsRef) { if self.verbosity != Verbosity::Quiet { - eprintln!("Warning: {}", message.as_ref()); + let _ = writeln!(self.writer.borrow_mut(), "Warning: {}", message.as_ref()); } } /// Emits an informational message only when verbosity is [`Verbosity::Verbose`]. pub fn info(&self, message: impl AsRef) { if self.verbosity == Verbosity::Verbose { - eprintln!("Info: {}", message.as_ref()); + let _ = writeln!(self.writer.borrow_mut(), "Info: {}", message.as_ref()); } } } -impl Default for Logger { - fn default() -> Self { - Self { - verbosity: Verbosity::Quiet, +#[cfg(test)] +mod tests { + use std::io::{self, Write}; + + use super::{Logger, Verbosity}; + + fn logged_output(logger: &Logger>) -> String { + String::from_utf8(logger.writer.borrow().clone()).unwrap() + } + + #[test] + fn quiet_suppresses_warnings() { + let logger = Logger::with_writer(Verbosity::Quiet, Vec::new()); + logger.warn("skipped file"); + assert_eq!(logged_output(&logger), ""); + } + + #[test] + fn quiet_suppresses_info_messages() { + let logger = Logger::with_writer(Verbosity::Quiet, Vec::new()); + logger.info("rendering file"); + assert_eq!(logged_output(&logger), ""); + } + + #[test] + fn normal_writes_warnings() { + let logger = Logger::with_writer(Verbosity::Normal, Vec::new()); + logger.warn("skipped file"); + assert_eq!(logged_output(&logger), "Warning: skipped file\n"); + } + + #[test] + fn normal_suppresses_info_messages() { + let logger = Logger::with_writer(Verbosity::Normal, Vec::new()); + logger.info("rendering file"); + assert_eq!(logged_output(&logger), ""); + } + + #[test] + fn verbose_writes_warnings() { + let logger = Logger::with_writer(Verbosity::Verbose, Vec::new()); + logger.warn("skipped file"); + assert_eq!(logged_output(&logger), "Warning: skipped file\n"); + } + + #[test] + fn verbose_writes_info_messages() { + let logger = Logger::with_writer(Verbosity::Verbose, Vec::new()); + logger.info("rendering file"); + assert_eq!(logged_output(&logger), "Info: rendering file\n"); + } + + #[test] + fn verbose_preserves_log_order() { + let logger = Logger::with_writer(Verbosity::Verbose, Vec::new()); + + logger.info("rendering a.rs"); + logger.warn("skipping duplicate file: a.rs"); + logger.info("rendering b.rs"); + + assert_eq!( + logged_output(&logger), + "Info: rendering a.rs\n\ + Warning: skipping duplicate file: a.rs\n\ + Info: rendering b.rs\n" + ); + } + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("intentional write failure")) } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn logging_ignores_writer_errors() { + let logger = Logger::with_writer(Verbosity::Verbose, FailingWriter); + + logger.warn("this should not panic"); + logger.info("this should not panic"); } } diff --git a/src/renderer.rs b/src/renderer.rs index 1db1215..e89482d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -22,26 +22,21 @@ pub const DEFAULT_MAX_FILE_SIZE: u64 = 1_000_000; /// Renders files and project metadata as Markdown. #[derive(Debug)] -pub struct Renderer { +pub struct Renderer<'a, W: Write, LW: Write> { output: W, + logger: &'a Logger, max_file_size: u64, - logger: Logger, placeholder_for_binary_files: bool, placeholder_for_large_files: bool, } -impl Renderer { - /// Creates a renderer with default [`RenderOptions`]. - pub fn new_with_defaults(output: W) -> Self { - Self::new(output, RenderOptions::default()) - } - +impl<'a, W: Write, LW: Write> Renderer<'a, W, LW> { /// Creates a renderer with explicit rendering options. - pub fn new(output: W, config: RenderOptions) -> Self { + pub fn new(output: W, logger: &'a Logger, config: RenderOptions) -> Self { Self { output, + logger, max_file_size: config.max_file_size, - logger: Logger::default(), placeholder_for_binary_files: config.placeholder_for_binary_files, placeholder_for_large_files: config.placeholder_for_large_files, } @@ -53,12 +48,6 @@ impl Renderer { self } - /// Sets the logger used for warnings and informational messages. - pub fn with_logger(mut self, logger: Logger) -> Self { - self.logger = logger; - self - } - /// Controls whether skipped binary files are represented by placeholders. pub fn with_binary_file_placeholder(mut self, placeholder_for_binary_files: bool) -> Self { self.placeholder_for_binary_files = placeholder_for_binary_files; @@ -198,21 +187,33 @@ fn human_readable_size(bytes: u64) -> String { mod tests { use std::{ ffi::OsStr, - io::Cursor, + io::{Cursor, Write}, os::unix::ffi::OsStrExt, path::{Path, PathBuf}, }; use tempfile::tempdir; - use crate::normalizer::NormalizedPath; + use crate::{ + logger::{Logger, Verbosity}, + normalizer::NormalizedPath, + util::test_support::{logger_with_shared_log, quiet_logger}, + }; + + use super::{RenderOptions, Renderer, human_readable_size}; - use super::{Renderer, human_readable_size}; + fn renderer_for_tests<'a, W: Write, LW: Write>( + output: W, + logger: &'a Logger, + ) -> Renderer<'a, W, LW> { + Renderer::new(output, logger, RenderOptions::default()) + } #[test] fn renderer_writes_header() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); renderer.render_header("Project name").unwrap(); @@ -222,193 +223,205 @@ mod tests { #[test] fn renderer_renders_single_rust_file_with_language_fence() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("fn main() {}"); renderer.render_file(Path::new("main.rs"), input).unwrap(); - let expected = "\n## File: main.rs\n```rust\nfn main() {}\n```\n"; + let expected = "\n## File: main.rs\n```rust\nfn main() {}\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn binary_files_are_not_included_in_output_at_all_by_default() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new(&[0x00, 0x01, 0x02, 0xc3]); renderer.render_file(Path::new("image.png"), input).unwrap(); - let expected = ""; - assert_eq!(String::from_utf8(output).unwrap(), expected); + assert_eq!(String::from_utf8(output).unwrap(), ""); } #[test] fn renderer_can_place_a_placeholder_for_binary_files() { let mut output = Vec::new(); + let logger = quiet_logger(); let mut renderer = - Renderer::new_with_defaults(&mut output).with_binary_file_placeholder(true); + renderer_for_tests(&mut output, &logger).with_binary_file_placeholder(true); let input = Cursor::new(&[0x00, 0x01, 0x02, 0xc3]); renderer.render_file(Path::new("image.png"), input).unwrap(); - let expected = "\n## File: image.png\n[BINARY FILE]\n"; + let expected = "\n## File: image.png\n[BINARY FILE]\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn filename_with_linebreaks_and_invalid_chars_handled_properly() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("fn main() {}"); let filename = Path::new(OsStr::from_bytes(b"jap\xE3\x81\x82dir/some\nma\xc3in.rs")); renderer.render_file(filename, input).unwrap(); - let expected = "\n## File: japあdir/some\\nma\\xC3in.rs\n```rust\nfn main() {}\n```\n"; + let expected = "\n## File: japあdir/some\\nma\\xC3in.rs\n```rust\nfn main() {}\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn file_with_backticks_is_handled_safely() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("fn main() { println!(\"``` inside\"); }"); renderer .render_file(Path::new("example.rs"), input) .unwrap(); - let expected = "\n## File: example.rs\n````rust\n\ - fn main() { println!(\"``` inside\"); }\n````\n"; + let expected = + "\n## File: example.rs\n````rust\nfn main() { println!(\"``` inside\"); }\n````\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn unknown_filetype_has_no_language_fence() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("hello world"); renderer.render_file(Path::new("readme"), input).unwrap(); - let expected = "\n## File: readme\n```\nhello world\n```\n"; + let expected = "\n## File: readme\n```\nhello world\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn txt_file_has_no_language_fence() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("hello world"); renderer.render_file(Path::new("hello.txt"), input).unwrap(); - let expected = "\n## File: hello.txt\n```\nhello world\n```\n"; + let expected = "\n## File: hello.txt\n```\nhello world\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn python_file_has_python_language_fence() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("print('hello')"); renderer.render_file(Path::new("main.py"), input).unwrap(); - let expected = "\n## File: main.py\n```python\nprint('hello')\n```\n"; + let expected = "\n## File: main.py\n```python\nprint('hello')\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn json_file_has_json_language_fence() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("{\"key\":\"value\"}"); renderer.render_file(Path::new("main.json"), input).unwrap(); - let expected = "\n## File: main.json\n```json\n{\"key\":\"value\"}\n```\n"; + let expected = "\n## File: main.json\n```json\n{\"key\":\"value\"}\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn uppercase_extension_is_still_detected() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("fn main() {}"); renderer.render_file(Path::new("MAIN.RS"), input).unwrap(); - let expected = "\n## File: MAIN.RS\n```rust\nfn main() {}\n```\n"; + let expected = "\n## File: MAIN.RS\n```rust\nfn main() {}\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn bash_file_detected_from_shebang() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("#! /bin/bash\necho hello"); renderer.render_file(Path::new("hello"), input).unwrap(); - let expected = "\n## File: hello\n```bash\n#! /bin/bash\necho hello\n```\n"; + let expected = "\n## File: hello\n```bash\n#! /bin/bash\necho hello\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn python_file_detected_from_shebang() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output); + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger); let input = Cursor::new("#! /bin/python3\nprint('hello')"); renderer.render_file(Path::new("hello"), input).unwrap(); - let expected = "\n## File: hello\n```python\n#! /bin/python3\nprint('hello')\n```\n"; + let expected = "\n## File: hello\n```python\n#! /bin/python3\nprint('hello')\n```\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn large_files_are_not_included_in_output_at_all_by_default() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output).with_max_file_size(5); // smaller than file + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5); let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("big.txt"); - let content = "A".repeat(10); // 10 bytes -> bigger than the limit - std::fs::write(&file_path, &content).unwrap(); + std::fs::write(&file_path, "A".repeat(10)).unwrap(); + let normalized_path = NormalizedPath { root_relative: PathBuf::from("big.txt"), absolute: file_path, }; renderer.render_path(&normalized_path).unwrap(); - let expected = ""; - assert_eq!(String::from_utf8(output).unwrap(), expected); + assert_eq!(String::from_utf8(output).unwrap(), ""); } #[test] fn renderer_can_place_a_placeholder_for_large_files() { let mut output = Vec::new(); - let mut renderer = Renderer::new_with_defaults(&mut output) + let logger = quiet_logger(); + let mut renderer = renderer_for_tests(&mut output, &logger) .with_large_file_placeholder(true) - .with_max_file_size(5); // smaller than file + .with_max_file_size(5); let temp_dir = tempdir().unwrap(); let file_path = temp_dir.path().join("big.txt"); - let content = "A".repeat(10); // 10 bytes -> bigger than the limit - std::fs::write(&file_path, &content).unwrap(); + std::fs::write(&file_path, "A".repeat(10)).unwrap(); + let normalized_path = NormalizedPath { root_relative: PathBuf::from("big.txt"), absolute: file_path, }; renderer.render_path(&normalized_path).unwrap(); - let expected = "\n## File: big.txt\n[FILE TOO LARGE]\n"; + let expected = "\n## File: big.txt\n[FILE TOO LARGE]\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } @@ -419,4 +432,108 @@ mod tests { assert_eq!(human_readable_size(1_048_576), "1.0 MiB"); assert_eq!(human_readable_size(5_242_880), "5.0 MiB"); } + + #[test] + fn verbose_verbosity_logs_rendered_text_files() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Verbose); + let mut renderer = renderer_for_tests(&mut output, &logger); + + renderer + .render_file(Path::new("main.rs"), Cursor::new("fn main() {}")) + .unwrap(); + + assert_eq!(logs.contents(), "Info: rendering file: main.rs\n"); + } + + #[test] + fn normal_verbosity_does_not_log_rendered_text_files() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Normal); + let mut renderer = renderer_for_tests(&mut output, &logger); + + renderer + .render_file(Path::new("main.rs"), Cursor::new("fn main() {}")) + .unwrap(); + + assert_eq!(logs.contents(), ""); + } + + #[test] + fn normal_verbosity_logs_skipped_binary_files() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Normal); + let mut renderer = renderer_for_tests(&mut output, &logger); + + renderer + .render_file( + Path::new("image.png"), + Cursor::new(&[0x00, 0x01, 0x02, 0xc3]), + ) + .unwrap(); + + assert_eq!( + logs.contents(), + "Warning: skipping binary file: image.png\n" + ); + } + + #[test] + fn quiet_verbosity_suppresses_skipped_binary_file_warning() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Quiet); + let mut renderer = renderer_for_tests(&mut output, &logger); + + renderer + .render_file( + Path::new("image.png"), + Cursor::new(&[0x00, 0x01, 0x02, 0xc3]), + ) + .unwrap(); + + assert_eq!(logs.contents(), ""); + } + + #[test] + fn normal_verbosity_logs_skipped_large_files() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Normal); + let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5); + + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("big.txt"); + std::fs::write(&file_path, "A".repeat(10)).unwrap(); + + let normalized_path = NormalizedPath { + root_relative: PathBuf::from("big.txt"), + absolute: file_path, + }; + + renderer.render_path(&normalized_path).unwrap(); + + assert_eq!( + logs.contents(), + "Warning: skipping large file: big.txt (10 B > limit 5 B)\n" + ); + } + + #[test] + fn quiet_verbosity_suppresses_skipped_large_file_warning() { + let mut output = Vec::new(); + let (logger, logs) = logger_with_shared_log(Verbosity::Quiet); + let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5); + + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("big.txt"); + std::fs::write(&file_path, "A".repeat(10)).unwrap(); + + let normalized_path = NormalizedPath { + root_relative: PathBuf::from("big.txt"), + absolute: file_path, + }; + + renderer.render_path(&normalized_path).unwrap(); + + assert_eq!(logs.contents(), ""); + } } diff --git a/src/run.rs b/src/run.rs index c28d7f3..2a51f1b 100644 --- a/src/run.rs +++ b/src/run.rs @@ -31,16 +31,15 @@ pub fn run( let paths = paths_from_null_separated_bytes(&input_buf); let md_generator = MarkdownGenerator::new(&paths) - .with_logger(logger) .with_origin(&cli.origin) .with_project_title(cli.name.as_deref()) .with_render_options(cli.render_options()) .with_root(&cli.root); if cli.fenced { - md_generator.generate_fenced(output) + md_generator.generate_fenced(output, &logger) } else { - md_generator.generate(output) + md_generator.generate(output, &logger) } } diff --git a/src/util/mod.rs b/src/util/mod.rs index eda8605..d038ae8 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -2,3 +2,6 @@ pub mod fence; pub mod language; pub mod path_display; pub mod path_list; + +#[cfg(test)] +pub(crate) mod test_support; diff --git a/src/util/test_support.rs b/src/util/test_support.rs new file mode 100644 index 0000000..14a7a62 --- /dev/null +++ b/src/util/test_support.rs @@ -0,0 +1,40 @@ +use std::{ + cell::RefCell, + io::{self, Write}, + rc::Rc, +}; + +use crate::logger::{Logger, Verbosity}; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SharedLog { + bytes: Rc>>, +} + +impl SharedLog { + pub(crate) fn contents(&self) -> String { + String::from_utf8(self.bytes.borrow().clone()).unwrap() + } +} + +impl Write for SharedLog { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(crate) fn quiet_logger() -> Logger> { + Logger::with_writer(Verbosity::Quiet, Vec::new()) +} + +pub(crate) fn logger_with_shared_log(verbosity: Verbosity) -> (Logger, SharedLog) { + let shared_log = SharedLog::default(); + let logger = Logger::with_writer(verbosity, shared_log.clone()); + + (logger, shared_log) +} -- cgit v1.3.1