use edda_core::Event;
use edda_ledger::DecisionView;
use edda_ledger::Ledger;
use serde::Serialize;
pub mod staleness;
const SEMANTIC_CANDIDATE_LIMIT: usize = 500;
#[derive(Debug)]
struct ScoredDecision {
row: DecisionView,
score: f64,
}
#[derive(Debug, PartialEq)]
pub enum InputType {
ExactKey(String),
Domain(String),
Keyword(String),
Overview,
}
pub fn detect_input_type(query: &str, known_domains: &[String]) -> InputType {
let q = query.trim();
if q.is_empty() {
return InputType::Overview;
}
if q.contains('.') && q.split('.').count() >= 2 && q.split('.').all(|p| !p.is_empty()) {
return InputType::ExactKey(q.to_string());
}
let q_lower = q.to_lowercase();
if known_domains.iter().any(|d| d.to_lowercase() == q_lower) {
return InputType::Domain(q.to_string());
}
InputType::Keyword(q.to_string())
}
#[derive(Debug, Serialize)]
pub struct AskResult {
pub query: String,
pub input_type: String,
pub decisions: Vec<DecisionHit>,
pub timeline: Vec<DecisionHit>,
pub related_commits: Vec<CommitHit>,
pub related_notes: Vec<NoteHit>,
pub conversations: Vec<ConversationHit>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dependents: Vec<DependentHit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub override_risk: Option<OverrideRisk>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionHit {
pub event_id: String,
pub key: String,
pub value: String,
pub reason: String,
pub domain: String,
pub branch: String,
pub ts: String,
pub is_active: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub village_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub staleness: Option<crate::staleness::DecisionStaleness>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CommitHit {
pub event_id: String,
pub title: String,
pub purpose: String,
pub ts: String,
pub branch: String,
pub match_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct NoteHit {
pub event_id: String,
pub text: String,
pub ts: String,
pub branch: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConversationHit {
pub doc_id: String,
pub session_id: String,
pub ts: String,
pub snippet: String,
pub rank: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DependentHit {
pub key: String,
pub value: String,
pub dep_type: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct OverrideRisk {
pub level: String,
pub dependent_count: usize,
pub suggestion: Option<String>,
}
pub struct AskOptions {
pub limit: usize,
pub include_superseded: bool,
pub branch: Option<String>,
pub impact: bool,
pub after: Option<String>,
pub before: Option<String>,
pub tags: Vec<String>,
pub village_id: Option<String>,
}
impl Default for AskOptions {
fn default() -> Self {
Self {
limit: 20,
include_superseded: false,
branch: None,
impact: false,
after: None,
before: None,
tags: vec![],
village_id: None,
}
}
}
pub type TranscriptSearchFn = dyn Fn(&str, usize) -> Vec<ConversationHit>;
pub fn ask(
ledger: &Ledger,
query: &str,
opts: &AskOptions,
transcript_search: Option<&TranscriptSearchFn>,
) -> anyhow::Result<AskResult> {
let domains = ledger.list_domains()?;
let input_type = detect_input_type(query, &domains);
let branch_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
match &opts.branch {
Some(b) => hits.into_iter().filter(|d| d.branch == *b).collect(),
None => hits,
}
};
let tags_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
if opts.tags.is_empty() {
return hits;
}
hits.into_iter()
.filter(|d| d.tags.iter().any(|t| opts.tags.contains(t)))
.collect()
};
let village_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
match &opts.village_id {
Some(v) => hits
.into_iter()
.filter(|d| d.village_id.as_deref() == Some(v.as_str()))
.collect(),
None => hits,
}
};
let after_ref = opts.after.as_deref();
let before_ref = opts.before.as_deref();
let (decisions, timeline) = match &input_type {
InputType::ExactKey(key) => {
let all = ledger
.decision_timeline(key, after_ref, before_ref)?
.into_iter()
.map(|r| to_decision_hit(&r))
.collect::<Vec<_>>();
let all = branch_filter(all);
let active: Vec<DecisionHit> = if opts.include_superseded {
all.clone()
} else {
all.iter().filter(|d| d.is_active).cloned().collect()
};
(active, all)
}
InputType::Domain(domain) => {
let active = branch_filter(
ledger
.active_decisions_limited(
Some(domain),
None,
after_ref,
before_ref,
opts.limit,
)?
.into_iter()
.map(|r| to_decision_hit(&r))
.collect(),
);
let tl = branch_filter(
ledger
.domain_timeline(domain, after_ref, before_ref)?
.into_iter()
.map(|r| to_decision_hit(&r))
.collect(),
);
(active, tl)
}
InputType::Keyword(kw) => {
let mut semantic_hits = semantic_decision_search(
ledger,
kw,
opts.branch.as_deref(),
after_ref,
before_ref,
opts.limit,
)?;
let lexical_fallback = ledger
.active_decisions_limited(None, Some(kw), after_ref, before_ref, opts.limit)?
.into_iter()
.map(|r| to_decision_hit(&r));
for hit in lexical_fallback {
if !semantic_hits.iter().any(|h| h.event_id == hit.event_id) {
semantic_hits.push(hit);
}
}
let mut hits = branch_filter(semantic_hits);
if hits.len() > opts.limit {
hits.truncate(opts.limit);
}
if opts.include_superseded {
let events = ledger.iter_events_by_type("note")?;
let kw_lower = kw.to_lowercase();
for event in &events {
if let Some(ref b) = opts.branch {
if event.branch != *b {
continue;
}
}
if event.event_type == "note"
&& edda_core::decision::is_decision(&event.payload)
{
if let Some(dp) = edda_core::decision::extract_decision(&event.payload) {
let reason_str = dp.reason.as_deref().unwrap_or("").to_string();
if (dp.key.to_lowercase().contains(&kw_lower)
|| dp.value.to_lowercase().contains(&kw_lower)
|| reason_str.to_lowercase().contains(&kw_lower))
&& !hits.iter().any(|h| h.event_id == event.event_id)
{
let domain = edda_core::decision::extract_domain(&dp.key);
hits.push(DecisionHit {
event_id: event.event_id.clone(),
key: dp.key,
value: dp.value,
reason: reason_str,
domain,
branch: event.branch.clone(),
ts: event.ts.clone(),
is_active: false,
tags: dp.tags.unwrap_or_default(),
village_id: dp.village_id,
staleness: None,
});
}
}
}
}
}
(hits, vec![])
}
InputType::Overview => {
let active = branch_filter(
ledger
.active_decisions_limited(None, None, after_ref, before_ref, opts.limit)?
.into_iter()
.map(|r| to_decision_hit(&r))
.collect(),
);
(active, vec![])
}
};
let decisions = tags_filter(decisions);
let timeline = tags_filter(timeline);
let decisions = village_filter(decisions);
let timeline = village_filter(timeline);
let decision_event_ids: Vec<&str> = decisions
.iter()
.map(|d| d.event_id.as_str())
.chain(timeline.iter().map(|d| d.event_id.as_str()))
.collect();
let q = query.trim();
let commit_events =
ledger.find_related_commits(opts.branch.as_deref(), q, &decision_event_ids, opts.limit)?;
let related_commits = to_commit_hits(&commit_events, &decision_event_ids, q, opts.limit);
let note_events = ledger.find_related_notes(opts.branch.as_deref(), q, opts.limit)?;
let related_notes = to_note_hits(¬e_events, opts.limit);
let conversations = match transcript_search {
Some(search_fn) if !q.is_empty() => search_fn(q, opts.limit),
_ => vec![],
};
let input_type_str = match &input_type {
InputType::ExactKey(_) => "exact_key",
InputType::Domain(_) => "domain",
InputType::Keyword(_) => "keyword",
InputType::Overview => "overview",
};
let (dependents, override_risk) = if opts.impact {
match &input_type {
InputType::ExactKey(key) => compute_impact(ledger, key)?,
InputType::Domain(domain) => compute_domain_impact(ledger, domain)?,
_ => (vec![], None),
}
} else {
(vec![], None)
};
Ok(AskResult {
query: q.to_string(),
input_type: input_type_str.to_string(),
decisions,
timeline,
related_commits,
related_notes,
conversations,
dependents,
override_risk,
})
}
fn compute_impact(
ledger: &Ledger,
key: &str,
) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
let transitive_deps = ledger.transitive_dependents_of(key, 3)?;
let dependents: Vec<DependentHit> = transitive_deps
.iter()
.map(|(dep, decision, depth)| DependentHit {
key: decision.key.clone(),
value: decision.value.clone(),
dep_type: dep.dep_type.clone(),
depth: *depth,
})
.collect();
let risk = compute_override_risk(&dependents);
Ok((dependents, Some(risk)))
}
fn compute_override_risk(dependents: &[DependentHit]) -> OverrideRisk {
let count = dependents.len();
let explicit_direct: usize = dependents
.iter()
.filter(|d| d.dep_type == "explicit" && d.depth == 1)
.count();
let auto_domain_direct: usize = dependents
.iter()
.filter(|d| d.dep_type == "auto_domain" && d.depth == 1)
.count();
let transitive: usize = dependents.iter().filter(|d| d.depth > 1).count();
let mut domains: std::collections::HashSet<&str> = std::collections::HashSet::new();
for d in dependents {
if let Some(dom) = d.key.split('.').next() {
domains.insert(dom);
}
}
let cross_domain_bonus = if domains.len() > 1 { domains.len() } else { 0 };
let score = explicit_direct * 3 + auto_domain_direct + transitive * 2 + cross_domain_bonus * 2;
let level = if score <= 2 {
"LOW"
} else if score <= 6 {
"MEDIUM"
} else {
"HIGH"
};
let suggestion = if count > 0 {
let mut sorted: Vec<&DependentHit> = dependents.iter().collect();
sorted.sort_by_key(|dependent| std::cmp::Reverse(dependent.depth));
let keys: Vec<&str> = sorted.iter().map(|d| d.key.as_str()).collect();
Some(format!("建議覆蓋順序: {}", keys.join(" → ")))
} else {
None
};
OverrideRisk {
level: level.to_string(),
dependent_count: count,
suggestion,
}
}
fn compute_domain_impact(
ledger: &Ledger,
domain: &str,
) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
let keys_in_domain = ledger.active_decisions(Some(domain), None, None, None)?;
if keys_in_domain.is_empty() {
return Ok((vec![], None));
}
let mut all_dependents: Vec<DependentHit> = Vec::new();
let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
for decision in &keys_in_domain {
let (deps, _) = compute_impact(ledger, &decision.key)?;
for d in deps {
if seen_keys.insert(d.key.clone()) {
all_dependents.push(d);
}
}
}
let risk = if all_dependents.is_empty() {
Some(OverrideRisk {
level: "LOW".to_string(),
dependent_count: 0,
suggestion: None,
})
} else {
Some(compute_override_risk(&all_dependents))
};
Ok((all_dependents, risk))
}
fn to_commit_hits(
events: &[Event],
decision_event_ids: &[&str],
query: &str,
limit: usize,
) -> Vec<CommitHit> {
let mut hits: Vec<CommitHit> = Vec::new();
let q_lower = query.to_lowercase();
for event in events {
let title = event
.payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("");
let purpose = event
.payload
.get("purpose")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut match_type = None;
for ref_id in &event.refs.events {
if decision_event_ids.contains(&ref_id.as_str()) {
match_type = Some("evidence");
break;
}
}
if match_type.is_none() {
for prov in &event.refs.provenance {
if decision_event_ids.contains(&prov.target.as_str()) {
match_type = Some("evidence");
break;
}
}
}
if match_type.is_none()
&& !query.is_empty()
&& (title.to_lowercase().contains(&q_lower)
|| purpose.to_lowercase().contains(&q_lower))
{
match_type = Some("title");
}
if let Some(mt) = match_type {
if !hits.iter().any(|h| h.event_id == event.event_id) {
hits.push(CommitHit {
event_id: event.event_id.clone(),
title: title.to_string(),
purpose: purpose.to_string(),
ts: event.ts.clone(),
branch: event.branch.clone(),
match_type: mt.to_string(),
});
if hits.len() >= limit {
break;
}
}
}
}
hits
}
fn to_note_hits(events: &[Event], limit: usize) -> Vec<NoteHit> {
events
.iter()
.take(limit)
.map(|event| {
let text = event
.payload
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
NoteHit {
event_id: event.event_id.clone(),
text: text.to_string(),
ts: event.ts.clone(),
branch: event.branch.clone(),
}
})
.collect()
}
fn semantic_decision_search(
ledger: &Ledger,
query: &str,
branch: Option<&str>,
after: Option<&str>,
before: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<DecisionHit>> {
if limit == 0 {
return Ok(vec![]);
}
let candidates =
ledger.active_decisions_limited(None, None, after, before, SEMANTIC_CANDIDATE_LIMIT)?;
let scored = rank_decisions_by_similarity(query, candidates, branch);
Ok(scored
.into_iter()
.take(limit)
.map(|s| to_decision_hit(&s.row))
.collect())
}
fn rank_decisions_by_similarity(
query: &str,
candidates: Vec<DecisionView>,
branch: Option<&str>,
) -> Vec<ScoredDecision> {
let query_tokens = tokenize_for_similarity(query);
if query_tokens.is_empty() {
return vec![];
}
let mut tokenized_docs: Vec<(DecisionView, Vec<String>)> = Vec::new();
for row in candidates {
if branch.is_some_and(|b| row.branch != b) {
continue;
}
let text = format!("{} {} {} {}", row.domain, row.key, row.value, row.reason);
let tokens = tokenize_for_similarity(&text);
if !tokens.is_empty() {
tokenized_docs.push((row, tokens));
}
}
if tokenized_docs.is_empty() {
return vec![];
}
let mut doc_freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (_, tokens) in &tokenized_docs {
let unique: std::collections::HashSet<&str> = tokens.iter().map(|t| t.as_str()).collect();
for token in unique {
*doc_freq.entry(token.to_string()).or_insert(0) += 1;
}
}
let n_docs = tokenized_docs.len() as f64;
let query_tf = term_freq(&query_tokens);
let query_norm = vector_norm(&query_tf, &doc_freq, n_docs);
let mut scored: Vec<ScoredDecision> = Vec::new();
for (row, tokens) in tokenized_docs {
let tf = term_freq(&tokens);
let doc_norm = vector_norm(&tf, &doc_freq, n_docs);
if doc_norm <= f64::EPSILON || query_norm <= f64::EPSILON {
continue;
}
let mut dot = 0.0;
for (token, q_tf) in &query_tf {
if let Some(d_tf) = tf.get(token) {
let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
dot += (*q_tf as f64 * idf) * (*d_tf as f64 * idf);
}
}
let score = dot / (query_norm * doc_norm);
if score > 0.0 {
scored.push(ScoredDecision { row, score });
}
}
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored
}
fn tokenize_for_similarity(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter_map(|t| {
let normalized = t.trim().to_lowercase();
if normalized.len() >= 2 {
Some(normalized)
} else {
None
}
})
.collect()
}
fn term_freq(tokens: &[String]) -> std::collections::HashMap<String, usize> {
let mut tf = std::collections::HashMap::new();
for token in tokens {
*tf.entry(token.clone()).or_insert(0) += 1;
}
tf
}
fn inv_doc_freq(df: usize, n_docs: f64) -> f64 {
((n_docs + 1.0) / (df as f64 + 1.0)).ln() + 1.0
}
fn vector_norm(
tf: &std::collections::HashMap<String, usize>,
doc_freq: &std::collections::HashMap<String, usize>,
n_docs: f64,
) -> f64 {
let mut sum_sq = 0.0;
for (token, count) in tf {
let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
let weight = *count as f64 * idf;
sum_sq += weight * weight;
}
sum_sq.sqrt()
}
pub fn format_human(result: &AskResult) -> String {
let mut out = String::new();
if !result.decisions.is_empty() {
out.push_str("── Decisions ──────────────────────────\n");
for d in &result.decisions {
let status = if d.is_active { "active" } else { "superseded" };
out.push_str(&format!(
" {} = {} — {}\n branch: {} | {} | {}\n",
d.key, d.value, d.reason, d.branch, d.ts, status
));
if let Some(st) = &d.staleness {
if st.is_stale {
let bad: Vec<String> = st
.paths
.iter()
.filter(|p| {
p.status != staleness::PathStatus::Fresh
&& p.status != staleness::PathStatus::Unknown
})
.map(|p| format!("{} ({:?})", p.path, p.status))
.collect();
if !bad.is_empty() {
out.push_str(&format!(" ⚠ stale-code hint: {}\n", bad.join(", ")));
}
}
}
out.push('\n');
}
}
if !result.timeline.is_empty() {
out.push_str("── Timeline ───────────────────────────\n");
for d in &result.timeline {
let status = if d.is_active { "active" } else { "superseded" };
out.push_str(&format!(
" {} {} = {} ({})\n",
d.ts, d.key, d.value, status
));
}
out.push('\n');
}
if !result.related_commits.is_empty() {
out.push_str("── Related Commits ────────────────────\n");
for c in &result.related_commits {
out.push_str(&format!(
" {} ({}, {})\n match: {}\n\n",
c.title, c.ts, c.branch, c.match_type
));
}
}
if !result.related_notes.is_empty() {
out.push_str("── Related Notes ──────────────────────\n");
for n in &result.related_notes {
if n.text.len() > 120 {
let end = n.text.floor_char_boundary(117);
out.push_str(&format!(
" \"{}...\" ({}, {})\n\n",
&n.text[..end],
n.ts,
n.branch
));
} else {
out.push_str(&format!(" \"{}\" ({}, {})\n\n", n.text, n.ts, n.branch));
}
}
}
if !result.dependents.is_empty() {
out.push_str("── Dependents ─────────────────────────\n");
for d in &result.dependents {
out.push_str(&format!(
" \u{2192} {} = {} ({}, depth {})\n",
d.key, d.value, d.dep_type, d.depth
));
}
out.push('\n');
}
if let Some(ref risk) = result.override_risk {
out.push_str("── Override Risk ──────────────────────\n");
let emoji = match risk.level.as_str() {
"HIGH" => "\u{1f534}",
"MEDIUM" => "\u{1f7e1}",
_ => "\u{1f7e2}",
};
out.push_str(&format!(
" {} {} ({} dependents)\n",
emoji, risk.level, risk.dependent_count
));
if let Some(ref suggestion) = risk.suggestion {
out.push_str(&format!(" {suggestion}\n"));
}
out.push('\n');
}
if !result.conversations.is_empty() {
out.push_str("── Conversations ──────────────────────\n");
for c in &result.conversations {
out.push_str(&format!(
" [{}] {}\n rank: {:.2}\n\n",
c.session_id, c.snippet, c.rank
));
}
}
if out.is_empty() {
out.push_str("No results found.\n");
}
out
}
fn to_decision_hit(row: &DecisionView) -> DecisionHit {
DecisionHit {
event_id: row.event_id.clone(),
key: row.key.clone(),
value: row.value.clone(),
reason: row.reason.clone(),
domain: row.domain.clone(),
branch: row.branch.clone(),
ts: row.ts.clone().unwrap_or_default(),
is_active: matches!(row.status.as_str(), "active" | "experimental"),
tags: row.tags.clone(),
village_id: row.village_id.clone(),
staleness: None,
}
}
pub fn affected_paths_for_hits(ledger: &Ledger, hits: &[DecisionHit]) -> Vec<Vec<String>> {
hits.iter()
.map(|h| {
ledger
.get_decision_by_event_id(&h.event_id)
.ok()
.flatten()
.map(|row| row.affected_paths)
.unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use edda_core::event::{finalize_event, new_note_event};
use edda_core::Provenance;
use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
use edda_ledger::paths::EddaPaths;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn setup() -> (std::path::PathBuf, Ledger) {
let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let tmp = std::env::temp_dir().join(format!("edda_ask_test_{}_{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let paths = EddaPaths::discover(&tmp);
init_workspace(&paths).unwrap();
init_head(&paths, "main").unwrap();
init_branches_json(&paths, "main").unwrap();
let ledger = Ledger::open(&tmp).unwrap();
(tmp, ledger)
}
fn make_decision(
branch: &str,
key: &str,
value: &str,
reason: Option<&str>,
supersedes: Option<&str>,
) -> Event {
let text = match reason {
Some(r) => format!("{key}: {value} — {r}"),
None => format!("{key}: {value}"),
};
let tags = vec!["decision".to_string()];
let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
let decision_obj = match reason {
Some(r) => serde_json::json!({"key": key, "value": value, "reason": r}),
None => serde_json::json!({"key": key, "value": value}),
};
event.payload["decision"] = decision_obj;
if let Some(target) = supersedes {
event.refs.provenance.push(Provenance {
target: target.to_string(),
rel: "supersedes".to_string(),
note: Some(format!("key '{key}' re-decided")),
});
}
finalize_event(&mut event).unwrap();
event
}
fn make_commit(branch: &str, title: &str, purpose: &str, evidence: &[&str]) -> Event {
use edda_core::event::finalize_event;
let payload = serde_json::json!({
"title": title,
"purpose": purpose,
"sha": "abc123",
});
let mut event = Event {
event_id: format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase()),
ts: time_now(),
event_type: "commit".to_string(),
branch: branch.to_string(),
parent_hash: None,
hash: String::new(),
payload,
refs: edda_core::Refs {
events: evidence.iter().map(|s| s.to_string()).collect(),
..Default::default()
},
schema_version: 1,
digests: vec![],
event_family: None,
event_level: None,
};
finalize_event(&mut event).unwrap();
event
}
fn make_note(branch: &str, text: &str) -> Event {
new_note_event(branch, None, "user", text, &[]).unwrap()
}
fn time_now() -> String {
let now = time::OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.expect("rfc3339")
}
#[test]
fn detect_exact_key() {
let domains = vec!["db".into(), "auth".into()];
assert_eq!(
detect_input_type("db.engine", &domains),
InputType::ExactKey("db.engine".into())
);
}
#[test]
fn detect_domain() {
let domains = vec!["db".into(), "auth".into()];
assert_eq!(
detect_input_type("db", &domains),
InputType::Domain("db".into())
);
}
#[test]
fn detect_domain_case_insensitive() {
let domains = vec!["db".into()];
assert_eq!(
detect_input_type("DB", &domains),
InputType::Domain("DB".into())
);
}
#[test]
fn detect_keyword() {
let domains = vec!["db".into()];
assert_eq!(
detect_input_type("postgres", &domains),
InputType::Keyword("postgres".into())
);
}
#[test]
fn detect_overview() {
assert_eq!(detect_input_type("", &[]), InputType::Overview);
assert_eq!(detect_input_type(" ", &[]), InputType::Overview);
}
#[test]
fn ask_exact_key() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "sqlite", Some("MVP"), None);
let d1_id = d1.event_id.clone();
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.engine", "postgres", Some("JSONB"), Some(&d1_id));
ledger.append_event(&d2).unwrap();
let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
assert_eq!(result.input_type, "exact_key");
assert_eq!(result.decisions.len(), 1);
assert_eq!(result.decisions[0].value, "postgres");
assert_eq!(result.timeline.len(), 2);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_domain() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
ledger
.append_event(&make_decision("main", "db.pool", "10", None, None))
.unwrap();
ledger
.append_event(&make_decision("main", "auth.method", "JWT", None, None))
.unwrap();
let result = ask(&ledger, "db", &AskOptions::default(), None).unwrap();
assert_eq!(result.input_type, "domain");
assert_eq!(result.decisions.len(), 2);
assert!(result.timeline.len() >= 2);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_keyword() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision(
"main",
"db.engine",
"postgres",
Some("JSONB"),
None,
))
.unwrap();
ledger
.append_event(&make_decision("main", "auth.method", "JWT", None, None))
.unwrap();
let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
assert_eq!(result.input_type, "keyword");
assert_eq!(result.decisions.len(), 1);
assert_eq!(result.decisions[0].key, "db.engine");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_overview() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
ledger
.append_event(&make_decision("main", "auth.method", "JWT", None, None))
.unwrap();
let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
assert_eq!(result.input_type, "overview");
assert_eq!(result.decisions.len(), 2);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_with_transcript_callback() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
let callback = |_query: &str, _limit: usize| -> Vec<ConversationHit> {
vec![ConversationHit {
doc_id: "t1".into(),
session_id: "s1".into(),
ts: "2026-02-14T10:00:00Z".into(),
snippet: "discussed postgres JSONB".into(),
rank: 5.0,
}]
};
let result = ask(&ledger, "postgres", &AskOptions::default(), Some(&callback)).unwrap();
assert_eq!(result.conversations.len(), 1);
assert_eq!(result.conversations[0].snippet, "discussed postgres JSONB");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_without_transcript_callback() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
assert!(result.conversations.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn find_related_commits_evidence_chain() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
let d1_id = d1.event_id.clone();
ledger.append_event(&d1).unwrap();
let c1 = make_commit(
"main",
"feat: migrate to postgres",
"db migration",
&[&d1_id],
);
ledger.append_event(&c1).unwrap();
let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
assert_eq!(result.related_commits.len(), 1);
assert_eq!(result.related_commits[0].match_type, "evidence");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn find_related_commits_title_match() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
let c1 = make_commit("main", "fix: postgres connection pool", "pool fix", &[]);
ledger.append_event(&c1).unwrap();
let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
assert!(result
.related_commits
.iter()
.any(|c| c.match_type == "title"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn find_related_notes_text_match() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision("main", "db.engine", "postgres", None, None))
.unwrap();
ledger
.append_event(&make_note(
"main",
"discussed mysql but rejected for licensing",
))
.unwrap();
let result = ask(&ledger, "mysql", &AskOptions::default(), None).unwrap();
assert_eq!(result.related_notes.len(), 1);
assert!(result.related_notes[0].text.contains("mysql"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn find_related_notes_excludes_decision_notes() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision(
"main",
"db.engine",
"postgres",
Some("JSONB"),
None,
))
.unwrap();
let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
assert!(result.related_notes.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_branch_filter() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision(
"main",
"db.engine",
"postgres",
Some("prod"),
None,
))
.unwrap();
ledger
.append_event(&make_decision(
"dev",
"db.engine",
"sqlite",
Some("dev speed"),
None,
))
.unwrap();
let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
assert_eq!(result.decisions.len(), 2);
let opts = AskOptions {
branch: Some("dev".into()),
..Default::default()
};
let result = ask(&ledger, "", &opts, None).unwrap();
assert_eq!(result.decisions.len(), 1);
assert_eq!(result.decisions[0].value, "sqlite");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn format_human_contains_sections() {
let result = AskResult {
query: "postgres".into(),
input_type: "keyword".into(),
decisions: vec![DecisionHit {
event_id: "e1".into(),
key: "db.engine".into(),
value: "postgres".into(),
reason: "JSONB".into(),
domain: "db".into(),
branch: "main".into(),
ts: "2026-02-15".into(),
is_active: true,
tags: vec![],
village_id: None,
staleness: None,
}],
timeline: vec![],
related_commits: vec![CommitHit {
event_id: "c1".into(),
title: "feat: migrate".into(),
purpose: "migration".into(),
ts: "2026-02-15".into(),
branch: "main".into(),
match_type: "evidence".into(),
}],
related_notes: vec![],
conversations: vec![],
dependents: vec![],
override_risk: None,
};
let output = format_human(&result);
assert!(output.contains("Decisions"));
assert!(output.contains("postgres"));
assert!(output.contains("Related Commits"));
assert!(output.contains("feat: migrate"));
}
#[test]
fn find_related_notes_excludes_session_digests() {
let (tmp, ledger) = setup();
let tags = vec!["session_digest".to_string()];
let mut digest = new_note_event(
"main",
None,
"system",
"discussed postgres migration",
&tags,
)
.unwrap();
digest.payload["session_stats"] = serde_json::json!({
"tool_calls": 10,
"tasks_snapshot": [{"subject": "Fix postgres pool", "status": "completed"}],
});
finalize_event(&mut digest).unwrap();
ledger.append_event(&digest).unwrap();
let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
assert!(
result.related_notes.is_empty(),
"session digest should be filtered: {:?}",
result.related_notes
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn format_human_truncates_long_notes() {
let long_text = "a".repeat(200);
let result = AskResult {
query: "test".into(),
input_type: "keyword".into(),
decisions: vec![],
timeline: vec![],
related_commits: vec![],
related_notes: vec![NoteHit {
event_id: "n1".into(),
text: long_text,
ts: "2026-02-26".into(),
branch: "main".into(),
}],
conversations: vec![],
dependents: vec![],
override_risk: None,
};
let output = format_human(&result);
assert!(output.contains("..."), "long note should be truncated");
assert!(
!output.contains(&"a".repeat(200)),
"should not contain full text"
);
}
#[test]
fn format_human_empty_result() {
let result = AskResult {
query: "nonexistent".into(),
input_type: "keyword".into(),
decisions: vec![],
timeline: vec![],
related_commits: vec![],
related_notes: vec![],
conversations: vec![],
dependents: vec![],
override_risk: None,
};
let output = format_human(&result);
assert!(output.contains("No results found"));
}
#[test]
fn impact_no_dependents() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
ledger.append_event(&d1).unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert!(result.dependents.is_empty());
assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
assert_eq!(result.override_risk.as_ref().unwrap().dependent_count, 0);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_single_explicit_dep() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.schema", "JSONB", Some("needs postgres"), None);
ledger.append_event(&d2).unwrap();
ledger
.insert_dep("db.schema", "db.engine", "explicit", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert_eq!(result.dependents.len(), 1);
assert_eq!(result.dependents[0].key, "db.schema");
assert_eq!(result.dependents[0].dep_type, "explicit");
assert_eq!(result.dependents[0].depth, 1);
assert_eq!(result.override_risk.as_ref().unwrap().level, "MEDIUM");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_multiple_auto_domain() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", None, None);
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.pool", "10", None, None);
ledger.append_event(&d2).unwrap();
let d3 = make_decision("main", "db.timeout", "30s", None, None);
ledger.append_event(&d3).unwrap();
ledger
.insert_dep("db.pool", "db.engine", "auto_domain", None)
.unwrap();
ledger
.insert_dep("db.timeout", "db.engine", "auto_domain", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert_eq!(result.dependents.len(), 2);
assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_transitive_chain() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", None, None);
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.schema", "JSONB", None, None);
ledger.append_event(&d2).unwrap();
let d3 = make_decision("main", "api.format", "JSON", None, None);
ledger.append_event(&d3).unwrap();
ledger
.insert_dep("db.schema", "db.engine", "explicit", None)
.unwrap();
ledger
.insert_dep("api.format", "db.schema", "explicit", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert_eq!(result.dependents.len(), 2);
let schema_hit = result
.dependents
.iter()
.find(|d| d.key == "db.schema")
.unwrap();
assert_eq!(schema_hit.depth, 1);
let api_hit = result
.dependents
.iter()
.find(|d| d.key == "api.format")
.unwrap();
assert_eq!(api_hit.depth, 2);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_cross_domain() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", None, None);
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.schema", "JSONB", None, None);
ledger.append_event(&d2).unwrap();
let d3 = make_decision("main", "api.format", "JSON", None, None);
ledger.append_event(&d3).unwrap();
let d4 = make_decision("main", "cache.strategy", "redis", None, None);
ledger.append_event(&d4).unwrap();
ledger
.insert_dep("db.schema", "db.engine", "explicit", None)
.unwrap();
ledger
.insert_dep("api.format", "db.engine", "explicit", None)
.unwrap();
ledger
.insert_dep("cache.strategy", "db.engine", "explicit", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert_eq!(result.dependents.len(), 3);
assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_high_risk_scenario() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", None, None);
ledger.append_event(&d1).unwrap();
for (i, key) in ["db.schema", "db.pool", "api.format", "auth.store"]
.iter()
.enumerate()
{
let d = make_decision("main", key, &format!("val{i}"), None, None);
ledger.append_event(&d).unwrap();
ledger
.insert_dep(key, "db.engine", "explicit", None)
.unwrap();
}
let d_cache = make_decision("main", "cache.ttl", "60s", None, None);
ledger.append_event(&d_cache).unwrap();
ledger
.insert_dep("cache.ttl", "api.format", "explicit", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db.engine", &opts, None).unwrap();
assert_eq!(result.dependents.len(), 5);
assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_domain_query() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", None, None);
ledger.append_event(&d1).unwrap();
let d2 = make_decision("main", "db.pool", "10", None, None);
ledger.append_event(&d2).unwrap();
let d3 = make_decision("main", "api.format", "JSON", None, None);
ledger.append_event(&d3).unwrap();
ledger
.insert_dep("api.format", "db.engine", "explicit", None)
.unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "db", &opts, None).unwrap();
assert_eq!(result.input_type, "domain");
assert!(
result.dependents.iter().any(|d| d.key == "api.format"),
"domain impact should find api.format as dependent"
);
assert!(result.override_risk.is_some());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn impact_flag_ignored_for_keyword() {
let (tmp, ledger) = setup();
let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
ledger.append_event(&d1).unwrap();
let opts = AskOptions {
impact: true,
..Default::default()
};
let result = ask(&ledger, "postgres", &opts, None).unwrap();
assert_eq!(result.input_type, "keyword");
assert!(result.dependents.is_empty());
assert!(result.override_risk.is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn ask_keyword_semantic_similarity() {
let (tmp, ledger) = setup();
ledger
.append_event(&make_decision(
"main",
"pricing.discount_policy",
"daytime_revenue_shield",
Some("avoid aggressive daytime markdowns"),
None,
))
.unwrap();
ledger
.append_event(&make_decision(
"main",
"inventory.reorder_buffer",
"40_percent",
Some("protect stock for midnight spikes"),
None,
))
.unwrap();
let query = "similar daytime discount outcome for chiefs";
let result = ask(&ledger, query, &AskOptions::default(), None).unwrap();
assert_eq!(result.input_type, "keyword");
assert!(
result
.decisions
.iter()
.any(|d| d.key == "pricing.discount_policy"),
"semantic search should retrieve discount precedent"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn format_human_with_impact() {
let result = AskResult {
query: "db.engine".into(),
input_type: "exact_key".into(),
decisions: vec![DecisionHit {
event_id: "e1".into(),
key: "db.engine".into(),
value: "postgres".into(),
reason: "JSONB".into(),
domain: "db".into(),
branch: "main".into(),
ts: "2026-02-15".into(),
is_active: true,
tags: vec![],
village_id: None,
staleness: None,
}],
timeline: vec![],
related_commits: vec![],
related_notes: vec![],
conversations: vec![],
dependents: vec![
DependentHit {
key: "db.schema".into(),
value: "JSONB".into(),
dep_type: "explicit".into(),
depth: 1,
},
DependentHit {
key: "api.format".into(),
value: "JSON".into(),
dep_type: "auto_domain".into(),
depth: 2,
},
],
override_risk: Some(OverrideRisk {
level: "HIGH".to_string(),
dependent_count: 2,
suggestion: Some("建議覆蓋順序: api.format → db.schema".into()),
}),
};
let output = format_human(&result);
assert!(
output.contains("Dependents"),
"should have Dependents section"
);
assert!(
output.contains("depth 1"),
"should show depth for dependents"
);
assert!(
output.contains("depth 2"),
"should show depth for transitive"
);
assert!(
output.contains("Override Risk"),
"should have Override Risk section"
);
assert!(output.contains("HIGH"), "should show HIGH risk level");
assert!(
output.contains("建議覆蓋順序"),
"should show override order suggestion"
);
}
#[test]
fn ask_filters_by_village_id() {
let (_tmp, ledger) = setup();
let mut ev1 = make_decision("main", "db.engine", "sqlite", Some("embedded"), None);
ev1.payload["decision"]["village_id"] = serde_json::json!("village-a");
finalize_event(&mut ev1).unwrap();
ledger.append_event(&ev1).unwrap();
let ev2 = make_decision("main", "cache.ttl", "3600", Some("perf"), None);
ledger.append_event(&ev2).unwrap();
let opts_all = AskOptions::default();
let result_all = ask(&ledger, "db OR cache", &opts_all, None).unwrap();
assert!(
result_all.decisions.len() >= 2,
"expected at least 2 decisions without village filter, got {}",
result_all.decisions.len()
);
let opts_village = AskOptions {
village_id: Some("village-a".to_string()),
..Default::default()
};
let result_village = ask(&ledger, "db OR cache", &opts_village, None).unwrap();
assert_eq!(
result_village.decisions.len(),
1,
"expected 1 decision with village filter"
);
assert_eq!(result_village.decisions[0].key, "db.engine");
assert_eq!(
result_village.decisions[0].village_id.as_deref(),
Some("village-a")
);
let opts_empty = AskOptions {
village_id: Some("no-such-village".to_string()),
..Default::default()
};
let result_empty = ask(&ledger, "db OR cache", &opts_empty, None).unwrap();
assert!(
result_empty.decisions.is_empty(),
"expected 0 decisions for non-existent village"
);
}
}