#![cfg(feature = "wasm")]
use luff::wasm::{
OutputFormat, ProcessResult, ProcessorOptions, VirtualFile, WasmError, WasmProcessor,
};
fn file(path: &str, content: &str) -> VirtualFile {
VirtualFile::new(path, content).expect("test path should be valid")
}
fn default_processor() -> WasmProcessor {
WasmProcessor::default()
}
fn sample_files() -> Vec<VirtualFile> {
vec![
file("src/main.rs", "fn main() {}\n"),
file("src/lib.rs", "pub mod utils;\n"),
file("README.md", "# My Project\n"),
file("Cargo.toml", "[package]\nname = \"demo\"\n"),
]
}
#[test]
fn process_to_string_returns_output_and_metadata() {
let proc = default_processor();
let (output, result) = proc.process_to_string(sample_files()).unwrap();
assert!(!output.is_empty());
assert_eq!(result.files_processed, 4);
assert_eq!(result.files_skipped, 0);
assert_eq!(result.total_files(), 4);
assert!(result.output_bytes > 0);
assert_eq!(result.output_bytes, output.len());
}
#[test]
fn process_writes_to_external_sink() {
let proc = default_processor();
let mut sink = String::new();
let result = proc.process(sample_files(), &mut sink).unwrap();
assert_eq!(result.files_processed, 4);
assert_eq!(sink.len(), result.output_bytes);
}
#[test]
fn process_and_process_to_string_produce_identical_output() {
let proc = default_processor();
let files = sample_files();
let (string_output, string_result) = proc.process_to_string(files.clone()).unwrap();
let mut sink_output = String::new();
let sink_result = proc.process(files, &mut sink_output).unwrap();
assert_eq!(string_output, sink_output);
assert_eq!(string_result, sink_result);
}
#[test]
fn empty_input_produces_empty_output() {
let proc = default_processor();
let (output, result) = proc.process_to_string(Vec::<VirtualFile>::new()).unwrap();
assert!(output.is_empty());
assert_eq!(result.files_processed, 0);
assert_eq!(result.files_skipped, 0);
assert_eq!(result.output_bytes, 0);
}
#[test]
fn output_is_deterministic_across_input_orderings() {
let proc = default_processor();
let forward = sample_files();
let mut reversed = sample_files();
reversed.reverse();
let (out_a, _) = proc.process_to_string(forward).unwrap();
let (out_b, _) = proc.process_to_string(reversed).unwrap();
assert_eq!(
out_a, out_b,
"output should be identical regardless of input order"
);
}
#[test]
fn markdown_output_contains_fenced_code_blocks() {
let proc = default_processor();
let (output, _) = proc
.process_to_string(vec![file("main.rs", "fn main() {}\n")])
.unwrap();
assert!(output.contains("```rs"));
assert!(output.contains("```\n"));
assert!(output.contains("## `main.rs`"));
}
#[test]
fn markdown_uses_extension_as_language_hint() {
let proc = default_processor();
let files = vec![
file("app.py", "print('hello')\n"),
file("style.css", "body {}\n"),
file("Makefile", "all:\n"),
];
let (output, _) = proc.process_to_string(files).unwrap();
assert!(output.contains("```py"));
assert!(output.contains("```css"));
assert!(output.contains("```\nall:"));
}
#[test]
fn markdown_handles_content_with_backticks() {
let proc = default_processor();
let content = "```rust\nfn example() {}\n```\n";
let (output, _) = proc
.process_to_string(vec![file("nested.md", content)])
.unwrap();
assert!(
output.contains("````"),
"outer fence must escape inner triple backticks"
);
assert!(output.contains(content));
}
#[test]
fn tree_output_shows_directory_hierarchy() {
let opts = ProcessorOptions::builder()
.output_format(OutputFormat::Tree)
.root_label("project")
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (output, result) = proc.process_to_string(sample_files()).unwrap();
assert!(output.starts_with("project\n"));
assert!(output.contains("src/"));
assert!(output.contains("main.rs"));
assert!(output.contains("lib.rs"));
assert!(output.contains("README.md"));
assert!(output.contains("Cargo.toml"));
assert_eq!(result.files_processed, 4);
}
#[test]
fn tree_uses_box_drawing_characters() {
let opts = ProcessorOptions::builder()
.output_format(OutputFormat::Tree)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (output, _) = proc
.process_to_string(vec![file("a.txt", ""), file("b.txt", "")])
.unwrap();
assert!(output.contains("├── ") || output.contains("└── "));
}
#[test]
fn dotfiles_excluded_by_default() {
let proc = default_processor();
let files = vec![
file(".gitignore", "target/\n"),
file("src/.env", "SECRET=x\n"),
file("visible.rs", "fn v() {}\n"),
];
let (output, result) = proc.process_to_string(files).unwrap();
assert!(!output.contains(".gitignore"));
assert!(!output.contains(".env"));
assert!(output.contains("visible.rs"));
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_skipped, 2);
}
#[test]
fn dotfiles_included_when_configured() {
let opts = ProcessorOptions::builder()
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let files = vec![
file(".gitignore", "target/\n"),
file("visible.rs", "fn v() {}\n"),
];
let (output, result) = proc.process_to_string(files).unwrap();
assert!(output.contains(".gitignore"));
assert!(output.contains("visible.rs"));
assert_eq!(result.files_processed, 2);
assert_eq!(result.files_skipped, 0);
}
#[test]
fn extension_filtering_is_case_insensitive() {
let opts = ProcessorOptions::builder()
.ignore_extensions(["lock", "log"])
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let files = vec![
file("Cargo.lock", ""),
file("debug.LOG", ""),
file("app.rs", "fn app() {}\n"),
];
let (output, result) = proc.process_to_string(files).unwrap();
assert!(!output.contains("Cargo.lock"));
assert!(!output.contains("debug.LOG"));
assert!(output.contains("app.rs"));
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_skipped, 2);
}
#[test]
fn glob_filtering_excludes_matched_paths() {
let opts = ProcessorOptions::builder()
.ignore_globs(["**/target/**", "dist/**"])
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let files = vec![
file("src/main.rs", "fn main() {}\n"),
file("target/debug/app", "binary"),
file("dist/bundle.js", "compiled"),
];
let (output, result) = proc.process_to_string(files).unwrap();
assert!(output.contains("main.rs"));
assert!(!output.contains("target"));
assert!(!output.contains("dist"));
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_skipped, 2);
}
#[test]
fn glob_matches_normalized_paths() {
let opts = ProcessorOptions::builder()
.ignore_globs(["src/**"])
.include_dotfiles(true)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (output, result) = proc
.process_to_string(vec![
file("./src/main.rs", "fn main() {}\n"),
file("README.md", "# Hello\n"),
])
.unwrap();
assert!(!output.contains("main.rs"));
assert!(output.contains("README.md"));
assert_eq!(result.files_processed, 1);
assert_eq!(result.files_skipped, 1);
}
#[test]
fn max_files_truncates_output() {
let opts = ProcessorOptions::builder().max_files(2).build().unwrap();
let proc = WasmProcessor::new(opts);
let (_, result) = proc.process_to_string(sample_files()).unwrap();
assert_eq!(result.files_processed, 2);
assert_eq!(result.files_skipped, 0);
assert_eq!(result.files_truncated, 2);
assert_eq!(result.total_files(), 4);
}
#[test]
fn max_files_larger_than_input_is_noop() {
let opts = ProcessorOptions::builder().max_files(100).build().unwrap();
let proc = WasmProcessor::new(opts);
let (_, result) = proc.process_to_string(sample_files()).unwrap();
assert_eq!(result.files_processed, 4);
assert_eq!(result.files_skipped, 0);
}
#[test]
fn max_output_bytes_triggers_error() {
let opts = ProcessorOptions::builder()
.max_output_bytes(10)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let err = proc
.process_to_string(vec![file(
"big.txt",
"this is definitely more than 10 bytes",
)])
.unwrap_err();
match err {
WasmError::OutputTooLarge { size, max } => {
assert!(size > max);
assert_eq!(max, 10);
}
other => panic!("expected OutputTooLarge, got: {other}"),
}
}
#[test]
fn generous_max_output_bytes_allows_processing() {
let opts = ProcessorOptions::builder()
.max_output_bytes(1_000_000)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let (_, result) = proc.process_to_string(sample_files()).unwrap();
assert_eq!(result.files_processed, 4);
}
#[test]
fn processed_plus_skipped_plus_truncated_equals_total_input() {
let opts = ProcessorOptions::builder()
.ignore_extensions(["lock"])
.max_files(2)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let files = vec![
file("a.rs", ""),
file("b.lock", ""),
file("c.rs", ""),
file("d.rs", ""),
];
let total = files.len();
let (_, result) = proc.process_to_string(files).unwrap();
assert_eq!(
result.total_files(),
total,
"processed ({}) + skipped ({}) + truncated ({}) must equal total ({})",
result.files_processed,
result.files_skipped,
result.files_truncated,
total,
);
assert_eq!(result.files_skipped, 1);
assert_eq!(result.files_truncated, 1);
assert_eq!(result.files_processed, 2);
}
#[test]
fn virtual_file_rejects_empty_path() {
assert!(VirtualFile::new("", "content").is_err());
}
#[test]
fn virtual_file_rejects_null_byte() {
assert!(VirtualFile::new("src/\0bad.rs", "").is_err());
}
#[test]
fn virtual_file_rejects_path_traversal() {
assert!(VirtualFile::new("../etc/passwd", "").is_err());
assert!(VirtualFile::new("src/../../etc/shadow", "").is_err());
}
#[test]
fn virtual_file_allows_double_dot_in_filename() {
assert!(VirtualFile::new("src/..config", "").is_ok());
}
#[test]
fn virtual_file_serde_round_trip() {
let original = VirtualFile::new("src/lib.rs", "pub fn hello() {}\n").unwrap();
let json = serde_json::to_string(&original).unwrap();
let recovered: VirtualFile = serde_json::from_str(&json).unwrap();
assert_eq!(original, recovered);
}
#[test]
fn virtual_file_deserialize_rejects_traversal() {
let json = r#"{"path": "../etc/passwd", "content": "root:x:0:0"}"#;
let result: Result<VirtualFile, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn builder_default_values() {
let opts = ProcessorOptions::builder().build().unwrap();
assert_eq!(opts.output_format(), OutputFormat::Markdown);
assert!(!opts.include_dotfiles());
assert!(opts.max_files().is_none());
assert!(opts.max_output_bytes().is_none());
assert!(opts.ignore_extensions().is_empty());
assert!(opts.ignore_glob_strings().is_empty());
assert_eq!(opts.root_label(), ".");
}
#[test]
fn builder_rejects_invalid_glob() {
let result = ProcessorOptions::builder()
.ignore_globs(["[unterminated"])
.build();
assert!(result.is_err());
}
#[test]
fn builder_glob_strings_round_trip() {
let patterns = ["**/target/**", "dist/**"];
let opts = ProcessorOptions::builder()
.ignore_globs(patterns)
.build()
.unwrap();
assert_eq!(opts.ignore_glob_strings(), &["**/target/**", "dist/**"]);
}
#[test]
fn process_result_default_is_zero() {
let r = ProcessResult::default();
assert_eq!(r.files_processed, 0);
assert_eq!(r.files_skipped, 0);
assert_eq!(r.output_bytes, 0);
assert_eq!(r.total_files(), 0);
}
#[test]
fn process_result_is_copy() {
let proc = default_processor();
let (_, result) = proc.process_to_string(sample_files()).unwrap();
let a = result;
let b = result; assert_eq!(a, b);
}
#[test]
fn all_filters_compose() {
let opts = ProcessorOptions::builder()
.ignore_extensions(["lock"])
.ignore_globs(["dist/**"])
.include_dotfiles(false)
.max_files(1)
.build()
.unwrap();
let proc = WasmProcessor::new(opts);
let files = vec![
file(".hidden", "secret"),
file("Cargo.lock", "lockfile"),
file("dist/bundle.js", "compiled"),
file("src/a.rs", "first"),
file("src/b.rs", "second"),
];
let total = files.len();
let (output, result) = proc.process_to_string(files).unwrap();
assert_eq!(result.files_skipped, 3);
assert_eq!(result.files_truncated, 1);
assert_eq!(result.files_processed, 1);
assert_eq!(result.total_files(), total);
assert!(output.contains("src/a.rs") || output.contains("src/b.rs"));
}
#[test]
fn output_format_display() {
assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
assert_eq!(OutputFormat::Tree.to_string(), "tree");
}
#[test]
fn output_format_serde_round_trip() {
for fmt in [OutputFormat::Markdown, OutputFormat::Tree] {
let json = serde_json::to_string(&fmt).unwrap();
let recovered: OutputFormat = serde_json::from_str(&json).unwrap();
assert_eq!(fmt, recovered);
}
}
mod prop {
use super::*;
use proptest::prelude::*;
fn path_component() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-zA-Z_][a-zA-Z0-9_.\\-]{0,30}").unwrap()
}
fn valid_path() -> impl Strategy<Value = String> {
proptest::collection::vec(path_component(), 1..=4).prop_map(|parts| parts.join("/"))
}
fn dotfile_path() -> impl Strategy<Value = String> {
(
proptest::collection::vec(path_component(), 0..=2),
proptest::string::string_regex("\\.[a-zA-Z][a-zA-Z0-9_]{0,10}").unwrap(),
)
.prop_map(|(prefix, dot)| {
let mut parts = prefix;
parts.push(dot);
parts.join("/")
})
}
fn file_content() -> impl Strategy<Value = String> {
proptest::string::string_regex("[\\s\\S]{0,200}").unwrap()
}
fn virtual_file() -> impl Strategy<Value = VirtualFile> {
prop_oneof![
(valid_path(), file_content()),
(dotfile_path(), file_content()),
]
.prop_map(|(path, content)| VirtualFile::new(&path, &content).unwrap())
}
fn virtual_files(max: usize) -> impl Strategy<Value = Vec<VirtualFile>> {
proptest::collection::vec(virtual_file(), 0..=max)
}
fn extension() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-z]{1,6}").unwrap()
}
fn glob_pattern() -> impl Strategy<Value = String> {
prop_oneof![
Just("**/target/**".to_string()),
Just("*.lock".to_string()),
Just("dist/**".to_string()),
extension().prop_map(|e| format!("*.{e}")),
]
}
fn processor_options() -> impl Strategy<Value = ProcessorOptions> {
(
proptest::bool::ANY,
proptest::option::of(1..100usize),
proptest::option::of(64..50_000usize),
proptest::collection::vec(extension(), 0..=3),
proptest::collection::vec(glob_pattern(), 0..=2),
prop_oneof![Just(OutputFormat::Markdown), Just(OutputFormat::Tree)],
)
.prop_map(|(dotfiles, max_files, max_bytes, exts, globs, fmt)| {
let mut b = ProcessorOptions::builder()
.output_format(fmt)
.ignore_extensions(exts)
.ignore_globs(globs)
.include_dotfiles(dotfiles);
if let Some(m) = max_files {
b = b.max_files(m);
}
if let Some(m) = max_bytes {
b = b.max_output_bytes(m);
}
b.build().expect("generated globs are always valid")
})
}
proptest! {
#[test]
fn conservation_invariant(
files in virtual_files(30),
opts in 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,
"processed={} skipped={} truncated={} total={}",
result.files_processed,
result.files_skipped,
result.files_truncated,
total,
);
}
}
#[test]
fn deterministic_output(
files in virtual_files(20),
opts in processor_options(),
) {
let proc = WasmProcessor::new(opts);
let mut shuffled = files.clone();
shuffled.reverse();
match (proc.process_to_string(files), proc.process_to_string(shuffled)) {
(Ok((a, ra)), Ok((b, rb))) => {
prop_assert_eq!(a, b);
prop_assert_eq!(ra, rb);
}
(Err(_), Err(_)) => {}
(a, b) => prop_assert!(false, "divergent: {a:?} vs {b:?}"),
}
}
#[test]
fn process_matches_process_to_string(
files in virtual_files(20),
opts in processor_options(),
) {
let proc = WasmProcessor::new(opts);
let str_result = proc.process_to_string(files.clone());
let mut sink = String::new();
let sink_result = proc.process(files, &mut sink);
match (str_result, sink_result) {
(Ok((s, sr)), Ok(wr)) => {
prop_assert_eq!(s, sink);
prop_assert_eq!(sr, wr);
}
(Err(_), Err(_)) => {}
(a, b) => prop_assert!(false, "divergent: {a:?} vs {b:?}"),
}
}
#[test]
fn max_files_always_respected(
files in virtual_files(30),
limit in 1..20usize,
) {
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 no_filters_means_no_skips(
files in proptest::collection::vec(
(valid_path(), file_content()).prop_map(|(p, c)| VirtualFile::new(&p, &c).unwrap()),
0..=20,
),
) {
let opts = ProcessorOptions::builder()
.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_eq!(result.files_skipped, 0);
prop_assert_eq!(result.files_truncated, 0);
}
}
#[test]
fn output_bytes_matches_string_len(
files in virtual_files(15),
opts in processor_options(),
) {
let proc = WasmProcessor::new(opts);
if let Ok((output, result)) = proc.process_to_string(files) {
prop_assert_eq!(result.output_bytes, output.len());
}
}
#[test]
fn valid_paths_always_accepted(
path in valid_path(),
content in file_content(),
) {
prop_assert!(VirtualFile::new(&path, &content).is_ok());
}
#[test]
fn virtual_file_serde_identity(
path in valid_path(),
content in file_content(),
) {
let original = VirtualFile::new(&path, &content).unwrap();
let json = serde_json::to_string(&original).unwrap();
let recovered: VirtualFile = serde_json::from_str(&json).unwrap();
prop_assert_eq!(original, recovered);
}
}
}