use std::cell::RefCell;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
use crate::exit::{EXIT_FAILURE, EXIT_NO_RESULT};
use crate::freshness::{validate, FreshnessState};
use crate::graph;
use crate::query;
use crate::query::json::J;
use crate::suggest;
use crate::telemetry;
use super::root::require_repo;
pub(crate) const ZERO_HIT_FIND: &str = "devscout find: zero hits — the manifest was searched and nothing matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
pub(crate) const ZERO_HIT_REFS: &str = "devscout refs: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
pub(crate) const ZERO_HIT_READ: &str = "devscout read: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
pub(crate) const ZERO_HIT_IMPACT: &str = "devscout impact: zero hits — the graph was searched and no affected file came back. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
pub(crate) const ZERO_HIT_TESTS: &str = "devscout tests: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
pub(crate) fn emit_zero_hit_note(code: i32, note: Option<&str>, cwd: &Path, query: Option<&str>) {
let Some(note) = note else { return };
if code != EXIT_NO_RESULT {
return;
}
let mut text = String::from(note);
let rows = query.map(|q| nearest_names(cwd, q)).unwrap_or_default();
if !rows.is_empty() {
text.push_str("\ndid you mean:");
for row in rows {
text.push('\n');
text.push_str(&row);
}
}
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = lock
.write_all(text.as_bytes())
.and_then(|()| lock.write_all(b"\n"));
}
thread_local! {
static SLOT: RefCell<Option<Slot>> = const { RefCell::new(None) };
}
struct Slot {
root: PathBuf,
begin: validate::Begin,
finished: Option<validate::Validation>,
}
pub(crate) fn begin_validation(root: &Path, invocation_dir: &Path) {
let begin = validate::begin(root, invocation_dir);
SLOT.with(|slot| {
*slot.borrow_mut() = Some(Slot {
root: root.to_path_buf(),
begin,
finished: None,
});
});
}
fn validation_for(root: &Path) -> Option<validate::Validation> {
SLOT.with(|slot| {
let mut slot = slot.borrow_mut();
let entry = slot.as_mut()?;
if entry.root != root {
return None;
}
if entry.finished.is_none() {
entry.finished = Some(entry.begin.finish());
}
entry.finished.clone()
})
}
pub(crate) fn emit_freshness_warning(cwd: &Path) {
let Some(root) = crate::repo::find_scout_root(cwd).or_else(|| crate::repo::find_repo_root(cwd))
else {
return;
};
let validation = validation_for(&root).unwrap_or_else(|| validate::begin(&root, cwd).finish());
let Some(warning) = validation.warning else {
return;
};
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = lock
.write_all(warning.as_bytes())
.and_then(|()| lock.write_all(b"\n"));
}
fn nearest_names(cwd: &Path, query: &str) -> Vec<String> {
let Ok(root) = require_repo(cwd) else {
return Vec::new();
};
let Some(g) = graph::read_graph(&root) else {
return Vec::new();
};
suggest::suggestion_lines(&g.names, query)
}
fn ambiguous_json(q: &str, candidates: Vec<J>) -> String {
J::Obj(vec![
("schema_version", J::UInt(query::SCHEMA_VERSION)),
(
"outcome",
J::Str(query::Outcome::Ambiguous.as_str().to_string()),
),
("query", J::Str(q.to_string())),
("candidates", J::Arr(candidates)),
])
.to_json_string()
}
pub(crate) fn type_candidate_object(index: &query::GraphIndex, id: &str) -> J {
let d = query::candidate_def(index, id);
J::Obj(vec![
("id", J::Str(id.to_string())),
("file", J::Str(d.file.clone())),
("line", J::UInt(d.line as u64)),
("kind", J::Str(d.kind.clone())),
])
}
pub(crate) fn member_candidate_object(c: &query::MemberCandidate) -> J {
J::Obj(vec![
("owner", J::Str(c.owner.clone())),
("name", J::Str(c.name.clone())),
("file", J::Str(c.file.clone())),
("line", J::UInt(c.line as u64)),
])
}
pub(crate) fn ambiguous_candidates_out(
index: &query::GraphIndex,
q: &str,
ids: &[String],
json: bool,
) -> (i32, String) {
let ordered = query::type_candidates_in_refusal_order(index, ids);
if json {
let candidates = ordered
.iter()
.map(|id| type_candidate_object(index, id))
.collect();
return (EXIT_FAILURE, ambiguous_json(q, candidates));
}
let mut out = vec![format!(
"ambiguous symbol \"{q}\" — {} candidates:",
ordered.len()
)];
out.extend(
ordered
.iter()
.map(|id| query::type_candidate_row(id, query::candidate_def(index, id))),
);
(EXIT_FAILURE, out.join("\n"))
}
pub(crate) fn member_ambiguous_out(
q: &str,
candidates: &[query::MemberCandidate],
json: bool,
) -> (i32, String) {
if json {
let rows = candidates.iter().map(member_candidate_object).collect();
return (EXIT_FAILURE, ambiguous_json(q, rows));
}
let mut out = vec![format!(
"ambiguous member \"{q}\" — {} candidates:",
candidates.len()
)];
out.extend(candidates.iter().map(query::member_candidate_row));
(EXIT_FAILURE, out.join("\n"))
}
pub(crate) fn fallback_advised_out(q: &str, json: bool, plain: String) -> (i32, String) {
if json {
let out = J::Obj(vec![
("schema_version", J::UInt(query::SCHEMA_VERSION)),
(
"outcome",
J::Str(query::Outcome::FallbackAdvised.as_str().to_string()),
),
("query", J::Str(q.to_string())),
])
.to_json_string();
(EXIT_NO_RESULT, out)
} else {
(EXIT_NO_RESULT, plain)
}
}
pub(crate) fn freshness_json(state: &FreshnessState) -> J {
match state {
FreshnessState::Fresh => J::Obj(vec![("state", J::Str("fresh".to_string()))]),
FreshnessState::Stale {
indexed_head,
current_head,
changed_files,
} => J::Obj(vec![
("state", J::Str("stale".to_string())),
("indexedHead", J::Str(indexed_head.clone())),
("currentHead", J::Str(current_head.clone())),
("changedFiles", J::UInt(*changed_files as u64)),
]),
FreshnessState::Unknown { reason } => J::Obj(vec![
("state", J::Str("unknown".to_string())),
("reason", J::Str(reason.as_str().to_string())),
]),
}
}
pub(crate) fn with_next_trailer(out: String, next: &[Vec<String>]) -> String {
match crate::render::next_line(next) {
Some(line) => format!("{out}\n{line}"),
None => out,
}
}
fn append_freshness(out: &str, state: &FreshnessState) -> String {
debug_assert!(
out.ends_with('}'),
"a --json answer must be exactly one top-level object: {out}"
);
let mut spliced = out.strip_suffix('}').unwrap_or(out).to_string();
spliced.push_str(",\"freshness\":");
spliced.push_str(&freshness_json(state).to_json_string());
spliced.push('}');
spliced
}
pub(crate) fn finish_query(
root: &Path,
verb: &'static str,
seed: &str,
start: Instant,
json: bool,
answer: (i32, String, query::Outcome, usize),
) -> (i32, String) {
let (code, mut out, outcome, candidate_count) = answer;
if json {
let state = validation_for(root)
.map(|v| v.state)
.unwrap_or_else(|| validate::begin(root, root).finish().state);
out = append_freshness(&out, &state);
}
telemetry::record(
root,
&telemetry::QueryEvent {
verb,
seed,
outcome,
candidate_count,
},
start.elapsed(),
out.len(),
);
(code, out)
}