use anyhow::Result;
use edda_core::Event;
use edda_ledger::Ledger;
use serde_json::Value;
use crate::types::*;
pub(crate) fn as_str(v: &Value, key: &str) -> String {
v.get(key)
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string()
}
pub(crate) fn as_arr_str(v: &Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn fmt_cmd_argv(payload: &Value) -> String {
payload
.get("argv")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
pub(crate) fn fmt_evidence_item(item: &Value) -> Option<String> {
if let Some(s) = item.as_str() {
return Some(s.to_string());
}
if let Some(obj) = item.as_object() {
let why = obj.get("why").and_then(|x| x.as_str()).unwrap_or("").trim();
if let Some(eid) = obj.get("event_id").and_then(|x| x.as_str()) {
return Some(if why.is_empty() {
eid.to_string()
} else {
format!("{eid}: {why}")
});
}
if let Some(blob) = obj.get("blob").and_then(|x| x.as_str()) {
return Some(if why.is_empty() {
blob.to_string()
} else {
format!("{blob}: {why}")
});
}
}
None
}
pub(crate) fn collect_branch_events(ledger: &Ledger, branch: &str) -> Result<Vec<Event>> {
ledger.iter_branch_events(branch)
}
pub(crate) fn resolve_branch_created_at_fallback(
ledger: &Ledger,
branch: &str,
) -> Result<Option<String>> {
for ev in ledger.iter_events()? {
if ev.event_type == "branch_create" {
if let Some(name) = ev.payload.get("name").and_then(|x| x.as_str()) {
if name == branch {
return Ok(Some(ev.ts.clone()));
}
}
}
}
Ok(None)
}
pub(crate) fn build_branch_snapshot(ledger: &Ledger, branch: &str) -> Result<BranchSnapshot> {
let branch_events = collect_branch_events(ledger, branch)?;
let mut created_at = branch_events
.first()
.map(|e| e.ts.clone())
.unwrap_or_default();
if created_at.is_empty() {
if let Some(ts) = resolve_branch_created_at_fallback(ledger, branch)? {
created_at = ts;
}
}
let last_event_id = branch_events.last().map(|e| e.event_id.clone());
let mut commits: Vec<CommitEntry> = Vec::new();
let mut signals: Vec<SignalEntry> = Vec::new();
let mut merges: Vec<MergeEntry> = Vec::new();
let mut session_digests: Vec<SessionDigestEntry> = Vec::new();
let mut last_commit_event_index: Option<usize> = None;
for (idx, ev) in branch_events.iter().enumerate() {
match ev.event_type.as_str() {
"commit" => {
last_commit_event_index = Some(idx);
let p = &ev.payload;
let evidence_lines = p
.get("evidence")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(fmt_evidence_item).collect())
.unwrap_or_default();
commits.push(CommitEntry {
ts: ev.ts.clone(),
event_id: ev.event_id.clone(),
title: as_str(p, "title"),
purpose: as_str(p, "purpose"),
prev_summary: as_str(p, "prev_summary"),
contribution: as_str(p, "contribution"),
evidence_lines,
labels: as_arr_str(p, "labels"),
});
}
"note" => {
let tags: Vec<&str> = ev
.payload
.get("tags")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(|i| i.as_str()).collect())
.unwrap_or_default();
if tags.contains(&"todo") {
let text = ev
.payload
.get("text")
.and_then(|x| x.as_str())
.unwrap_or("");
signals.push(SignalEntry {
ts: ev.ts.clone(),
kind: SignalKind::NoteTodo,
text: text.to_string(),
event_id: ev.event_id.clone(),
supersedes: None,
});
}
if tags.contains(&"decision") {
let text = ev
.payload
.get("text")
.and_then(|x| x.as_str())
.unwrap_or("");
let supersedes = ev
.refs
.provenance
.iter()
.find(|p| p.rel == "supersedes")
.map(|p| p.target.clone());
signals.push(SignalEntry {
ts: ev.ts.clone(),
kind: SignalKind::NoteDecision,
text: text.to_string(),
event_id: ev.event_id.clone(),
supersedes,
});
}
if tags.contains(&"session_digest") {
let stats = ev.payload.get("session_stats");
let sid = ev
.payload
.get("session_id")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
session_digests.push(SessionDigestEntry {
ts: ev.ts.clone(),
event_id: ev.event_id.clone(),
session_id: sid,
tool_calls: stats
.and_then(|s| s.get("tool_calls"))
.and_then(|x| x.as_u64())
.unwrap_or(0),
tool_failures: stats
.and_then(|s| s.get("tool_failures"))
.and_then(|x| x.as_u64())
.unwrap_or(0),
user_prompts: stats
.and_then(|s| s.get("user_prompts"))
.and_then(|x| x.as_u64())
.unwrap_or(0),
duration_minutes: stats
.and_then(|s| s.get("duration_minutes"))
.and_then(|x| x.as_u64())
.unwrap_or(0),
files_modified: stats
.and_then(|s| s.get("files_modified"))
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
failed_commands: stats
.and_then(|s| s.get("failed_commands"))
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
commits_made: stats
.and_then(|s| s.get("commits_made"))
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
tasks_snapshot: stats
.and_then(|s| s.get("tasks_snapshot"))
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let subject = item.get("subject")?.as_str()?.to_string();
let status = item.get("status")?.as_str()?.to_string();
Some(TaskSnapshotEntry { subject, status })
})
.collect()
})
.unwrap_or_default(),
outcome: stats
.and_then(|s| s.get("outcome"))
.and_then(|x| x.as_str())
.unwrap_or("completed")
.to_string(),
notes: stats
.and_then(|s| s.get("notes"))
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| i.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
tool_call_breakdown: stats
.and_then(|s| s.get("tool_call_breakdown"))
.and_then(|x| x.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_u64().map(|c| (k.clone(), c)))
.collect()
})
.unwrap_or_default(),
edit_ratio: stats
.and_then(|s| s.get("edit_ratio"))
.and_then(|x| x.as_f64())
.unwrap_or(0.0),
search_ratio: stats
.and_then(|s| s.get("search_ratio"))
.and_then(|x| x.as_f64())
.unwrap_or(0.0),
activity: stats
.and_then(|s| s.get("activity"))
.and_then(|x| x.as_str())
.unwrap_or("unknown")
.to_string(),
});
}
}
"cmd" => {
let exit_code = ev
.payload
.get("exit_code")
.and_then(|x| x.as_i64())
.unwrap_or(0);
if exit_code != 0 {
let argv = fmt_cmd_argv(&ev.payload);
signals.push(SignalEntry {
ts: ev.ts.clone(),
kind: SignalKind::CmdFail,
text: format!("{argv} (exit={exit_code})"),
event_id: ev.event_id.clone(),
supersedes: None,
});
}
}
"merge" => {
let p = &ev.payload;
merges.push(MergeEntry {
ts: ev.ts.clone(),
event_id: ev.event_id.clone(),
src: as_str(p, "src"),
dst: as_str(p, "dst"),
reason: as_str(p, "reason"),
adopted_commits: as_arr_str(p, "adopted_commits"),
});
}
_ => {}
}
}
let last_commit = commits.last().cloned();
let last_commit_id = last_commit.as_ref().map(|c| c.event_id.clone());
let uncommitted_events = match last_commit_event_index {
Some(i) => branch_events.len().saturating_sub(i + 1),
None => branch_events.len(),
};
Ok(BranchSnapshot {
branch: branch.to_string(),
created_at,
last_event_id,
last_commit_id,
last_commit,
commits,
signals,
merges,
session_digests,
uncommitted_events,
})
}
#[cfg(test)]
mod tests {
use super::*;
use edda_core::event::{new_cmd_event, CmdEventParams};
#[test]
fn failed_cmd_produces_signal_regardless_of_duration() {
let (_, ledger) = crate::test_support::setup_workspace();
let argv_bridge = vec!["cargo".to_string(), "check".to_string()];
let bridge_cmd = new_cmd_event(&CmdEventParams {
branch: "main",
parent_hash: None,
argv: &argv_bridge,
cwd: ".",
exit_code: -1,
duration_ms: 0,
stdout_blob: "",
stderr_blob: "",
})
.unwrap();
ledger.append_event(&bridge_cmd).unwrap();
let argv_real = vec!["cargo".to_string(), "test".to_string()];
let real = new_cmd_event(&CmdEventParams {
branch: "main",
parent_hash: None,
argv: &argv_real,
cwd: ".",
exit_code: 1,
duration_ms: 350,
stdout_blob: "",
stderr_blob: "",
})
.unwrap();
ledger.append_event(&real).unwrap();
let snap = build_branch_snapshot(&ledger, "main").unwrap();
assert_eq!(snap.signals.len(), 2);
assert!(snap.signals[0].text.contains("cargo check"));
assert!(snap.signals[1].text.contains("cargo test"));
}
#[test]
fn successful_cmd_not_a_signal() {
let (_, ledger) = crate::test_support::setup_workspace();
let argv = vec!["cargo".to_string(), "build".to_string()];
let cmd = new_cmd_event(&CmdEventParams {
branch: "main",
parent_hash: None,
argv: &argv,
cwd: ".",
exit_code: 0,
duration_ms: 500,
stdout_blob: "",
stderr_blob: "",
})
.unwrap();
ledger.append_event(&cmd).unwrap();
let snap = build_branch_snapshot(&ledger, "main").unwrap();
assert_eq!(snap.signals.len(), 0);
}
#[test]
fn session_digest_parses_tool_call_breakdown() {
use edda_core::event::finalize_event;
use edda_core::types::{Refs, SCHEMA_VERSION};
let (_, ledger) = crate::test_support::setup_workspace();
let payload = serde_json::json!({
"role": "system",
"text": "Session digest",
"tags": ["session_digest"],
"session_id": "sess-001",
"session_stats": {
"tool_calls": 31,
"tool_failures": 1,
"user_prompts": 5,
"duration_minutes": 20,
"files_modified": ["src/lib.rs"],
"failed_commands": [],
"commits_made": ["feat: something"],
"tasks_snapshot": [],
"outcome": "completed",
"notes": [],
"tool_call_breakdown": {
"Read": 15,
"Edit": 8,
"Grep": 5,
"Bash": 3
},
"edit_ratio": 0.258,
"search_ratio": 0.645
}
});
let mut event = edda_core::types::Event {
event_id: "evt_test_digest".to_string(),
ts: "2025-01-01T00:00:00Z".to_string(),
event_type: "note".to_string(),
branch: "main".to_string(),
parent_hash: None,
hash: String::new(),
payload,
refs: Refs::default(),
schema_version: SCHEMA_VERSION,
digests: Vec::new(),
event_family: None,
event_level: None,
};
finalize_event(&mut event).unwrap();
ledger.append_event(&event).unwrap();
let snap = build_branch_snapshot(&ledger, "main").unwrap();
assert_eq!(snap.session_digests.len(), 1);
let digest = &snap.session_digests[0];
assert_eq!(digest.session_id, "sess-001");
assert_eq!(digest.tool_calls, 31);
assert_eq!(digest.tool_call_breakdown.get("Read"), Some(&15));
assert_eq!(digest.tool_call_breakdown.get("Edit"), Some(&8));
assert_eq!(digest.tool_call_breakdown.get("Grep"), Some(&5));
assert_eq!(digest.tool_call_breakdown.get("Bash"), Some(&3));
assert!((digest.edit_ratio - 0.258).abs() < 1e-6);
assert!((digest.search_ratio - 0.645).abs() < 1e-6);
}
}