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