use std::fmt;
use crate::format::OutputFormat;
use super::error::WasmError;
use super::options::ProcessorOptions;
use super::render;
use super::virtual_fs::VirtualFile;
#[derive(
Clone, Copy, Debug, Default, Hash, PartialEq, Eq, serde::Serialize, serde::Deserialize,
)]
#[non_exhaustive]
pub struct ProcessResult {
pub files_processed: usize,
pub files_skipped: usize,
pub files_truncated: usize,
pub output_bytes: usize,
}
impl ProcessResult {
#[must_use]
pub const fn total_files(&self) -> usize {
self.files_processed + self.files_skipped + self.files_truncated
}
}
#[derive(Clone, Debug)]
pub struct WasmProcessor {
options: ProcessorOptions,
}
impl Default for WasmProcessor {
fn default() -> Self {
Self::new(ProcessorOptions::default())
}
}
impl From<ProcessorOptions> for WasmProcessor {
fn from(options: ProcessorOptions) -> Self {
Self::new(options)
}
}
impl WasmProcessor {
#[must_use]
pub const fn new(options: ProcessorOptions) -> Self {
Self { options }
}
#[must_use]
pub const fn options(&self) -> &ProcessorOptions {
&self.options
}
pub fn process<W: fmt::Write>(
&self,
files: impl IntoIterator<Item = VirtualFile>,
sink: &mut W,
) -> super::Result<ProcessResult> {
self.process_inner(files.into_iter().collect(), sink)
}
pub fn process_to_string(
&self,
files: impl IntoIterator<Item = VirtualFile>,
) -> super::Result<(String, ProcessResult)> {
let files: Vec<VirtualFile> = files.into_iter().collect();
let estimated_size: usize = files
.iter()
.map(|f| f.path().len() + f.content().len() + 64)
.sum();
let mut output = String::with_capacity(estimated_size);
let result = self.process_inner(files, &mut output)?;
Ok((output, result))
}
fn process_inner<W: fmt::Write>(
&self,
mut files: Vec<VirtualFile>,
sink: &mut W,
) -> super::Result<ProcessResult> {
let total = files.len();
files.retain(|f| !self.should_ignore(f));
let after_filter = files.len();
let files_skipped = total - after_filter;
files.sort();
let files_truncated = self.options.max_files.map_or(0, |max| {
let before = files.len();
files.truncate(max);
before - files.len()
});
let files_processed = files.len();
debug_assert_eq!(
files_processed + files_skipped + files_truncated,
total,
"conservation invariant: processed + skipped + truncated == total"
);
let mut counting_sink = CountingWriter::new(sink, self.options.max_output_bytes);
let fmt_result = match self.options.output_format {
OutputFormat::Markdown => render::write_markdown(&files, &mut counting_sink),
OutputFormat::Tree => {
render::write_tree(&files, &self.options.root_label, &mut counting_sink)
}
};
if let Err(e) = fmt_result {
if counting_sink.overflowed() {
return Err(WasmError::OutputTooLarge {
size: counting_sink.bytes_written(),
max: self
.options
.max_output_bytes
.expect("overflowed implies max_bytes is Some"),
});
}
return Err(WasmError::Fmt(e));
}
Ok(ProcessResult {
files_processed,
files_skipped,
files_truncated,
output_bytes: counting_sink.bytes_written(),
})
}
fn should_ignore(&self, file: &VirtualFile) -> bool {
if !self.options.include_dotfiles && file.is_dotfile() {
return true;
}
if let Some(ext) = file.extension() {
if self
.options
.ignore_extensions
.iter()
.any(|e| e.eq_ignore_ascii_case(ext))
{
return true;
}
}
if self.options.ignore_globs.is_match(file.normalized_path()) {
return true;
}
false
}
}
struct CountingWriter<'a, W> {
inner: &'a mut W,
bytes_written: usize,
max_bytes: Option<usize>,
overflowed: bool,
}
impl<'a, W> CountingWriter<'a, W> {
const fn new(inner: &'a mut W, max_bytes: Option<usize>) -> Self {
Self {
inner,
bytes_written: 0,
max_bytes,
overflowed: false,
}
}
const fn bytes_written(&self) -> usize {
self.bytes_written
}
const fn overflowed(&self) -> bool {
self.overflowed
}
}
impl<W: fmt::Write> fmt::Write for CountingWriter<'_, W> {
fn write_str(&mut self, s: &str) -> fmt::Result {
if self.overflowed {
return Err(fmt::Error);
}
let new_total = self.bytes_written.saturating_add(s.len());
if let Some(max) = self.max_bytes {
if new_total > max {
self.bytes_written = new_total;
self.overflowed = true;
return Err(fmt::Error);
}
}
self.inner.write_str(s)?;
self.bytes_written = new_total;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::fmt::Write;
use super::*;
fn file(path: &str, content: &str) -> VirtualFile {
VirtualFile::new_unchecked(path, content)
}
fn default_processor() -> WasmProcessor {
WasmProcessor::default()
}
mod prop {
use super::*;
use crate::wasm::test_strategies;
use proptest::prelude::*;
proptest! {
#[test]
fn process_never_panics(
files in test_strategies::virtual_files(50),
opts in test_strategies::processor_options(),
) {
let proc = WasmProcessor::new(opts);
let _ = proc.process_to_string(files);
}
#[test]
fn processed_plus_skipped_plus_truncated_equals_input(
files in test_strategies::virtual_files(50),
opts in test_strategies::processor_options(),
) {
let total = files.len();
let proc = WasmProcessor::new(opts);
let mut sink = String::new();
if let Ok(result) = proc.process(files, &mut sink) {
prop_assert_eq!(result.total_files(), total);
}
}
#[test]
fn output_is_deterministic(
files in test_strategies::virtual_files(30),
opts in test_strategies::processor_options(),
) {
let proc = WasmProcessor::new(opts);
let a = proc.process_to_string(files.clone());
let b = proc.process_to_string(files);
match (a, b) {
(Ok((out_a, res_a)), Ok((out_b, res_b))) => {
prop_assert_eq!(out_a, out_b);
prop_assert_eq!(res_a, res_b);
}
(Err(_), Err(_)) => {} (a, b) => {
prop_assert!(
false,
"divergent results: {a:?} vs {b:?}"
);
}
}
}
#[test]
fn max_files_respected(
files in test_strategies::virtual_files(50),
) {
let limit = 5;
let opts = ProcessorOptions::builder()
.max_files(limit)
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let mut sink = String::new();
if let Ok(result) = proc.process(files, &mut sink) {
prop_assert!(result.files_processed <= limit);
}
}
#[test]
fn process_result_is_copy(
files in test_strategies::virtual_files(10),
) {
let proc = WasmProcessor::default();
let mut sink = String::new();
if let Ok(result) = proc.process(files, &mut sink) {
let a = result;
let b = result;
prop_assert_eq!(a, b);
}
}
#[test]
fn truncation_counted_separately_from_filtering(
files in test_strategies::virtual_files(50),
) {
let limit = 3;
let opts = ProcessorOptions::builder()
.max_files(limit)
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let mut sink = String::new();
if let Ok(result) = proc.process(files.clone(), &mut sink) {
prop_assert_eq!(result.files_skipped, 0);
if files.len() > limit {
prop_assert_eq!(result.files_truncated, files.len() - limit);
}
prop_assert_eq!(result.total_files(), files.len());
}
}
}
}
#[test]
fn default_processor_uses_markdown() {
let proc = WasmProcessor::default();
assert_eq!(proc.options().output_format(), OutputFormat::Markdown);
}
#[test]
fn from_options_is_equivalent_to_new() {
let opts = ProcessorOptions::builder()
.output_format(OutputFormat::Tree)
.build()
.unwrap();
let via_new = WasmProcessor::new(opts.clone());
let via_from = WasmProcessor::from(opts);
assert_eq!(
via_new.options().output_format(),
via_from.options().output_format()
);
}
#[test]
fn markdown_single_file() {
let proc = default_processor();
let (result, meta) = proc
.process_to_string(vec![file("src/main.rs", "fn main() {}\n")])
.unwrap();
assert_eq!(result, "## `src/main.rs`\n\n```rs\nfn main() {}\n```\n");
assert_eq!(meta.files_processed, 1);
assert_eq!(meta.files_skipped, 0);
assert_eq!(meta.files_truncated, 0);
}
#[test]
fn markdown_multiple_files_sorted() {
let proc = default_processor();
let (result, _) = proc
.process_to_string(vec![file("b.py", "pass\n"), file("a.rs", "fn a() {}\n")])
.unwrap();
assert!(result.starts_with("## `a.rs`"));
assert!(result.contains("## `b.py`"));
}
#[test]
fn markdown_adds_trailing_newline() {
let proc = default_processor();
let (result, _) = proc
.process_to_string(vec![file("f.txt", "no newline")])
.unwrap();
assert!(result.contains("no newline\n```"));
}
#[test]
fn markdown_escapes_backtick_content() {
let proc = default_processor();
let content_with_fence = "before\n```\ninner\n```\nafter\n";
let (result, _) = proc
.process_to_string(vec![file("tricky.md", content_with_fence)])
.unwrap();
assert!(
result.contains("````"),
"fence should be at least 4 backticks when content contains ```"
);
assert!(result.contains(content_with_fence));
}
#[test]
fn markdown_escapes_long_backtick_runs() {
let proc = default_processor();
let content = "some ```````` long run\n";
let (result, _) = proc
.process_to_string(vec![file("f.txt", content)])
.unwrap();
assert!(
result.contains("`````````"),
"fence should be at least 9 backticks: {result}"
);
}
#[test]
fn tree_output() {
let opts = ProcessorOptions::builder()
.output_format(OutputFormat::Tree)
.root_label("project")
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (result, _) = proc
.process_to_string(vec![
file("src/main.rs", ""),
file("src/lib.rs", ""),
file("Cargo.toml", ""),
])
.unwrap();
assert!(result.starts_with("project\n"));
assert!(result.contains("Cargo.toml"));
assert!(result.contains("src/"));
assert!(result.contains("main.rs"));
assert!(result.contains("lib.rs"));
}
#[test]
fn filters_dotfiles_by_default() {
let proc = default_processor();
let (result, meta) = proc
.process_to_string(vec![
file(".gitignore", ""),
file("src/.hidden", ""),
file("visible.rs", "fn v() {}\n"),
])
.unwrap();
assert!(!result.contains(".gitignore"));
assert!(!result.contains(".hidden"));
assert!(result.contains("visible.rs"));
assert_eq!(meta.files_processed, 1);
assert_eq!(meta.files_skipped, 2);
assert_eq!(meta.files_truncated, 0);
}
#[test]
fn output_too_large_error() {
let opts = ProcessorOptions::builder()
.max_output_bytes(10)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let result = proc.process_to_string(vec![file(
"big.txt",
"this content is definitely longer than 10 bytes",
)]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, WasmError::OutputTooLarge { .. }),
"expected OutputTooLarge, got: {err}"
);
}
#[test]
fn process_result_counts() {
let opts = ProcessorOptions::builder()
.ignore_extensions(["lock"])
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let mut output = String::new();
let result = proc
.process(
vec![
file("keep.rs", "fn keep() {}\n"),
file("skip.lock", ""),
file("also_keep.py", "pass\n"),
],
&mut output,
)
.unwrap();
assert_eq!(result.files_processed, 2);
assert_eq!(result.files_skipped, 1);
assert_eq!(result.files_truncated, 0);
assert_eq!(result.total_files(), 3);
assert!(result.output_bytes > 0);
}
#[test]
fn process_result_distinguishes_skipped_from_truncated() {
let opts = ProcessorOptions::builder()
.ignore_extensions(["lock"])
.max_files(1)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let mut output = String::new();
let result = proc
.process(
vec![
file("a.rs", "fn a() {}\n"),
file("b.rs", "fn b() {}\n"),
file("c.lock", ""),
],
&mut output,
)
.unwrap();
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_skipped, 1); assert_eq!(result.files_truncated, 1); assert_eq!(result.total_files(), 3);
}
#[test]
fn processor_exposes_options() {
let opts = ProcessorOptions::builder()
.output_format(OutputFormat::Tree)
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
assert_eq!(proc.options().output_format(), OutputFormat::Tree);
assert!(proc.options().include_dotfiles());
}
#[test]
fn glob_matches_normalized_paths() {
let opts = ProcessorOptions::builder()
.ignore_globs(["src/**"])
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (result, meta) = proc
.process_to_string(vec![
file("./src/main.rs", "fn main() {}"),
file("README.md", "# Hello"),
])
.unwrap();
assert!(!result.contains("main.rs"));
assert!(result.contains("README.md"));
assert_eq!(meta.files_processed, 1);
assert_eq!(meta.files_skipped, 1);
assert_eq!(meta.files_truncated, 0);
}
#[test]
fn process_result_serde_round_trip() {
let result = ProcessResult {
files_processed: 42,
files_skipped: 7,
files_truncated: 3,
output_bytes: 12345,
};
let json = serde_json::to_string(&result).unwrap();
let recovered: ProcessResult = serde_json::from_str(&json).unwrap();
assert_eq!(result, recovered);
}
#[test]
fn empty_input_produces_empty_output() {
let proc = default_processor();
let (output, meta) = proc.process_to_string(Vec::new()).unwrap();
assert!(output.is_empty());
assert_eq!(meta.files_processed, 0);
assert_eq!(meta.files_skipped, 0);
assert_eq!(meta.files_truncated, 0);
assert_eq!(meta.output_bytes, 0);
}
#[test]
fn counting_writer_short_circuits_after_overflow() {
let mut buf = String::new();
let mut writer = CountingWriter::new(&mut buf, Some(5));
assert!(writer.write_str("abcdef").is_err());
assert!(writer.overflowed());
let size_at_overflow = writer.bytes_written();
assert!(writer.write_str("more").is_err());
assert_eq!(
writer.bytes_written(),
size_at_overflow,
"bytes_written must not change after overflow"
);
assert!(
buf.is_empty(),
"inner writer must not receive data after overflow"
);
}
#[test]
fn counting_writer_no_limit() {
let mut buf = String::new();
let mut writer = CountingWriter::new(&mut buf, None);
writer.write_str("hello").unwrap();
writer.write_str(" world").unwrap();
assert_eq!(writer.bytes_written(), 11);
assert!(!writer.overflowed());
assert_eq!(buf, "hello world");
}
#[test]
fn counting_writer_exact_limit() {
let mut buf = String::new();
let mut writer = CountingWriter::new(&mut buf, Some(5));
writer.write_str("hello").unwrap();
assert_eq!(writer.bytes_written(), 5);
assert!(!writer.overflowed());
assert_eq!(buf, "hello");
}
}