Skip to main content

edda_ask/
lib.rs

1use edda_core::Event;
2use edda_ledger::DecisionView;
3use edda_ledger::Ledger;
4use serde::Serialize;
5
6pub mod staleness;
7
8const SEMANTIC_CANDIDATE_LIMIT: usize = 500;
9
10#[derive(Debug)]
11struct ScoredDecision {
12    row: DecisionView,
13    score: f64,
14}
15
16// ── Input type detection ─────────────────────────────────────────────
17
18/// Detected input type for a query string.
19#[derive(Debug, PartialEq)]
20pub enum InputType {
21    /// "db.engine" — contains '.' matching word.word
22    ExactKey(String),
23    /// "db" — matches a known domain
24    Domain(String),
25    /// "postgres" — default keyword search
26    Keyword(String),
27    /// Empty query — show all active decisions
28    Overview,
29}
30
31/// Classify a query string into one of the four input types.
32pub fn detect_input_type(query: &str, known_domains: &[String]) -> InputType {
33    let q = query.trim();
34    if q.is_empty() {
35        return InputType::Overview;
36    }
37    // Check for exact key pattern: word.word (e.g. "db.engine")
38    if q.contains('.') && q.split('.').count() >= 2 && q.split('.').all(|p| !p.is_empty()) {
39        return InputType::ExactKey(q.to_string());
40    }
41    // Check if query matches a known domain (case-insensitive)
42    let q_lower = q.to_lowercase();
43    if known_domains.iter().any(|d| d.to_lowercase() == q_lower) {
44        return InputType::Domain(q.to_string());
45    }
46    InputType::Keyword(q.to_string())
47}
48
49// ── Result types ─────────────────────────────────────────────────────
50
51#[derive(Debug, Serialize)]
52pub struct AskResult {
53    pub query: String,
54    pub input_type: String,
55    pub decisions: Vec<DecisionHit>,
56    pub timeline: Vec<DecisionHit>,
57    pub related_commits: Vec<CommitHit>,
58    pub related_notes: Vec<NoteHit>,
59    pub conversations: Vec<ConversationHit>,
60    #[serde(skip_serializing_if = "Vec::is_empty")]
61    pub dependents: Vec<DependentHit>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub override_risk: Option<OverrideRisk>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct DecisionHit {
68    pub event_id: String,
69    pub key: String,
70    pub value: String,
71    pub reason: String,
72    pub domain: String,
73    pub branch: String,
74    pub ts: String,
75    pub is_active: bool,
76    /// Tags parsed from JSON array in the decision row.
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub tags: Vec<String>,
79    /// Village scope identifier.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub village_id: Option<String>,
82    /// Decision-code freshness (q334 EDDA-STALENESS1). None ⇒ not checked
83    /// (feature off, or repo_root missing at query time). Serialized only when
84    /// present so existing JSON consumers stay unaffected.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub staleness: Option<crate::staleness::DecisionStaleness>,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct CommitHit {
91    pub event_id: String,
92    pub title: String,
93    pub purpose: String,
94    pub ts: String,
95    pub branch: String,
96    pub match_type: String,
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub struct NoteHit {
101    pub event_id: String,
102    pub text: String,
103    pub ts: String,
104    pub branch: String,
105}
106
107#[derive(Debug, Clone, Serialize)]
108pub struct ConversationHit {
109    pub doc_id: String,
110    pub session_id: String,
111    pub ts: String,
112    pub snippet: String,
113    pub rank: f64,
114}
115
116// ── Impact types ────────────────────────────────────────────────────
117
118#[derive(Debug, Clone, Serialize)]
119pub struct DependentHit {
120    pub key: String,
121    pub value: String,
122    pub dep_type: String,
123    /// Hop distance from the queried key (1 = direct dependent).
124    pub depth: usize,
125}
126
127#[derive(Debug, Clone, Serialize)]
128pub struct OverrideRisk {
129    pub level: String,
130    pub dependent_count: usize,
131    pub suggestion: Option<String>,
132}
133
134// ── Options ──────────────────────────────────────────────────────────
135
136pub struct AskOptions {
137    pub limit: usize,
138    pub include_superseded: bool,
139    pub branch: Option<String>,
140    pub impact: bool,
141    /// ISO 8601 lower bound (inclusive) for temporal filtering.
142    pub after: Option<String>,
143    /// ISO 8601 upper bound (inclusive) for temporal filtering.
144    pub before: Option<String>,
145    /// Filter decisions whose tags contain any of these values (OR semantics).
146    pub tags: Vec<String>,
147    /// Filter decisions belonging to a specific village.
148    pub village_id: Option<String>,
149}
150
151impl Default for AskOptions {
152    fn default() -> Self {
153        Self {
154            limit: 20,
155            include_superseded: false,
156            branch: None,
157            impact: false,
158            after: None,
159            before: None,
160            tags: vec![],
161            village_id: None,
162        }
163    }
164}
165
166/// Transcript search callback type.
167pub type TranscriptSearchFn = dyn Fn(&str, usize) -> Vec<ConversationHit>;
168
169// ── Core ask function ────────────────────────────────────────────────
170
171pub fn ask(
172    ledger: &Ledger,
173    query: &str,
174    opts: &AskOptions,
175    transcript_search: Option<&TranscriptSearchFn>,
176) -> anyhow::Result<AskResult> {
177    let domains = ledger.list_domains()?;
178    let input_type = detect_input_type(query, &domains);
179
180    // Branch filter helper: keep only decisions matching the requested branch
181    let branch_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
182        match &opts.branch {
183            Some(b) => hits.into_iter().filter(|d| d.branch == *b).collect(),
184            None => hits,
185        }
186    };
187
188    // Tags filter helper: keep only decisions that have at least one matching tag (OR semantics)
189    let tags_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
190        if opts.tags.is_empty() {
191            return hits;
192        }
193        hits.into_iter()
194            .filter(|d| d.tags.iter().any(|t| opts.tags.contains(t)))
195            .collect()
196    };
197
198    // Village filter helper: keep only decisions belonging to the specified village
199    let village_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
200        match &opts.village_id {
201            Some(v) => hits
202                .into_iter()
203                .filter(|d| d.village_id.as_deref() == Some(v.as_str()))
204                .collect(),
205            None => hits,
206        }
207    };
208
209    let after_ref = opts.after.as_deref();
210    let before_ref = opts.before.as_deref();
211
212    let (decisions, timeline) = match &input_type {
213        InputType::ExactKey(key) => {
214            let all = ledger
215                .decision_timeline(key, after_ref, before_ref)?
216                .into_iter()
217                .map(|r| to_decision_hit(&r))
218                .collect::<Vec<_>>();
219            let all = branch_filter(all);
220            let active: Vec<DecisionHit> = if opts.include_superseded {
221                all.clone()
222            } else {
223                all.iter().filter(|d| d.is_active).cloned().collect()
224            };
225            (active, all)
226        }
227        InputType::Domain(domain) => {
228            let active = branch_filter(
229                ledger
230                    .active_decisions_limited(
231                        Some(domain),
232                        None,
233                        after_ref,
234                        before_ref,
235                        opts.limit,
236                    )?
237                    .into_iter()
238                    .map(|r| to_decision_hit(&r))
239                    .collect(),
240            );
241            let tl = branch_filter(
242                ledger
243                    .domain_timeline(domain, after_ref, before_ref)?
244                    .into_iter()
245                    .map(|r| to_decision_hit(&r))
246                    .collect(),
247            );
248            (active, tl)
249        }
250        InputType::Keyword(kw) => {
251            let mut semantic_hits = semantic_decision_search(
252                ledger,
253                kw,
254                opts.branch.as_deref(),
255                after_ref,
256                before_ref,
257                opts.limit,
258            )?;
259
260            let lexical_fallback = ledger
261                .active_decisions_limited(None, Some(kw), after_ref, before_ref, opts.limit)?
262                .into_iter()
263                .map(|r| to_decision_hit(&r));
264
265            for hit in lexical_fallback {
266                if !semantic_hits.iter().any(|h| h.event_id == hit.event_id) {
267                    semantic_hits.push(hit);
268                }
269            }
270
271            let mut hits = branch_filter(semantic_hits);
272            if hits.len() > opts.limit {
273                hits.truncate(opts.limit);
274            }
275
276            if opts.include_superseded {
277                // Scan only note events for superseded decisions matching keyword
278                let events = ledger.iter_events_by_type("note")?;
279                let kw_lower = kw.to_lowercase();
280                for event in &events {
281                    if let Some(ref b) = opts.branch {
282                        if event.branch != *b {
283                            continue;
284                        }
285                    }
286                    if event.event_type == "note"
287                        && edda_core::decision::is_decision(&event.payload)
288                    {
289                        if let Some(dp) = edda_core::decision::extract_decision(&event.payload) {
290                            let reason_str = dp.reason.as_deref().unwrap_or("").to_string();
291                            if (dp.key.to_lowercase().contains(&kw_lower)
292                                || dp.value.to_lowercase().contains(&kw_lower)
293                                || reason_str.to_lowercase().contains(&kw_lower))
294                                && !hits.iter().any(|h| h.event_id == event.event_id)
295                            {
296                                let domain = edda_core::decision::extract_domain(&dp.key);
297                                hits.push(DecisionHit {
298                                    event_id: event.event_id.clone(),
299                                    key: dp.key,
300                                    value: dp.value,
301                                    reason: reason_str,
302                                    domain,
303                                    branch: event.branch.clone(),
304                                    ts: event.ts.clone(),
305                                    is_active: false,
306                                    tags: dp.tags.unwrap_or_default(),
307                                    village_id: dp.village_id,
308                                    staleness: None,
309                                });
310                            }
311                        }
312                    }
313                }
314            }
315            (hits, vec![])
316        }
317        InputType::Overview => {
318            let active = branch_filter(
319                ledger
320                    .active_decisions_limited(None, None, after_ref, before_ref, opts.limit)?
321                    .into_iter()
322                    .map(|r| to_decision_hit(&r))
323                    .collect(),
324            );
325            (active, vec![])
326        }
327    };
328
329    // Apply tags filter (OR semantics) across all code paths
330    let decisions = tags_filter(decisions);
331    let timeline = tags_filter(timeline);
332
333    // Apply village filter across all code paths
334    let decisions = village_filter(decisions);
335    let timeline = village_filter(timeline);
336
337    // Collect decision event_ids for evidence chain matching
338    let decision_event_ids: Vec<&str> = decisions
339        .iter()
340        .map(|d| d.event_id.as_str())
341        .chain(timeline.iter().map(|d| d.event_id.as_str()))
342        .collect();
343
344    let q = query.trim();
345    // SQL push-down: find related commits and notes via targeted queries
346    let commit_events =
347        ledger.find_related_commits(opts.branch.as_deref(), q, &decision_event_ids, opts.limit)?;
348    let related_commits = to_commit_hits(&commit_events, &decision_event_ids, q, opts.limit);
349    let note_events = ledger.find_related_notes(opts.branch.as_deref(), q, opts.limit)?;
350    let related_notes = to_note_hits(&note_events, opts.limit);
351
352    let conversations = match transcript_search {
353        Some(search_fn) if !q.is_empty() => search_fn(q, opts.limit),
354        _ => vec![],
355    };
356
357    let input_type_str = match &input_type {
358        InputType::ExactKey(_) => "exact_key",
359        InputType::Domain(_) => "domain",
360        InputType::Keyword(_) => "keyword",
361        InputType::Overview => "overview",
362    };
363
364    // Impact analysis: ExactKey or Domain queries with --impact
365    let (dependents, override_risk) = if opts.impact {
366        match &input_type {
367            InputType::ExactKey(key) => compute_impact(ledger, key)?,
368            InputType::Domain(domain) => compute_domain_impact(ledger, domain)?,
369            _ => (vec![], None),
370        }
371    } else {
372        (vec![], None)
373    };
374
375    Ok(AskResult {
376        query: q.to_string(),
377        input_type: input_type_str.to_string(),
378        decisions,
379        timeline,
380        related_commits,
381        related_notes,
382        conversations,
383        dependents,
384        override_risk,
385    })
386}
387
388// ── Impact analysis helpers ──────────────────────────────────────────
389
390fn compute_impact(
391    ledger: &Ledger,
392    key: &str,
393) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
394    let transitive_deps = ledger.transitive_dependents_of(key, 3)?;
395    let dependents: Vec<DependentHit> = transitive_deps
396        .iter()
397        .map(|(dep, decision, depth)| DependentHit {
398            key: decision.key.clone(),
399            value: decision.value.clone(),
400            dep_type: dep.dep_type.clone(),
401            depth: *depth,
402        })
403        .collect();
404    let risk = compute_override_risk(&dependents);
405    Ok((dependents, Some(risk)))
406}
407
408fn compute_override_risk(dependents: &[DependentHit]) -> OverrideRisk {
409    let count = dependents.len();
410
411    // Weighted scoring:
412    //   explicit direct deps  x3
413    //   auto_domain direct    x1
414    //   transitive deps       x2  (depth > 1)
415    //   cross-domain spread   x2
416    let explicit_direct: usize = dependents
417        .iter()
418        .filter(|d| d.dep_type == "explicit" && d.depth == 1)
419        .count();
420    let auto_domain_direct: usize = dependents
421        .iter()
422        .filter(|d| d.dep_type == "auto_domain" && d.depth == 1)
423        .count();
424    let transitive: usize = dependents.iter().filter(|d| d.depth > 1).count();
425
426    // Count distinct domains among dependents
427    let mut domains: std::collections::HashSet<&str> = std::collections::HashSet::new();
428    for d in dependents {
429        if let Some(dom) = d.key.split('.').next() {
430            domains.insert(dom);
431        }
432    }
433    let cross_domain_bonus = if domains.len() > 1 { domains.len() } else { 0 };
434
435    let score = explicit_direct * 3 + auto_domain_direct + transitive * 2 + cross_domain_bonus * 2;
436
437    let level = if score <= 2 {
438        "LOW"
439    } else if score <= 6 {
440        "MEDIUM"
441    } else {
442        "HIGH"
443    };
444
445    // Suggestion: reverse BFS order = override leaves first
446    let suggestion = if count > 0 {
447        let mut sorted: Vec<&DependentHit> = dependents.iter().collect();
448        sorted.sort_by_key(|dependent| std::cmp::Reverse(dependent.depth));
449        let keys: Vec<&str> = sorted.iter().map(|d| d.key.as_str()).collect();
450        Some(format!("建議覆蓋順序: {}", keys.join(" → ")))
451    } else {
452        None
453    };
454
455    OverrideRisk {
456        level: level.to_string(),
457        dependent_count: count,
458        suggestion,
459    }
460}
461
462/// Aggregate impact across all active keys in a domain.
463fn compute_domain_impact(
464    ledger: &Ledger,
465    domain: &str,
466) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
467    let keys_in_domain = ledger.active_decisions(Some(domain), None, None, None)?;
468    if keys_in_domain.is_empty() {
469        return Ok((vec![], None));
470    }
471
472    let mut all_dependents: Vec<DependentHit> = Vec::new();
473    let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
474
475    for decision in &keys_in_domain {
476        let (deps, _) = compute_impact(ledger, &decision.key)?;
477        for d in deps {
478            if seen_keys.insert(d.key.clone()) {
479                all_dependents.push(d);
480            }
481        }
482    }
483
484    let risk = if all_dependents.is_empty() {
485        Some(OverrideRisk {
486            level: "LOW".to_string(),
487            dependent_count: 0,
488            suggestion: None,
489        })
490    } else {
491        Some(compute_override_risk(&all_dependents))
492    };
493
494    Ok((all_dependents, risk))
495}
496
497// ── Related event helpers ────────────────────────────────────────────
498
499/// Convert SQL-prefiltered commit events into `CommitHit` with match_type classification.
500/// Events are already filtered by SQL (branch, keyword/evidence); this determines the
501/// specific match_type ("evidence" or "title") for each.
502fn to_commit_hits(
503    events: &[Event],
504    decision_event_ids: &[&str],
505    query: &str,
506    limit: usize,
507) -> Vec<CommitHit> {
508    let mut hits: Vec<CommitHit> = Vec::new();
509    let q_lower = query.to_lowercase();
510
511    for event in events {
512        let title = event
513            .payload
514            .get("title")
515            .and_then(|v| v.as_str())
516            .unwrap_or("");
517        let purpose = event
518            .payload
519            .get("purpose")
520            .and_then(|v| v.as_str())
521            .unwrap_or("");
522
523        // Determine match type
524        let mut match_type = None;
525        for ref_id in &event.refs.events {
526            if decision_event_ids.contains(&ref_id.as_str()) {
527                match_type = Some("evidence");
528                break;
529            }
530        }
531        if match_type.is_none() {
532            for prov in &event.refs.provenance {
533                if decision_event_ids.contains(&prov.target.as_str()) {
534                    match_type = Some("evidence");
535                    break;
536                }
537            }
538        }
539        if match_type.is_none()
540            && !query.is_empty()
541            && (title.to_lowercase().contains(&q_lower)
542                || purpose.to_lowercase().contains(&q_lower))
543        {
544            match_type = Some("title");
545        }
546
547        if let Some(mt) = match_type {
548            if !hits.iter().any(|h| h.event_id == event.event_id) {
549                hits.push(CommitHit {
550                    event_id: event.event_id.clone(),
551                    title: title.to_string(),
552                    purpose: purpose.to_string(),
553                    ts: event.ts.clone(),
554                    branch: event.branch.clone(),
555                    match_type: mt.to_string(),
556                });
557                if hits.len() >= limit {
558                    break;
559                }
560            }
561        }
562    }
563
564    hits
565}
566
567/// Convert SQL-prefiltered note events into `NoteHit`.
568/// Events are already filtered by SQL (branch, keyword, excluding decisions/digests).
569fn to_note_hits(events: &[Event], limit: usize) -> Vec<NoteHit> {
570    events
571        .iter()
572        .take(limit)
573        .map(|event| {
574            let text = event
575                .payload
576                .get("text")
577                .and_then(|v| v.as_str())
578                .unwrap_or("");
579            NoteHit {
580                event_id: event.event_id.clone(),
581                text: text.to_string(),
582                ts: event.ts.clone(),
583                branch: event.branch.clone(),
584            }
585        })
586        .collect()
587}
588
589fn semantic_decision_search(
590    ledger: &Ledger,
591    query: &str,
592    branch: Option<&str>,
593    after: Option<&str>,
594    before: Option<&str>,
595    limit: usize,
596) -> anyhow::Result<Vec<DecisionHit>> {
597    if limit == 0 {
598        return Ok(vec![]);
599    }
600
601    let candidates =
602        ledger.active_decisions_limited(None, None, after, before, SEMANTIC_CANDIDATE_LIMIT)?;
603
604    let scored = rank_decisions_by_similarity(query, candidates, branch);
605    Ok(scored
606        .into_iter()
607        .take(limit)
608        .map(|s| to_decision_hit(&s.row))
609        .collect())
610}
611
612fn rank_decisions_by_similarity(
613    query: &str,
614    candidates: Vec<DecisionView>,
615    branch: Option<&str>,
616) -> Vec<ScoredDecision> {
617    let query_tokens = tokenize_for_similarity(query);
618    if query_tokens.is_empty() {
619        return vec![];
620    }
621
622    let mut tokenized_docs: Vec<(DecisionView, Vec<String>)> = Vec::new();
623    for row in candidates {
624        if branch.is_some_and(|b| row.branch != b) {
625            continue;
626        }
627        let text = format!("{} {} {} {}", row.domain, row.key, row.value, row.reason);
628        let tokens = tokenize_for_similarity(&text);
629        if !tokens.is_empty() {
630            tokenized_docs.push((row, tokens));
631        }
632    }
633
634    if tokenized_docs.is_empty() {
635        return vec![];
636    }
637
638    let mut doc_freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
639    for (_, tokens) in &tokenized_docs {
640        let unique: std::collections::HashSet<&str> = tokens.iter().map(|t| t.as_str()).collect();
641        for token in unique {
642            *doc_freq.entry(token.to_string()).or_insert(0) += 1;
643        }
644    }
645
646    let n_docs = tokenized_docs.len() as f64;
647    let query_tf = term_freq(&query_tokens);
648    let query_norm = vector_norm(&query_tf, &doc_freq, n_docs);
649
650    let mut scored: Vec<ScoredDecision> = Vec::new();
651    for (row, tokens) in tokenized_docs {
652        let tf = term_freq(&tokens);
653        let doc_norm = vector_norm(&tf, &doc_freq, n_docs);
654        if doc_norm <= f64::EPSILON || query_norm <= f64::EPSILON {
655            continue;
656        }
657
658        let mut dot = 0.0;
659        for (token, q_tf) in &query_tf {
660            if let Some(d_tf) = tf.get(token) {
661                let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
662                dot += (*q_tf as f64 * idf) * (*d_tf as f64 * idf);
663            }
664        }
665
666        let score = dot / (query_norm * doc_norm);
667        if score > 0.0 {
668            scored.push(ScoredDecision { row, score });
669        }
670    }
671
672    scored.sort_by(|a, b| {
673        b.score
674            .partial_cmp(&a.score)
675            .unwrap_or(std::cmp::Ordering::Equal)
676    });
677    scored
678}
679
680fn tokenize_for_similarity(text: &str) -> Vec<String> {
681    text.split(|c: char| !c.is_alphanumeric())
682        .filter_map(|t| {
683            let normalized = t.trim().to_lowercase();
684            if normalized.len() >= 2 {
685                Some(normalized)
686            } else {
687                None
688            }
689        })
690        .collect()
691}
692
693fn term_freq(tokens: &[String]) -> std::collections::HashMap<String, usize> {
694    let mut tf = std::collections::HashMap::new();
695    for token in tokens {
696        *tf.entry(token.clone()).or_insert(0) += 1;
697    }
698    tf
699}
700
701fn inv_doc_freq(df: usize, n_docs: f64) -> f64 {
702    ((n_docs + 1.0) / (df as f64 + 1.0)).ln() + 1.0
703}
704
705fn vector_norm(
706    tf: &std::collections::HashMap<String, usize>,
707    doc_freq: &std::collections::HashMap<String, usize>,
708    n_docs: f64,
709) -> f64 {
710    let mut sum_sq = 0.0;
711    for (token, count) in tf {
712        let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
713        let weight = *count as f64 * idf;
714        sum_sq += weight * weight;
715    }
716    sum_sq.sqrt()
717}
718
719// ── Human-readable formatting ────────────────────────────────────────
720
721pub fn format_human(result: &AskResult) -> String {
722    let mut out = String::new();
723
724    if !result.decisions.is_empty() {
725        out.push_str("── Decisions ──────────────────────────\n");
726        for d in &result.decisions {
727            let status = if d.is_active { "active" } else { "superseded" };
728            out.push_str(&format!(
729                "  {} = {} — {}\n  branch: {} | {} | {}\n",
730                d.key, d.value, d.reason, d.branch, d.ts, status
731            ));
732            if let Some(st) = &d.staleness {
733                if st.is_stale {
734                    let bad: Vec<String> = st
735                        .paths
736                        .iter()
737                        .filter(|p| {
738                            p.status != staleness::PathStatus::Fresh
739                                && p.status != staleness::PathStatus::Unknown
740                        })
741                        .map(|p| format!("{} ({:?})", p.path, p.status))
742                        .collect();
743                    if !bad.is_empty() {
744                        out.push_str(&format!("  ⚠ stale-code hint: {}\n", bad.join(", ")));
745                    }
746                }
747            }
748            out.push('\n');
749        }
750    }
751
752    if !result.timeline.is_empty() {
753        out.push_str("── Timeline ───────────────────────────\n");
754        for d in &result.timeline {
755            let status = if d.is_active { "active" } else { "superseded" };
756            out.push_str(&format!(
757                "  {}  {} = {}  ({})\n",
758                d.ts, d.key, d.value, status
759            ));
760        }
761        out.push('\n');
762    }
763
764    if !result.related_commits.is_empty() {
765        out.push_str("── Related Commits ────────────────────\n");
766        for c in &result.related_commits {
767            out.push_str(&format!(
768                "  {} ({}, {})\n  match: {}\n\n",
769                c.title, c.ts, c.branch, c.match_type
770            ));
771        }
772    }
773
774    if !result.related_notes.is_empty() {
775        out.push_str("── Related Notes ──────────────────────\n");
776        for n in &result.related_notes {
777            if n.text.len() > 120 {
778                let end = n.text.floor_char_boundary(117);
779                out.push_str(&format!(
780                    "  \"{}...\" ({}, {})\n\n",
781                    &n.text[..end],
782                    n.ts,
783                    n.branch
784                ));
785            } else {
786                out.push_str(&format!("  \"{}\" ({}, {})\n\n", n.text, n.ts, n.branch));
787            }
788        }
789    }
790
791    if !result.dependents.is_empty() {
792        out.push_str("── Dependents ─────────────────────────\n");
793        for d in &result.dependents {
794            out.push_str(&format!(
795                "  \u{2192} {} = {} ({}, depth {})\n",
796                d.key, d.value, d.dep_type, d.depth
797            ));
798        }
799        out.push('\n');
800    }
801
802    if let Some(ref risk) = result.override_risk {
803        out.push_str("── Override Risk ──────────────────────\n");
804        let emoji = match risk.level.as_str() {
805            "HIGH" => "\u{1f534}",
806            "MEDIUM" => "\u{1f7e1}",
807            _ => "\u{1f7e2}",
808        };
809        out.push_str(&format!(
810            "  {} {} ({} dependents)\n",
811            emoji, risk.level, risk.dependent_count
812        ));
813        if let Some(ref suggestion) = risk.suggestion {
814            out.push_str(&format!("  {suggestion}\n"));
815        }
816        out.push('\n');
817    }
818
819    if !result.conversations.is_empty() {
820        out.push_str("── Conversations ──────────────────────\n");
821        for c in &result.conversations {
822            out.push_str(&format!(
823                "  [{}] {}\n  rank: {:.2}\n\n",
824                c.session_id, c.snippet, c.rank
825            ));
826        }
827    }
828
829    if out.is_empty() {
830        out.push_str("No results found.\n");
831    }
832
833    out
834}
835
836// ── Internal helpers ─────────────────────────────────────────────────
837
838fn to_decision_hit(row: &DecisionView) -> DecisionHit {
839    DecisionHit {
840        event_id: row.event_id.clone(),
841        key: row.key.clone(),
842        value: row.value.clone(),
843        reason: row.reason.clone(),
844        domain: row.domain.clone(),
845        branch: row.branch.clone(),
846        ts: row.ts.clone().unwrap_or_default(),
847        is_active: matches!(row.status.as_str(), "active" | "experimental"),
848        tags: row.tags.clone(),
849        village_id: row.village_id.clone(),
850        staleness: None,
851    }
852}
853
854/// Return the affected_paths carried by each decision in `result.decisions`,
855/// looked up from the ledger by event_id. Position-aligned with the input;
856/// missing/unreadable rows return empty Vec (best-effort).
857pub fn affected_paths_for_hits(ledger: &Ledger, hits: &[DecisionHit]) -> Vec<Vec<String>> {
858    hits.iter()
859        .map(|h| {
860            ledger
861                .get_decision_by_event_id(&h.event_id)
862                .ok()
863                .flatten()
864                .map(|row| row.affected_paths)
865                .unwrap_or_default()
866        })
867        .collect()
868}
869
870// Decision helpers centralized in edda_core::decision.
871
872// ── Tests ────────────────────────────────────────────────────────────
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use edda_core::event::{finalize_event, new_note_event};
878    use edda_core::Provenance;
879    use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
880    use edda_ledger::paths::EddaPaths;
881    use std::sync::atomic::{AtomicU64, Ordering};
882
883    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
884
885    fn setup() -> (std::path::PathBuf, Ledger) {
886        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
887        let tmp = std::env::temp_dir().join(format!("edda_ask_test_{}_{n}", std::process::id()));
888        let _ = std::fs::remove_dir_all(&tmp);
889        let paths = EddaPaths::discover(&tmp);
890        init_workspace(&paths).unwrap();
891        init_head(&paths, "main").unwrap();
892        init_branches_json(&paths, "main").unwrap();
893        let ledger = Ledger::open(&tmp).unwrap();
894        (tmp, ledger)
895    }
896
897    fn make_decision(
898        branch: &str,
899        key: &str,
900        value: &str,
901        reason: Option<&str>,
902        supersedes: Option<&str>,
903    ) -> Event {
904        let text = match reason {
905            Some(r) => format!("{key}: {value} — {r}"),
906            None => format!("{key}: {value}"),
907        };
908        let tags = vec!["decision".to_string()];
909        let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
910        let decision_obj = match reason {
911            Some(r) => serde_json::json!({"key": key, "value": value, "reason": r}),
912            None => serde_json::json!({"key": key, "value": value}),
913        };
914        event.payload["decision"] = decision_obj;
915        if let Some(target) = supersedes {
916            event.refs.provenance.push(Provenance {
917                target: target.to_string(),
918                rel: "supersedes".to_string(),
919                note: Some(format!("key '{key}' re-decided")),
920            });
921        }
922        finalize_event(&mut event).unwrap();
923        event
924    }
925
926    fn make_commit(branch: &str, title: &str, purpose: &str, evidence: &[&str]) -> Event {
927        use edda_core::event::finalize_event;
928        let payload = serde_json::json!({
929            "title": title,
930            "purpose": purpose,
931            "sha": "abc123",
932        });
933        let mut event = Event {
934            event_id: format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase()),
935            ts: time_now(),
936            event_type: "commit".to_string(),
937            branch: branch.to_string(),
938            parent_hash: None,
939            hash: String::new(),
940            payload,
941            refs: edda_core::Refs {
942                events: evidence.iter().map(|s| s.to_string()).collect(),
943                ..Default::default()
944            },
945            schema_version: 1,
946            digests: vec![],
947            event_family: None,
948            event_level: None,
949        };
950        finalize_event(&mut event).unwrap();
951        event
952    }
953
954    fn make_note(branch: &str, text: &str) -> Event {
955        new_note_event(branch, None, "user", text, &[]).unwrap()
956    }
957
958    fn time_now() -> String {
959        let now = time::OffsetDateTime::now_utc();
960        now.format(&time::format_description::well_known::Rfc3339)
961            .expect("rfc3339")
962    }
963
964    // ── detect_input_type tests ──────────────────────────────────────
965
966    #[test]
967    fn detect_exact_key() {
968        let domains = vec!["db".into(), "auth".into()];
969        assert_eq!(
970            detect_input_type("db.engine", &domains),
971            InputType::ExactKey("db.engine".into())
972        );
973    }
974
975    #[test]
976    fn detect_domain() {
977        let domains = vec!["db".into(), "auth".into()];
978        assert_eq!(
979            detect_input_type("db", &domains),
980            InputType::Domain("db".into())
981        );
982    }
983
984    #[test]
985    fn detect_domain_case_insensitive() {
986        let domains = vec!["db".into()];
987        assert_eq!(
988            detect_input_type("DB", &domains),
989            InputType::Domain("DB".into())
990        );
991    }
992
993    #[test]
994    fn detect_keyword() {
995        let domains = vec!["db".into()];
996        assert_eq!(
997            detect_input_type("postgres", &domains),
998            InputType::Keyword("postgres".into())
999        );
1000    }
1001
1002    #[test]
1003    fn detect_overview() {
1004        assert_eq!(detect_input_type("", &[]), InputType::Overview);
1005        assert_eq!(detect_input_type("  ", &[]), InputType::Overview);
1006    }
1007
1008    // ── ask() tests ──────────────────────────────────────────────────
1009
1010    #[test]
1011    fn ask_exact_key() {
1012        let (tmp, ledger) = setup();
1013        let d1 = make_decision("main", "db.engine", "sqlite", Some("MVP"), None);
1014        let d1_id = d1.event_id.clone();
1015        ledger.append_event(&d1).unwrap();
1016
1017        let d2 = make_decision("main", "db.engine", "postgres", Some("JSONB"), Some(&d1_id));
1018        ledger.append_event(&d2).unwrap();
1019
1020        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1021        assert_eq!(result.input_type, "exact_key");
1022        // decisions = only active (default)
1023        assert_eq!(result.decisions.len(), 1);
1024        assert_eq!(result.decisions[0].value, "postgres");
1025        // timeline = both
1026        assert_eq!(result.timeline.len(), 2);
1027
1028        let _ = std::fs::remove_dir_all(&tmp);
1029    }
1030
1031    #[test]
1032    fn ask_domain() {
1033        let (tmp, ledger) = setup();
1034        ledger
1035            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1036            .unwrap();
1037        ledger
1038            .append_event(&make_decision("main", "db.pool", "10", None, None))
1039            .unwrap();
1040        ledger
1041            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1042            .unwrap();
1043
1044        let result = ask(&ledger, "db", &AskOptions::default(), None).unwrap();
1045        assert_eq!(result.input_type, "domain");
1046        assert_eq!(result.decisions.len(), 2);
1047        assert!(result.timeline.len() >= 2);
1048
1049        let _ = std::fs::remove_dir_all(&tmp);
1050    }
1051
1052    #[test]
1053    fn ask_keyword() {
1054        let (tmp, ledger) = setup();
1055        ledger
1056            .append_event(&make_decision(
1057                "main",
1058                "db.engine",
1059                "postgres",
1060                Some("JSONB"),
1061                None,
1062            ))
1063            .unwrap();
1064        ledger
1065            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1066            .unwrap();
1067
1068        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1069        assert_eq!(result.input_type, "keyword");
1070        assert_eq!(result.decisions.len(), 1);
1071        assert_eq!(result.decisions[0].key, "db.engine");
1072
1073        let _ = std::fs::remove_dir_all(&tmp);
1074    }
1075
1076    #[test]
1077    fn ask_overview() {
1078        let (tmp, ledger) = setup();
1079        ledger
1080            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1081            .unwrap();
1082        ledger
1083            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1084            .unwrap();
1085
1086        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1087        assert_eq!(result.input_type, "overview");
1088        assert_eq!(result.decisions.len(), 2);
1089
1090        let _ = std::fs::remove_dir_all(&tmp);
1091    }
1092
1093    #[test]
1094    fn ask_with_transcript_callback() {
1095        let (tmp, ledger) = setup();
1096        ledger
1097            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1098            .unwrap();
1099
1100        let callback = |_query: &str, _limit: usize| -> Vec<ConversationHit> {
1101            vec![ConversationHit {
1102                doc_id: "t1".into(),
1103                session_id: "s1".into(),
1104                ts: "2026-02-14T10:00:00Z".into(),
1105                snippet: "discussed postgres JSONB".into(),
1106                rank: 5.0,
1107            }]
1108        };
1109
1110        let result = ask(&ledger, "postgres", &AskOptions::default(), Some(&callback)).unwrap();
1111        assert_eq!(result.conversations.len(), 1);
1112        assert_eq!(result.conversations[0].snippet, "discussed postgres JSONB");
1113
1114        let _ = std::fs::remove_dir_all(&tmp);
1115    }
1116
1117    #[test]
1118    fn ask_without_transcript_callback() {
1119        let (tmp, ledger) = setup();
1120        ledger
1121            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1122            .unwrap();
1123
1124        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1125        assert!(result.conversations.is_empty());
1126
1127        let _ = std::fs::remove_dir_all(&tmp);
1128    }
1129
1130    // ── Related events tests ─────────────────────────────────────────
1131
1132    #[test]
1133    fn find_related_commits_evidence_chain() {
1134        let (tmp, ledger) = setup();
1135        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1136        let d1_id = d1.event_id.clone();
1137        ledger.append_event(&d1).unwrap();
1138
1139        let c1 = make_commit(
1140            "main",
1141            "feat: migrate to postgres",
1142            "db migration",
1143            &[&d1_id],
1144        );
1145        ledger.append_event(&c1).unwrap();
1146
1147        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1148        assert_eq!(result.related_commits.len(), 1);
1149        assert_eq!(result.related_commits[0].match_type, "evidence");
1150
1151        let _ = std::fs::remove_dir_all(&tmp);
1152    }
1153
1154    #[test]
1155    fn find_related_commits_title_match() {
1156        let (tmp, ledger) = setup();
1157        ledger
1158            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1159            .unwrap();
1160
1161        // Commit not linked via evidence, but title matches
1162        let c1 = make_commit("main", "fix: postgres connection pool", "pool fix", &[]);
1163        ledger.append_event(&c1).unwrap();
1164
1165        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1166        assert!(result
1167            .related_commits
1168            .iter()
1169            .any(|c| c.match_type == "title"));
1170
1171        let _ = std::fs::remove_dir_all(&tmp);
1172    }
1173
1174    #[test]
1175    fn find_related_notes_text_match() {
1176        let (tmp, ledger) = setup();
1177        ledger
1178            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1179            .unwrap();
1180
1181        // Regular note (not a decision)
1182        ledger
1183            .append_event(&make_note(
1184                "main",
1185                "discussed mysql but rejected for licensing",
1186            ))
1187            .unwrap();
1188
1189        let result = ask(&ledger, "mysql", &AskOptions::default(), None).unwrap();
1190        assert_eq!(result.related_notes.len(), 1);
1191        assert!(result.related_notes[0].text.contains("mysql"));
1192
1193        let _ = std::fs::remove_dir_all(&tmp);
1194    }
1195
1196    #[test]
1197    fn find_related_notes_excludes_decision_notes() {
1198        let (tmp, ledger) = setup();
1199        ledger
1200            .append_event(&make_decision(
1201                "main",
1202                "db.engine",
1203                "postgres",
1204                Some("JSONB"),
1205                None,
1206            ))
1207            .unwrap();
1208
1209        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1210        // Decision note should NOT appear in related_notes
1211        assert!(result.related_notes.is_empty());
1212
1213        let _ = std::fs::remove_dir_all(&tmp);
1214    }
1215
1216    #[test]
1217    fn ask_branch_filter() {
1218        let (tmp, ledger) = setup();
1219        ledger
1220            .append_event(&make_decision(
1221                "main",
1222                "db.engine",
1223                "postgres",
1224                Some("prod"),
1225                None,
1226            ))
1227            .unwrap();
1228        ledger
1229            .append_event(&make_decision(
1230                "dev",
1231                "db.engine",
1232                "sqlite",
1233                Some("dev speed"),
1234                None,
1235            ))
1236            .unwrap();
1237
1238        // No branch filter → both
1239        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1240        assert_eq!(result.decisions.len(), 2);
1241
1242        // Filter to dev → only sqlite
1243        let opts = AskOptions {
1244            branch: Some("dev".into()),
1245            ..Default::default()
1246        };
1247        let result = ask(&ledger, "", &opts, None).unwrap();
1248        assert_eq!(result.decisions.len(), 1);
1249        assert_eq!(result.decisions[0].value, "sqlite");
1250
1251        let _ = std::fs::remove_dir_all(&tmp);
1252    }
1253
1254    #[test]
1255    fn format_human_contains_sections() {
1256        let result = AskResult {
1257            query: "postgres".into(),
1258            input_type: "keyword".into(),
1259            decisions: vec![DecisionHit {
1260                event_id: "e1".into(),
1261                key: "db.engine".into(),
1262                value: "postgres".into(),
1263                reason: "JSONB".into(),
1264                domain: "db".into(),
1265                branch: "main".into(),
1266                ts: "2026-02-15".into(),
1267                is_active: true,
1268                tags: vec![],
1269                village_id: None,
1270                staleness: None,
1271            }],
1272            timeline: vec![],
1273            related_commits: vec![CommitHit {
1274                event_id: "c1".into(),
1275                title: "feat: migrate".into(),
1276                purpose: "migration".into(),
1277                ts: "2026-02-15".into(),
1278                branch: "main".into(),
1279                match_type: "evidence".into(),
1280            }],
1281            related_notes: vec![],
1282            conversations: vec![],
1283            dependents: vec![],
1284            override_risk: None,
1285        };
1286
1287        let output = format_human(&result);
1288        assert!(output.contains("Decisions"));
1289        assert!(output.contains("postgres"));
1290        assert!(output.contains("Related Commits"));
1291        assert!(output.contains("feat: migrate"));
1292    }
1293
1294    #[test]
1295    fn find_related_notes_excludes_session_digests() {
1296        let (tmp, ledger) = setup();
1297        // Create a session digest note containing a keyword
1298        let tags = vec!["session_digest".to_string()];
1299        let mut digest = new_note_event(
1300            "main",
1301            None,
1302            "system",
1303            "discussed postgres migration",
1304            &tags,
1305        )
1306        .unwrap();
1307        digest.payload["session_stats"] = serde_json::json!({
1308            "tool_calls": 10,
1309            "tasks_snapshot": [{"subject": "Fix postgres pool", "status": "completed"}],
1310        });
1311        finalize_event(&mut digest).unwrap();
1312        ledger.append_event(&digest).unwrap();
1313
1314        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1315        // Session digest note should NOT appear in related_notes
1316        assert!(
1317            result.related_notes.is_empty(),
1318            "session digest should be filtered: {:?}",
1319            result.related_notes
1320        );
1321
1322        let _ = std::fs::remove_dir_all(&tmp);
1323    }
1324
1325    #[test]
1326    fn format_human_truncates_long_notes() {
1327        let long_text = "a".repeat(200);
1328        let result = AskResult {
1329            query: "test".into(),
1330            input_type: "keyword".into(),
1331            decisions: vec![],
1332            timeline: vec![],
1333            related_commits: vec![],
1334            related_notes: vec![NoteHit {
1335                event_id: "n1".into(),
1336                text: long_text,
1337                ts: "2026-02-26".into(),
1338                branch: "main".into(),
1339            }],
1340            conversations: vec![],
1341            dependents: vec![],
1342            override_risk: None,
1343        };
1344
1345        let output = format_human(&result);
1346        assert!(output.contains("..."), "long note should be truncated");
1347        // Should not contain the full 200-char string
1348        assert!(
1349            !output.contains(&"a".repeat(200)),
1350            "should not contain full text"
1351        );
1352    }
1353
1354    #[test]
1355    fn format_human_empty_result() {
1356        let result = AskResult {
1357            query: "nonexistent".into(),
1358            input_type: "keyword".into(),
1359            decisions: vec![],
1360            timeline: vec![],
1361            related_commits: vec![],
1362            related_notes: vec![],
1363            conversations: vec![],
1364            dependents: vec![],
1365            override_risk: None,
1366        };
1367
1368        let output = format_human(&result);
1369        assert!(output.contains("No results found"));
1370    }
1371
1372    // ── Impact analysis tests ───────────────────────────────────────
1373
1374    #[test]
1375    fn impact_no_dependents() {
1376        let (tmp, ledger) = setup();
1377        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1378        ledger.append_event(&d1).unwrap();
1379
1380        let opts = AskOptions {
1381            impact: true,
1382            ..Default::default()
1383        };
1384        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1385        assert!(result.dependents.is_empty());
1386        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1387        assert_eq!(result.override_risk.as_ref().unwrap().dependent_count, 0);
1388
1389        let _ = std::fs::remove_dir_all(&tmp);
1390    }
1391
1392    #[test]
1393    fn impact_single_explicit_dep() {
1394        let (tmp, ledger) = setup();
1395        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1396        ledger.append_event(&d1).unwrap();
1397
1398        let d2 = make_decision("main", "db.schema", "JSONB", Some("needs postgres"), None);
1399        ledger.append_event(&d2).unwrap();
1400
1401        // db.schema depends on db.engine (explicit)
1402        ledger
1403            .insert_dep("db.schema", "db.engine", "explicit", None)
1404            .unwrap();
1405
1406        let opts = AskOptions {
1407            impact: true,
1408            ..Default::default()
1409        };
1410        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1411        assert_eq!(result.dependents.len(), 1);
1412        assert_eq!(result.dependents[0].key, "db.schema");
1413        assert_eq!(result.dependents[0].dep_type, "explicit");
1414        assert_eq!(result.dependents[0].depth, 1);
1415        // 1 explicit direct dep => score = 3 => MEDIUM
1416        assert_eq!(result.override_risk.as_ref().unwrap().level, "MEDIUM");
1417
1418        let _ = std::fs::remove_dir_all(&tmp);
1419    }
1420
1421    #[test]
1422    fn impact_multiple_auto_domain() {
1423        let (tmp, ledger) = setup();
1424        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1425        ledger.append_event(&d1).unwrap();
1426        let d2 = make_decision("main", "db.pool", "10", None, None);
1427        ledger.append_event(&d2).unwrap();
1428        let d3 = make_decision("main", "db.timeout", "30s", None, None);
1429        ledger.append_event(&d3).unwrap();
1430
1431        // auto_domain deps
1432        ledger
1433            .insert_dep("db.pool", "db.engine", "auto_domain", None)
1434            .unwrap();
1435        ledger
1436            .insert_dep("db.timeout", "db.engine", "auto_domain", None)
1437            .unwrap();
1438
1439        let opts = AskOptions {
1440            impact: true,
1441            ..Default::default()
1442        };
1443        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1444        assert_eq!(result.dependents.len(), 2);
1445        // 2 auto_domain direct deps => score = 2 => LOW
1446        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1447
1448        let _ = std::fs::remove_dir_all(&tmp);
1449    }
1450
1451    #[test]
1452    fn impact_transitive_chain() {
1453        let (tmp, ledger) = setup();
1454        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1455        ledger.append_event(&d1).unwrap();
1456        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1457        ledger.append_event(&d2).unwrap();
1458        let d3 = make_decision("main", "api.format", "JSON", None, None);
1459        ledger.append_event(&d3).unwrap();
1460
1461        // db.schema -> db.engine, api.format -> db.schema
1462        ledger
1463            .insert_dep("db.schema", "db.engine", "explicit", None)
1464            .unwrap();
1465        ledger
1466            .insert_dep("api.format", "db.schema", "explicit", None)
1467            .unwrap();
1468
1469        let opts = AskOptions {
1470            impact: true,
1471            ..Default::default()
1472        };
1473        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1474        assert_eq!(result.dependents.len(), 2);
1475
1476        // Check depths
1477        let schema_hit = result
1478            .dependents
1479            .iter()
1480            .find(|d| d.key == "db.schema")
1481            .unwrap();
1482        assert_eq!(schema_hit.depth, 1);
1483        let api_hit = result
1484            .dependents
1485            .iter()
1486            .find(|d| d.key == "api.format")
1487            .unwrap();
1488        assert_eq!(api_hit.depth, 2);
1489
1490        let _ = std::fs::remove_dir_all(&tmp);
1491    }
1492
1493    #[test]
1494    fn impact_cross_domain() {
1495        let (tmp, ledger) = setup();
1496        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1497        ledger.append_event(&d1).unwrap();
1498        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1499        ledger.append_event(&d2).unwrap();
1500        let d3 = make_decision("main", "api.format", "JSON", None, None);
1501        ledger.append_event(&d3).unwrap();
1502        let d4 = make_decision("main", "cache.strategy", "redis", None, None);
1503        ledger.append_event(&d4).unwrap();
1504
1505        // All depend on db.engine via explicit
1506        ledger
1507            .insert_dep("db.schema", "db.engine", "explicit", None)
1508            .unwrap();
1509        ledger
1510            .insert_dep("api.format", "db.engine", "explicit", None)
1511            .unwrap();
1512        ledger
1513            .insert_dep("cache.strategy", "db.engine", "explicit", None)
1514            .unwrap();
1515
1516        let opts = AskOptions {
1517            impact: true,
1518            ..Default::default()
1519        };
1520        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1521        assert_eq!(result.dependents.len(), 3);
1522        // 3 explicit direct (9) + cross_domain: 3 domains × 2 (6) => score = 15 => HIGH
1523        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1524
1525        let _ = std::fs::remove_dir_all(&tmp);
1526    }
1527
1528    #[test]
1529    fn impact_high_risk_scenario() {
1530        let (tmp, ledger) = setup();
1531        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1532        ledger.append_event(&d1).unwrap();
1533
1534        // Create 4 explicit dependents + 1 transitive
1535        for (i, key) in ["db.schema", "db.pool", "api.format", "auth.store"]
1536            .iter()
1537            .enumerate()
1538        {
1539            let d = make_decision("main", key, &format!("val{i}"), None, None);
1540            ledger.append_event(&d).unwrap();
1541            ledger
1542                .insert_dep(key, "db.engine", "explicit", None)
1543                .unwrap();
1544        }
1545        // Transitive: cache.ttl -> api.format -> db.engine
1546        let d_cache = make_decision("main", "cache.ttl", "60s", None, None);
1547        ledger.append_event(&d_cache).unwrap();
1548        ledger
1549            .insert_dep("cache.ttl", "api.format", "explicit", None)
1550            .unwrap();
1551
1552        let opts = AskOptions {
1553            impact: true,
1554            ..Default::default()
1555        };
1556        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1557        assert_eq!(result.dependents.len(), 5);
1558        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1559
1560        let _ = std::fs::remove_dir_all(&tmp);
1561    }
1562
1563    #[test]
1564    fn impact_domain_query() {
1565        let (tmp, ledger) = setup();
1566        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1567        ledger.append_event(&d1).unwrap();
1568        let d2 = make_decision("main", "db.pool", "10", None, None);
1569        ledger.append_event(&d2).unwrap();
1570
1571        // api.format depends on db.engine
1572        let d3 = make_decision("main", "api.format", "JSON", None, None);
1573        ledger.append_event(&d3).unwrap();
1574        ledger
1575            .insert_dep("api.format", "db.engine", "explicit", None)
1576            .unwrap();
1577
1578        let opts = AskOptions {
1579            impact: true,
1580            ..Default::default()
1581        };
1582        let result = ask(&ledger, "db", &opts, None).unwrap();
1583        assert_eq!(result.input_type, "domain");
1584        // api.format is a dependent of db.engine
1585        assert!(
1586            result.dependents.iter().any(|d| d.key == "api.format"),
1587            "domain impact should find api.format as dependent"
1588        );
1589        assert!(result.override_risk.is_some());
1590
1591        let _ = std::fs::remove_dir_all(&tmp);
1592    }
1593
1594    #[test]
1595    fn impact_flag_ignored_for_keyword() {
1596        let (tmp, ledger) = setup();
1597        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1598        ledger.append_event(&d1).unwrap();
1599
1600        let opts = AskOptions {
1601            impact: true,
1602            ..Default::default()
1603        };
1604        let result = ask(&ledger, "postgres", &opts, None).unwrap();
1605        assert_eq!(result.input_type, "keyword");
1606        // Impact should be empty for keyword queries
1607        assert!(result.dependents.is_empty());
1608        assert!(result.override_risk.is_none());
1609
1610        let _ = std::fs::remove_dir_all(&tmp);
1611    }
1612
1613    #[test]
1614    fn ask_keyword_semantic_similarity() {
1615        let (tmp, ledger) = setup();
1616        ledger
1617            .append_event(&make_decision(
1618                "main",
1619                "pricing.discount_policy",
1620                "daytime_revenue_shield",
1621                Some("avoid aggressive daytime markdowns"),
1622                None,
1623            ))
1624            .unwrap();
1625        ledger
1626            .append_event(&make_decision(
1627                "main",
1628                "inventory.reorder_buffer",
1629                "40_percent",
1630                Some("protect stock for midnight spikes"),
1631                None,
1632            ))
1633            .unwrap();
1634
1635        let query = "similar daytime discount outcome for chiefs";
1636        let result = ask(&ledger, query, &AskOptions::default(), None).unwrap();
1637
1638        assert_eq!(result.input_type, "keyword");
1639        assert!(
1640            result
1641                .decisions
1642                .iter()
1643                .any(|d| d.key == "pricing.discount_policy"),
1644            "semantic search should retrieve discount precedent"
1645        );
1646
1647        let _ = std::fs::remove_dir_all(&tmp);
1648    }
1649
1650    #[test]
1651    fn format_human_with_impact() {
1652        let result = AskResult {
1653            query: "db.engine".into(),
1654            input_type: "exact_key".into(),
1655            decisions: vec![DecisionHit {
1656                event_id: "e1".into(),
1657                key: "db.engine".into(),
1658                value: "postgres".into(),
1659                reason: "JSONB".into(),
1660                domain: "db".into(),
1661                branch: "main".into(),
1662                ts: "2026-02-15".into(),
1663                is_active: true,
1664                tags: vec![],
1665                village_id: None,
1666                staleness: None,
1667            }],
1668            timeline: vec![],
1669            related_commits: vec![],
1670            related_notes: vec![],
1671            conversations: vec![],
1672            dependents: vec![
1673                DependentHit {
1674                    key: "db.schema".into(),
1675                    value: "JSONB".into(),
1676                    dep_type: "explicit".into(),
1677                    depth: 1,
1678                },
1679                DependentHit {
1680                    key: "api.format".into(),
1681                    value: "JSON".into(),
1682                    dep_type: "auto_domain".into(),
1683                    depth: 2,
1684                },
1685            ],
1686            override_risk: Some(OverrideRisk {
1687                level: "HIGH".to_string(),
1688                dependent_count: 2,
1689                suggestion: Some("建議覆蓋順序: api.format → db.schema".into()),
1690            }),
1691        };
1692
1693        let output = format_human(&result);
1694        assert!(
1695            output.contains("Dependents"),
1696            "should have Dependents section"
1697        );
1698        assert!(
1699            output.contains("depth 1"),
1700            "should show depth for dependents"
1701        );
1702        assert!(
1703            output.contains("depth 2"),
1704            "should show depth for transitive"
1705        );
1706        assert!(
1707            output.contains("Override Risk"),
1708            "should have Override Risk section"
1709        );
1710        assert!(output.contains("HIGH"), "should show HIGH risk level");
1711        assert!(
1712            output.contains("建議覆蓋順序"),
1713            "should show override order suggestion"
1714        );
1715    }
1716
1717    #[test]
1718    fn ask_filters_by_village_id() {
1719        let (_tmp, ledger) = setup();
1720
1721        // Create two decisions: one with village_id, one without
1722        let mut ev1 = make_decision("main", "db.engine", "sqlite", Some("embedded"), None);
1723        ev1.payload["decision"]["village_id"] = serde_json::json!("village-a");
1724        finalize_event(&mut ev1).unwrap();
1725        ledger.append_event(&ev1).unwrap();
1726
1727        let ev2 = make_decision("main", "cache.ttl", "3600", Some("perf"), None);
1728        ledger.append_event(&ev2).unwrap();
1729
1730        // Without village filter: should find both
1731        let opts_all = AskOptions::default();
1732        let result_all = ask(&ledger, "db OR cache", &opts_all, None).unwrap();
1733        assert!(
1734            result_all.decisions.len() >= 2,
1735            "expected at least 2 decisions without village filter, got {}",
1736            result_all.decisions.len()
1737        );
1738
1739        // With village filter: should find only village-a
1740        let opts_village = AskOptions {
1741            village_id: Some("village-a".to_string()),
1742            ..Default::default()
1743        };
1744        let result_village = ask(&ledger, "db OR cache", &opts_village, None).unwrap();
1745        assert_eq!(
1746            result_village.decisions.len(),
1747            1,
1748            "expected 1 decision with village filter"
1749        );
1750        assert_eq!(result_village.decisions[0].key, "db.engine");
1751        assert_eq!(
1752            result_village.decisions[0].village_id.as_deref(),
1753            Some("village-a")
1754        );
1755
1756        // Non-existent village: should find none
1757        let opts_empty = AskOptions {
1758            village_id: Some("no-such-village".to_string()),
1759            ..Default::default()
1760        };
1761        let result_empty = ask(&ledger, "db OR cache", &opts_empty, None).unwrap();
1762        assert!(
1763            result_empty.decisions.is_empty(),
1764            "expected 0 decisions for non-existent village"
1765        );
1766    }
1767}