Skip to content

Commit 9a7d7ee

Browse files
ind-igoclaude
andcommitted
Support directory paths in --file/--from filters and fix overview CWD resolution
The --file flag on symbols/references/kind_counts and --from on definition only accepted exact file paths. Passing a directory (e.g. --file src/) caused "unsupported file type: .(none)". Now directories filter by prefix via a shared resolve_file_filter helper. Also fixes overview resolving relative paths against the project root instead of CWD, which broke directory detection in monorepos. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 41c3253 commit 9a7d7ee

5 files changed

Lines changed: 144 additions & 44 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cx-cli"
3-
version = "0.6.4"
3+
version = "0.6.5"
44
edition = "2024"
55
description = "Semantic code navigation for AI agents"
66
license = "MIT"

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ fn main() {
171171
Commands::Overview { path, full } => {
172172
let idx = index::Index::load_or_build(&root);
173173
let abs = if path.is_absolute() { path.clone() } else {
174-
root.join(&path)
174+
env::current_dir().unwrap_or_else(|_| root.clone()).join(&path)
175175
};
176176
if abs.is_dir() {
177177
query::dir_overview(&idx, &path, full, cli.json, &resolve_pagination(None))

src/query.rs

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -125,24 +125,14 @@ pub fn symbols(
125125

126126
let rel_path = file.map(|f| make_relative(f, &index.root));
127127

128-
if let Some(ref rel) = rel_path
129-
&& !index.entries.contains_key(rel) {
130-
let abs = index.root.join(rel);
131-
if abs.exists() && detect_language(&abs).is_none() {
132-
let ext = abs.extension().and_then(|e| e.to_str()).unwrap_or("(none)");
133-
eprintln!("cx: unsupported file type: .{}", ext);
134-
} else {
135-
eprintln!("cx: file not in index: {}", display_path(rel));
136-
}
137-
return 1;
138-
}
139-
140128
let files_to_search: Vec<(&PathBuf, &FileData)> = match rel_path {
141-
Some(ref rel) => {
142-
index.entries.get_key_value(rel).into_iter().collect()
143-
}
129+
Some(ref rel) => match resolve_file_filter(rel, index) {
130+
Ok(v) => v,
131+
Err(code) => return code,
132+
},
144133
None => index.entries.iter().collect(),
145134
};
135+
let is_single_file = file.is_some() && files_to_search.len() == 1;
146136

147137
for (path, data) in files_to_search {
148138
for sym in &data.symbols {
@@ -170,7 +160,7 @@ pub fn symbols(
170160

171161
rows.sort_by(|a, b| a.file.cmp(b.file).then(a.symbol.name.cmp(&b.symbol.name)));
172162

173-
let single_file = file.is_some();
163+
let single_file = is_single_file;
174164
let out: Vec<SymbolRowOut> = rows
175165
.into_iter()
176166
.map(|r| SymbolRowOut {
@@ -216,15 +206,10 @@ pub fn kind_counts(
216206
let rel_path = file.map(|f| make_relative(f, &index.root));
217207

218208
let files_to_search: Vec<(&PathBuf, &FileData)> = match rel_path {
219-
Some(ref rel) => {
220-
match index.entries.get_key_value(rel) {
221-
Some(kv) => vec![kv],
222-
None => {
223-
eprintln!("cx: file not in index: {}", display_path(rel));
224-
return 1;
225-
}
226-
}
227-
}
209+
Some(ref rel) => match resolve_file_filter(rel, index) {
210+
Ok(v) => v,
211+
Err(code) => return code,
212+
},
228213
None => index.entries.iter().collect(),
229214
};
230215

@@ -281,9 +266,12 @@ pub fn definition(
281266
}
282267

283268
if let Some(ref from_path) = from_rel {
269+
let is_dir = index.root.join(from_path).is_dir();
284270
let from_matches: Vec<_> = matches
285271
.iter()
286-
.filter(|(path, _)| *path == from_path)
272+
.filter(|(path, _)| {
273+
if is_dir { path.starts_with(from_path) } else { *path == from_path }
274+
})
287275
.cloned()
288276
.collect();
289277
if !from_matches.is_empty() {
@@ -404,21 +392,10 @@ pub fn references(
404392
let rel_path = file.map(|f| make_relative(f, &index.root));
405393

406394
let files_to_search: Vec<(&PathBuf, &FileData)> = match rel_path {
407-
Some(ref rel) => {
408-
match index.entries.get_key_value(rel) {
409-
Some(kv) => vec![kv],
410-
None => {
411-
let abs = index.root.join(rel);
412-
if abs.exists() && detect_language(&abs).is_none() {
413-
let ext = abs.extension().and_then(|e| e.to_str()).unwrap_or("(none)");
414-
eprintln!("cx: unsupported file type: .{}", ext);
415-
} else {
416-
eprintln!("cx: file not in index: {}", display_path(rel));
417-
}
418-
return 1;
419-
}
420-
}
421-
}
395+
Some(ref rel) => match resolve_file_filter(rel, index) {
396+
Ok(v) => v,
397+
Err(code) => return code,
398+
},
422399
None => index.entries.iter().collect(),
423400
};
424401

@@ -768,6 +745,35 @@ fn display_path(path: &Path) -> String {
768745
path.to_string_lossy().replace('\\', "/")
769746
}
770747

748+
fn resolve_file_filter<'a>(
749+
rel: &Path,
750+
index: &'a Index,
751+
) -> Result<Vec<(&'a PathBuf, &'a FileData)>, i32> {
752+
if let Some(kv) = index.entries.get_key_value(rel) {
753+
return Ok(vec![kv]);
754+
}
755+
let abs = index.root.join(rel);
756+
if abs.is_dir() {
757+
let matches: Vec<_> = index
758+
.entries
759+
.iter()
760+
.filter(|(path, _)| path.starts_with(rel))
761+
.collect();
762+
if matches.is_empty() {
763+
eprintln!("cx: no indexed files under {}", display_path(rel));
764+
return Err(1);
765+
}
766+
return Ok(matches);
767+
}
768+
if abs.exists() && detect_language(&abs).is_none() {
769+
let ext = abs.extension().and_then(|e| e.to_str()).unwrap_or("(none)");
770+
eprintln!("cx: unsupported file type: .{}", ext);
771+
} else {
772+
eprintln!("cx: file not in index: {}", display_path(rel));
773+
}
774+
Err(1)
775+
}
776+
771777
/// Make a path relative to the project root if it's absolute,
772778
/// or resolve it from cwd if relative.
773779
fn make_relative(path: &Path, root: &Path) -> PathBuf {

tests/integration.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,97 @@ fn no_pagination_when_under_limit() {
543543
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
544544
assert!(parsed.is_array(), "single result should be bare array: {stdout}");
545545
}
546+
547+
// --- Directory filtering ---
548+
549+
fn dir_project() -> tempfile::TempDir {
550+
temp_project(&[
551+
("src/lib.rs", "pub fn alpha() {}\npub fn beta() {}\n"),
552+
("src/util.rs", "pub fn gamma() {}\n"),
553+
("tests/test_main.rs", "fn test_alpha() {}\n"),
554+
])
555+
}
556+
557+
#[test]
558+
fn symbols_file_directory_returns_all_files_in_dir() {
559+
let dir = dir_project();
560+
let out = cx_in(dir.path()).args(["symbols", "--kind", "fn", "--file", "src"]).output().unwrap();
561+
let stdout = String::from_utf8_lossy(&out.stdout);
562+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
563+
assert!(stdout.contains("alpha"), "should find alpha in src/: {stdout}");
564+
assert!(stdout.contains("gamma"), "should find gamma in src/: {stdout}");
565+
assert!(!stdout.contains("test_alpha"), "should not include tests/: {stdout}");
566+
}
567+
568+
#[test]
569+
fn symbols_file_directory_shows_file_column() {
570+
let dir = dir_project();
571+
let out = cx_in(dir.path()).args(["--json", "symbols", "--kind", "fn", "--file", "src"]).output().unwrap();
572+
let stdout = String::from_utf8_lossy(&out.stdout);
573+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
574+
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
575+
let arr = parsed.as_array().unwrap();
576+
assert!(arr.iter().all(|r| r.get("file").is_some()), "directory query should include file column: {stdout}");
577+
}
578+
579+
#[test]
580+
fn symbols_file_single_file_hides_file_column() {
581+
let dir = dir_project();
582+
let out = cx_in(dir.path()).args(["--json", "symbols", "--kind", "fn", "--file", "src/lib.rs"]).output().unwrap();
583+
let stdout = String::from_utf8_lossy(&out.stdout);
584+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
585+
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
586+
let arr = parsed.as_array().unwrap();
587+
assert!(arr.iter().all(|r| r.get("file").is_none()), "single-file query should omit file column: {stdout}");
588+
}
589+
590+
#[test]
591+
fn references_file_directory() {
592+
let dir = dir_project();
593+
let out = cx_in(dir.path()).args(["references", "--name", "alpha", "--file", "src"]).output().unwrap();
594+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
595+
}
596+
597+
#[test]
598+
fn kind_counts_file_directory() {
599+
let dir = dir_project();
600+
let out = cx_in(dir.path()).args(["symbols", "--kinds", "--file", "src"]).output().unwrap();
601+
let stdout = String::from_utf8_lossy(&out.stdout);
602+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
603+
assert!(stdout.contains("fn"), "should list fn kind: {stdout}");
604+
}
605+
606+
#[test]
607+
fn definition_from_directory() {
608+
let dir = dir_project();
609+
let out = cx_in(dir.path()).args(["definition", "--name", "alpha", "--from", "src"]).output().unwrap();
610+
let stdout = String::from_utf8_lossy(&out.stdout);
611+
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
612+
assert!(stdout.contains("alpha"), "should find alpha from src/: {stdout}");
613+
}
614+
615+
#[test]
616+
fn symbols_file_nonexistent_directory() {
617+
let dir = dir_project();
618+
let out = cx_in(dir.path()).args(["symbols", "--file", "nonexistent"]).output().unwrap();
619+
assert_eq!(out.status.code(), Some(1));
620+
}
621+
622+
#[test]
623+
fn symbols_file_empty_directory() {
624+
let dir = dir_project();
625+
std::fs::create_dir(dir.path().join("empty")).unwrap();
626+
let out = cx_in(dir.path()).args(["symbols", "--file", "empty"]).output().unwrap();
627+
assert_eq!(out.status.code(), Some(1));
628+
let stderr = String::from_utf8_lossy(&out.stderr);
629+
assert!(stderr.contains("no indexed files under"), "should report empty dir: {stderr}");
630+
}
631+
632+
#[test]
633+
fn overview_directory_from_subdirectory() {
634+
let dir = dir_project();
635+
let out = cx_in(&dir.path().join("src")).args(["overview", "."]).output().unwrap();
636+
let stdout = String::from_utf8_lossy(&out.stdout);
637+
assert!(out.status.success(), "overview from subdir should work: {}", String::from_utf8_lossy(&out.stderr));
638+
assert!(stdout.contains("alpha") || stdout.contains("lib.rs"), "should find files in src/: {stdout}");
639+
}

0 commit comments

Comments
 (0)