//! Markdown rendering primitives. //! //! This module renders project headers and normalized file paths into Markdown. //! Rendering behavior is controlled by [`RenderOptions`]. use std::{ fs::File, io::{Read, Write}, path::Path, }; use crate::{ logger::Logger, normalizer::NormalizedPath, util::{ fence::generate_outer_backticks, language::detect_language, path_display::display_path, }, }; /// Default maximum file size, in bytes. Larger files are not included in Markdown output. pub const DEFAULT_MAX_FILE_SIZE: u64 = 1_000_000; /// Renders files and project metadata as Markdown. #[derive(Debug)] pub struct Renderer<'a, W: Write, LW: Write> { output: W, logger: &'a Logger, max_file_size: u64, placeholder_for_binary_files: bool, placeholder_for_large_files: bool, } impl<'a, W: Write, LW: Write> Renderer<'a, W, LW> { /// Creates a renderer with explicit rendering options. pub fn new(output: W, logger: &'a Logger, config: RenderOptions) -> Self { Self { output, logger, max_file_size: config.max_file_size, placeholder_for_binary_files: config.placeholder_for_binary_files, placeholder_for_large_files: config.placeholder_for_large_files, } } /// Sets the maximum file size, in bytes, that will be rendered. pub fn with_max_file_size(mut self, max_file_size: u64) -> Self { self.max_file_size = max_file_size; 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; self } /// Controls whether skipped large files are represented by placeholders. pub fn with_large_file_placeholder(mut self, placeholder_for_large_files: bool) -> Self { self.placeholder_for_large_files = placeholder_for_large_files; self } /// Renders the top-level project heading. pub fn render_header(&mut self, project_title: &str) -> std::io::Result<()> { writeln!(self.output, "# {}", project_title) } /// Renders a normalized path. /// /// Text files are rendered as fenced Markdown code blocks. Large files and binary files are /// skipped unless their corresponding placeholder options are enabled. pub fn render_path(&mut self, normalized_path: &NormalizedPath) -> std::io::Result<()> { let metadata = std::fs::metadata(&normalized_path.absolute)?; if metadata.len() > self.max_file_size { self.warn_about_filesize(&normalized_path.root_relative, metadata.len()); self.render_large_file(&normalized_path.root_relative) } else { let file = File::open(&normalized_path.absolute)?; self.render_file(&normalized_path.root_relative, file) } } fn render_file(&mut self, filename: &Path, mut reader: R) -> std::io::Result<()> { let mut bytes = Vec::new(); reader.read_to_end(&mut bytes)?; let contents = if let Ok(utf8string) = std::str::from_utf8(&bytes) { utf8string } else { self.warn_about_binary_file(filename); return self.render_binary_file(filename); }; self.write_file(filename, contents) } fn write_file(&mut self, filename: &Path, contents: &str) -> std::io::Result<()> { let name = display_path(filename); let fence = generate_outer_backticks(contents); let language = detect_language(filename, contents); self.log_file_render(filename); writeln!(self.output)?; writeln!(self.output, "## File: {}", name)?; writeln!(self.output, "{}{}", fence, language)?; writeln!(self.output, "{}", contents)?; writeln!(self.output, "{}", fence) } fn render_large_file(&mut self, filename: &Path) -> std::io::Result<()> { if !self.placeholder_for_large_files { return Ok(()); } let name = display_path(filename); writeln!(self.output)?; writeln!(self.output, "## File: {}", name)?; writeln!(self.output, "[FILE TOO LARGE]") } fn render_binary_file(&mut self, filename: &Path) -> std::io::Result<()> { if !self.placeholder_for_binary_files { return Ok(()); } let name = display_path(filename); writeln!(self.output)?; writeln!(self.output, "## File: {}", name)?; writeln!(self.output, "[BINARY FILE]") } fn warn_about_filesize(&self, filename: &Path, filesize: u64) { self.logger.warn(format!( "skipping large file: {} ({} > limit {})", display_path(filename), human_readable_size(filesize), human_readable_size(self.max_file_size), )) } fn warn_about_binary_file(&self, filename: &Path) { self.logger .warn(format!("skipping binary file: {}", display_path(filename))) } fn log_file_render(&self, filename: &Path) { self.logger .info(format!("rendering file: {}", display_path(filename))); } } /// Options controlling how files are rendered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RenderOptions { /// Maximum file size, in bytes, to include in Markdown output. pub max_file_size: u64, /// Whether skipped binary files should be shown as `[BINARY FILE]`. pub placeholder_for_binary_files: bool, /// Whether skipped large files should be shown as `[FILE TOO LARGE]`. pub placeholder_for_large_files: bool, } impl Default for RenderOptions { fn default() -> Self { RenderOptions { max_file_size: DEFAULT_MAX_FILE_SIZE, placeholder_for_binary_files: false, placeholder_for_large_files: false, } } } fn human_readable_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; let mut size = bytes as f64; let mut unit = 0; while size >= 1024.0 && unit < UNITS.len() - 1 { size /= 1024.0; unit += 1; } if unit == 0 { format!("{} {}", bytes, UNITS[unit]) } else { format!("{:.1} {}", size, UNITS[unit]) } } #[cfg(test)] mod tests { use std::{ ffi::OsStr, io::{Cursor, Write}, os::unix::ffi::OsStrExt, path::{Path, PathBuf}, }; use tempfile::tempdir; use crate::{ logger::{Logger, Verbosity}, normalizer::NormalizedPath, util::test_support::{logger_with_shared_log, quiet_logger}, }; use super::{RenderOptions, 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 logger = quiet_logger(); let mut renderer = renderer_for_tests(&mut output, &logger); renderer.render_header("Project name").unwrap(); assert_eq!(String::from_utf8(output).unwrap(), "# Project name\n"); } #[test] fn renderer_renders_single_rust_file_with_language_fence() { let mut output = Vec::new(); 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"; 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 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(); 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_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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn filename_with_linebreaks_and_invalid_chars_handled_properly() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn file_with_backticks_is_handled_safely() { let mut output = Vec::new(); 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\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 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn txt_file_has_no_language_fence() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn python_file_has_python_language_fence() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn json_file_has_json_language_fence() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn uppercase_extension_is_still_detected() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn bash_file_detected_from_shebang() { let mut output = Vec::new(); 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"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn python_file_detected_from_shebang() { let mut output = Vec::new(); 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"; 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 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"); 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!(String::from_utf8(output).unwrap(), ""); } #[test] fn renderer_can_place_a_placeholder_for_large_files() { let mut output = Vec::new(); let logger = quiet_logger(); let mut renderer = renderer_for_tests(&mut output, &logger) .with_large_file_placeholder(true) .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(); let expected = "\n## File: big.txt\n[FILE TOO LARGE]\n"; assert_eq!(String::from_utf8(output).unwrap(), expected); } #[test] fn format_readable_filesizes() { assert_eq!(human_readable_size(10), "10 B"); assert_eq!(human_readable_size(1500), "1.5 KiB"); 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(), ""); } }