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