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#[derive(Debug, PartialEq)]
20pub enum InputType {
21 ExactKey(String),
23 Domain(String),
25 Keyword(String),
27 Overview,
29}
30
31pub 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 if q.contains('.') && q.split('.').count() >= 2 && q.split('.').all(|p| !p.is_empty()) {
39 return InputType::ExactKey(q.to_string());
40 }
41 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#[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 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub tags: Vec<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub village_id: Option<String>,
82 #[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#[derive(Debug, Clone, Serialize)]
119pub struct DependentHit {
120 pub key: String,
121 pub value: String,
122 pub dep_type: String,
123 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
134pub struct AskOptions {
137 pub limit: usize,
138 pub include_superseded: bool,
139 pub branch: Option<String>,
140 pub impact: bool,
141 pub after: Option<String>,
143 pub before: Option<String>,
145 pub tags: Vec<String>,
147 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
166pub type TranscriptSearchFn = dyn Fn(&str, usize) -> Vec<ConversationHit>;
168
169pub 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 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 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 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 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 let decisions = tags_filter(decisions);
331 let timeline = tags_filter(timeline);
332
333 let decisions = village_filter(decisions);
335 let timeline = village_filter(timeline);
336
337 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 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(¬e_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 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
388fn 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 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 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 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
462fn 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
497fn 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 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
567fn 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
719pub 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
832fn 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
850pub 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#[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 #[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 #[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 assert_eq!(result.decisions.len(), 1);
1023 assert_eq!(result.decisions[0].value, "postgres");
1024 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 #[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 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 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 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 let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1239 assert_eq!(result.decisions.len(), 2);
1240
1241 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}