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