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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ jobs:
run: cargo test --locked -p terraphim_agent --test memory_capture_cli_tests --test memory_apply_cli_tests --test memory_rubric_cli_tests
# Exercise the released grep command against real files and exported
# synonym graphs, including Linux-only hostile filesystem names.
- run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree
- run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree --test single_file_search
- name: Verify authored synonyms and role boundaries over real MCP stdio
run: cargo test --locked -p terraphim_mcp_server --test test_mcp_role_contract
# GitHub-side home for the updater integration contracts (managed-mode
Expand Down
86 changes: 79 additions & 7 deletions crates/terraphim_grep/src/hybrid_searcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,45 @@ impl HybridSearcher {
parse_grep_query,
};

// Refuse the tree before its base path reaches fff-search: a
let metadata = std::fs::symlink_metadata(&search_path).map_err(|error| {
format!(
"Cannot inspect search path {}: {error}",
search_path.display()
)
})?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(format!(
"Search path must be a regular file or directory, not a symlink or special file: {}",
search_path.display()
));
}
// An explicit file uses its parent only as the path base. Never
// scan that directory: neighbours were not selected by the caller.
let selected_file = if metadata.is_file() {
Some(std::fs::canonicalize(&search_path).map_err(|error| {
format!(
"Cannot resolve search file {}: {error}",
search_path.display()
)
})?)
} else {
None
};
let guarded_path = selected_file.as_deref().unwrap_or(&search_path);
// Refuse the tree or exact file before its path reaches fff-search: a
// non-UTF-8 or over-long entry name defeats the library's
// `from_utf8_unchecked` on the stored relative path. UF-040 / #296.
crate::path_guard::reject_hostile_tree(&search_path)?;
crate::path_guard::reject_hostile_tree(guarded_path)?;
let base_path = if selected_file.is_some() {
guarded_path
.parent()
.ok_or("Search file has no parent directory")?
} else {
&search_path
};

