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(|a, b| b.depth.cmp(&a.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.paths.iter()
735                        .filter(|p| p.status != staleness::PathStatus::Fresh
736                                 && p.status != staleness::PathStatus::Unknown)
737                        .map(|p| format!("{} ({:?})", p.path, p.status))
738                        .collect();
739                    if !bad.is_empty() {
740                        out.push_str(&format!("  ⚠ stale-code hint: {}\n", bad.join(", ")));
741                    }
742                }
743            }
744            out.push('\n');
745        }
746    }
747
748    if !result.timeline.is_empty() {
749        out.push_str("── Timeline ───────────────────────────\n");
750        for d in &result.timeline {
751            let status = if d.is_active { "active" } else { "superseded" };
752            out.push_str(&format!(
753                "  {}  {} = {}  ({})\n",
754                d.ts, d.key, d.value, status
755            ));
756        }
757        out.push('\n');
758    }
759
760    if !result.related_commits.is_empty() {
761        out.push_str("── Related Commits ────────────────────\n");
762        for c in &result.related_commits {
763            out.push_str(&format!(
764                "  {} ({}, {})\n  match: {}\n\n",
765                c.title, c.ts, c.branch, c.match_type
766            ));
767        }
768    }
769
770    if !result.related_notes.is_empty() {
771        out.push_str("── Related Notes ──────────────────────\n");
772        for n in &result.related_notes {
773            if n.text.len() > 120 {
774                let end = n.text.floor_char_boundary(117);
775                out.push_str(&format!(
776                    "  \"{}...\" ({}, {})\n\n",
777                    &n.text[..end],
778                    n.ts,
779                    n.branch
780                ));
781            } else {
782                out.push_str(&format!("  \"{}\" ({}, {})\n\n", n.text, n.ts, n.branch));
783            }
784        }
785    }
786
787    if !result.dependents.is_empty() {
788        out.push_str("── Dependents ─────────────────────────\n");
789        for d in &result.dependents {
790            out.push_str(&format!(
791                "  \u{2192} {} = {} ({}, depth {})\n",
792                d.key, d.value, d.dep_type, d.depth
793            ));
794        }
795        out.push('\n');
796    }
797
798    if let Some(ref risk) = result.override_risk {
799        out.push_str("── Override Risk ──────────────────────\n");
800        let emoji = match risk.level.as_str() {
801            "HIGH" => "\u{1f534}",
802            "MEDIUM" => "\u{1f7e1}",
803            _ => "\u{1f7e2}",
804        };
805        out.push_str(&format!(
806            "  {} {} ({} dependents)\n",
807            emoji, risk.level, risk.dependent_count
808        ));
809        if let Some(ref suggestion) = risk.suggestion {
810            out.push_str(&format!("  {suggestion}\n"));
811        }
812        out.push('\n');
813    }
814
815    if !result.conversations.is_empty() {
816        out.push_str("── Conversations ──────────────────────\n");
817        for c in &result.conversations {
818            out.push_str(&format!(
819                "  [{}] {}\n  rank: {:.2}\n\n",
820                c.session_id, c.snippet, c.rank
821            ));
822        }
823    }
824
825    if out.is_empty() {
826        out.push_str("No results found.\n");
827    }
828
829    out
830}
831
832// ── Internal helpers ─────────────────────────────────────────────────
833
834fn to_decision_hit(row: &DecisionView) -> DecisionHit {
835    DecisionHit {
836        event_id: row.event_id.clone(),
837        key: row.key.clone(),
838        value: row.value.clone(),
839        reason: row.reason.clone(),
840        domain: row.domain.clone(),
841        branch: row.branch.clone(),
842        ts: row.ts.clone().unwrap_or_default(),
843        is_active: matches!(row.status.as_str(), "active" | "experimental"),
844        tags: row.tags.clone(),
845        village_id: row.village_id.clone(),
846        staleness: None,
847    }
848}
849
850/// Return the affected_paths carried by each decision in `result.decisions`,
851/// looked up from the ledger by event_id. Position-aligned with the input;
852/// missing/unreadable rows return empty Vec (best-effort).
853pub fn affected_paths_for_hits(
854    ledger: &Ledger,
855    hits: &[DecisionHit],
856) -> Vec<Vec<String>> {
857    hits.iter()
858        .map(|h| {
859            ledger
860                .get_decision_by_event_id(&h.event_id)
861                .ok()
862                .flatten()
863                .map(|row| row.affected_paths)
864                .unwrap_or_default()
865        })
866        .collect()
867}
868
869// Decision helpers centralized in edda_core::decision.
870
871// ── Tests ────────────────────────────────────────────────────────────
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use edda_core::event::{finalize_event, new_note_event};
877    use edda_core::Provenance;
878    use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
879    use edda_ledger::paths::EddaPaths;
880    use std::sync::atomic::{AtomicU64, Ordering};
881
882    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
883
884    fn setup() -> (std::path::PathBuf, Ledger) {
885        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
886        let tmp = std::env::temp_dir().join(format!("edda_ask_test_{}_{n}", std::process::id()));
887        let _ = std::fs::remove_dir_all(&tmp);
888        let paths = EddaPaths::discover(&tmp);
889        init_workspace(&paths).unwrap();
890        init_head(&paths, "main").unwrap();
891        init_branches_json(&paths, "main").unwrap();
892        let ledger = Ledger::open(&tmp).unwrap();
893        (tmp, ledger)
894    }
895
896    fn make_decision(
897        branch: &str,
898        key: &str,
899        value: &str,
900        reason: Option<&str>,
901        supersedes: Option<&str>,
902    ) -> Event {
903        let text = match reason {
904            Some(r) => format!("{key}: {value} — {r}"),
905            None => format!("{key}: {value}"),
906        };
907        let tags = vec!["decision".to_string()];
908        let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
909        let decision_obj = match reason {
910            Some(r) => serde_json::json!({"key": key, "value": value, "reason": r}),
911            None => serde_json::json!({"key": key, "value": value}),
912        };
913        event.payload["decision"] = decision_obj;
914        if let Some(target) = supersedes {
915            event.refs.provenance.push(Provenance {
916                target: target.to_string(),
917                rel: "supersedes".to_string(),
918                note: Some(format!("key '{key}' re-decided")),
919            });
920        }
921        finalize_event(&mut event).unwrap();
922        event
923    }
924
925    fn make_commit(branch: &str, title: &str, purpose: &str, evidence: &[&str]) -> Event {
926        use edda_core::event::finalize_event;
927        let payload = serde_json::json!({
928            "title": title,
929            "purpose": purpose,
930            "sha": "abc123",
931        });
932        let mut event = Event {
933            event_id: format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase()),
934            ts: time_now(),
935            event_type: "commit".to_string(),
936            branch: branch.to_string(),
937            parent_hash: None,
938            hash: String::new(),
939            payload,
940            refs: edda_core::Refs {
941                events: evidence.iter().map(|s| s.to_string()).collect(),
942                ..Default::default()
943            },
944            schema_version: 1,
945            digests: vec![],
946            event_family: None,
947            event_level: None,
948        };
949        finalize_event(&mut event).unwrap();
950        event
951    }
952
953    fn make_note(branch: &str, text: &str) -> Event {
954        new_note_event(branch, None, "user", text, &[]).unwrap()
955    }
956
957    fn time_now() -> String {
958        let now = time::OffsetDateTime::now_utc();
959        now.format(&time::format_description::well_known::Rfc3339)
960            .expect("rfc3339")
961    }
962
963    // ── detect_input_type tests ──────────────────────────────────────
964
965    #[test]
966    fn detect_exact_key() {
967        let domains = vec!["db".into(), "auth".into()];
968        assert_eq!(
969            detect_input_type("db.engine", &domains),
970            InputType::ExactKey("db.engine".into())
971        );
972    }
973
974    #[test]
975    fn detect_domain() {
976        let domains = vec!["db".into(), "auth".into()];
977        assert_eq!(
978            detect_input_type("db", &domains),
979            InputType::Domain("db".into())
980        );
981    }
982
983    #[test]
984    fn detect_domain_case_insensitive() {
985        let domains = vec!["db".into()];
986        assert_eq!(
987            detect_input_type("DB", &domains),
988            InputType::Domain("DB".into())
989        );
990    }
991
992    #[test]
993    fn detect_keyword() {
994        let domains = vec!["db".into()];
995        assert_eq!(
996            detect_input_type("postgres", &domains),
997            InputType::Keyword("postgres".into())
998        );
999    }
1000
1001    #[test]
1002    fn detect_overview() {
1003        assert_eq!(detect_input_type("", &[]), InputType::Overview);
1004        assert_eq!(detect_input_type("  ", &[]), InputType::Overview);
1005    }
1006
1007    // ── ask() tests ──────────────────────────────────────────────────
1008
1009    #[test]
1010    fn ask_exact_key() {
1011        let (tmp, ledger) = setup();
1012        let d1 = make_decision("main", "db.engine", "sqlite", Some("MVP"), None);
1013        let d1_id = d1.event_id.clone();
1014        ledger.append_event(&d1).unwrap();
1015
1016        let d2 = make_decision("main", "db.engine", "postgres", Some("JSONB"), Some(&d1_id));
1017        ledger.append_event(&d2).unwrap();
1018
1019        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1020        assert_eq!(result.input_type, "exact_key");
1021        // decisions = only active (default)
1022        assert_eq!(result.decisions.len(), 1);
1023        assert_eq!(result.decisions[0].value, "postgres");
1024        // timeline = both
1025        assert_eq!(result.timeline.len(), 2);
1026
1027        let _ = std::fs::remove_dir_all(&tmp);
1028    }
1029
1030    #[test]
1031    fn ask_domain() {
1032        let (tmp, ledger) = setup();
1033        ledger
1034            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1035            .unwrap();
1036        ledger
1037            .append_event(&make_decision("main", "db.pool", "10", None, None))
1038            .unwrap();
1039        ledger
1040            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1041            .unwrap();
1042
1043        let result = ask(&ledger, "db", &AskOptions::default(), None).unwrap();
1044        assert_eq!(result.input_type, "domain");
1045        assert_eq!(result.decisions.len(), 2);
1046        assert!(result.timeline.len() >= 2);
1047
1048        let _ = std::fs::remove_dir_all(&tmp);
1049    }
1050
1051    #[test]
1052    fn ask_keyword() {
1053        let (tmp, ledger) = setup();
1054        ledger
1055            .append_event(&make_decision(
1056                "main",
1057                "db.engine",
1058                "postgres",
1059                Some("JSONB"),
1060                None,
1061            ))
1062            .unwrap();
1063        ledger
1064            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1065            .unwrap();
1066
1067        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1068        assert_eq!(result.input_type, "keyword");
1069        assert_eq!(result.decisions.len(), 1);
1070        assert_eq!(result.decisions[0].key, "db.engine");
1071
1072        let _ = std::fs::remove_dir_all(&tmp);
1073    }
1074
1075    #[test]
1076    fn ask_overview() {
1077        let (tmp, ledger) = setup();
1078        ledger
1079            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1080            .unwrap();
1081        ledger
1082            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1083            .unwrap();
1084
1085        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1086        assert_eq!(result.input_type, "overview");
1087        assert_eq!(result.decisions.len(), 2);
1088
1089        let _ = std::fs::remove_dir_all(&tmp);
1090    }
1091
1092    #[test]
1093    fn ask_with_transcript_callback() {
1094        let (tmp, ledger) = setup();
1095        ledger
1096            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1097            .unwrap();
1098
1099        let callback = |_query: &str, _limit: usize| -> Vec<ConversationHit> {
1100            vec![ConversationHit {
1101                doc_id: "t1".into(),
1102                session_id: "s1".into(),
1103                ts: "2026-02-14T10:00:00Z".into(),
1104                snippet: "discussed postgres JSONB".into(),
1105                rank: 5.0,
1106            }]
1107        };
1108
1109        let result = ask(&ledger, "postgres", &AskOptions::default(), Some(&callback)).unwrap();
1110        assert_eq!(result.conversations.len(), 1);
1111        assert_eq!(result.conversations[0].snippet, "discussed postgres JSONB");
1112
1113        let _ = std::fs::remove_dir_all(&tmp);
1114    }
1115
1116    #[test]
1117    fn ask_without_transcript_callback() {
1118        let (tmp, ledger) = setup();
1119        ledger
1120            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1121            .unwrap();
1122
1123        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1124        assert!(result.conversations.is_empty());
1125
1126        let _ = std::fs::remove_dir_all(&tmp);
1127    }
1128
1129    // ── Related events tests ─────────────────────────────────────────
1130
1131    #[test]
1132    fn find_related_commits_evidence_chain() {
1133        let (tmp, ledger) = setup();
1134        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1135        let d1_id = d1.event_id.clone();
1136        ledger.append_event(&d1).unwrap();
1137
1138        let c1 = make_commit(
1139            "main",
1140            "feat: migrate to postgres",
1141            "db migration",
1142            &[&d1_id],
1143        );
1144        ledger.append_event(&c1).unwrap();
1145
1146        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1147        assert_eq!(result.related_commits.len(), 1);
1148        assert_eq!(result.related_commits[0].match_type, "evidence");
1149
1150        let _ = std::fs::remove_dir_all(&tmp);
1151    }
1152
1153    #[test]
1154    fn find_related_commits_title_match() {
1155        let (tmp, ledger) = setup();
1156        ledger
1157            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1158            .unwrap();
1159
1160        // Commit not linked via evidence, but title matches
1161        let c1 = make_commit("main", "fix: postgres connection pool", "pool fix", &[]);
1162        ledger.append_event(&c1).unwrap();
1163
1164        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1165        assert!(result
1166            .related_commits
1167            .iter()
1168            .any(|c| c.match_type == "title"));
1169
1170        let _ = std::fs::remove_dir_all(&tmp);
1171    }
1172
1173    #[test]
1174    fn find_related_notes_text_match() {
1175        let (tmp, ledger) = setup();
1176        ledger
1177            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1178            .unwrap();
1179
1180        // Regular note (not a decision)
1181        ledger
1182            .append_event(&make_note(
1183                "main",
1184                "discussed mysql but rejected for licensing",
1185            ))
1186            .unwrap();
1187
1188        let result = ask(&ledger, "mysql", &AskOptions::default(), None).unwrap();
1189        assert_eq!(result.related_notes.len(), 1);
1190        assert!(result.related_notes[0].text.contains("mysql"));
1191
1192        let _ = std::fs::remove_dir_all(&tmp);
1193    }
1194
1195    #[test]
1196    fn find_related_notes_excludes_decision_notes() {
1197        let (tmp, ledger) = setup();
1198        ledger
1199            .append_event(&make_decision(
1200                "main",
1201                "db.engine",
1202                "postgres",
1203                Some("JSONB"),
1204                None,
1205            ))
1206            .unwrap();
1207
1208        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1209        // Decision note should NOT appear in related_notes
1210        assert!(result.related_notes.is_empty());
1211
1212        let _ = std::fs::remove_dir_all(&tmp);
1213    }
1214
1215    #[test]
1216    fn ask_branch_filter() {
1217        let (tmp, ledger) = setup();
1218        ledger
1219            .append_event(&make_decision(
1220                "main",
1221                "db.engine",
1222                "postgres",
1223                Some("prod"),
1224                None,
1225            ))
1226            .unwrap();
1227        ledger
1228            .append_event(&make_decision(
1229                "dev",
1230                "db.engine",
1231                "sqlite",
1232                Some("dev speed"),
1233                None,
1234            ))
1235            .unwrap();
1236
1237        // No branch filter → both
1238        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1239        assert_eq!(result.decisions.len(), 2);
1240
1241        // Filter to dev → only sqlite
1242        let opts = AskOptions {
1243            branch: Some("dev".into()),
1244            ..Default::default()
1245        };
1246        let result = ask(&ledger, "", &opts, None).unwrap();
1247        assert_eq!(result.decisions.len(), 1);
1248        assert_eq!(result.decisions[0].value, "sqlite");
1249
1250        let _ = std::fs::remove_dir_all(&tmp);
1251    }
1252
1253    #[test]
1254    fn format_human_contains_sections() {
1255        let result = AskResult {
1256            query: "postgres".into(),
1257            input_type: "keyword".into(),
1258            decisions: vec![DecisionHit {
1259                event_id: "e1".into(),
1260                key: "db.engine".into(),
1261                value: "postgres".into(),
1262                reason: "JSONB".into(),
1263                domain: "db".into(),
1264                branch: "main".into(),
1265                ts: "2026-02-15".into(),
1266                is_active: true,
1267                tags: vec![],
1268                village_id: None,
1269                staleness: None,
1270            }],
1271            timeline: vec![],
1272            related_commits: vec![CommitHit {
1273                event_id: "c1".into(),
1274                title: "feat: migrate".into(),
1275                purpose: "migration".into(),
1276                ts: "2026-02-15".into(),
1277                branch: "main".into(),
1278                match_type: "evidence".into(),
1279            }],
1280            related_notes: vec![],
1281            conversations: vec![],
1282            dependents: vec![],
1283            override_risk: None,
1284        };
1285
1286        let output = format_human(&result);
1287        assert!(output.contains("Decisions"));
1288        assert!(output.contains("postgres"));
1289        assert!(output.contains("Related Commits"));
1290        assert!(output.contains("feat: migrate"));
1291    }
1292
1293    #[test]
1294    fn find_related_notes_excludes_session_digests() {
1295        let (tmp, ledger) = setup();
1296        // Create a session digest note containing a keyword
1297        let tags = vec!["session_digest".to_string()];
1298        let mut digest = new_note_event(
1299            "main",
1300            None,
1301            "system",
1302            "discussed postgres migration",
1303            &tags,
1304        )
1305        .unwrap();
1306        digest.payload["session_stats"] = serde_json::json!({
1307            "tool_calls": 10,
1308            "tasks_snapshot": [{"subject": "Fix postgres pool", "status": "completed"}],
1309        });
1310        finalize_event(&mut digest).unwrap();
1311        ledger.append_event(&digest).unwrap();
1312
1313        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1314        // Session digest note should NOT appear in related_notes
1315        assert!(
1316            result.related_notes.is_empty(),
1317            "session digest should be filtered: {:?}",
1318            result.related_notes
1319        );
1320
1321        let _ = std::fs::remove_dir_all(&tmp);
1322    }
1323
1324    #[test]
1325    fn format_human_truncates_long_notes() {
1326        let long_text = "a".repeat(200);
1327        let result = AskResult {
1328            query: "test".into(),
1329            input_type: "keyword".into(),
1330            decisions: vec![],
1331            timeline: vec![],
1332            related_commits: vec![],
1333            related_notes: vec![NoteHit {
1334                event_id: "n1".into(),
1335                text: long_text,
1336                ts: "2026-02-26".into(),
1337                branch: "main".into(),
1338            }],
1339            conversations: vec![],
1340            dependents: vec![],
1341            override_risk: None,
1342        };
1343
1344        let output = format_human(&result);
1345        assert!(output.contains("..."), "long note should be truncated");
1346        // Should not contain the full 200-char string
1347        assert!(
1348            !output.contains(&"a".repeat(200)),
1349            "should not contain full text"
1350        );
1351    }
1352
1353    #[test]
1354    fn format_human_empty_result() {
1355        let result = AskResult {
1356            query: "nonexistent".into(),
1357            input_type: "keyword".into(),
1358            decisions: vec![],
1359            timeline: vec![],
1360            related_commits: vec![],
1361            related_notes: vec![],
1362            conversations: vec![],
1363            dependents: vec![],
1364            override_risk: None,
1365        };
1366
1367        let output = format_human(&result);
1368        assert!(output.contains("No results found"));
1369    }
1370
1371    // ── Impact analysis tests ───────────────────────────────────────
1372
1373    #[test]
1374    fn impact_no_dependents() {
1375        let (tmp, ledger) = setup();
1376        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1377        ledger.append_event(&d1).unwrap();
1378
1379        let opts = AskOptions {
1380            impact: true,
1381            ..Default::default()
1382        };
1383        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1384        assert!(result.dependents.is_empty());
1385        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1386        assert_eq!(result.override_risk.as_ref().unwrap().dependent_count, 0);
1387
1388        let _ = std::fs::remove_dir_all(&tmp);
1389    }
1390
1391    #[test]
1392    fn impact_single_explicit_dep() {
1393        let (tmp, ledger) = setup();
1394        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1395        ledger.append_event(&d1).unwrap();
1396
1397        let d2 = make_decision("main", "db.schema", "JSONB", Some("needs postgres"), None);
1398        ledger.append_event(&d2).unwrap();
1399
1400        // db.schema depends on db.engine (explicit)
1401        ledger
1402            .insert_dep("db.schema", "db.engine", "explicit", None)
1403            .unwrap();
1404
1405        let opts = AskOptions {
1406            impact: true,
1407            ..Default::default()
1408        };
1409        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1410        assert_eq!(result.dependents.len(), 1);
1411        assert_eq!(result.dependents[0].key, "db.schema");
1412        assert_eq!(result.dependents[0].dep_type, "explicit");
1413        assert_eq!(result.dependents[0].depth, 1);
1414        // 1 explicit direct dep => score = 3 => MEDIUM
1415        assert_eq!(result.override_risk.as_ref().unwrap().level, "MEDIUM");
1416
1417        let _ = std::fs::remove_dir_all(&tmp);
1418    }
1419
1420    #[test]
1421    fn impact_multiple_auto_domain() {
1422        let (tmp, ledger) = setup();
1423        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1424        ledger.append_event(&d1).unwrap();
1425        let d2 = make_decision("main", "db.pool", "10", None, None);
1426        ledger.append_event(&d2).unwrap();
1427        let d3 = make_decision("main", "db.timeout", "30s", None, None);
1428        ledger.append_event(&d3).unwrap();
1429
1430        // auto_domain deps
1431        ledger
1432            .insert_dep("db.pool", "db.engine", "auto_domain", None)
1433            .unwrap();
1434        ledger
1435            .insert_dep("db.timeout", "db.engine", "auto_domain", None)
1436            .unwrap();
1437
1438        let opts = AskOptions {
1439            impact: true,
1440            ..Default::default()
1441        };
1442        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1443        assert_eq!(result.dependents.len(), 2);
1444        // 2 auto_domain direct deps => score = 2 => LOW
1445        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1446
1447        let _ = std::fs::remove_dir_all(&tmp);
1448    }
1449
1450    #[test]
1451    fn impact_transitive_chain() {
1452        let (tmp, ledger) = setup();
1453        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1454        ledger.append_event(&d1).unwrap();
1455        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1456        ledger.append_event(&d2).unwrap();
1457        let d3 = make_decision("main", "api.format", "JSON", None, None);
1458        ledger.append_event(&d3).unwrap();
1459
1460        // db.schema -> db.engine, api.format -> db.schema
1461        ledger
1462            .insert_dep("db.schema", "db.engine", "explicit", None)
1463            .unwrap();
1464        ledger
1465            .insert_dep("api.format", "db.schema", "explicit", None)
1466            .unwrap();
1467
1468        let opts = AskOptions {
1469            impact: true,
1470            ..Default::default()
1471        };
1472        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1473        assert_eq!(result.dependents.len(), 2);
1474
1475        // Check depths
1476        let schema_hit = result
1477            .dependents
1478            .iter()
1479            .find(|d| d.key == "db.schema")
1480            .unwrap();
1481        assert_eq!(schema_hit.depth, 1);
1482        let api_hit = result
1483            .dependents
1484            .iter()
1485            .find(|d| d.key == "api.format")
1486            .unwrap();
1487        assert_eq!(api_hit.depth, 2);
1488
1489        let _ = std::fs::remove_dir_all(&tmp);
1490    }
1491
1492    #[test]
1493    fn impact_cross_domain() {
1494        let (tmp, ledger) = setup();
1495        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1496        ledger.append_event(&d1).unwrap();
1497        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1498        ledger.append_event(&d2).unwrap();
1499        let d3 = make_decision("main", "api.format", "JSON", None, None);
1500        ledger.append_event(&d3).unwrap();
1501        let d4 = make_decision("main", "cache.strategy", "redis", None, None);
1502        ledger.append_event(&d4).unwrap();
1503
1504        // All depend on db.engine via explicit
1505        ledger
1506            .insert_dep("db.schema", "db.engine", "explicit", None)
1507            .unwrap();
1508        ledger
1509            .insert_dep("api.format", "db.engine", "explicit", None)
1510            .unwrap();
1511        ledger
1512            .insert_dep("cache.strategy", "db.engine", "explicit", None)
1513            .unwrap();
1514
1515        let opts = AskOptions {
1516            impact: true,
1517            ..Default::default()
1518        };
1519        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1520        assert_eq!(result.dependents.len(), 3);
1521        // 3 explicit direct (9) + cross_domain: 3 domains × 2 (6) => score = 15 => HIGH
1522        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1523
1524        let _ = std::fs::remove_dir_all(&tmp);
1525    }
1526
1527    #[test]
1528    fn impact_high_risk_scenario() {
1529        let (tmp, ledger) = setup();
1530        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1531        ledger.append_event(&d1).unwrap();
1532
1533        // Create 4 explicit dependents + 1 transitive
1534        for (i, key) in ["db.schema", "db.pool", "api.format", "auth.store"]
1535            .iter()
1536            .enumerate()
1537        {
1538            let d = make_decision("main", key, &format!("val{i}"), None, None);
1539            ledger.append_event(&d).unwrap();
1540            ledger
1541                .insert_dep(key, "db.engine", "explicit", None)
1542                .unwrap();
1543        }
1544        // Transitive: cache.ttl -> api.format -> db.engine
1545        let d_cache = make_decision("main", "cache.ttl", "60s", None, None);
1546        ledger.append_event(&d_cache).unwrap();
1547        ledger
1548            .insert_dep("cache.ttl", "api.format", "explicit", None)
1549            .unwrap();
1550
1551        let opts = AskOptions {
1552            impact: true,
1553            ..Default::default()
1554        };
1555        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1556        assert_eq!(result.dependents.len(), 5);
1557        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1558
1559        let _ = std::fs::remove_dir_all(&tmp);
1560    }
1561
1562    #[test]
1563    fn impact_domain_query() {
1564        let (tmp, ledger) = setup();
1565        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1566        ledger.append_event(&d1).unwrap();
1567        let d2 = make_decision("main", "db.pool", "10", None, None);
1568        ledger.append_event(&d2).unwrap();
1569
1570        // api.format depends on db.engine
1571        let d3 = make_decision("main", "api.format", "JSON", None, None);
1572        ledger.append_event(&d3).unwrap();
1573        ledger
1574            .insert_dep("api.format", "db.engine", "explicit", None)
1575            .unwrap();
1576
1577        let opts = AskOptions {
1578            impact: true,
1579            ..Default::default()
1580        };
1581        let result = ask(&ledger, "db", &opts, None).unwrap();
1582        assert_eq!(result.input_type, "domain");
1583        // api.format is a dependent of db.engine
1584        assert!(
1585            result.dependents.iter().any(|d| d.key == "api.format"),
1586            "domain impact should find api.format as dependent"
1587        );
1588        assert!(result.override_risk.is_some());
1589
1590        let _ = std::fs::remove_dir_all(&tmp);
1591    }
1592
1593    #[test]
1594    fn impact_flag_ignored_for_keyword() {
1595        let (tmp, ledger) = setup();
1596        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1597        ledger.append_event(&d1).unwrap();
1598
1599        let opts = AskOptions {
1600            impact: true,
1601            ..Default::default()
1602        };
1603        let result = ask(&ledger, "postgres", &opts, None).unwrap();
1604        assert_eq!(result.input_type, "keyword");
1605        // Impact should be empty for keyword queries
1606        assert!(result.dependents.is_empty());
1607        assert!(result.override_risk.is_none());
1608
1609        let _ = std::fs::remove_dir_all(&tmp);
1610    }
1611
1612    #[test]
1613    fn ask_keyword_semantic_similarity() {
1614        let (tmp, ledger) = setup();
1615        ledger
1616            .append_event(&make_decision(
1617                "main",
1618                "pricing.discount_policy",
1619                "daytime_revenue_shield",
1620                Some("avoid aggressive daytime markdowns"),
1621                None,
1622            ))
1623            .unwrap();
1624        ledger
1625            .append_event(&make_decision(
1626                "main",
1627                "inventory.reorder_buffer",
1628                "40_percent",
1629                Some("protect stock for midnight spikes"),
1630                None,
1631            ))
1632            .unwrap();
1633
1634        let query = "similar daytime discount outcome for chiefs";
1635        let result = ask(&ledger, query, &AskOptions::default(), None).unwrap();
1636
1637        assert_eq!(result.input_type, "keyword");
1638        assert!(
1639            result
1640                .decisions
1641                .iter()
1642                .any(|d| d.key == "pricing.discount_policy"),
1643            "semantic search should retrieve discount precedent"
1644        );
1645
1646        let _ = std::fs::remove_dir_all(&tmp);
1647    }
1648
1649    #[test]
1650    fn format_human_with_impact() {
1651        let result = AskResult {
1652            query: "db.engine".into(),
1653            input_type: "exact_key".into(),
1654            decisions: vec![DecisionHit {
1655                event_id: "e1".into(),
1656                key: "db.engine".into(),
1657                value: "postgres".into(),
1658                reason: "JSONB".into(),
1659                domain: "db".into(),
1660                branch: "main".into(),
1661                ts: "2026-02-15".into(),
1662                is_active: true,
1663                tags: vec![],
1664                village_id: None,
1665                staleness: None,
1666            }],
1667            timeline: vec![],
1668            related_commits: vec![],
1669            related_notes: vec![],
1670            conversations: vec![],
1671            dependents: vec![
1672                DependentHit {
1673                    key: "db.schema".into(),
1674                    value: "JSONB".into(),
1675                    dep_type: "explicit".into(),
1676                    depth: 1,
1677                },
1678                DependentHit {
1679                    key: "api.format".into(),
1680                    value: "JSON".into(),
1681                    dep_type: "auto_domain".into(),
1682                    depth: 2,
1683                },
1684            ],
1685            override_risk: Some(OverrideRisk {
1686                level: "HIGH".to_string(),
1687                dependent_count: 2,
1688                suggestion: Some("建議覆蓋順序: api.format → db.schema".into()),
1689            }),
1690        };
1691
1692        let output = format_human(&result);
1693        assert!(
1694            output.contains("Dependents"),
1695            "should have Dependents section"
1696        );
1697        assert!(
1698            output.contains("depth 1"),
1699            "should show depth for dependents"
1700        );
1701        assert!(
1702            output.contains("depth 2"),
1703            "should show depth for transitive"
1704        );
1705        assert!(
1706            output.contains("Override Risk"),
1707            "should have Override Risk section"
1708        );
1709        assert!(output.contains("HIGH"), "should show HIGH risk level");
1710        assert!(
1711            output.contains("建議覆蓋順序"),
1712            "should show override order suggestion"
1713        );
1714    }
1715
1716    #[test]
1717    fn ask_filters_by_village_id() {
1718        let (_tmp, ledger) = setup();
1719
1720        // Create two decisions: one with village_id, one without
1721        let mut ev1 = make_decision("main", "db.engine", "sqlite", Some("embedded"), None);
1722        ev1.payload["decision"]["village_id"] = serde_json::json!("village-a");
1723        finalize_event(&mut ev1).unwrap();
1724        ledger.append_event(&ev1).unwrap();
1725
1726        let ev2 = make_decision("main", "cache.ttl", "3600", Some("perf"), None);
1727        ledger.append_event(&ev2).unwrap();
1728
1729        // Without village filter: should find both
1730        let opts_all = AskOptions::default();
1731        let result_all = ask(&ledger, "db OR cache", &opts_all, None).unwrap();
1732        assert!(
1733            result_all.decisions.len() >= 2,
1734            "expected at least 2 decisions without village filter, got {}",
1735            result_all.decisions.len()
1736        );
1737
1738        // With village filter: should find only village-a
1739        let opts_village = AskOptions {
1740            village_id: Some("village-a".to_string()),
1741            ..Default::default()
1742        };
1743        let result_village = ask(&ledger, "db OR cache", &opts_village, None).unwrap();
1744        assert_eq!(
1745            result_village.decisions.len(),
1746            1,
1747            "expected 1 decision with village filter"
1748        );
1749        assert_eq!(result_village.decisions[0].key, "db.engine");
1750        assert_eq!(
1751            result_village.decisions[0].village_id.as_deref(),
1752            Some("village-a")
1753        );
1754
1755        // Non-existent village: should find none
1756        let opts_empty = AskOptions {
1757            village_id: Some("no-such-village".to_string()),
1758            ..Default::default()
1759        };
1760        let result_empty = ask(&ledger, "db OR cache", &opts_empty, None).unwrap();
1761        assert!(
1762            result_empty.decisions.is_empty(),
1763            "expected 0 decisions for non-existent village"
1764        );
1765    }
1766}