use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::Serialize;
use cartog_core::{Compact, Symbol};
use cartog_db::{Database, MAX_SEARCH_LIMIT};
use cartog_registry::ProjectRow;
use crate::cli::SymbolKindFilter;
const DEFAULT_MAX_PROJECTS: usize = 10;
const MAX_FANOUT_PROJECTS: usize = 50;
#[derive(Debug, Clone, Default)]
pub struct FanoutFilter {
pub under: Option<PathBuf>,
pub lang: Option<String>,
pub max_projects: Option<usize>,
}
#[derive(Debug, Clone, Copy)]
pub struct OutputOpts {
pub json: bool,
pub compact: bool,
pub token_budget: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct FederatedResults {
projects: Vec<ProjectHits>,
#[serde(skip_serializing_if = "Vec::is_empty")]
unreadable: Vec<UnreadableJson>,
queried: usize,
#[serde(skip_serializing_if = "super::shared::is_zero")]
elided_by_cap: usize,
total_matches: usize,
}
#[derive(Debug, Serialize)]
struct UnreadableJson {
name: String,
reason: String,
}
#[derive(Debug, Serialize)]
struct ProjectHits {
name: String,
root: String,
db_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
symbols: Vec<Symbol>,
}
pub fn cmd_search_all(
current_db: &Path,
query: &str,
kind: Option<SymbolKindFilter>,
limit: u32,
filter: &FanoutFilter,
out: OutputOpts,
) -> Result<()> {
let OutputOpts {
json,
compact,
token_budget,
} = out;
let listing = cartog_registry::list_projects(cartog_db::CURRENT_SCHEMA_VERSION);
if !listing.available {
bail!(
"no project registry on this machine, so there are no other projects to search \
(CARTOG_REGISTRY is disabled, or nothing has been indexed yet)"
);
}
let (candidates, elided_by_cap) = select_candidates(listing.projects, current_db, filter);
if candidates.is_empty() {
let empty = FederatedResults {
projects: Vec::new(),
unreadable: Vec::new(),
queried: 0,
elided_by_cap,
total_matches: 0,
};
return super::shared::output(&empty, json, token_budget, |_| {
format!("No other indexed project matches {}.\n", describe(filter))
});
}
let kind_filter = match kind {
Some(SymbolKindFilter::All) | None => None,
Some(k) => Some(cartog_core::SymbolKind::from(k)),
};
let limit = limit.min(MAX_SEARCH_LIMIT);
let mut projects = Vec::new();
let mut unreadable = Vec::new();
let queried = candidates.len();
for row in candidates {
match query_one(&row, query, kind_filter, limit, compact) {
Ok(symbols) if symbols.is_empty() => {}
Ok(symbols) => projects.push(ProjectHits {
name: row.display_name().to_string(),
root: row.root.display().to_string(),
db_path: row.db_path.display().to_string(),
description: row.description.as_ref().map(|d| d.text.clone()),
symbols,
}),
Err(e) => unreadable.push(UnreadableJson {
name: row.display_name().to_string(),
reason: e.root_cause().to_string(),
}),
}
}
let total_matches = projects.iter().map(|p| p.symbols.len()).sum();
let results = FederatedResults {
projects,
unreadable,
queried,
elided_by_cap,
total_matches,
};
let query = query.to_string();
super::shared::output(&results, json, token_budget, |r| render(r, &query))
}
fn select_candidates(
rows: Vec<ProjectRow>,
current_db: &Path,
filter: &FanoutFilter,
) -> (Vec<ProjectRow>, usize) {
let current = canonical(current_db);
let mut kept: Vec<ProjectRow> = rows
.into_iter()
.filter(|r| canonical(&r.db_path) != current)
.filter(|r| !r.markers.missing)
.filter(|r| matches_filter(r, filter))
.collect();
kept.sort_by(|a, b| {
b.symbol_count
.unwrap_or(0)
.cmp(&a.symbol_count.unwrap_or(0))
.then_with(|| a.display_name().cmp(b.display_name()))
});
let cap = filter
.max_projects
.unwrap_or(DEFAULT_MAX_PROJECTS)
.clamp(1, MAX_FANOUT_PROJECTS);
let elided = kept.len().saturating_sub(cap);
kept.truncate(cap);
(kept, elided)
}
fn matches_filter(row: &ProjectRow, filter: &FanoutFilter) -> bool {
if let Some(under) = &filter.under {
if !canonical(&row.root).starts_with(canonical(under)) {
return false;
}
}
if let Some(lang) = &filter.lang {
let wanted = lang.to_ascii_lowercase();
if !row
.languages
.iter()
.any(|(l, _)| l.eq_ignore_ascii_case(&wanted))
{
return false;
}
}
true
}
fn query_one(
row: &ProjectRow,
query: &str,
kind: Option<cartog_core::SymbolKind>,
limit: u32,
compact: bool,
) -> Result<Vec<Symbol>> {
let db = Database::open_readonly(&row.db_path)?;
let mut symbols = db.search(query, kind, None, limit)?;
if compact {
symbols.compact_in_place();
}
Ok(symbols)
}
fn canonical(p: &Path) -> PathBuf {
let expanded = crate::config::expand_tilde(p.to_path_buf());
expanded.canonicalize().unwrap_or(expanded)
}
fn describe(filter: &FanoutFilter) -> String {
let mut parts = Vec::new();
if let Some(u) = &filter.under {
parts.push(format!("under {}", u.display()));
}
if let Some(l) = &filter.lang {
parts.push(format!("language {l}"));
}
if parts.is_empty() {
return "this machine's registry".to_string();
}
parts.join(" and ")
}
fn render(r: &FederatedResults, query: &str) -> String {
let mut out = if r.projects.is_empty() {
let searched = r.queried.saturating_sub(r.unreadable.len());
if searched == 0 && r.queried > 0 {
format!(
"No project could be searched for '{query}' — none of the {} candidate{} \
could be read.\n",
r.queried,
if r.queried == 1 { "" } else { "s" },
)
} else {
format!(
"No symbols matching '{query}' in {searched} other project{}.\n",
if searched == 1 { "" } else { "s" },
)
}
} else {
header(r, query)
};
if !r.projects.is_empty() {
out.push_str(&hits(r));
}
out.push_str(&diagnostics(r));
out
}
fn header(r: &FederatedResults, query: &str) -> String {
format!(
"{} match{} for '{query}' across {} of {} project{}:\n",
r.total_matches,
if r.total_matches == 1 { "" } else { "es" },
r.projects.len(),
r.queried,
if r.queried == 1 { "" } else { "s" },
)
}
fn hits(r: &FederatedResults) -> String {
let mut out = String::new();
for p in &r.projects {
out.push('\n');
out.push_str(&format!("{} ({})\n", p.name, p.root));
if let Some(d) = &p.description {
out.push_str(&format!(" {d}\n"));
}
for s in &p.symbols {
out.push_str(&format!(
" {:<28} {}:{}\n",
s.name, s.file_path, s.start_line
));
}
out.push_str(&format!(" --db {}\n", p.db_path));
}
out
}
fn diagnostics(r: &FederatedResults) -> String {
let mut out = String::new();
if !r.unreadable.is_empty() {
out.push_str(&format!(
"\n{} project{} could not be read:\n",
r.unreadable.len(),
if r.unreadable.len() == 1 { "" } else { "s" },
));
for u in &r.unreadable {
out.push_str(&format!(" {}: {}\n", u.name, u.reason));
}
}
if r.elided_by_cap > 0 {
out.push_str(&format!(
"\n{} more project{} matched but were not queried — raise --max-projects to include them.\n",
r.elided_by_cap,
if r.elided_by_cap == 1 { "" } else { "s" },
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use cartog_registry::{Description, DescriptionSource, Markers};
fn row(name: &str, root: &str, symbols: Option<u32>, langs: &[&str]) -> ProjectRow {
ProjectRow {
id: format!("serve-{name}"),
db_path: PathBuf::from(format!("{root}/.cartog/db.sqlite")),
root: PathBuf::from(root),
name: name.to_string(),
declared_name: None,
description: None,
languages: langs.iter().map(|l| ((*l).to_string(), 10)).collect(),
schema_version: Some(8),
file_count: Some(10),
symbol_count: symbols,
edge_count: None,
resolved_count: None,
embedding_count: None,
embed_provider: None,
embed_model: None,
embed_dim: None,
last_indexed: None,
last_seen: 0,
markers: Markers::default(),
}
}
#[test]
fn the_callers_own_project_is_never_queried() {
let mine = PathBuf::from("/w/a/.cartog/db.sqlite");
let rows = vec![
row("a", "/w/a", Some(5), &["rust"]),
row("b", "/w/b", Some(5), &["rust"]),
];
let (kept, _) = select_candidates(rows, &mine, &FanoutFilter::default());
let names: Vec<&str> = kept.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["b"],
"the caller's own project must be excluded"
);
}
#[test]
fn a_project_whose_database_is_gone_is_not_queried() {
let mut gone = row("gone", "/w/gone", Some(5), &["rust"]);
gone.markers = Markers {
missing: true,
..Markers::default()
};
let rows = vec![gone, row("here", "/w/here", Some(5), &["rust"])];
let (kept, _) = select_candidates(rows, Path::new("/w/x/db"), &FanoutFilter::default());
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "here");
}
#[test]
fn under_keeps_only_projects_inside_that_subtree() {
let rows = vec![
row("in", "/w/team/in", Some(5), &["rust"]),
row("out", "/other/out", Some(5), &["rust"]),
];
let filter = FanoutFilter {
under: Some(PathBuf::from("/w/team")),
..FanoutFilter::default()
};
let (kept, _) = select_candidates(rows, Path::new("/none"), &filter);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "in");
}
#[test]
fn lang_keeps_only_projects_that_indexed_that_language() {
let rows = vec![
row("rb", "/w/rb", Some(5), &["ruby", "markdown"]),
row("ts", "/w/ts", Some(5), &["typescript"]),
];
let filter = FanoutFilter {
lang: Some("ruby".to_string()),
..FanoutFilter::default()
};
let (kept, _) = select_candidates(rows, Path::new("/none"), &filter);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "rb");
}
#[test]
fn lang_matching_ignores_case() {
let rows = vec![row("ts", "/w/ts", Some(5), &["TypeScript"])];
let filter = FanoutFilter {
lang: Some("typescript".to_string()),
..FanoutFilter::default()
};
assert_eq!(
select_candidates(rows, Path::new("/none"), &filter).0.len(),
1
);
}
#[test]
fn under_and_lang_compose_as_an_and() {
let rows = vec![
row("both", "/w/team/both", Some(5), &["ruby"]),
row("wrong-lang", "/w/team/ts", Some(5), &["typescript"]),
row("wrong-path", "/other/rb", Some(5), &["ruby"]),
];
let filter = FanoutFilter {
under: Some(PathBuf::from("/w/team")),
lang: Some("ruby".to_string()),
max_projects: None,
};
let (kept, _) = select_candidates(rows, Path::new("/none"), &filter);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].name, "both");
}
#[test]
fn the_cap_keeps_the_largest_projects_and_reports_what_it_dropped() {
let rows = vec![
row("small", "/w/small", Some(1), &["rust"]),
row("big", "/w/big", Some(9000), &["rust"]),
row("mid", "/w/mid", Some(50), &["rust"]),
];
let filter = FanoutFilter {
max_projects: Some(2),
..FanoutFilter::default()
};
let (kept, elided) = select_candidates(rows, Path::new("/none"), &filter);
assert_eq!(
kept.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["big", "mid"],
"the cap must keep the most-populous projects"
);
assert_eq!(elided, 1, "the dropped project must be counted, not hidden");
}
#[test]
fn an_unmeasurable_project_stays_eligible_but_sorts_last() {
let rows = vec![
row("unknown", "/w/unknown", None, &[]),
row("known", "/w/known", Some(10), &["rust"]),
];
let (kept, _) = select_candidates(rows, Path::new("/none"), &FanoutFilter::default());
assert_eq!(
kept.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["known", "unknown"]
);
}
#[test]
fn the_project_cap_is_clamped_to_the_same_range_as_the_mcp_tool() {
let rows: Vec<ProjectRow> = (0..60)
.map(|i| row(&format!("p{i}"), &format!("/w/p{i}"), Some(i), &["rust"]))
.collect();
let zero = FanoutFilter {
max_projects: Some(0),
..FanoutFilter::default()
};
let (kept, _) = select_candidates(rows.clone(), Path::new("/none"), &zero);
assert_eq!(kept.len(), 1, "a cap of 0 must still query one project");
let huge = FanoutFilter {
max_projects: Some(usize::MAX),
..FanoutFilter::default()
};
let (kept, elided) = select_candidates(rows, Path::new("/none"), &huge);
assert_eq!(kept.len(), 50, "the cap must be bounded above");
assert_eq!(elided, 10);
}
#[test]
fn under_expands_a_leading_tilde() {
let home = std::env::var("HOME").expect("HOME must be set");
let rows = vec![
row("in", &format!("{home}/work/in"), Some(5), &["rust"]),
row("out", "/elsewhere/out", Some(5), &["rust"]),
];
let filter = FanoutFilter {
under: Some(PathBuf::from("~/work")),
..FanoutFilter::default()
};
let (kept, _) = select_candidates(rows, Path::new("/none"), &filter);
assert_eq!(
kept.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["in"],
"a tilde in --under must expand to $HOME"
);
}
fn results(
projects: Vec<ProjectHits>,
unreadable: Vec<UnreadableJson>,
queried: usize,
elided_by_cap: usize,
) -> FederatedResults {
let total_matches = projects.iter().map(|p| p.symbols.len()).sum();
FederatedResults {
projects,
unreadable,
queried,
elided_by_cap,
total_matches,
}
}
fn unreadable(name: &str, reason: &str) -> UnreadableJson {
UnreadableJson {
name: name.to_string(),
reason: reason.to_string(),
}
}
#[test]
fn a_search_where_every_candidate_was_unreadable_does_not_claim_no_match() {
let r = results(
Vec::new(),
vec![unreadable(
"legacy-service",
"schema_version mismatch: expects 8, DB has 3",
)],
1,
0,
);
let out = render(&r, "CreateOrder");
assert!(
out.contains("could not be searched") || out.contains("could not be read"),
"must say the search could not run, got: {out}"
);
assert!(
!out.contains("No symbols matching"),
"must not claim a genuine no-match, got: {out}"
);
assert!(
out.contains("legacy-service") && out.contains("schema_version"),
"the reason must survive an empty match list, got: {out}"
);
}
#[test]
fn an_empty_result_still_reports_projects_elided_by_the_cap() {
let r = results(Vec::new(), Vec::new(), 2, 7);
let out = render(&r, "Widget");
assert!(
out.contains('7') && out.contains("--max-projects"),
"the elision notice must survive an empty match list, got: {out}"
);
}
#[test]
fn a_readable_project_with_no_hit_still_reports_a_genuine_no_match() {
let r = results(Vec::new(), Vec::new(), 3, 0);
let out = render(&r, "Nope");
assert!(
out.contains("No symbols matching"),
"a real no-match must still read as one, got: {out}"
);
}
#[test]
fn describe_names_the_filter_that_matched_nothing() {
let filter = FanoutFilter {
under: Some(PathBuf::from("/w/team")),
lang: Some("ruby".to_string()),
max_projects: None,
};
let text = describe(&filter);
assert!(text.contains("/w/team"), "got {text}");
assert!(text.contains("ruby"), "got {text}");
}
#[test]
fn the_zero_candidate_line_names_only_what_was_applied() {
let no_filter = FanoutFilter {
under: None,
lang: None,
max_projects: None,
};
let line = format!(
"No other indexed project matches {}.\n",
describe(&no_filter)
);
assert!(
line.contains("this machine's registry"),
"with no filter set, blame the registry, not a filter: {line}"
);
assert!(
!line.contains("under") && !line.contains("language"),
"must not name a filter the caller never set: {line}"
);
let filtered = FanoutFilter {
under: Some(PathBuf::from("/w/team")),
lang: None,
max_projects: None,
};
let line = format!(
"No other indexed project matches {}.\n",
describe(&filtered)
);
assert!(line.contains("/w/team"), "name the applied filter: {line}");
}
#[test]
fn a_description_is_carried_through_for_routing() {
let mut r = row("svc", "/w/svc", Some(5), &["ruby"]);
r.description = Some(Description {
text: "Handles billing.".to_string(),
source: DescriptionSource::Config,
});
let (kept, _) = select_candidates(vec![r], Path::new("/none"), &FanoutFilter::default());
assert_eq!(
kept[0].description.as_ref().map(|d| d.text.as_str()),
Some("Handles billing.")
);
}
}