Skip to main content

edda_derive/
evidence.rs

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
20/// Scan the ledger for auto-evidence on the given branch.
21/// Collects todo notes, decision notes, and failed commands
22/// since the last commit on that branch. Returns at most `max` items.
23pub 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    // Find the index of the last commit on this branch
31    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    // Scan range: after last commit to end, filtered to this branch
39    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    // Iterate newest-first
49    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                    // Also collect stderr blob if present and under limit
113                    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        // "café" is 5 chars but 6 bytes (é = 2 bytes)
173        let s = "café hello world";
174        let result = truncate(s, 4);
175        // Should not panic; boundary lands inside 'é', floor_char_boundary rounds down
176        assert!(result.ends_with("..."));
177        // With max=5 we should get the full "café " prefix
178        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        // Add a todo note
187        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(&note).unwrap();
190
191        // Add a failed command
192        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        // Add a regular note (not evidence)
207        let note2 = new_note_event("main", None, "user", "just a note", &[]).unwrap();
208        ledger.append_event(&note2).unwrap();
209
210        let result = build_auto_evidence(&ledger, "main", 20).unwrap();
211
212        // Should have at least the todo + failed cmd
213        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        // No commits yet
228        let result = last_commit_contribution(&ledger, "main").unwrap();
229        assert!(result.is_none());
230
231        // First commit
232        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        // Second commit
246        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        // Wrong branch returns None
263        let result = last_commit_contribution(&ledger, "nonexistent").unwrap();
264        assert!(result.is_none());
265
266        let _ = std::fs::remove_dir_all(&tmp);
267    }
268}