1use anyhow::Result;
2use edda_ledger::Ledger;
3
4use crate::snapshot::fmt_cmd_argv;
5
6#[derive(Debug, Clone)]
7pub struct AutoEvidenceResult {
8 pub items: Vec<serde_json::Value>,
9 pub preview_lines: Vec<String>,
10}
11
12fn truncate(s: &str, max: usize) -> String {
13 if s.len() <= max {
14 s.to_string()
15 } else {
16 format!("{}...", &s[..s.floor_char_boundary(max)])
17 }
18}
19
20pub fn build_auto_evidence(
24 ledger: &Ledger,
25 branch: &str,
26 max: usize,
27) -> Result<AutoEvidenceResult> {
28 let all_events = ledger.iter_events()?;
29
30 let last_commit_idx = all_events
32 .iter()
33 .enumerate()
34 .rev()
35 .find(|(_, ev)| ev.branch == branch && ev.event_type == "commit")
36 .map(|(i, _)| i);
37
38 let start = last_commit_idx.map(|i| i + 1).unwrap_or(0);
40 let scan_events: Vec<_> = all_events[start..]
41 .iter()
42 .filter(|ev| ev.branch == branch)
43 .collect();
44
45 let mut items: Vec<serde_json::Value> = Vec::new();
46 let mut preview_lines: Vec<String> = Vec::new();
47
48 for ev in scan_events.iter().rev() {
50 if items.len() >= max {
51 break;
52 }
53
54 match ev.event_type.as_str() {
55 "note" => {
56 let tags: Vec<&str> = ev
57 .payload
58 .get("tags")
59 .and_then(|x| x.as_array())
60 .map(|arr| arr.iter().filter_map(|i| i.as_str()).collect())
61 .unwrap_or_default();
62
63 let matching_tag = if tags.contains(&"todo") {
64 Some("todo")
65 } else if tags.contains(&"decision") {
66 Some("decision")
67 } else {
68 None
69 };
70
71 if let Some(tag) = matching_tag {
72 let text = ev
73 .payload
74 .get("text")
75 .and_then(|x| x.as_str())
76 .unwrap_or("");
77 items.push(serde_json::json!({
78 "type": "note",
79 "event_id": ev.event_id,
80 "text": text,
81 "tag": tag,
82 }));
83 preview_lines.push(format!(
84 "[{}] {} ({})",
85 tag,
86 truncate(text, 60),
87 ev.event_id
88 ));
89 }
90 }
91 "cmd" => {
92 let exit_code = ev
93 .payload
94 .get("exit_code")
95 .and_then(|x| x.as_i64())
96 .unwrap_or(0);
97 if exit_code != 0 {
98 let argv = fmt_cmd_argv(&ev.payload);
99 items.push(serde_json::json!({
100 "type": "cmd_fail",
101 "event_id": ev.event_id,
102 "command": argv,
103 "exit_code": exit_code,
104 }));
105 preview_lines.push(format!(
106 "[cmd_fail] {} exit={} ({})",
107 truncate(&argv, 40),
108 exit_code,
109 ev.event_id
110 ));
111
112 if items.len() < max {
114 let stderr_blob = ev
115 .payload
116 .get("stderr_blob")
117 .and_then(|x| x.as_str())
118 .unwrap_or("");
119 if !stderr_blob.is_empty() {
120 items.push(serde_json::json!({
121 "type": "blob_ref",
122 "event_id": ev.event_id,
123 "blob_id": stderr_blob,
124 "kind": "stderr",
125 }));
126 preview_lines.push(format!(
127 "[blob_ref] stderr {} ({})",
128 truncate(stderr_blob, 40),
129 ev.event_id
130 ));
131 }
132 }
133 }
134 }
135 _ => {}
136 }
137 }
138
139 Ok(AutoEvidenceResult {
140 items,
141 preview_lines,
142 })
143}
144
145pub fn last_commit_contribution(ledger: &Ledger, branch: &str) -> Result<Option<String>> {
146 let mut last: Option<String> = None;
147 for ev in ledger.iter_events()? {
148 if ev.branch != branch || ev.event_type != "commit" {
149 continue;
150 }
151 let contrib = ev
152 .payload
153 .get("contribution")
154 .and_then(|x| x.as_str())
155 .unwrap_or("")
156 .to_string();
157 last = Some(contrib);
158 }
159 Ok(last)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::test_support::setup_workspace;
166 use edda_core::event::{
167 new_cmd_event, new_commit_event, new_note_event, CmdEventParams, CommitEventParams,
168 };
169
170 #[test]
171 fn truncate_handles_multibyte_chars() {
172 let s = "café hello world";
174 let result = truncate(s, 4);
175 assert!(result.ends_with("..."));
177 let result2 = truncate(s, 5);
179 assert!(result2.starts_with("café"));
180 }
181
182 #[test]
183 fn build_auto_evidence_collects_todos_and_fails() {
184 let (tmp, ledger) = setup_workspace();
185
186 let todo_tags = vec!["todo".to_string()];
188 let note = new_note_event("main", None, "user", "remember to test", &todo_tags).unwrap();
189 ledger.append_event(¬e).unwrap();
190
191 let argv = vec!["cargo".to_string(), "test".to_string()];
193 let cmd = new_cmd_event(&CmdEventParams {
194 branch: "main",
195 parent_hash: None,
196 argv: &argv,
197 cwd: ".",
198 exit_code: 1,
199 duration_ms: 500,
200 stdout_blob: "",
201 stderr_blob: "blob:sha256:err123",
202 })
203 .unwrap();
204 ledger.append_event(&cmd).unwrap();
205
206 let note2 = new_note_event("main", None, "user", "just a note", &[]).unwrap();
208 ledger.append_event(¬e2).unwrap();
209
210 let result = build_auto_evidence(&ledger, "main", 20).unwrap();
211
212 assert!(
214 result.items.len() >= 2,
215 "expected >=2 items, got {}",
216 result.items.len()
217 );
218 assert_eq!(result.items.len(), result.preview_lines.len());
219
220 let _ = std::fs::remove_dir_all(&tmp);
221 }
222
223 #[test]
224 fn last_commit_contribution_returns_latest() {
225 let (tmp, ledger) = setup_workspace();
226
227 let result = last_commit_contribution(&ledger, "main").unwrap();
229 assert!(result.is_none());
230
231 let mut params = CommitEventParams {
233 branch: "main",
234 parent_hash: None,
235 title: "first",
236 purpose: None,
237 prev_summary: "",
238 contribution: "alpha work",
239 evidence: vec![],
240 labels: vec![],
241 };
242 let c1 = new_commit_event(&mut params).unwrap();
243 ledger.append_event(&c1).unwrap();
244
245 let mut params2 = CommitEventParams {
247 branch: "main",
248 parent_hash: None,
249 title: "second",
250 purpose: None,
251 prev_summary: "",
252 contribution: "beta work",
253 evidence: vec![],
254 labels: vec![],
255 };
256 let c2 = new_commit_event(&mut params2).unwrap();
257 ledger.append_event(&c2).unwrap();
258
259 let result = last_commit_contribution(&ledger, "main").unwrap();
260 assert_eq!(result.as_deref(), Some("beta work"));
261
262 let result = last_commit_contribution(&ledger, "nonexistent").unwrap();
264 assert!(result.is_none());
265
266 let _ = std::fs::remove_dir_all(&tmp);
267 }
268}