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