Skip to main content

edda_ask/
lib.rs

1use edda_core::Event;
2use edda_ledger::DecisionView;
3use edda_ledger::Ledger;
4use serde::{Deserialize, 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, Default, 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    /// Tasks whose title/receipt/reason match the query — the rail's answer to
61    /// "what shipped about X, verified how" (GH-404).
62    #[serde(skip_serializing_if = "Vec::is_empty")]
63    pub tasks: Vec<TaskHit>,
64    #[serde(skip_serializing_if = "Vec::is_empty")]
65    pub dependents: Vec<DependentHit>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub override_risk: Option<OverrideRisk>,
68    /// Count of total events in the workspace ledger, if known.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub workspace_event_count: Option<u64>,
71    /// Count of total decisions in the workspace ledger, if known.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub workspace_decision_count: Option<u64>,
74}
75
76/// A task matched by `ask`. The receipt is the point — it is where a finished
77/// task records what it did and how it was verified.
78#[derive(Debug, Clone, Serialize)]
79pub struct TaskHit {
80    pub task_id: u64,
81    pub title: String,
82    pub status: String,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub receipt: Option<String>,
85    #[serde(skip_serializing_if = "Vec::is_empty")]
86    pub evidence_paths: Vec<String>,
87}
88
89/// Decision governance status (GH-806).
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct DecisionGovernance {
92    /// "ratified" | "unratified"
93    pub status: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub ratified_by: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub ratified_at: Option<String>,
98}
99
100impl Default for DecisionGovernance {
101    fn default() -> Self {
102        Self {
103            status: "unratified".to_string(),
104            ratified_by: None,
105            ratified_at: None,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize)]
111pub struct DecisionHit {
112    pub event_id: String,
113    pub key: String,
114    pub value: String,
115    pub reason: String,
116    pub domain: String,
117    pub branch: String,
118    pub ts: String,
119    pub is_active: bool,
120    /// Governance status (GH-806).
121    pub governance: DecisionGovernance,
122    /// Tags parsed from JSON array in the decision row.
123    #[serde(skip_serializing_if = "Vec::is_empty")]
124    pub tags: Vec<String>,
125    /// Village scope identifier.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub village_id: Option<String>,
128    /// Decision-code freshness (q334 EDDA-STALENESS1). None ⇒ not checked
129    /// (feature off, or repo_root missing at query time). Serialized only when
130    /// present so existing JSON consumers stay unaffected.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub staleness: Option<crate::staleness::DecisionStaleness>,
133}
134
135#[derive(Debug, Clone, Serialize)]
136pub struct CommitHit {
137    pub event_id: String,
138    pub title: String,
139    pub purpose: String,
140    pub ts: String,
141    pub branch: String,
142    pub match_type: String,
143}
144
145#[derive(Debug, Clone, Serialize)]
146pub struct NoteHit {
147    pub event_id: String,
148    pub text: String,
149    pub ts: String,
150    pub branch: String,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct ConversationHit {
155    pub doc_id: String,
156    pub session_id: String,
157    pub ts: String,
158    pub snippet: String,
159    pub rank: f64,
160}
161
162// ── Impact types ────────────────────────────────────────────────────
163
164#[derive(Debug, Clone, Serialize)]
165pub struct DependentHit {
166    pub key: String,
167    pub value: String,
168    pub dep_type: String,
169    /// Hop distance from the queried key (1 = direct dependent).
170    pub depth: usize,
171}
172
173#[derive(Debug, Clone, Serialize)]
174pub struct OverrideRisk {
175    pub level: String,
176    pub dependent_count: usize,
177    pub suggestion: Option<String>,
178}
179
180// ── Options ──────────────────────────────────────────────────────────
181
182pub struct AskOptions {
183    pub limit: usize,
184    pub include_superseded: bool,
185    pub branch: Option<String>,
186    pub impact: bool,
187    /// ISO 8601 lower bound (inclusive) for temporal filtering.
188    pub after: Option<String>,
189    /// ISO 8601 upper bound (inclusive) for temporal filtering.
190    pub before: Option<String>,
191    /// Filter decisions whose tags contain any of these values (OR semantics).
192    pub tags: Vec<String>,
193    /// Filter decisions belonging to a specific village.
194    pub village_id: Option<String>,
195}
196
197impl Default for AskOptions {
198    fn default() -> Self {
199        Self {
200            limit: 20,
201            include_superseded: false,
202            branch: None,
203            impact: false,
204            after: None,
205            before: None,
206            tags: vec![],
207            village_id: None,
208        }
209    }
210}
211
212/// Transcript search callback type.
213pub type TranscriptSearchFn = dyn Fn(&str, usize) -> Vec<ConversationHit>;
214
215// ── Core ask function ────────────────────────────────────────────────
216
217#[allow(clippy::too_many_lines)] // 264 lines at #779; split tracked in none
218pub fn ask(
219    ledger: &Ledger,
220    query: &str,
221    opts: &AskOptions,
222    transcript_search: Option<&TranscriptSearchFn>,
223) -> anyhow::Result<AskResult> {
224    let domains = ledger.list_domains()?;
225    let input_type = detect_input_type(query, &domains);
226
227    // Branch filter helper: keep only decisions matching the requested branch
228    let branch_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
229        match &opts.branch {
230            Some(b) => hits.into_iter().filter(|d| d.branch == *b).collect(),
231            None => hits,
232        }
233    };
234
235    // Tags filter helper: keep only decisions that have at least one matching tag (OR semantics)
236    let tags_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
237        if opts.tags.is_empty() {
238            return hits;
239        }
240        hits.into_iter()
241            .filter(|d| d.tags.iter().any(|t| opts.tags.contains(t)))
242            .collect()
243    };
244
245    // Village filter helper: keep only decisions belonging to the specified village
246    let village_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
247        match &opts.village_id {
248            Some(v) => hits
249                .into_iter()
250                .filter(|d| d.village_id.as_deref() == Some(v.as_str()))
251                .collect(),
252            None => hits,
253        }
254    };
255
256    let after_ref = opts.after.as_deref();
257    let before_ref = opts.before.as_deref();
258
259    let (decisions, timeline) = match &input_type {
260        InputType::ExactKey(key) => {
261            let all = ledger
262                .decision_timeline(key, after_ref, before_ref)?
263                .into_iter()
264                .map(|r| to_decision_hit(&r))
265                .collect::<Vec<_>>();
266            let all = branch_filter(all);
267            let active: Vec<DecisionHit> = if opts.include_superseded {
268                all.clone()
269            } else {
270                all.iter().filter(|d| d.is_active).cloned().collect()
271            };
272            (active, all)
273        }
274        InputType::Domain(domain) => {
275            let active = branch_filter(
276                ledger
277                    .active_decisions_limited(
278                        Some(domain),
279                        None,
280                        after_ref,
281                        before_ref,
282                        opts.limit,
283                    )?
284                    .into_iter()
285                    .map(|r| to_decision_hit(&r))
286                    .collect(),
287            );
288            let tl = branch_filter(
289                ledger
290                    .domain_timeline(domain, after_ref, before_ref)?
291                    .into_iter()
292                    .map(|r| to_decision_hit(&r))
293                    .collect(),
294            );
295            (active, tl)
296        }
297        InputType::Keyword(kw) => {
298            let mut semantic_hits = semantic_decision_search(
299                ledger,
300                kw,
301                opts.branch.as_deref(),
302                after_ref,
303                before_ref,
304                opts.limit,
305            )?;
306
307            let lexical_fallback = ledger
308                .active_decisions_limited(None, Some(kw), after_ref, before_ref, opts.limit)?
309                .into_iter()
310                .map(|r| to_decision_hit(&r));
311
312            for hit in lexical_fallback {
313                if !semantic_hits.iter().any(|h| h.event_id == hit.event_id) {
314                    semantic_hits.push(hit);
315                }
316            }
317
318            let mut hits = branch_filter(semantic_hits);
319            if hits.len() > opts.limit {
320                hits.truncate(opts.limit);
321            }
322
323            if opts.include_superseded {
324                // Scan only note events for superseded decisions matching keyword
325                let events = ledger.iter_events_by_type("note")?;
326                let kw_lower = kw.to_lowercase();
327                for event in &events {
328                    if let Some(ref b) = opts.branch {
329                        if event.branch != *b {
330                            continue;
331                        }
332                    }
333                    if event.event_type == "note"
334                        && edda_core::decision::is_decision(&event.payload)
335                    {
336                        if let Some(dp) = edda_core::decision::extract_decision(&event.payload) {
337                            let reason_str = dp.reason.as_deref().unwrap_or("").to_string();
338                            if (dp.key.to_lowercase().contains(&kw_lower)
339                                || dp.value.to_lowercase().contains(&kw_lower)
340                                || reason_str.to_lowercase().contains(&kw_lower))
341                                && !hits.iter().any(|h| h.event_id == event.event_id)
342                            {
343                                let domain = edda_core::decision::extract_domain(&dp.key);
344                                hits.push(DecisionHit {
345                                    event_id: event.event_id.clone(),
346                                    key: dp.key,
347                                    value: dp.value,
348                                    reason: reason_str,
349                                    domain,
350                                    branch: event.branch.clone(),
351                                    ts: event.ts.clone(),
352                                    is_active: false,
353                                    governance: DecisionGovernance::default(),
354                                    tags: dp.tags.unwrap_or_default(),
355                                    village_id: dp.village_id,
356                                    staleness: None,
357                                });
358                            }
359                        }
360                    }
361                }
362            }
363            (hits, vec![])
364        }
365        InputType::Overview => {
366            let active = branch_filter(
367                ledger
368                    .active_decisions_limited(None, None, after_ref, before_ref, opts.limit)?
369                    .into_iter()
370                    .map(|r| to_decision_hit(&r))
371                    .collect(),
372            );
373            (active, vec![])
374        }
375    };
376
377    // Apply tags filter (OR semantics) across all code paths
378    let decisions = tags_filter(decisions);
379    let timeline = tags_filter(timeline);
380
381    // Apply village filter across all code paths
382    let mut decisions = village_filter(decisions);
383    let mut timeline = village_filter(timeline);
384
385    // Annotate governance status (GH-806)
386    let ratifications = ledger.ratified_decisions_map()?;
387    annotate_governance(&mut decisions, &ratifications);
388    annotate_governance(&mut timeline, &ratifications);
389
390    // Collect decision event_ids for evidence chain matching
391    let decision_event_ids: Vec<&str> = decisions
392        .iter()
393        .map(|d| d.event_id.as_str())
394        .chain(timeline.iter().map(|d| d.event_id.as_str()))
395        .collect();
396
397    let q = query.trim();
398    // SQL push-down: find related commits and notes via targeted queries
399    let commit_events =
400        ledger.find_related_commits(opts.branch.as_deref(), q, &decision_event_ids, opts.limit)?;
401    let related_commits = to_commit_hits(&commit_events, &decision_event_ids, q, opts.limit);
402    let note_events = ledger.find_related_notes(opts.branch.as_deref(), q, opts.limit)?;
403    let mut related_notes = find_checkpoint_notes(ledger, q, opts)?;
404    related_notes.extend(to_note_hits(&note_events, opts.limit));
405    related_notes.truncate(opts.limit);
406
407    let conversations = match transcript_search {
408        Some(search_fn) if !q.is_empty() => search_fn(q, opts.limit),
409        _ => vec![],
410    };
411
412    // Tasks whose text matches the query. What a reader is after is "what
413    // shipped about X, verified how", so a task matches when the query appears
414    // in its title, receipt, or failure reason — the receipt being the point.
415    // Newest first, since a rail answer is usually a recent one (GH-404).
416    //
417    // Deliberately not branch-filtered, unlike decisions above: the task rail is
418    // a workspace-global queue, not a per-branch record, so `--branch` narrowing
419    // the decisions but not the tasks is the intended asymmetry.
420    let tasks: Vec<TaskHit> = if q.is_empty() {
421        vec![]
422    } else {
423        let needle = q.to_lowercase();
424        let mut views = ledger.task_views()?;
425        views.sort_by_key(|t| std::cmp::Reverse(t.task_id)); // newest first
426        views
427            .into_iter()
428            .filter(|t| {
429                let hay = format!(
430                    "{} {} {}",
431                    t.title,
432                    t.receipt.as_deref().unwrap_or(""),
433                    t.failure_reason.as_deref().unwrap_or("")
434                )
435                .to_lowercase();
436                hay.contains(&needle)
437            })
438            .take(opts.limit)
439            .map(|t| TaskHit {
440                task_id: t.task_id,
441                title: t.title,
442                status: t.status.to_string(),
443                receipt: t.receipt,
444                evidence_paths: t.evidence_paths,
445            })
446            .collect()
447    };
448
449    let input_type_str = match &input_type {
450        InputType::ExactKey(_) => "exact_key",
451        InputType::Domain(_) => "domain",
452        InputType::Keyword(_) => "keyword",
453        InputType::Overview => "overview",
454    };
455
456    // Impact analysis: ExactKey or Domain queries with --impact
457    let (dependents, override_risk) = if opts.impact {
458        match &input_type {
459            InputType::ExactKey(key) => compute_impact(ledger, key)?,
460            InputType::Domain(domain) => compute_domain_impact(ledger, domain)?,
461            _ => (vec![], None),
462        }
463    } else {
464        (vec![], None)
465    };
466
467    let workspace_event_count = ledger.count_events().ok();
468    let workspace_decision_count = ledger.count_decisions().ok();
469
470    Ok(AskResult {
471        query: q.to_string(),
472        input_type: input_type_str.to_string(),
473        decisions,
474        timeline,
475        related_commits,
476        related_notes,
477        conversations,
478        tasks,
479        dependents,
480        override_risk,
481        workspace_event_count,
482        workspace_decision_count,
483    })
484}
485
486// ── Impact analysis helpers ──────────────────────────────────────────
487
488fn compute_impact(
489    ledger: &Ledger,
490    key: &str,
491) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
492    let transitive_deps = ledger.transitive_dependents_of(key, 3)?;
493    let dependents: Vec<DependentHit> = transitive_deps
494        .iter()
495        .map(|(dep, decision, depth)| DependentHit {
496            key: decision.key.clone(),
497            value: decision.value.clone(),
498            dep_type: dep.dep_type.clone(),
499            depth: *depth,
500        })
501        .collect();
502    let risk = compute_override_risk(&dependents);
503    Ok((dependents, Some(risk)))
504}
505
506fn compute_override_risk(dependents: &[DependentHit]) -> OverrideRisk {
507    let count = dependents.len();
508
509    // Weighted scoring:
510    //   explicit direct deps  x3
511    //   auto_domain direct    x1
512    //   transitive deps       x2  (depth > 1)
513    //   cross-domain spread   x2
514    let explicit_direct: usize = dependents
515        .iter()
516        .filter(|d| d.dep_type == "explicit" && d.depth == 1)
517        .count();
518    let auto_domain_direct: usize = dependents
519        .iter()
520        .filter(|d| d.dep_type == "auto_domain" && d.depth == 1)
521        .count();
522    let transitive: usize = dependents.iter().filter(|d| d.depth > 1).count();
523
524    // Count distinct domains among dependents
525    let mut domains: std::collections::HashSet<&str> = std::collections::HashSet::new();
526    for d in dependents {
527        if let Some(dom) = d.key.split('.').next() {
528            domains.insert(dom);
529        }
530    }
531    let cross_domain_bonus = if domains.len() > 1 { domains.len() } else { 0 };
532
533    let score = explicit_direct * 3 + auto_domain_direct + transitive * 2 + cross_domain_bonus * 2;
534
535    let level = if score <= 2 {
536        "LOW"
537    } else if score <= 6 {
538        "MEDIUM"
539    } else {
540        "HIGH"
541    };
542
543    // Suggestion: reverse BFS order = override leaves first
544    let suggestion = if count > 0 {
545        let mut sorted: Vec<&DependentHit> = dependents.iter().collect();
546        sorted.sort_by_key(|dependent| std::cmp::Reverse(dependent.depth));
547        let keys: Vec<&str> = sorted.iter().map(|d| d.key.as_str()).collect();
548        Some(format!("建議覆蓋順序: {}", keys.join(" → ")))
549    } else {
550        None
551    };
552
553    OverrideRisk {
554        level: level.to_string(),
555        dependent_count: count,
556        suggestion,
557    }
558}
559
560/// Aggregate impact across all active keys in a domain.
561fn compute_domain_impact(
562    ledger: &Ledger,
563    domain: &str,
564) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
565    let keys_in_domain = ledger.active_decisions(Some(domain), None, None, None)?;
566    if keys_in_domain.is_empty() {
567        return Ok((vec![], None));
568    }
569
570    let mut all_dependents: Vec<DependentHit> = Vec::new();
571    let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
572
573    for decision in &keys_in_domain {
574        let (deps, _) = compute_impact(ledger, &decision.key)?;
575        for d in deps {
576            if seen_keys.insert(d.key.clone()) {
577                all_dependents.push(d);
578            }
579        }
580    }
581
582    let risk = if all_dependents.is_empty() {
583        Some(OverrideRisk {
584            level: "LOW".to_string(),
585            dependent_count: 0,
586            suggestion: None,
587        })
588    } else {
589        Some(compute_override_risk(&all_dependents))
590    };
591
592    Ok((all_dependents, risk))
593}
594
595// ── Related event helpers ────────────────────────────────────────────
596
597/// Convert SQL-prefiltered commit events into `CommitHit` with match_type classification.
598/// Events are already filtered by SQL (branch, keyword/evidence); this determines the
599/// specific match_type ("evidence" or "title") for each.
600fn to_commit_hits(
601    events: &[Event],
602    decision_event_ids: &[&str],
603    query: &str,
604    limit: usize,
605) -> Vec<CommitHit> {
606    let mut hits: Vec<CommitHit> = Vec::new();
607    let q_lower = query.to_lowercase();
608
609    for event in events {
610        let title = event
611            .payload
612            .get("title")
613            .and_then(|v| v.as_str())
614            .unwrap_or("");
615        let purpose = event
616            .payload
617            .get("purpose")
618            .and_then(|v| v.as_str())
619            .unwrap_or("");
620
621        // Determine match type
622        let mut match_type = None;
623        for ref_id in &event.refs.events {
624            if decision_event_ids.contains(&ref_id.as_str()) {
625                match_type = Some("evidence");
626                break;
627            }
628        }
629        if match_type.is_none() {
630            for prov in &event.refs.provenance {
631                if decision_event_ids.contains(&prov.target.as_str()) {
632                    match_type = Some("evidence");
633                    break;
634                }
635            }
636        }
637        if match_type.is_none()
638            && !query.is_empty()
639            && (title.to_lowercase().contains(&q_lower)
640                || purpose.to_lowercase().contains(&q_lower))
641        {
642            match_type = Some("title");
643        }
644
645        if let Some(mt) = match_type {
646            if !hits.iter().any(|h| h.event_id == event.event_id) {
647                hits.push(CommitHit {
648                    event_id: event.event_id.clone(),
649                    title: title.to_string(),
650                    purpose: purpose.to_string(),
651                    ts: event.ts.clone(),
652                    branch: event.branch.clone(),
653                    match_type: mt.to_string(),
654                });
655                if hits.len() >= limit {
656                    break;
657                }
658            }
659        }
660    }
661
662    hits
663}
664
665/// Convert SQL-prefiltered note events into `NoteHit`.
666/// Events are already filtered by SQL (branch, keyword, excluding decisions/digests).
667fn to_note_hits(events: &[Event], limit: usize) -> Vec<NoteHit> {
668    events
669        .iter()
670        .take(limit)
671        .map(|event| {
672            let text = event
673                .payload
674                .get("text")
675                .and_then(|v| v.as_str())
676                .unwrap_or("");
677            NoteHit {
678                event_id: event.event_id.clone(),
679                text: text.to_string(),
680                ts: event.ts.clone(),
681                branch: event.branch.clone(),
682            }
683        })
684        .collect()
685}
686
687fn semantic_decision_search(
688    ledger: &Ledger,
689    query: &str,
690    branch: Option<&str>,
691    after: Option<&str>,
692    before: Option<&str>,
693    limit: usize,
694) -> anyhow::Result<Vec<DecisionHit>> {
695    if limit == 0 {
696        return Ok(vec![]);
697    }
698
699    let candidates =
700        ledger.active_decisions_limited(None, None, after, before, SEMANTIC_CANDIDATE_LIMIT)?;
701
702    let scored = rank_decisions_by_similarity(query, candidates, branch);
703    Ok(scored
704        .into_iter()
705        .take(limit)
706        .map(|s| to_decision_hit(&s.row))
707        .collect())
708}
709
710fn rank_decisions_by_similarity(
711    query: &str,
712    candidates: Vec<DecisionView>,
713    branch: Option<&str>,
714) -> Vec<ScoredDecision> {
715    let query_tokens = tokenize_for_similarity(query);
716    if query_tokens.is_empty() {
717        return vec![];
718    }
719
720    let mut tokenized_docs: Vec<(DecisionView, Vec<String>)> = Vec::new();
721    for row in candidates {
722        if branch.is_some_and(|b| row.branch != b) {
723            continue;
724        }
725        let text = format!("{} {} {} {}", row.domain, row.key, row.value, row.reason);
726        let tokens = tokenize_for_similarity(&text);
727        if !tokens.is_empty() {
728            tokenized_docs.push((row, tokens));
729        }
730    }
731
732    if tokenized_docs.is_empty() {
733        return vec![];
734    }
735
736    let mut doc_freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
737    for (_, tokens) in &tokenized_docs {
738        let unique: std::collections::HashSet<&str> = tokens.iter().map(|t| t.as_str()).collect();
739        for token in unique {
740            *doc_freq.entry(token.to_string()).or_insert(0) += 1;
741        }
742    }
743
744    let n_docs = tokenized_docs.len() as f64;
745    let query_tf = term_freq(&query_tokens);
746    let query_norm = vector_norm(&query_tf, &doc_freq, n_docs);
747
748    let mut scored: Vec<ScoredDecision> = Vec::new();
749    for (row, tokens) in tokenized_docs {
750        let tf = term_freq(&tokens);
751        let doc_norm = vector_norm(&tf, &doc_freq, n_docs);
752        if doc_norm <= f64::EPSILON || query_norm <= f64::EPSILON {
753            continue;
754        }
755
756        let mut dot = 0.0;
757        for (token, q_tf) in &query_tf {
758            if let Some(d_tf) = tf.get(token) {
759                let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
760                dot += (*q_tf as f64 * idf) * (*d_tf as f64 * idf);
761            }
762        }
763
764        let score = dot / (query_norm * doc_norm);
765        if score > 0.0 {
766            scored.push(ScoredDecision { row, score });
767        }
768    }
769
770    scored.sort_by(|a, b| {
771        b.score
772            .partial_cmp(&a.score)
773            .unwrap_or(std::cmp::Ordering::Equal)
774    });
775    scored
776}
777
778fn tokenize_for_similarity(text: &str) -> Vec<String> {
779    text.split(|c: char| !c.is_alphanumeric())
780        .filter_map(|t| {
781            let normalized = t.trim().to_lowercase();
782            if normalized.len() >= 2 {
783                Some(normalized)
784            } else {
785                None
786            }
787        })
788        .collect()
789}
790
791fn term_freq(tokens: &[String]) -> std::collections::HashMap<String, usize> {
792    let mut tf = std::collections::HashMap::new();
793    for token in tokens {
794        *tf.entry(token.clone()).or_insert(0) += 1;
795    }
796    tf
797}
798
799fn inv_doc_freq(df: usize, n_docs: f64) -> f64 {
800    ((n_docs + 1.0) / (df as f64 + 1.0)).ln() + 1.0
801}
802
803fn vector_norm(
804    tf: &std::collections::HashMap<String, usize>,
805    doc_freq: &std::collections::HashMap<String, usize>,
806    n_docs: f64,
807) -> f64 {
808    let mut sum_sq = 0.0;
809    for (token, count) in tf {
810        let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
811        let weight = *count as f64 * idf;
812        sum_sq += weight * weight;
813    }
814    sum_sq.sqrt()
815}
816
817// ── Human-readable formatting ────────────────────────────────────────
818
819pub fn format_human(result: &AskResult) -> String {
820    let mut out = String::new();
821
822    if !result.decisions.is_empty() {
823        out.push_str("── Decisions ──────────────────────────\n");
824        for d in &result.decisions {
825            let status = if d.is_active { "active" } else { "superseded" };
826            let gov = match d.governance.status.as_str() {
827                "ratified" => {
828                    if let Some(by) = &d.governance.ratified_by {
829                        format!("ratified (by {by})")
830                    } else {
831                        "ratified".to_string()
832                    }
833                }
834                _ => "unratified".to_string(),
835            };
836            out.push_str(&format!(
837                "  {} = {} — {}\n  branch: {} | {} | {} | {}\n",
838                d.key, d.value, d.reason, d.branch, d.ts, status, gov
839            ));
840            if let Some(st) = &d.staleness {
841                if st.is_stale {
842                    let bad: Vec<String> = st
843                        .paths
844                        .iter()
845                        .filter(|p| {
846                            p.status != staleness::PathStatus::Fresh
847                                && p.status != staleness::PathStatus::Unknown
848                        })
849                        .map(|p| format!("{} ({:?})", p.path, p.status))
850                        .collect();
851                    if !bad.is_empty() {
852                        out.push_str(&format!("  ⚠ stale-code hint: {}\n", bad.join(", ")));
853                    }
854                }
855            }
856            out.push('\n');
857        }
858    }
859
860    if !result.timeline.is_empty() {
861        out.push_str("── Timeline ───────────────────────────\n");
862        for d in &result.timeline {
863            let status = if d.is_active { "active" } else { "superseded" };
864            let gov = &d.governance.status;
865            out.push_str(&format!(
866                "  {}  {} = {}  ({}, {})\n",
867                d.ts, d.key, d.value, status, gov
868            ));
869        }
870        out.push('\n');
871    }
872
873    if !result.related_commits.is_empty() {
874        out.push_str("── Related Commits ────────────────────\n");
875        for c in &result.related_commits {
876            out.push_str(&format!(
877                "  {} ({}, {})\n  match: {}\n\n",
878                c.title, c.ts, c.branch, c.match_type
879            ));
880        }
881    }
882
883    if !result.related_notes.is_empty() {
884        out.push_str("── Related Notes ──────────────────────\n");
885        for n in &result.related_notes {
886            if n.text.len() > 120 {
887                let end = n.text.floor_char_boundary(117);
888                out.push_str(&format!(
889                    "  \"{}...\" ({}, {})\n\n",
890                    &n.text[..end],
891                    n.ts,
892                    n.branch
893                ));
894            } else {
895                out.push_str(&format!("  \"{}\" ({}, {})\n\n", n.text, n.ts, n.branch));
896            }
897        }
898    }
899
900    if !result.tasks.is_empty() {
901        out.push_str("── Tasks ──────────────────────────────\n");
902        for t in &result.tasks {
903            out.push_str(&format!("  #{} [{}] {}\n", t.task_id, t.status, t.title));
904            if let Some(r) = &t.receipt {
905                let excerpt = if r.len() > 120 {
906                    format!("{}...", &r[..r.floor_char_boundary(117)])
907                } else {
908                    r.clone()
909                };
910                out.push_str(&format!("     receipt: {excerpt}\n"));
911            }
912            if !t.evidence_paths.is_empty() {
913                out.push_str(&format!("     evidence: {}\n", t.evidence_paths.join(", ")));
914            }
915            out.push('\n');
916        }
917    }
918
919    if !result.dependents.is_empty() {
920        out.push_str("── Dependents ─────────────────────────\n");
921        for d in &result.dependents {
922            out.push_str(&format!(
923                "  \u{2192} {} = {} ({}, depth {})\n",
924                d.key, d.value, d.dep_type, d.depth
925            ));
926        }
927        out.push('\n');
928    }
929
930    if let Some(ref risk) = result.override_risk {
931        out.push_str("── Override Risk ──────────────────────\n");
932        let emoji = match risk.level.as_str() {
933            "HIGH" => "\u{1f534}",
934            "MEDIUM" => "\u{1f7e1}",
935            _ => "\u{1f7e2}",
936        };
937        out.push_str(&format!(
938            "  {} {} ({} dependents)\n",
939            emoji, risk.level, risk.dependent_count
940        ));
941        if let Some(ref suggestion) = risk.suggestion {
942            out.push_str(&format!("  {suggestion}\n"));
943        }
944        out.push('\n');
945    }
946
947    if !result.conversations.is_empty() {
948        out.push_str("── Conversations ──────────────────────\n");
949        for c in &result.conversations {
950            out.push_str(&format!(
951                "  [{}] {}\n  rank: {:.2}\n\n",
952                c.session_id, c.snippet, c.rank
953            ));
954        }
955    }
956
957    if out.is_empty() {
958        if result.workspace_event_count == Some(0) {
959            out.push_str("No results found (workspace ledger is empty; 0 events recorded).\n");
960        } else if result.workspace_decision_count == Some(0) {
961            out.push_str(
962                "No results found (workspace ledger has 0 decisions recorded; run `edda decide` to record decisions).\n",
963            );
964        } else {
965            out.push_str("No results found.\n");
966        }
967    }
968
969    out
970}
971
972// ── Internal helpers ─────────────────────────────────────────────────
973
974fn to_decision_hit(row: &DecisionView) -> DecisionHit {
975    DecisionHit {
976        event_id: row.event_id.clone(),
977        key: row.key.clone(),
978        value: row.value.clone(),
979        reason: row.reason.clone(),
980        domain: row.domain.clone(),
981        branch: row.branch.clone(),
982        ts: row.ts.clone().unwrap_or_default(),
983        is_active: matches!(row.status.as_str(), "active" | "experimental"),
984        governance: DecisionGovernance::default(),
985        tags: row.tags.clone(),
986        village_id: row.village_id.clone(),
987        staleness: None,
988    }
989}
990
991fn annotate_governance(
992    hits: &mut [DecisionHit],
993    ratifications: &std::collections::BTreeMap<String, edda_ledger::RatificationInfo>,
994) {
995    for hit in hits {
996        if let Some(info) = ratifications.get(&hit.event_id) {
997            hit.governance = DecisionGovernance {
998                status: "ratified".to_string(),
999                ratified_by: Some(info.ratified_by.clone()),
1000                ratified_at: Some(info.ts.clone()),
1001            };
1002        } else {
1003            hit.governance = DecisionGovernance {
1004                status: "unratified".to_string(),
1005                ratified_by: None,
1006                ratified_at: None,
1007            };
1008        }
1009    }
1010}
1011
1012fn find_checkpoint_notes(
1013    ledger: &Ledger,
1014    query: &str,
1015    opts: &AskOptions,
1016) -> anyhow::Result<Vec<NoteHit>> {
1017    let query = query.trim().to_lowercase();
1018    let terms: Vec<&str> = query
1019        .split(" or ")
1020        .map(str::trim)
1021        .filter(|term| !term.is_empty())
1022        .collect();
1023
1024    let mut events = ledger.iter_events_by_type("checkpoint")?;
1025    events.reverse();
1026    Ok(events
1027        .into_iter()
1028        .filter(|event| {
1029            opts.branch
1030                .as_deref()
1031                .is_none_or(|branch| branch == event.branch)
1032        })
1033        .filter(|event| {
1034            opts.after
1035                .as_deref()
1036                .is_none_or(|after| event.ts.as_str() >= after)
1037                && opts
1038                    .before
1039                    .as_deref()
1040                    .is_none_or(|before| event.ts.as_str() <= before)
1041        })
1042        .filter_map(|event| {
1043            let payload: edda_core::event::CheckpointPayload =
1044                serde_json::from_value(event.payload.clone()).ok()?;
1045            let searchable = serde_json::to_string(&payload).ok()?.to_lowercase();
1046            if !terms.is_empty() && !terms.iter().any(|term| searchable.contains(term)) {
1047                return None;
1048            }
1049            let rejected = payload
1050                .rejected
1051                .iter()
1052                .map(|item| format!("{} — {}", item.hypothesis, item.reason))
1053                .collect::<Vec<_>>();
1054            let text = format!(
1055                "checkpoint: hypotheses=[{}]; rejected=[{}]; open=[{}]; next={}",
1056                payload.hypotheses.join(" | "),
1057                rejected.join(" | "),
1058                payload.open.join(" | "),
1059                payload.next,
1060            );
1061            Some(NoteHit {
1062                event_id: event.event_id,
1063                text,
1064                ts: event.ts,
1065                branch: event.branch,
1066            })
1067        })
1068        .take(opts.limit)
1069        .collect())
1070}
1071
1072/// Return the affected_paths carried by each decision in `result.decisions`,
1073/// looked up from the ledger by event_id. Position-aligned with the input;
1074/// missing/unreadable rows return empty Vec (best-effort).
1075pub fn affected_paths_for_hits(ledger: &Ledger, hits: &[DecisionHit]) -> Vec<Vec<String>> {
1076    hits.iter()
1077        .map(|h| {
1078            ledger
1079                .get_decision_by_event_id(&h.event_id)
1080                .ok()
1081                .flatten()
1082                .map(|row| row.affected_paths)
1083                .unwrap_or_default()
1084        })
1085        .collect()
1086}
1087
1088// Decision helpers centralized in edda_core::decision.
1089
1090// ── Tests ────────────────────────────────────────────────────────────
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use edda_core::event::{
1096        finalize_event, new_checkpoint_event, new_note_event, CheckpointPayload, RejectedHypothesis,
1097    };
1098    use edda_core::Provenance;
1099    use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
1100    use edda_ledger::paths::EddaPaths;
1101    use std::sync::atomic::{AtomicU64, Ordering};
1102
1103    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
1104
1105    struct TestLedger(Ledger);
1106
1107    impl std::ops::Deref for TestLedger {
1108        type Target = Ledger;
1109
1110        fn deref(&self) -> &Self::Target {
1111            &self.0
1112        }
1113    }
1114
1115    impl TestLedger {
1116        fn append_event(&self, event: &Event) -> anyhow::Result<()> {
1117            let mut chained = event.clone();
1118            chained.parent_hash = self.0.last_event_hash()?;
1119            finalize_event(&mut chained)?;
1120            self.0.append_event(&chained)
1121        }
1122    }
1123
1124    fn setup() -> (std::path::PathBuf, TestLedger) {
1125        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
1126        let tmp = std::env::temp_dir().join(format!("edda_ask_test_{}_{n}", std::process::id()));
1127        let _ = std::fs::remove_dir_all(&tmp);
1128        let paths = EddaPaths::discover(&tmp);
1129        init_workspace(&paths).unwrap();
1130        init_head(&paths, "main").unwrap();
1131        init_branches_json(&paths, "main").unwrap();
1132        let ledger = TestLedger(Ledger::open(&tmp).unwrap());
1133        (tmp, ledger)
1134    }
1135
1136    /// A finished task's receipt is the fleet's answer to "what shipped about X,
1137    /// verified how" — and before GH-404 `ask` had no Tasks section at all, so
1138    /// it never surfaced. Matching on the receipt is the point: that is where
1139    /// the verification lives.
1140    #[test]
1141    fn ask_surfaces_a_task_whose_receipt_matches_the_query() {
1142        let (_tmp, ledger) = setup();
1143
1144        let created =
1145            edda_core::event::new_task_created_event(&edda_core::event::TaskCreatedParams {
1146                branch: "main",
1147                parent_hash: None,
1148                task_id: 7,
1149                title: "run the onboarding drill",
1150                assignee: None,
1151                agent_kind: None,
1152                after: &[],
1153                plan_id: None,
1154                work_unit_ref: None,
1155                brief_ref: None,
1156                idempotency_key: None,
1157                scope_paths: &[],
1158            })
1159            .unwrap();
1160        ledger.append_event(&created).unwrap();
1161        let done = edda_core::event::new_task_done_event(
1162            "main",
1163            None,
1164            7,
1165            "drill passed: 9/9 green, artifact in dist/drill.json",
1166            &["dist/drill.json".to_string()],
1167        )
1168        .unwrap();
1169        ledger.append_event(&done).unwrap();
1170
1171        // A second task that does NOT mention "drill". Without it, the test
1172        // could not tell a working filter from one that returns every task —
1173        // with a single task, "the match is present" and "everything is
1174        // present" are the same assertion.
1175        let other =
1176            edda_core::event::new_task_created_event(&edda_core::event::TaskCreatedParams {
1177                branch: "main",
1178                parent_hash: None,
1179                task_id: 8,
1180                title: "write the changelog",
1181                assignee: None,
1182                agent_kind: None,
1183                after: &[],
1184                plan_id: None,
1185                work_unit_ref: None,
1186                brief_ref: None,
1187                idempotency_key: None,
1188                scope_paths: &[],
1189            })
1190            .unwrap();
1191        ledger.append_event(&other).unwrap();
1192
1193        let opts = AskOptions {
1194            limit: 10,
1195            ..Default::default()
1196        };
1197        let result = ask(&ledger, "drill", &opts, None).unwrap();
1198
1199        assert_eq!(
1200            result.tasks.len(),
1201            1,
1202            "only the drill task matches — the changelog task must be filtered out: {:?}",
1203            result.tasks
1204        );
1205        let hit = &result.tasks[0];
1206        assert_eq!(hit.task_id, 7);
1207        assert!(hit.title.contains("drill"), "title: {}", hit.title);
1208        assert_eq!(hit.status, "done");
1209        assert!(
1210            hit.receipt.as_deref().unwrap_or("").contains("9/9 green"),
1211            "the receipt is the point: {:?}",
1212            hit.receipt
1213        );
1214        assert_eq!(hit.evidence_paths, vec!["dist/drill.json".to_string()]);
1215    }
1216
1217    #[test]
1218    fn ask_recalls_checkpoint_judgment_state() {
1219        let (_tmp, ledger) = setup();
1220        let checkpoint = CheckpointPayload {
1221            hypotheses: vec!["cache invalidation is the cause".to_string()],
1222            rejected: vec![RejectedHypothesis {
1223                hypothesis: "database corruption".to_string(),
1224                reason: "integrity check passes".to_string(),
1225            }],
1226            open: vec!["confirm the next rebuild".to_string()],
1227            next: "run the rebuild check".to_string(),
1228        };
1229        let event = new_checkpoint_event("main", None, "agent", &checkpoint).unwrap();
1230        ledger.append_event(&event).unwrap();
1231
1232        let result = ask(&ledger, "rebuild", &AskOptions::default(), None).unwrap();
1233
1234        assert_eq!(result.related_notes.len(), 1);
1235        assert!(result.related_notes[0]
1236            .text
1237            .contains("next=run the rebuild check"));
1238        assert!(result.related_notes[0]
1239            .text
1240            .contains("integrity check passes"));
1241    }
1242
1243    fn make_decision(
1244        branch: &str,
1245        key: &str,
1246        value: &str,
1247        reason: Option<&str>,
1248        supersedes: Option<&str>,
1249    ) -> Event {
1250        let text = match reason {
1251            Some(r) => format!("{key}: {value} — {r}"),
1252            None => format!("{key}: {value}"),
1253        };
1254        let tags = vec!["decision".to_string()];
1255        let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
1256        let decision_obj = match reason {
1257            Some(r) => serde_json::json!({"key": key, "value": value, "reason": r}),
1258            None => serde_json::json!({"key": key, "value": value}),
1259        };
1260        event.payload["decision"] = decision_obj;
1261        if let Some(target) = supersedes {
1262            event.refs.provenance.push(Provenance {
1263                target: target.to_string(),
1264                rel: "supersedes".to_string(),
1265                note: Some(format!("key '{key}' re-decided")),
1266            });
1267        }
1268        finalize_event(&mut event).unwrap();
1269        event
1270    }
1271
1272    fn make_commit(branch: &str, title: &str, purpose: &str, evidence: &[&str]) -> Event {
1273        use edda_core::event::finalize_event;
1274        let payload = serde_json::json!({
1275            "title": title,
1276            "purpose": purpose,
1277            "sha": "abc123",
1278        });
1279        let mut event = Event {
1280            event_id: format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase()),
1281            ts: time_now(),
1282            event_type: "commit".to_string(),
1283            branch: branch.to_string(),
1284            parent_hash: None,
1285            hash: String::new(),
1286            payload,
1287            refs: edda_core::Refs {
1288                events: evidence.iter().map(|s| s.to_string()).collect(),
1289                ..Default::default()
1290            },
1291            schema_version: 1,
1292            digests: vec![],
1293            event_family: None,
1294            event_level: None,
1295        };
1296        finalize_event(&mut event).unwrap();
1297        event
1298    }
1299
1300    fn make_note(branch: &str, text: &str) -> Event {
1301        new_note_event(branch, None, "user", text, &[]).unwrap()
1302    }
1303
1304    fn time_now() -> String {
1305        let now = time::OffsetDateTime::now_utc();
1306        now.format(&time::format_description::well_known::Rfc3339)
1307            .expect("rfc3339")
1308    }
1309
1310    // ── detect_input_type tests ──────────────────────────────────────
1311
1312    #[test]
1313    fn detect_exact_key() {
1314        let domains = vec!["db".into(), "auth".into()];
1315        assert_eq!(
1316            detect_input_type("db.engine", &domains),
1317            InputType::ExactKey("db.engine".into())
1318        );
1319    }
1320
1321    #[test]
1322    fn detect_domain() {
1323        let domains = vec!["db".into(), "auth".into()];
1324        assert_eq!(
1325            detect_input_type("db", &domains),
1326            InputType::Domain("db".into())
1327        );
1328    }
1329
1330    #[test]
1331    fn detect_domain_case_insensitive() {
1332        let domains = vec!["db".into()];
1333        assert_eq!(
1334            detect_input_type("DB", &domains),
1335            InputType::Domain("DB".into())
1336        );
1337    }
1338
1339    #[test]
1340    fn detect_keyword() {
1341        let domains = vec!["db".into()];
1342        assert_eq!(
1343            detect_input_type("postgres", &domains),
1344            InputType::Keyword("postgres".into())
1345        );
1346    }
1347
1348    #[test]
1349    fn detect_overview() {
1350        assert_eq!(detect_input_type("", &[]), InputType::Overview);
1351        assert_eq!(detect_input_type("  ", &[]), InputType::Overview);
1352    }
1353
1354    // ── ask() tests ──────────────────────────────────────────────────
1355
1356    #[test]
1357    fn ask_exact_key() {
1358        let (tmp, ledger) = setup();
1359        let d1 = make_decision("main", "db.engine", "sqlite", Some("MVP"), None);
1360        let d1_id = d1.event_id.clone();
1361        ledger.append_event(&d1).unwrap();
1362
1363        let d2 = make_decision("main", "db.engine", "postgres", Some("JSONB"), Some(&d1_id));
1364        ledger.append_event(&d2).unwrap();
1365
1366        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1367        assert_eq!(result.input_type, "exact_key");
1368        // decisions = only active (default)
1369        assert_eq!(result.decisions.len(), 1);
1370        assert_eq!(result.decisions[0].value, "postgres");
1371        // timeline = both
1372        assert_eq!(result.timeline.len(), 2);
1373
1374        let _ = std::fs::remove_dir_all(&tmp);
1375    }
1376
1377    #[test]
1378    fn ask_domain() {
1379        let (tmp, ledger) = setup();
1380        ledger
1381            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1382            .unwrap();
1383        ledger
1384            .append_event(&make_decision("main", "db.pool", "10", None, None))
1385            .unwrap();
1386        ledger
1387            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1388            .unwrap();
1389
1390        let result = ask(&ledger, "db", &AskOptions::default(), None).unwrap();
1391        assert_eq!(result.input_type, "domain");
1392        assert_eq!(result.decisions.len(), 2);
1393        assert!(result.timeline.len() >= 2);
1394
1395        let _ = std::fs::remove_dir_all(&tmp);
1396    }
1397
1398    #[test]
1399    fn ask_keyword() {
1400        let (tmp, ledger) = setup();
1401        ledger
1402            .append_event(&make_decision(
1403                "main",
1404                "db.engine",
1405                "postgres",
1406                Some("JSONB"),
1407                None,
1408            ))
1409            .unwrap();
1410        ledger
1411            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1412            .unwrap();
1413
1414        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1415        assert_eq!(result.input_type, "keyword");
1416        assert_eq!(result.decisions.len(), 1);
1417        assert_eq!(result.decisions[0].key, "db.engine");
1418
1419        let _ = std::fs::remove_dir_all(&tmp);
1420    }
1421
1422    #[test]
1423    fn ask_overview() {
1424        let (tmp, ledger) = setup();
1425        ledger
1426            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1427            .unwrap();
1428        ledger
1429            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1430            .unwrap();
1431
1432        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1433        assert_eq!(result.input_type, "overview");
1434        assert_eq!(result.decisions.len(), 2);
1435
1436        let _ = std::fs::remove_dir_all(&tmp);
1437    }
1438
1439    #[test]
1440    fn ask_with_transcript_callback() {
1441        let (tmp, ledger) = setup();
1442        ledger
1443            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1444            .unwrap();
1445
1446        let callback = |_query: &str, _limit: usize| -> Vec<ConversationHit> {
1447            vec![ConversationHit {
1448                doc_id: "t1".into(),
1449                session_id: "s1".into(),
1450                ts: "2026-02-14T10:00:00Z".into(),
1451                snippet: "discussed postgres JSONB".into(),
1452                rank: 5.0,
1453            }]
1454        };
1455
1456        let result = ask(&ledger, "postgres", &AskOptions::default(), Some(&callback)).unwrap();
1457        assert_eq!(result.conversations.len(), 1);
1458        assert_eq!(result.conversations[0].snippet, "discussed postgres JSONB");
1459
1460        let _ = std::fs::remove_dir_all(&tmp);
1461    }
1462
1463    #[test]
1464    fn ask_without_transcript_callback() {
1465        let (tmp, ledger) = setup();
1466        ledger
1467            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1468            .unwrap();
1469
1470        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1471        assert!(result.conversations.is_empty());
1472
1473        let _ = std::fs::remove_dir_all(&tmp);
1474    }
1475
1476    // ── Related events tests ─────────────────────────────────────────
1477
1478    #[test]
1479    fn find_related_commits_evidence_chain() {
1480        let (tmp, ledger) = setup();
1481        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1482        let d1_id = d1.event_id.clone();
1483        ledger.append_event(&d1).unwrap();
1484
1485        let c1 = make_commit(
1486            "main",
1487            "feat: migrate to postgres",
1488            "db migration",
1489            &[&d1_id],
1490        );
1491        ledger.append_event(&c1).unwrap();
1492
1493        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1494        assert_eq!(result.related_commits.len(), 1);
1495        assert_eq!(result.related_commits[0].match_type, "evidence");
1496
1497        let _ = std::fs::remove_dir_all(&tmp);
1498    }
1499
1500    #[test]
1501    fn find_related_commits_title_match() {
1502        let (tmp, ledger) = setup();
1503        ledger
1504            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1505            .unwrap();
1506
1507        // Commit not linked via evidence, but title matches
1508        let c1 = make_commit("main", "fix: postgres connection pool", "pool fix", &[]);
1509        ledger.append_event(&c1).unwrap();
1510
1511        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1512        assert!(result
1513            .related_commits
1514            .iter()
1515            .any(|c| c.match_type == "title"));
1516
1517        let _ = std::fs::remove_dir_all(&tmp);
1518    }
1519
1520    #[test]
1521    fn find_related_notes_text_match() {
1522        let (tmp, ledger) = setup();
1523        ledger
1524            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1525            .unwrap();
1526
1527        // Regular note (not a decision)
1528        ledger
1529            .append_event(&make_note(
1530                "main",
1531                "discussed mysql but rejected for licensing",
1532            ))
1533            .unwrap();
1534
1535        let result = ask(&ledger, "mysql", &AskOptions::default(), None).unwrap();
1536        assert_eq!(result.related_notes.len(), 1);
1537        assert!(result.related_notes[0].text.contains("mysql"));
1538
1539        let _ = std::fs::remove_dir_all(&tmp);
1540    }
1541
1542    #[test]
1543    fn find_related_notes_excludes_decision_notes() {
1544        let (tmp, ledger) = setup();
1545        ledger
1546            .append_event(&make_decision(
1547                "main",
1548                "db.engine",
1549                "postgres",
1550                Some("JSONB"),
1551                None,
1552            ))
1553            .unwrap();
1554
1555        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1556        // Decision note should NOT appear in related_notes
1557        assert!(result.related_notes.is_empty());
1558
1559        let _ = std::fs::remove_dir_all(&tmp);
1560    }
1561
1562    #[test]
1563    fn ask_annotates_governance_tier() {
1564        let (tmp, ledger) = setup();
1565
1566        // 1. Ratified decision: db.engine = postgres, ratified by alice
1567        ledger
1568            .append_event(&make_decision(
1569                "main",
1570                "db.engine",
1571                "postgres",
1572                Some("JSONB"),
1573                None,
1574            ))
1575            .unwrap();
1576        let ratify_ev =
1577            edda_core::event::new_decision_ratify_event("main", None, "db.engine", "alice", None)
1578                .unwrap();
1579        ledger.append_event(&ratify_ev).unwrap();
1580
1581        // 2. Unratified decision: auth.method = jwt
1582        ledger
1583            .append_event(&make_decision(
1584                "main",
1585                "auth.method",
1586                "jwt",
1587                Some("stateless"),
1588                None,
1589            ))
1590            .unwrap();
1591
1592        // Check db.engine hit
1593        let r1 = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1594        assert_eq!(r1.decisions.len(), 1);
1595        let d1 = &r1.decisions[0];
1596        assert_eq!(d1.key, "db.engine");
1597        assert_eq!(d1.governance.status, "ratified");
1598        assert_eq!(d1.governance.ratified_by.as_deref(), Some("alice"));
1599        assert!(d1.governance.ratified_at.is_some());
1600
1601        // Check human formatting includes ratification
1602        let human1 = format_human(&r1);
1603        assert!(human1.contains("ratified (by alice)"));
1604
1605        // Check auth.method hit
1606        let r2 = ask(&ledger, "auth.method", &AskOptions::default(), None).unwrap();
1607        assert_eq!(r2.decisions.len(), 1);
1608        let d2 = &r2.decisions[0];
1609        assert_eq!(d2.key, "auth.method");
1610        assert_eq!(d2.governance.status, "unratified");
1611        assert_eq!(d2.governance.ratified_by, None);
1612        assert_eq!(d2.governance.ratified_at, None);
1613
1614        let human2 = format_human(&r2);
1615        assert!(human2.contains("unratified"));
1616
1617        // 3. Re-decide db.engine = sqlite after ratify -> resets to unratified
1618        ledger
1619            .append_event(&make_decision(
1620                "main",
1621                "db.engine",
1622                "sqlite",
1623                Some("local dev"),
1624                None,
1625            ))
1626            .unwrap();
1627
1628        let r3 = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1629        assert_eq!(r3.decisions.len(), 1);
1630        let d3 = &r3.decisions[0];
1631        assert_eq!(d3.value, "sqlite");
1632        assert_eq!(d3.governance.status, "unratified");
1633
1634        let _ = std::fs::remove_dir_all(&tmp);
1635    }
1636
1637    #[test]
1638    fn ask_branch_filter() {
1639        let (tmp, ledger) = setup();
1640        ledger
1641            .append_event(&make_decision(
1642                "main",
1643                "db.engine",
1644                "postgres",
1645                Some("prod"),
1646                None,
1647            ))
1648            .unwrap();
1649        ledger
1650            .append_event(&make_decision(
1651                "dev",
1652                "db.engine",
1653                "sqlite",
1654                Some("dev speed"),
1655                None,
1656            ))
1657            .unwrap();
1658
1659        // No branch filter → both
1660        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1661        assert_eq!(result.decisions.len(), 2);
1662
1663        // Filter to dev → only sqlite
1664        let opts = AskOptions {
1665            branch: Some("dev".into()),
1666            ..Default::default()
1667        };
1668        let result = ask(&ledger, "", &opts, None).unwrap();
1669        assert_eq!(result.decisions.len(), 1);
1670        assert_eq!(result.decisions[0].value, "sqlite");
1671
1672        let _ = std::fs::remove_dir_all(&tmp);
1673    }
1674
1675    #[test]
1676    fn format_human_contains_sections() {
1677        let result = AskResult {
1678            query: "postgres".into(),
1679            input_type: "keyword".into(),
1680            decisions: vec![DecisionHit {
1681                event_id: "e1".into(),
1682                key: "db.engine".into(),
1683                value: "postgres".into(),
1684                reason: "JSONB".into(),
1685                domain: "db".into(),
1686                branch: "main".into(),
1687                ts: "2026-02-15".into(),
1688                is_active: true,
1689                governance: DecisionGovernance::default(),
1690                tags: vec![],
1691                village_id: None,
1692                staleness: None,
1693            }],
1694            timeline: vec![],
1695            related_commits: vec![CommitHit {
1696                event_id: "c1".into(),
1697                title: "feat: migrate".into(),
1698                purpose: "migration".into(),
1699                ts: "2026-02-15".into(),
1700                branch: "main".into(),
1701                match_type: "evidence".into(),
1702            }],
1703            related_notes: vec![],
1704            conversations: vec![],
1705            tasks: vec![],
1706            dependents: vec![],
1707            override_risk: None,
1708            ..Default::default()
1709        };
1710
1711        let output = format_human(&result);
1712        assert!(output.contains("Decisions"));
1713        assert!(output.contains("postgres"));
1714        assert!(output.contains("Related Commits"));
1715        assert!(output.contains("feat: migrate"));
1716    }
1717
1718    #[test]
1719    fn find_related_notes_excludes_session_digests() {
1720        let (tmp, ledger) = setup();
1721        // Create a session digest note containing a keyword
1722        let tags = vec!["session_digest".to_string()];
1723        let mut digest = new_note_event(
1724            "main",
1725            None,
1726            "system",
1727            "discussed postgres migration",
1728            &tags,
1729        )
1730        .unwrap();
1731        digest.payload["session_stats"] = serde_json::json!({
1732            "tool_calls": 10,
1733            "tasks_snapshot": [{"subject": "Fix postgres pool", "status": "completed"}],
1734        });
1735        finalize_event(&mut digest).unwrap();
1736        ledger.append_event(&digest).unwrap();
1737
1738        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1739        // Session digest note should NOT appear in related_notes
1740        assert!(
1741            result.related_notes.is_empty(),
1742            "session digest should be filtered: {:?}",
1743            result.related_notes
1744        );
1745
1746        let _ = std::fs::remove_dir_all(&tmp);
1747    }
1748
1749    #[test]
1750    fn format_human_truncates_long_notes() {
1751        let long_text = "a".repeat(200);
1752        let result = AskResult {
1753            query: "test".into(),
1754            input_type: "keyword".into(),
1755            decisions: vec![],
1756            timeline: vec![],
1757            related_commits: vec![],
1758            related_notes: vec![NoteHit {
1759                event_id: "n1".into(),
1760                text: long_text,
1761                ts: "2026-02-26".into(),
1762                branch: "main".into(),
1763            }],
1764            conversations: vec![],
1765            tasks: vec![],
1766            dependents: vec![],
1767            override_risk: None,
1768            ..Default::default()
1769        };
1770
1771        let output = format_human(&result);
1772        assert!(output.contains("..."), "long note should be truncated");
1773        // Should not contain the full 200-char string
1774        assert!(
1775            !output.contains(&"a".repeat(200)),
1776            "should not contain full text"
1777        );
1778    }
1779
1780    #[test]
1781    fn format_human_empty_result() {
1782        let result = AskResult {
1783            query: "nonexistent".into(),
1784            input_type: "keyword".into(),
1785            decisions: vec![],
1786            timeline: vec![],
1787            related_commits: vec![],
1788            related_notes: vec![],
1789            conversations: vec![],
1790            tasks: vec![],
1791            dependents: vec![],
1792            override_risk: None,
1793            workspace_event_count: Some(5),
1794            workspace_decision_count: Some(2),
1795        };
1796
1797        let output = format_human(&result);
1798        assert_eq!(output, "No results found.\n");
1799    }
1800
1801    #[test]
1802    fn format_human_empty_ledger_distinguished_from_empty_results() {
1803        let result_empty_ledger = AskResult {
1804            query: "test".into(),
1805            workspace_event_count: Some(0),
1806            workspace_decision_count: Some(0),
1807            ..Default::default()
1808        };
1809        assert_eq!(
1810            format_human(&result_empty_ledger),
1811            "No results found (workspace ledger is empty; 0 events recorded).\n"
1812        );
1813
1814        let result_zero_decisions = AskResult {
1815            query: "test".into(),
1816            workspace_event_count: Some(1),
1817            workspace_decision_count: Some(0),
1818            ..Default::default()
1819        };
1820        assert_eq!(
1821            format_human(&result_zero_decisions),
1822            "No results found (workspace ledger has 0 decisions recorded; run `edda decide` to record decisions).\n"
1823        );
1824    }
1825
1826    // ── Impact analysis tests ───────────────────────────────────────
1827
1828    #[test]
1829    fn impact_no_dependents() {
1830        let (tmp, ledger) = setup();
1831        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1832        ledger.append_event(&d1).unwrap();
1833
1834        let opts = AskOptions {
1835            impact: true,
1836            ..Default::default()
1837        };
1838        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1839        assert!(result.dependents.is_empty());
1840        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1841        assert_eq!(result.override_risk.as_ref().unwrap().dependent_count, 0);
1842
1843        let _ = std::fs::remove_dir_all(&tmp);
1844    }
1845
1846    #[test]
1847    fn impact_single_explicit_dep() {
1848        let (tmp, ledger) = setup();
1849        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1850        ledger.append_event(&d1).unwrap();
1851
1852        let d2 = make_decision("main", "db.schema", "JSONB", Some("needs postgres"), None);
1853        ledger.append_event(&d2).unwrap();
1854
1855        // db.schema depends on db.engine (explicit)
1856        ledger
1857            .insert_dep("db.schema", "db.engine", "explicit", None)
1858            .unwrap();
1859
1860        let opts = AskOptions {
1861            impact: true,
1862            ..Default::default()
1863        };
1864        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1865        assert_eq!(result.dependents.len(), 1);
1866        assert_eq!(result.dependents[0].key, "db.schema");
1867        assert_eq!(result.dependents[0].dep_type, "explicit");
1868        assert_eq!(result.dependents[0].depth, 1);
1869        // 1 explicit direct dep => score = 3 => MEDIUM
1870        assert_eq!(result.override_risk.as_ref().unwrap().level, "MEDIUM");
1871
1872        let _ = std::fs::remove_dir_all(&tmp);
1873    }
1874
1875    #[test]
1876    fn impact_multiple_auto_domain() {
1877        let (tmp, ledger) = setup();
1878        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1879        ledger.append_event(&d1).unwrap();
1880        let d2 = make_decision("main", "db.pool", "10", None, None);
1881        ledger.append_event(&d2).unwrap();
1882        let d3 = make_decision("main", "db.timeout", "30s", None, None);
1883        ledger.append_event(&d3).unwrap();
1884
1885        // auto_domain deps
1886        ledger
1887            .insert_dep("db.pool", "db.engine", "auto_domain", None)
1888            .unwrap();
1889        ledger
1890            .insert_dep("db.timeout", "db.engine", "auto_domain", None)
1891            .unwrap();
1892
1893        let opts = AskOptions {
1894            impact: true,
1895            ..Default::default()
1896        };
1897        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1898        assert_eq!(result.dependents.len(), 2);
1899        // 2 auto_domain direct deps => score = 2 => LOW
1900        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1901
1902        let _ = std::fs::remove_dir_all(&tmp);
1903    }
1904
1905    #[test]
1906    fn impact_transitive_chain() {
1907        let (tmp, ledger) = setup();
1908        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1909        ledger.append_event(&d1).unwrap();
1910        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1911        ledger.append_event(&d2).unwrap();
1912        let d3 = make_decision("main", "api.format", "JSON", None, None);
1913        ledger.append_event(&d3).unwrap();
1914
1915        // db.schema -> db.engine, api.format -> db.schema
1916        ledger
1917            .insert_dep("db.schema", "db.engine", "explicit", None)
1918            .unwrap();
1919        ledger
1920            .insert_dep("api.format", "db.schema", "explicit", None)
1921            .unwrap();
1922
1923        let opts = AskOptions {
1924            impact: true,
1925            ..Default::default()
1926        };
1927        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1928        assert_eq!(result.dependents.len(), 2);
1929
1930        // Check depths
1931        let schema_hit = result
1932            .dependents
1933            .iter()
1934            .find(|d| d.key == "db.schema")
1935            .unwrap();
1936        assert_eq!(schema_hit.depth, 1);
1937        let api_hit = result
1938            .dependents
1939            .iter()
1940            .find(|d| d.key == "api.format")
1941            .unwrap();
1942        assert_eq!(api_hit.depth, 2);
1943
1944        let _ = std::fs::remove_dir_all(&tmp);
1945    }
1946
1947    #[test]
1948    fn impact_cross_domain() {
1949        let (tmp, ledger) = setup();
1950        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1951        ledger.append_event(&d1).unwrap();
1952        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1953        ledger.append_event(&d2).unwrap();
1954        let d3 = make_decision("main", "api.format", "JSON", None, None);
1955        ledger.append_event(&d3).unwrap();
1956        let d4 = make_decision("main", "cache.strategy", "redis", None, None);
1957        ledger.append_event(&d4).unwrap();
1958
1959        // All depend on db.engine via explicit
1960        ledger
1961            .insert_dep("db.schema", "db.engine", "explicit", None)
1962            .unwrap();
1963        ledger
1964            .insert_dep("api.format", "db.engine", "explicit", None)
1965            .unwrap();
1966        ledger
1967            .insert_dep("cache.strategy", "db.engine", "explicit", None)
1968            .unwrap();
1969
1970        let opts = AskOptions {
1971            impact: true,
1972            ..Default::default()
1973        };
1974        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1975        assert_eq!(result.dependents.len(), 3);
1976        // 3 explicit direct (9) + cross_domain: 3 domains × 2 (6) => score = 15 => HIGH
1977        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1978
1979        let _ = std::fs::remove_dir_all(&tmp);
1980    }
1981
1982    #[test]
1983    fn impact_high_risk_scenario() {
1984        let (tmp, ledger) = setup();
1985        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1986        ledger.append_event(&d1).unwrap();
1987
1988        // Create 4 explicit dependents + 1 transitive
1989        for (i, key) in ["db.schema", "db.pool", "api.format", "auth.store"]
1990            .iter()
1991            .enumerate()
1992        {
1993            let d = make_decision("main", key, &format!("val{i}"), None, None);
1994            ledger.append_event(&d).unwrap();
1995            ledger
1996                .insert_dep(key, "db.engine", "explicit", None)
1997                .unwrap();
1998        }
1999        // Transitive: cache.ttl -> api.format -> db.engine
2000        let d_cache = make_decision("main", "cache.ttl", "60s", None, None);
2001        ledger.append_event(&d_cache).unwrap();
2002        ledger
2003            .insert_dep("cache.ttl", "api.format", "explicit", None)
2004            .unwrap();
2005
2006        let opts = AskOptions {
2007            impact: true,
2008            ..Default::default()
2009        };
2010        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
2011        assert_eq!(result.dependents.len(), 5);
2012        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
2013
2014        let _ = std::fs::remove_dir_all(&tmp);
2015    }
2016
2017    #[test]
2018    fn impact_domain_query() {
2019        let (tmp, ledger) = setup();
2020        let d1 = make_decision("main", "db.engine", "postgres", None, None);
2021        ledger.append_event(&d1).unwrap();
2022        let d2 = make_decision("main", "db.pool", "10", None, None);
2023        ledger.append_event(&d2).unwrap();
2024
2025        // api.format depends on db.engine
2026        let d3 = make_decision("main", "api.format", "JSON", None, None);
2027        ledger.append_event(&d3).unwrap();
2028        ledger
2029            .insert_dep("api.format", "db.engine", "explicit", None)
2030            .unwrap();
2031
2032        let opts = AskOptions {
2033            impact: true,
2034            ..Default::default()
2035        };
2036        let result = ask(&ledger, "db", &opts, None).unwrap();
2037        assert_eq!(result.input_type, "domain");
2038        // api.format is a dependent of db.engine
2039        assert!(
2040            result.dependents.iter().any(|d| d.key == "api.format"),
2041            "domain impact should find api.format as dependent"
2042        );
2043        assert!(result.override_risk.is_some());
2044
2045        let _ = std::fs::remove_dir_all(&tmp);
2046    }
2047
2048    #[test]
2049    fn impact_flag_ignored_for_keyword() {
2050        let (tmp, ledger) = setup();
2051        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
2052        ledger.append_event(&d1).unwrap();
2053
2054        let opts = AskOptions {
2055            impact: true,
2056            ..Default::default()
2057        };
2058        let result = ask(&ledger, "postgres", &opts, None).unwrap();
2059        assert_eq!(result.input_type, "keyword");
2060        // Impact should be empty for keyword queries
2061        assert!(result.dependents.is_empty());
2062        assert!(result.override_risk.is_none());
2063
2064        let _ = std::fs::remove_dir_all(&tmp);
2065    }
2066
2067    #[test]
2068    fn ask_keyword_semantic_similarity() {
2069        let (tmp, ledger) = setup();
2070        ledger
2071            .append_event(&make_decision(
2072                "main",
2073                "pricing.discount_policy",
2074                "daytime_revenue_shield",
2075                Some("avoid aggressive daytime markdowns"),
2076                None,
2077            ))
2078            .unwrap();
2079        ledger
2080            .append_event(&make_decision(
2081                "main",
2082                "inventory.reorder_buffer",
2083                "40_percent",
2084                Some("protect stock for midnight spikes"),
2085                None,
2086            ))
2087            .unwrap();
2088
2089        let query = "similar daytime discount outcome for chiefs";
2090        let result = ask(&ledger, query, &AskOptions::default(), None).unwrap();
2091
2092        assert_eq!(result.input_type, "keyword");
2093        assert!(
2094            result
2095                .decisions
2096                .iter()
2097                .any(|d| d.key == "pricing.discount_policy"),
2098            "semantic search should retrieve discount precedent"
2099        );
2100
2101        let _ = std::fs::remove_dir_all(&tmp);
2102    }
2103
2104    #[test]
2105    fn format_human_with_impact() {
2106        let result = AskResult {
2107            query: "db.engine".into(),
2108            input_type: "exact_key".into(),
2109            decisions: vec![DecisionHit {
2110                event_id: "e1".into(),
2111                key: "db.engine".into(),
2112                value: "postgres".into(),
2113                reason: "JSONB".into(),
2114                domain: "db".into(),
2115                branch: "main".into(),
2116                ts: "2026-02-15".into(),
2117                is_active: true,
2118                governance: DecisionGovernance::default(),
2119                tags: vec![],
2120                village_id: None,
2121                staleness: None,
2122            }],
2123            timeline: vec![],
2124            related_commits: vec![],
2125            related_notes: vec![],
2126            conversations: vec![],
2127            tasks: vec![],
2128            dependents: vec![
2129                DependentHit {
2130                    key: "db.schema".into(),
2131                    value: "JSONB".into(),
2132                    dep_type: "explicit".into(),
2133                    depth: 1,
2134                },
2135                DependentHit {
2136                    key: "api.format".into(),
2137                    value: "JSON".into(),
2138                    dep_type: "auto_domain".into(),
2139                    depth: 2,
2140                },
2141            ],
2142            override_risk: Some(OverrideRisk {
2143                level: "HIGH".to_string(),
2144                dependent_count: 2,
2145                suggestion: Some("建議覆蓋順序: api.format → db.schema".into()),
2146            }),
2147            ..Default::default()
2148        };
2149
2150        let output = format_human(&result);
2151        assert!(
2152            output.contains("Dependents"),
2153            "should have Dependents section"
2154        );
2155        assert!(
2156            output.contains("depth 1"),
2157            "should show depth for dependents"
2158        );
2159        assert!(
2160            output.contains("depth 2"),
2161            "should show depth for transitive"
2162        );
2163        assert!(
2164            output.contains("Override Risk"),
2165            "should have Override Risk section"
2166        );
2167        assert!(output.contains("HIGH"), "should show HIGH risk level");
2168        assert!(
2169            output.contains("建議覆蓋順序"),
2170            "should show override order suggestion"
2171        );
2172    }
2173
2174    #[test]
2175    fn ask_filters_by_village_id() {
2176        let (_tmp, ledger) = setup();
2177
2178        // Create two decisions: one with village_id, one without
2179        let mut ev1 = make_decision("main", "db.engine", "sqlite", Some("embedded"), None);
2180        ev1.payload["decision"]["village_id"] = serde_json::json!("village-a");
2181        finalize_event(&mut ev1).unwrap();
2182        ledger.append_event(&ev1).unwrap();
2183
2184        let ev2 = make_decision("main", "cache.ttl", "3600", Some("perf"), None);
2185        ledger.append_event(&ev2).unwrap();
2186
2187        // Without village filter: should find both
2188        let opts_all = AskOptions::default();
2189        let result_all = ask(&ledger, "db OR cache", &opts_all, None).unwrap();
2190        assert!(
2191            result_all.decisions.len() >= 2,
2192            "expected at least 2 decisions without village filter, got {}",
2193            result_all.decisions.len()
2194        );
2195
2196        // With village filter: should find only village-a
2197        let opts_village = AskOptions {
2198            village_id: Some("village-a".to_string()),
2199            ..Default::default()
2200        };
2201        let result_village = ask(&ledger, "db OR cache", &opts_village, None).unwrap();
2202        assert_eq!(
2203            result_village.decisions.len(),
2204            1,
2205            "expected 1 decision with village filter"
2206        );
2207        assert_eq!(result_village.decisions[0].key, "db.engine");
2208        assert_eq!(
2209            result_village.decisions[0].village_id.as_deref(),
2210            Some("village-a")
2211        );
2212
2213        // Non-existent village: should find none
2214        let opts_empty = AskOptions {
2215            village_id: Some("no-such-village".to_string()),
2216            ..Default::default()
2217        };
2218        let result_empty = ask(&ledger, "db OR cache", &opts_empty, None).unwrap();
2219        assert!(
2220            result_empty.decisions.is_empty(),
2221            "expected 0 decisions for non-existent village"
2222        );
2223    }
2224}