let mut picker = FilePicker::new(FilePickerOptions {
base_path: search_path.to_string_lossy().to_string(),
base_path: base_path.to_string_lossy().to_string(),
mode: FFFMode::Ai,
watch: false,
// See `no_mmap_budget` -- UF-039 / #293.
Expand All @@ -448,9 +480,15 @@ impl HybridSearcher {
})
.map_err(|e| format!("FilePicker init failed: {}", e))?;

picker
.collect_files()
.map_err(|e| format!("File scan failed: {}", e))?;
if let Some(file) = &selected_file {
picker
.add_new_file(file)
.ok_or_else(|| format!("Cannot index selected file: {}", file.display()))?;
} else {
picker
.collect_files()
.map_err(|e| format!("File scan failed: {}", e))?;
}

if picker.get_files().is_empty() {
return Ok(vec![]);
Expand All @@ -473,9 +511,43 @@ impl HybridSearcher {
// Exact KG aliases are data, so bypass the query parser entirely.
// One Aho-Corasick batch scans each file once, including aliases that
// sort after the displayed concepts. Free-text queries retain filters.
let fff_query;
let mut fff_query;
let result = if aliases.is_empty() {
fff_query = parse_grep_query(query);
if selected_file.is_some() && !fff_query.constraints.is_empty() {
// Pinned FFF 0.8.4's grep constraint path uses the base
// arena for overflow files. Its public file search splits
// arenas correctly, so apply the same parsed constraints
// there before running the unchanged content query.
let mut filter = fff_search::FFFQuery {
raw_query: "",
constraints: fff_query.constraints.clone(),
fuzzy_query: fff_search::FuzzyQuery::Empty,
location: None,
};
let mut matched = !picker
.fuzzy_search(&filter, None, fff_search::FuzzySearchOptions::default())
.items
.is_empty();
// Match grep's existing filename-hint fallback as well.
if !matched
&& filter.constraints.iter().any(|constraint| {
matches!(constraint, fff_search::Constraint::FilePath(_))
})
{
filter.constraints.retain(|constraint| {
!matches!(constraint, fff_search::Constraint::FilePath(_))
});
matched = !picker
.fuzzy_search(&filter, None, fff_search::FuzzySearchOptions::default())
.items
.is_empty();
}
if !matched {
return Ok(vec![]);
}
fff_query.constraints.clear();
}
picker.grep(&fff_query, &options)
} else {
let patterns: Vec<&str> = aliases.iter().map(String::as_str).collect();
Expand Down
191 changes: 191 additions & 0 deletions crates/terraphim_grep/tests/single_file_search.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! Real CLI regressions for explicitly selected files, using an authored synonym graph.
#![cfg(feature = "code-search")]

use std::path::Path;
use std::process::{Command, Output};

fn fixture() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
std::fs::write(
root.path().join("brief.md"),
"Judgment: review Café.\r\nRisk: lost context.\r\n",
)
.unwrap();
std::fs::write(
root.path().join("neighbour.md"),
"Risk: do not retrieve a sibling.\n",
)
.unwrap();
std::fs::write(root.path().join("graph.json"), r#"{"name":"file-search","data":{"risk":{"id":1,"nterm":"risk"},"concern":{"id":1,"nterm":"risk"}}}"#).unwrap();
root
}

fn search(root: &Path, query: &str, paths: &[&Path]) -> Output {
Command::new(env!("CARGO_BIN_EXE_terraphim-grep"))
.current_dir(root)
.args([
query,
"--search-only",
"--json",
"--thesaurus",
"graph.json",
"--paths",
])
.args(paths)
.env_remove("OPENROUTER_API_KEY")
.env_remove("OLLAMA_BASE_URL")
.output()
.unwrap()
}

fn chunks(output: Output) -> Vec<serde_json::Value> {
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(value["answer"].is_null());
value["chunks"].as_array().unwrap().clone()
}

#[test]
fn absolute_and_relative_file_search_use_synonyms_without_scanning_neighbours() {
let root = fixture();
let absolute = root.path().join("brief.md");
for path in [
absolute.as_path(),
Path::new("brief.md"),
Path::new("./brief.md"),
] {
let found = chunks(search(root.path(), "Concern", &[path]));
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0]["source"], "brief.md");
assert_eq!(found[0]["line_start"], 2);
assert_eq!(found[0]["content"], "Risk: lost context.");
}
assert_eq!(
std::fs::read(&absolute).unwrap(),
b"Judgment: review Caf\xc3\xa9.\r\nRisk: lost context.\r\n"
);
}

#[test]
fn explicit_file_preserves_plain_queries_and_file_filters() {
let root = fixture();
let selected = [Path::new("brief.md")];
let found = chunks(search(root.path(), "Café *.md", &selected));
assert_eq!(found.len(), 1);
assert_eq!(found[0]["line_start"], 1);
assert!(chunks(search(root.path(), "Café *.rs", &selected)).is_empty());
}

#[test]
fn file_filters_match_the_existing_directory_query_semantics() {
let root = fixture();
for query in [
"Café",
"Café *.md",
"Café *.rs",
"Café !*.rs",
"Café !*.md",
"Café b*.md",
"Café n*.md",
"Café !b*.md",
"Café brief.md",
"Café missing.md",
] {
let explicit = chunks(search(root.path(), query, &[Path::new("brief.md")]));
let directory = chunks(search(root.path(), query, &[Path::new(".")]));
assert_eq!(explicit, directory, "query {query:?}");
}
}

#[test]
fn unicode_filename_and_nested_relative_input_retain_actual_source() {
let root = fixture();
std::fs::create_dir(root.path().join("notes")).unwrap();
std::fs::write(
root.path().join("notes/résumé.md"),
"Risk: review Café.\r\n",
)
.unwrap();
let found = chunks(search(
root.path(),
"Concern",
&[Path::new("notes/résumé.md")],
));
assert_eq!(found.len(), 1);
assert_eq!(found[0]["source"], "résumé.md");
assert_eq!(found[0]["content"], "Risk: review Café.");
let found = chunks(search(
root.path(),
"Concern",
&[Path::new("notes/../brief.md")],
));
assert_eq!(found.len(), 1);
assert_eq!(found[0]["source"], "brief.md");
}

#[test]
fn multiple_explicit_files_do_not_add_unselected_siblings() {
let root = fixture();
std::fs::write(
root.path().join("second.md"),
"Risk: explicit second file.\n",
)
.unwrap();
let found = chunks(search(
root.path(),
"Concern",
&[Path::new("brief.md"), Path::new("second.md")],
));
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found
.iter()
.all(|c| c["source"] == "brief.md" || c["source"] == "second.md")
);
}

#[test]
fn missing_file_is_an_error_instead_of_an_empty_success() {
let root = fixture();
assert!(
!search(root.path(), "Concern", &[Path::new("missing.md")])
.status
.success()
);
}

#[cfg(unix)]
#[test]
fn explicit_symlink_and_socket_are_refused() {
use std::os::unix::{fs::symlink, net::UnixListener};
let root = fixture();
symlink(root.path().join("brief.md"), root.path().join("linked.md")).unwrap();
let _socket = UnixListener::bind(root.path().join("socket")).unwrap();
for path in [Path::new("linked.md"), Path::new("socket")] {
let output = search(root.path(), "Concern", &[path]);
assert!(!output.status.success(), "must refuse {path:?}");
}
}

#[cfg(target_os = "linux")]
#[test]
fn invalid_utf8_single_file_is_refused_without_inspecting_neighbours() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let root = fixture();
let invalid = root
.path()
.join(OsString::from_vec(b"bad-\xff.md".to_vec()));
std::fs::write(&invalid, "Risk: invalid path.\n").unwrap();
assert!(!search(root.path(), "Concern", &[&invalid]).status.success());
let found = chunks(search(root.path(), "Concern", &[Path::new("brief.md")]));
assert_eq!(
found.len(),
1,
"the explicit file must not scan hostile siblings"
);
}
Loading