diff --git a/Cargo.lock b/Cargo.lock index 5ce33e2..e9b1fdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,7 +34,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ambits" -version = "0.15.0" +version = "0.16.0" dependencies = [ "blake3", "clap", diff --git a/src/app.rs b/src/app.rs index 9554f86..aed6aff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,12 @@ use std::fs::File; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use crate::coverage::count_symbols; +use crate::filter::PathFilter; use crate::symbols::{ProjectTree, SymbolNode}; use crate::tracking::ReadDepth; use crate::tracking::ContextLedger; @@ -103,6 +105,13 @@ pub struct App { // Optional event log writer. pub event_log: Option>, + + /// Path filter restricting which files are tracked, if any. Shared with + /// the TUI re-parse paths (file watcher, Serena cache rescan) so that + /// changes to excluded files don't inject symbols back into the tree + /// after the initial filtered scan. `None` means no filter — track + /// everything. + pub filter: Option>, } impl App { @@ -139,6 +148,7 @@ impl App { show_compaction_overlay: false, compaction_overlay_index: 0, event_log, + filter: None, }; app.rebuild_tree_rows(); app diff --git a/src/coverage.rs b/src/coverage.rs index 2d20be9..c435024 100644 --- a/src/coverage.rs +++ b/src/coverage.rs @@ -79,6 +79,10 @@ pub struct CoverageReport { pub session_id: Option, /// Agent ID if filtering by agent. pub agent_id: Option, + /// Active path filter (display form, e.g. `"src/parser"` or `"re:^src/.*\\.rs$"`), + /// or `None` when no filter was applied. Populated by `run_report` so the + /// formatter can surface it to readers. + pub filter: Option, /// Per-file coverage metrics. pub files: Vec, /// Compactions detected in the session, in occurrence order. Empty when none. @@ -118,6 +122,7 @@ impl CoverageReport { Self { session_id: None, agent_id: agent_filter.map(|s| s.to_string()), + filter: None, files, compactions: Vec::new(), } @@ -229,6 +234,9 @@ impl CoverageFormatter for TextFormatter { .map(|a| format!(", agent: {}", a)) .unwrap_or_default(); output.push_str(&format!("Coverage Report (session: {}{})\n", session_str, agent_str)); + if let Some(ref f) = report.filter { + output.push_str(&format!("Filter: {f}\n")); + } // Calculate path width based on longest path let max_path_len = report @@ -447,6 +455,8 @@ impl CoverageFormatter for JsonFormatter { schema_version: u32, session_id: Option<&'a str>, agent_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option<&'a str>, totals: Totals, files: Vec>, compactions: Vec>, @@ -456,6 +466,7 @@ impl CoverageFormatter for JsonFormatter { schema_version: 2, session_id: report.session_id.as_deref(), agent_id: report.agent_id.as_deref(), + filter: report.filter.as_deref(), totals: Totals { symbols: report.total_symbols(), seen: report.total_seen(), @@ -514,6 +525,7 @@ pub fn run_report( log_dir_opt: &Option, session_opt: &Option, agent_opt: &Option, + filter: Option<&crate::filter::PathFilter>, ingester: &dyn crate::ingest::SessionIngester, formatter: &dyn CoverageFormatter, ) -> Result<()> { @@ -631,6 +643,7 @@ pub fn run_report( // 5. Generate and print report. let mut report = CoverageReport::from_project(project_tree, &ledger, resolved_agent.as_deref()); report.session_id = session_id; + report.filter = filter.map(|f| f.display()); report.compactions = compactions; print!("{}", formatter.format(&report)); @@ -639,13 +652,20 @@ pub fn run_report( } /// Print a project's symbol tree to stdout. -pub fn dump_tree(root: &Path, project_tree: &ProjectTree) { +pub fn dump_tree( + root: &Path, + project_tree: &ProjectTree, + filter: Option<&crate::filter::PathFilter>, +) { println!( "Project: {} ({} files, {} symbols)", root.display(), project_tree.total_files(), project_tree.total_symbols(), ); + if let Some(f) = filter { + println!("Filter: {}", f.display()); + } println!(); for file in &project_tree.files { @@ -818,6 +838,7 @@ mod tests { let report = CoverageReport { session_id: Some("abc-123".into()), agent_id: None, + filter: None, files: vec![FileCoverage { path: "src/main.rs".into(), total_symbols: 10, @@ -838,6 +859,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: Vec::new(), }; @@ -852,6 +874,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![FileCoverage { path: "a.rs".into(), total_symbols: 1, @@ -898,6 +921,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: Vec::new(), }; @@ -911,6 +935,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: vec![sample_compaction()], }; @@ -927,6 +952,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: Vec::new(), }; @@ -943,6 +969,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: vec![sample_compaction()], }; @@ -970,6 +997,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: vec![sample_compaction_with_metadata()], }; @@ -985,6 +1013,7 @@ mod tests { let report = CoverageReport { session_id: Some("s".into()), agent_id: None, + filter: None, files: vec![], compactions: vec![sample_compaction_with_metadata()], }; @@ -997,4 +1026,72 @@ mod tests { assert_eq!(metadata["post_tokens"], 6416); assert_eq!(metadata["duration_ms"], 64699); } + + #[test] + fn text_formatter_renders_filter_line_when_present() { + let report = CoverageReport { + session_id: Some("abc".into()), + agent_id: None, + filter: Some("src/parser".into()), + files: vec![], + compactions: Vec::new(), + }; + let output = TextFormatter::default().format(&report); + assert!( + output.contains("Filter: src/parser"), + "expected Filter line, got:\n{output}" + ); + } + + #[test] + fn text_formatter_omits_filter_line_when_absent() { + let report = CoverageReport { + session_id: Some("abc".into()), + agent_id: None, + filter: None, + files: vec![], + compactions: Vec::new(), + }; + let output = TextFormatter::default().format(&report); + assert!( + !output.contains("Filter:"), + "Filter line must not appear without a filter, got:\n{output}" + ); + } + + #[test] + fn json_formatter_includes_filter_when_present() { + let report = CoverageReport { + session_id: Some("s".into()), + agent_id: None, + filter: Some("re:^src/.*\\.rs$".into()), + files: vec![], + compactions: Vec::new(), + }; + let output = JsonFormatter.format(&report); + let value: serde_json::Value = + serde_json::from_str(output.trim()).expect("output must be valid JSON"); + assert_eq!(value["filter"], "re:^src/.*\\.rs$"); + } + + #[test] + fn json_formatter_omits_filter_when_absent() { + let report = CoverageReport { + session_id: Some("s".into()), + agent_id: None, + filter: None, + files: vec![], + compactions: Vec::new(), + }; + let output = JsonFormatter.format(&report); + let value: serde_json::Value = + serde_json::from_str(output.trim()).expect("output must be valid JSON"); + // The field is `skip_serializing_if = Option::is_none`, so it should + // be absent entirely rather than `null`. This keeps the schema + // additive — pre-filter consumers see an unchanged object. + assert!( + value.get("filter").is_none(), + "filter field should be omitted when None, got: {value}", + ); + } } diff --git a/src/main.rs b/src/main.rs index ca47868..60d1716 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,7 +176,7 @@ fn main() -> Result<()> { for w in &config_warnings { println!("[ambit warning] {w}"); } - coverage::dump_tree(&project_path, &project_tree); + coverage::dump_tree(&project_path, &project_tree, filter.as_ref()); return Ok(()); } @@ -194,6 +194,7 @@ fn main() -> Result<()> { &cli.log_dir, &cli.session, &cli.agent, + filter.as_ref(), &*ingester, &*formatter, ); @@ -231,6 +232,7 @@ fn main() -> Result<()> { }; let mut app = App::new(project_tree, project_path.clone(), event_log); + app.filter = filter.map(Arc::new); app.session_id = session_id.clone(); app.session_slug = log_dir.as_ref() .zip(session_id.as_ref()) diff --git a/src/tui.rs b/src/tui.rs index 86c3dd5..96c06b6 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -195,7 +195,8 @@ impl TuiSession { } } if changed { - if let Ok(new_tree) = crate::serena::scan_project_serena(project_path, None) { + let filter = app.filter.as_deref(); + if let Ok(new_tree) = crate::serena::scan_project_serena(project_path, filter) { let mut old_map = std::collections::HashMap::new(); for file in &app.project_tree.files { ambits::tracking::collect_symbol_hashes(&file.symbols, &mut old_map); @@ -211,6 +212,10 @@ impl TuiSession { } /// Re-parse a changed source file and update the project tree in `app`. + /// + /// If `app.filter` is set and the changed file's project-relative path + /// does not satisfy the filter, the function returns early — keeping the + /// excluded file out of the tree even after a `notify` event fires on it. pub fn handle_file_changed( path: PathBuf, project_path: &Path, @@ -218,6 +223,11 @@ impl TuiSession { app: &mut App, ) { if let Ok(rel) = path.strip_prefix(project_path) { + if let Some(filter) = app.filter.as_deref() { + if !filter.matches(rel) { + return; + } + } if let Some(parser) = registry.parser_for(&path) { if let Ok(source) = fs::read_to_string(&path) { if let Ok(new_file) = parser.parse_file(rel, &source) { diff --git a/src/ui/stats.rs b/src/ui/stats.rs index f5a96f5..73b0ccf 100644 --- a/src/ui/stats.rs +++ b/src/ui/stats.rs @@ -94,6 +94,13 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) { ])); } + if let Some(ref f) = app.filter { + lines.push(Line::from(vec![ + Span::raw(" Filter: "), + Span::styled(f.display(), Style::default().fg(colors::ACCENT_MUTED)), + ])); + } + // Compactions summary. if let Some(last) = app.compaction_history.last() { lines.push(Line::from(vec![