Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -103,6 +105,13 @@ pub struct App {

// Optional event log writer.
pub event_log: Option<BufWriter<File>>,

/// 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<Arc<PathFilter>>,
}

impl App {
Expand Down Expand Up @@ -139,6 +148,7 @@ impl App {
show_compaction_overlay: false,
compaction_overlay_index: 0,
event_log,
filter: None,
};
app.rebuild_tree_rows();
app
Expand Down
99 changes: 98 additions & 1 deletion src/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ pub struct CoverageReport {
pub session_id: Option<String>,
/// Agent ID if filtering by agent.
pub agent_id: Option<String>,
/// 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<String>,
/// Per-file coverage metrics.
pub files: Vec<FileCoverage>,
/// Compactions detected in the session, in occurrence order. Empty when none.
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<FileDto<'a>>,
compactions: Vec<CompactionDto<'a>>,
Expand All @@ -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(),
Expand Down Expand Up @@ -514,6 +525,7 @@ pub fn run_report(
log_dir_opt: &Option<PathBuf>,
session_opt: &Option<String>,
agent_opt: &Option<String>,
filter: Option<&crate::filter::PathFilter>,
ingester: &dyn crate::ingest::SessionIngester,
formatter: &dyn CoverageFormatter,
) -> Result<()> {
Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -838,6 +859,7 @@ mod tests {
let report = CoverageReport {
session_id: Some("s".into()),
agent_id: None,
filter: None,
files: vec![],
compactions: Vec::new(),
};
Expand All @@ -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,
Expand Down Expand Up @@ -898,6 +921,7 @@ mod tests {
let report = CoverageReport {
session_id: Some("s".into()),
agent_id: None,
filter: None,
files: vec![],
compactions: Vec::new(),
};
Expand All @@ -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()],
};
Expand All @@ -927,6 +952,7 @@ mod tests {
let report = CoverageReport {
session_id: Some("s".into()),
agent_id: None,
filter: None,
files: vec![],
compactions: Vec::new(),
};
Expand All @@ -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()],
};
Expand Down Expand Up @@ -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()],
};
Expand All @@ -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()],
};
Expand All @@ -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}",
);
}
}
4 changes: 3 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}

Expand All @@ -194,6 +194,7 @@ fn main() -> Result<()> {
&cli.log_dir,
&cli.session,
&cli.agent,
filter.as_ref(),
&*ingester,
&*formatter,
);
Expand Down Expand Up @@ -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())
Expand Down
12 changes: 11 additions & 1 deletion src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -211,13 +212,22 @@ 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,
registry: &ambits::parser::ParserRegistry,
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) {
Expand Down
7 changes: 7 additions & 0 deletions src/ui/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
Loading