1use crate::db::{Database, DecisionEdge, DecisionNode};
7use colored::Colorize;
8use serde::Serialize;
9use std::collections::{HashMap, HashSet};
10
11#[derive(Debug, Serialize)]
13pub struct PulseReport {
14 pub summary: PulseSummary,
15 pub active_goals: Vec<GoalTree>,
16 pub orphan_nodes: Vec<NodeRef>,
17 pub recent_nodes: Vec<NodeRef>,
18 pub coverage_gaps: Vec<CoverageGap>,
19}
20
21#[derive(Debug, Serialize)]
23pub struct PulseSummary {
24 pub total_nodes: usize,
25 pub total_edges: usize,
26 pub by_type: HashMap<String, usize>,
27 pub by_status: HashMap<String, usize>,
28 pub confidence: ConfidenceDistribution,
29}
30
31#[derive(Debug, Serialize)]
33pub struct ConfidenceDistribution {
34 pub high: usize, pub medium: usize, pub low: usize, pub unset: usize, }
39
40#[derive(Debug, Serialize)]
42pub struct GoalTree {
43 pub goal: NodeRef,
44 pub children: Vec<NodeRef>,
45}
46
47#[derive(Debug, Serialize)]
49pub struct NodeRef {
50 pub id: i32,
51 pub node_type: String,
52 pub title: String,
53 pub status: String,
54 pub confidence: Option<u8>,
55 pub created_at: String,
56}
57
58#[derive(Debug, Serialize)]
60pub struct CoverageGap {
61 pub node_id: i32,
62 pub node_type: String,
63 pub title: String,
64 pub gap_type: String,
65}
66
67fn node_to_ref(node: &DecisionNode) -> NodeRef {
68 let confidence = node
69 .metadata_json
70 .as_ref()
71 .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
72 .and_then(|v| v.get("confidence").and_then(|c| c.as_u64()))
73 .map(|c| c.min(100) as u8);
74 NodeRef {
75 id: node.id,
76 node_type: node.node_type.clone(),
77 title: node.title.clone(),
78 status: node.status.clone(),
79 confidence,
80 created_at: node.created_at.clone(),
81 }
82}
83
84fn get_branch(node: &DecisionNode) -> Option<String> {
85 node.metadata_json
86 .as_ref()
87 .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
88 .and_then(|v| {
89 v.get("branch")
90 .and_then(|b| b.as_str())
91 .map(|s| s.to_string())
92 })
93}
94
95pub fn generate_pulse(
97 db: &Database,
98 branch: Option<&str>,
99 recent_count: usize,
100) -> Result<PulseReport, String> {
101 let all_nodes = db.get_all_nodes().map_err(|e| e.to_string())?;
102 let all_edges = db.get_all_edges().map_err(|e| e.to_string())?;
103
104 let nodes: Vec<&DecisionNode> = if let Some(br) = branch {
106 all_nodes
107 .iter()
108 .filter(|n| get_branch(n).as_deref() == Some(br))
109 .collect()
110 } else {
111 all_nodes.iter().collect()
112 };
113
114 let node_ids: HashSet<i32> = nodes.iter().map(|n| n.id).collect();
115
116 let edges: Vec<&DecisionEdge> = all_edges
118 .iter()
119 .filter(|e| node_ids.contains(&e.from_node_id) && node_ids.contains(&e.to_node_id))
120 .collect();
121
122 let mut by_type: HashMap<String, usize> = HashMap::new();
124 let mut by_status: HashMap<String, usize> = HashMap::new();
125 let mut confidence = ConfidenceDistribution {
126 high: 0,
127 medium: 0,
128 low: 0,
129 unset: 0,
130 };
131
132 for node in &nodes {
133 *by_type.entry(node.node_type.clone()).or_insert(0) += 1;
134 *by_status.entry(node.status.clone()).or_insert(0) += 1;
135 let ref_node = node_to_ref(node);
136 match ref_node.confidence {
137 Some(c) if c >= 80 => confidence.high += 1,
138 Some(c) if c >= 50 => confidence.medium += 1,
139 Some(_) => confidence.low += 1,
140 None => confidence.unset += 1,
141 }
142 }
143
144 let summary = PulseSummary {
145 total_nodes: nodes.len(),
146 total_edges: edges.len(),
147 by_type,
148 by_status,
149 confidence,
150 };
151
152 let active_goals: Vec<&DecisionNode> = nodes
154 .iter()
155 .filter(|n| n.node_type == "goal" && n.status != "superseded" && n.status != "abandoned")
156 .copied()
157 .collect();
158
159 let mut outgoing: HashMap<i32, Vec<i32>> = HashMap::new();
161 for edge in &edges {
162 outgoing
163 .entry(edge.from_node_id)
164 .or_default()
165 .push(edge.to_node_id);
166 }
167
168 let node_map: HashMap<i32, &DecisionNode> = nodes.iter().map(|n| (n.id, *n)).collect();
169
170 let goal_trees: Vec<GoalTree> = active_goals
171 .iter()
172 .map(|goal| {
173 let mut children = Vec::new();
174 let mut visited = HashSet::new();
175 let mut queue = std::collections::VecDeque::new();
176 visited.insert(goal.id);
177 if let Some(outs) = outgoing.get(&goal.id) {
178 for &child_id in outs {
179 queue.push_back(child_id);
180 }
181 }
182 while let Some(nid) = queue.pop_front() {
183 if visited.insert(nid) {
184 if let Some(node) = node_map.get(&nid) {
185 children.push(node_to_ref(node));
186 }
187 if let Some(outs) = outgoing.get(&nid) {
188 for &child_id in outs {
189 queue.push_back(child_id);
190 }
191 }
192 }
193 }
194 GoalTree {
195 goal: node_to_ref(goal),
196 children,
197 }
198 })
199 .collect();
200
201 let mut nodes_with_edges: HashSet<i32> = HashSet::new();
203 for edge in &edges {
204 nodes_with_edges.insert(edge.from_node_id);
205 nodes_with_edges.insert(edge.to_node_id);
206 }
207 let orphans: Vec<NodeRef> = nodes
208 .iter()
209 .filter(|n| !nodes_with_edges.contains(&n.id) && n.node_type != "goal")
210 .map(|n| node_to_ref(n))
211 .collect();
212
213 let mut recent_sorted: Vec<&DecisionNode> = nodes.to_vec();
215 recent_sorted.sort_by(|a, b| b.created_at.cmp(&a.created_at));
216 let recent: Vec<NodeRef> = recent_sorted
217 .iter()
218 .take(recent_count)
219 .map(|n| node_to_ref(n))
220 .collect();
221
222 let mut gaps = Vec::new();
224 for node in &nodes {
225 let has_outgoing = outgoing.contains_key(&node.id);
226 match node.node_type.as_str() {
227 "goal"
228 if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
229 {
230 gaps.push(CoverageGap {
231 node_id: node.id,
232 node_type: node.node_type.clone(),
233 title: node.title.clone(),
234 gap_type: "goal_without_options".to_string(),
235 });
236 }
237 "decision"
238 if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
239 {
240 gaps.push(CoverageGap {
241 node_id: node.id,
242 node_type: node.node_type.clone(),
243 title: node.title.clone(),
244 gap_type: "decision_without_actions".to_string(),
245 });
246 }
247 "action"
248 if !has_outgoing && node.status != "superseded" && node.status != "abandoned" =>
249 {
250 gaps.push(CoverageGap {
251 node_id: node.id,
252 node_type: node.node_type.clone(),
253 title: node.title.clone(),
254 gap_type: "action_without_outcomes".to_string(),
255 });
256 }
257 _ => {}
258 }
259 }
260
261 Ok(PulseReport {
262 summary,
263 active_goals: goal_trees,
264 orphan_nodes: orphans,
265 recent_nodes: recent,
266 coverage_gaps: gaps,
267 })
268}
269
270pub fn print_pulse(report: &PulseReport, summary_only: bool) {
272 println!("{}", "=== PULSE ===".bold());
273 println!();
274
275 println!("{}:", "Summary".bold());
277 println!(
278 " Nodes: {} | Edges: {}",
279 report.summary.total_nodes.to_string().cyan(),
280 report.summary.total_edges.to_string().cyan()
281 );
282
283 let type_order = [
285 "goal",
286 "option",
287 "decision",
288 "action",
289 "outcome",
290 "observation",
291 "revisit",
292 ];
293 let type_parts: Vec<String> = type_order
294 .iter()
295 .filter_map(|t| {
296 report
297 .summary
298 .by_type
299 .get(*t)
300 .map(|c| format!("{}({})", t, c))
301 })
302 .collect();
303 if !type_parts.is_empty() {
304 println!(" Types: {}", type_parts.join(" "));
305 }
306
307 let status_parts: Vec<String> = report
309 .summary
310 .by_status
311 .iter()
312 .map(|(s, c)| format!("{}({})", s, c))
313 .collect();
314 if !status_parts.is_empty() {
315 println!(" Status: {}", status_parts.join(" "));
316 }
317
318 let conf = &report.summary.confidence;
320 println!(
321 " Confidence: high({}) medium({}) low({}) unset({})",
322 conf.high, conf.medium, conf.low, conf.unset
323 );
324
325 if summary_only {
326 return;
327 }
328
329 if !report.active_goals.is_empty() {
331 println!();
332 println!("{}:", "Active Goals".bold());
333 for tree in &report.active_goals {
334 let conf_str = tree
335 .goal
336 .confidence
337 .map(|c| format!(" {}%", c))
338 .unwrap_or_default();
339 println!(
340 " #{} {} {}{}",
341 tree.goal.id,
342 format!("[{}]", tree.goal.node_type).yellow(),
343 tree.goal.title,
344 conf_str.dimmed()
345 );
346 for child in &tree.children {
347 let child_conf = child
348 .confidence
349 .map(|c| format!(" {}%", c))
350 .unwrap_or_default();
351 let status_color = match child.status.as_str() {
352 "superseded" => child.status.dimmed().to_string(),
353 "abandoned" => child.status.red().to_string(),
354 _ => child.status.green().to_string(),
355 };
356 println!(
357 " ├── #{} {} {} ({}){}",
358 child.id,
359 format!("[{}]", child.node_type).blue(),
360 child.title,
361 status_color,
362 child_conf.dimmed()
363 );
364 }
365 }
366 }
367
368 if !report.orphan_nodes.is_empty() {
370 println!();
371 println!("{} ({}):", "Orphan Nodes".bold(), report.orphan_nodes.len());
372 for node in &report.orphan_nodes {
373 println!(
374 " #{} {} \"{}\" - {}",
375 node.id,
376 format!("[{}]", node.node_type).yellow(),
377 node.title,
378 "no connections".red()
379 );
380 }
381 }
382
383 if !report.recent_nodes.is_empty() {
385 println!();
386 println!("{}:", "Recent Activity".bold());
387 for node in &report.recent_nodes {
388 let date = node.created_at.get(..10).unwrap_or(&node.created_at);
389 let conf_str = node
390 .confidence
391 .map(|c| format!(" {}%", c))
392 .unwrap_or_default();
393 println!(
394 " {} #{:<3} {} {}{}",
395 date.dimmed(),
396 node.id,
397 format!("[{:<11}]", node.node_type).blue(),
398 node.title,
399 conf_str.dimmed()
400 );
401 }
402 }
403
404 if !report.coverage_gaps.is_empty() {
406 println!();
407 println!(
408 "{} ({}):",
409 "Coverage Gaps".bold(),
410 report.coverage_gaps.len()
411 );
412 for gap in &report.coverage_gaps {
413 let gap_desc = match gap.gap_type.as_str() {
414 "goal_without_options" => "no options/decisions",
415 "decision_without_actions" => "no actions",
416 "action_without_outcomes" => "no outcomes",
417 _ => &gap.gap_type,
418 };
419 println!(
420 " #{:<3} {} \"{}\" - {}",
421 gap.node_id,
422 format!("[{}]", gap.node_type).yellow(),
423 gap.title,
424 gap_desc.red()
425 );
426 }
427 }
428}