diff --git a/src/core/filter.rs b/src/core/filter.rs index 1fc5a23f..2c223678 100644 --- a/src/core/filter.rs +++ b/src/core/filter.rs @@ -389,6 +389,27 @@ pub fn smart_truncate(content: &str, max_lines: usize, _lang: &Language) -> Stri mod tests { use super::*; + #[test] + fn test_filter() { + use crate::datatest; + + datatest::run_tests("filter/*", |case| { + let level: FilterLevel = case + .params + .as_deref() + .expect("filter level required as params (no .after.. in filename)") + .parse() + .expect("unknown filter level in filename"); + let lang = Language::from_extension( + case.input_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""), + ); + get_filter(level).filter(&case.input, &lang) + }); + } + #[test] fn test_filter_level_parsing() { assert_eq!(FilterLevel::from_str("none").unwrap(), FilterLevel::None); @@ -480,20 +501,6 @@ mod tests { ); } - #[test] - fn test_minimal_filter_removes_comments() { - let code = r#" -// This is a comment -fn main() { - println!("Hello"); -} -"#; - let filter = MinimalFilter; - let result = filter.filter(code, &Language::Rust); - assert!(!result.contains("// This is a comment")); - assert!(result.contains("fn main()")); - } - // --- truncation accuracy --- #[test] diff --git a/src/datatest.rs b/src/datatest.rs new file mode 100644 index 00000000..3b3e8d2b --- /dev/null +++ b/src/datatest.rs @@ -0,0 +1,231 @@ +use std::collections::BTreeMap; +use std::fs; +use std::panic; +use std::path::{Path, PathBuf}; + +trait PathExt { + fn file_name_str(&self) -> &str; + fn file_stem_str(&self) -> &str; + fn extension_str(&self) -> &str; +} + +impl PathExt for Path { + fn file_name_str(&self) -> &str { + self.file_name() + .expect("path has no file name") + .to_str() + .expect("non-UTF-8 path") + } + fn file_stem_str(&self) -> &str { + self.file_stem() + .expect("path has no stem") + .to_str() + .expect("non-UTF-8 path") + } + fn extension_str(&self) -> &str { + self.extension() + .expect("path has no extension") + .to_str() + .expect("non-UTF-8 path") + } +} + +pub(crate) struct Case { + pub(crate) input: String, + pub(crate) expected: String, + /// The segment between `.after.` and the final extension. + /// `None` for files named `.after.` with no params segment. + pub(crate) params: Option, + pub(crate) input_path: PathBuf, + pub(crate) expected_path: PathBuf, +} + +/// Runs test cases found under `tests/data/`. +/// +/// **Path syntax**: +/// - `"subdir/*"` — run all groups in `tests/data/subdir/` +/// - `"subdir/prefix"` — run only groups whose name starts with `prefix` +/// in `tests/data/subdir/` (group = prefix + extension, e.g. `language.rs`) +/// +/// Each input file `.` is paired with one or more expected files: +/// `.after.` — no params +/// `.after..` — with params +/// +/// The closure returns the actual output; the engine compares it against the +/// expected file (both sides trimmed). +/// +/// **Update mode**: set `RTK_UPDATE_TEST_DATA=1` to overwrite expected files +/// with the actual output instead of failing. Re-run without the variable to +/// confirm everything is green. +/// +/// All failures are collected and reported together at the end. +pub(crate) fn run_tests(path: &str, test_fn: F) +where + F: Fn(&Case) -> String + panic::RefUnwindSafe, +{ + let (dir_str, prefix_filter) = match path.strip_suffix("/*") { + Some(dir) => (dir, None), + None => match path.rfind('/') { + Some(i) => (&path[..i], Some(&path[i + 1..])), + None => (path, None), + }, + }; + + let dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data") + .join(dir_str); + + let cases = discover(&dir, prefix_filter); + assert!( + !cases.is_empty(), + "no test cases found in {}{}", + dir.display(), + prefix_filter + .map(|p| format!(" (prefix: {p})")) + .unwrap_or_default() + ); + + let update = std::env::var("RTK_UPDATE_TEST_DATA").is_ok_and(|v| !v.is_empty()); + + let mut failures = Vec::new(); + let mut updated = Vec::new(); + + for case in &cases { + let file_name = case.input_path.file_name_str(); + let label = match &case.params { + Some(p) => format!("{file_name} [{p}]"), + None => file_name.to_string(), + }; + + let prev_hook = panic::take_hook(); + panic::set_hook(Box::new(|_| {})); + let result = panic::catch_unwind(|| test_fn(case)); + panic::set_hook(prev_hook); + + match result { + Err(e) => { + let msg = e + .downcast_ref::() + .map(String::as_str) + .or_else(|| e.downcast_ref::<&str>().copied()) + .unwrap_or("unknown panic"); + failures.push(format!("FAILED {label}:\n{msg}")); + } + Ok(actual) => { + if actual.trim() != case.expected.trim() { + if update { + let mut content = actual.trim().to_string(); + content.push('\n'); + fs::write(&case.expected_path, &content).unwrap_or_else(|e| { + panic!("write {}: {}", case.expected_path.display(), e) + }); + updated.push(label); + } else { + failures.push(format!( + "FAILED {label}:\n--- expected\n{}\n+++ actual\n{}", + case.expected.trim(), + actual.trim() + )); + } + } + } + } + } + + if !updated.is_empty() { + panic!( + "{} expected file(s) updated — re-run without RTK_UPDATE_TEST_DATA to verify:\n {}", + updated.len(), + updated.join("\n ") + ); + } + + assert!( + failures.is_empty(), + "{} case(s) failed:\n\n{}\n\n(set RTK_UPDATE_TEST_DATA=1 to overwrite)", + failures.len(), + failures.join("\n\n") + ); +} + +fn discover(dir: &Path, prefix_filter: Option<&str>) -> Vec { + // Group files by the name prefix before the first dot. + // With a prefix filter, only the matching group is processed; + // within that group each distinct extension forms its own input+expected set. + let mut groups: BTreeMap> = BTreeMap::new(); + for entry in fs::read_dir(dir).unwrap_or_else(|e| panic!("read dir {}: {}", dir.display(), e)) { + let path = entry + .unwrap_or_else(|e| panic!("read dir entry in {}: {}", dir.display(), e)) + .path(); + if !path.is_file() || path.extension().is_none() { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let prefix = name.split('.').next().unwrap_or(name).to_string(); + if prefix_filter.is_none_or(|f| prefix == f) { + groups.entry(prefix).or_default().push(path); + } + } + } + + let mut cases = Vec::new(); + + for (prefix, mut paths) in groups { + paths.sort(); + + let inputs: Vec<&PathBuf> = paths + .iter() + .filter(|p| { + !p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.contains(".after.")) + .unwrap_or(false) + }) + .collect(); + + if inputs.is_empty() { + panic!( + "no input file for group '{}' in {} (found only .after.* files)", + prefix, + dir.display() + ); + } + + for input_path in inputs { + let ext = input_path.extension_str().to_string(); + let stem = input_path.file_stem_str().to_string(); + let input = fs::read_to_string(input_path) + .unwrap_or_else(|e| panic!("read {}: {}", input_path.display(), e)); + + let after_prefix = format!("{}.after.", stem); + let ext_suffix = format!(".{}", ext); + + for expected_path in paths.iter().filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with(&after_prefix) && n.ends_with(&ext_suffix)) + .unwrap_or(false) + }) { + let expected_name = expected_path.file_name_str(); + let middle = expected_name + .strip_prefix(&after_prefix) + .and_then(|s| s.strip_suffix(&ext_suffix)) + .unwrap_or(""); + let params = (!middle.is_empty()).then(|| middle.to_string()); + + let expected = fs::read_to_string(expected_path) + .unwrap_or_else(|e| panic!("read {}: {}", expected_path.display(), e)); + + cases.push(Case { + input: input.clone(), + expected, + params, + input_path: input_path.clone(), + expected_path: expected_path.clone(), + }); + } + } + } + + cases +} diff --git a/src/main.rs b/src/main.rs index e43f9267..438fe520 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod analytics; mod cmds; mod core; +#[cfg(test)] +mod datatest; mod discover; mod hooks; mod learn; diff --git a/tests/data/filter/python.after.aggressive.py b/tests/data/filter/python.after.aggressive.py new file mode 100644 index 00000000..ac79c43c --- /dev/null +++ b/tests/data/filter/python.after.aggressive.py @@ -0,0 +1,22 @@ +import os +import unittest +from pathlib import Path +class Greeting: + def __init__(self, name): + // ... implementation +class _Internal: + def __init__(self, value): + // ... implementation +def greet(name): + // ... implementation +def _helper(name): + // ... implementation +def __helper(name): + // ... implementation +def farewell(name): + // ... implementation +def test_greet(): + // ... implementation +class TestGreeting(unittest.TestCase): + def test_greet(self): + // ... implementation diff --git a/tests/data/filter/python.after.minimal.py b/tests/data/filter/python.after.minimal.py new file mode 100644 index 00000000..7ce542e1 --- /dev/null +++ b/tests/data/filter/python.after.minimal.py @@ -0,0 +1,35 @@ +import os +import unittest +from pathlib import Path + +MAX = 100 + +class Greeting: + def __init__(self, name): + self.name = name + +class _Internal: + def __init__(self, value): + self.value = value + +def greet(name): + """ + Return a greeting. + """ + return "Hello, " + name + +def _helper(name): + return name.upper() + +def __helper(name): + return name.lower() + +def farewell(name): + return "Goodbye, " + name + +def test_greet(): + assert greet("World") == "Hello, World" + +class TestGreeting(unittest.TestCase): + def test_greet(self): + self.assertEqual(greet("World"), "Hello, World") diff --git a/tests/data/filter/python.py b/tests/data/filter/python.py new file mode 100644 index 00000000..5eac0033 --- /dev/null +++ b/tests/data/filter/python.py @@ -0,0 +1,37 @@ +# Module comment +import os +import unittest +from pathlib import Path + +MAX = 100 + +class Greeting: + def __init__(self, name): + self.name = name + +class _Internal: + def __init__(self, value): + self.value = value + +def greet(name): + """ + Return a greeting. + """ + # Inline comment. + return "Hello, " + name + +def _helper(name): + return name.upper() + +def __helper(name): + return name.lower() + +def farewell(name): + return "Goodbye, " + name + +def test_greet(): + assert greet("World") == "Hello, World" + +class TestGreeting(unittest.TestCase): + def test_greet(self): + self.assertEqual(greet("World"), "Hello, World") diff --git a/tests/data/filter/rust.after.aggressive.rs b/tests/data/filter/rust.after.aggressive.rs new file mode 100644 index 00000000..5c70bf17 --- /dev/null +++ b/tests/data/filter/rust.after.aggressive.rs @@ -0,0 +1,15 @@ +use std::fmt; +pub const MAX: usize = 100; +pub struct Greeting { + // ... implementation +pub fn greet(name: &str) -> String { + // ... implementation +pub fn farewell(name: &str) -> String { + // ... implementation +fn private_fn(name: &str) -> String { + // ... implementation +fn test_farewell() { + // ... implementation + use super::*; + fn test_greet() { + // ... implementation diff --git a/tests/data/filter/rust.after.minimal.rs b/tests/data/filter/rust.after.minimal.rs new file mode 100644 index 00000000..47124c18 --- /dev/null +++ b/tests/data/filter/rust.after.minimal.rs @@ -0,0 +1,47 @@ +use std::fmt; + +/** + * Doc block kept by minimal. + */ + +pub const MAX: usize = 100; + +pub struct Greeting { + name: String, +} + +pub(crate) struct Internal { + value: u32, +} + +/// Doc comment kept by minimal. +pub fn greet(name: &str) -> String { + format!("Hello, {}", name) +} + +pub(crate) fn helper(name: &str) -> String { + format!("Help, {}", name) +} + +pub fn farewell(name: &str) -> String { + format!("Goodbye, {}", name) +} + +fn private_fn(name: &str) -> String { + format!("Private, {}", name) +} + +#[test] +fn test_farewell() { + assert_eq!(farewell("World"), "Goodbye, World"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_greet() { + assert_eq!(greet("World"), "Hello, World"); + } +} diff --git a/tests/data/filter/rust.rs b/tests/data/filter/rust.rs new file mode 100644 index 00000000..15dda63d --- /dev/null +++ b/tests/data/filter/rust.rs @@ -0,0 +1,56 @@ +// Top-level comment +use std::fmt; + +/* Block comment */ + +/* + * Multi-line + * block comment + */ + +/** + * Doc block kept by minimal. + */ + +pub const MAX: usize = 100; + +pub struct Greeting { + name: String, +} + +pub(crate) struct Internal { + value: u32, +} + +/// Doc comment kept by minimal. +pub fn greet(name: &str) -> String { + // Inline comment removed. + format!("Hello, {}", name) +} + +pub(crate) fn helper(name: &str) -> String { + format!("Help, {}", name) +} + +pub fn farewell(name: &str) -> String { + format!("Goodbye, {}", name) +} + +fn private_fn(name: &str) -> String { + format!("Private, {}", name) +} + +#[test] +fn test_farewell() { + assert_eq!(farewell("World"), "Goodbye, World"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_greet() { + assert_eq!(greet("World"), "Hello, World"); + } +}