1use rusqlite::Connection;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum GapKind {
8 ClosedNoOutcome,
9 DecisionNoEvidence,
10 SuggestedUnconfirmed,
11 NoGoal,
12 PendingLeak,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct Gap {
17 pub kind: GapKind,
18 pub detail: String,
19}
20
21#[derive(Debug, Clone, Default, PartialEq)]
22pub struct CompletenessReport {
23 pub gaps: Vec<Gap>,
24}
25
26impl CompletenessReport {
27 pub fn is_complete(&self) -> bool {
28 self.gaps.is_empty()
29 }
30}
31
32pub fn assess(
36 conn: &Connection,
37 task_id: &str,
38 pending_count: usize,
39) -> anyhow::Result<CompletenessReport> {
40 let mut gaps = Vec::new();
41
42 let row: Option<(String, Option<String>, Option<String>)> = conn
44 .query_row(
45 "SELECT status, goal, outcome FROM tasks WHERE task_id = ?1",
46 rusqlite::params![task_id],
47 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
48 )
49 .ok();
50
51 let Some((status, goal, outcome)) = row else {
52 return Ok(CompletenessReport { gaps });
54 };
55
56 if goal.as_deref().unwrap_or("").is_empty() {
57 gaps.push(Gap {
58 kind: GapKind::NoGoal,
59 detail: "no goal recorded".to_string(),
60 });
61 }
62 if status == "closed"
63 && !goal.as_deref().unwrap_or("").is_empty()
64 && outcome.as_deref().unwrap_or("").is_empty()
65 {
66 gaps.push(Gap {
67 kind: GapKind::ClosedNoOutcome,
68 detail: "closed without a recorded outcome".to_string(),
69 });
70 }
71
72 let mut decisions = 0usize;
74 let mut evidence = 0usize;
75 let mut suggested = 0usize;
76 {
77 let mut stmt = conn.prepare("SELECT type, status FROM events_index WHERE task_id = ?1")?;
78 let rows = stmt.query_map(rusqlite::params![task_id], |r| {
79 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
80 })?;
81 for row in rows {
82 let (ty, st) = row?;
83 match ty.as_str() {
84 "decision" => decisions += 1,
85 "evidence" => evidence += 1,
86 _ => {}
87 }
88 if st == "suggested" {
89 suggested += 1;
90 }
91 }
92 }
93 if decisions > 0 && evidence == 0 {
94 gaps.push(Gap {
95 kind: GapKind::DecisionNoEvidence,
96 detail: "decisions unverified (no evidence captured)".to_string(),
97 });
98 }
99 if suggested > 0 {
100 gaps.push(Gap {
101 kind: GapKind::SuggestedUnconfirmed,
102 detail: format!("{suggested} suggested event(s) unconfirmed"),
103 });
104 }
105
106 if pending_count > 0 {
107 gaps.push(Gap {
108 kind: GapKind::PendingLeak,
109 detail: format!(
110 "{pending_count} pending entr{} not yet classified",
111 if pending_count == 1 { "y" } else { "ies" }
112 ),
113 });
114 }
115
116 Ok(CompletenessReport { gaps })
117}
118
119pub fn pending_count() -> usize {
123 fn inner() -> anyhow::Result<usize> {
124 let cwd = std::env::current_dir()?;
125 let project_hash = crate::project_hash::from_path(&cwd)?;
126 let events_path = crate::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
127 let dir = events_path
128 .parent()
129 .and_then(|p| p.parent())
130 .ok_or_else(|| anyhow::anyhow!("no grandparent"))?
131 .join("pending");
132 if !dir.exists() {
133 return Ok(0);
134 }
135 let mut n = 0;
136 for entry in std::fs::read_dir(&dir)? {
137 let path = entry?.path();
138 if path.extension().and_then(|e| e.to_str()) == Some("json") {
140 n += 1;
141 }
142 }
143 Ok(n)
144 }
145 inner().unwrap_or(0)
146}
147
148pub fn render_section(report: &CompletenessReport) -> Option<String> {
150 if report.gaps.is_empty() {
151 return None;
152 }
153 let mut s = format!("\n## Completeness ({})\n", report.gaps.len());
154 for g in &report.gaps {
155 s.push_str(&format!("- ⚠ {}\n", g.detail));
156 }
157 Some(s)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::event::{Author, Event, EventType, Source};
164 use tempfile::TempDir;
165
166 fn conn() -> (TempDir, Connection) {
167 let d = TempDir::new().unwrap();
168 let c = crate::db::open(d.path().join("s.sqlite")).unwrap();
169 (d, c)
170 }
171
172 fn open_task(c: &Connection, id: &str) {
173 let e = Event::new(id, EventType::Open, Author::User, Source::Cli, id.into());
174 crate::db::upsert_task_from_event(c, &e, "ph").unwrap();
175 }
176
177 fn add_event(c: &Connection, task: &str, ty: EventType, status: crate::event::EventStatus) {
178 let mut e = Event::new(task, ty, Author::Agent, Source::Hook, "x".into());
179 e.status = status;
180 crate::db::upsert_task_from_event(c, &e, "ph").unwrap();
181 crate::db::index_event(c, &e).unwrap();
182 }
183
184 #[test]
185 fn no_goal_fires_when_goal_absent() {
186 let (_d, c) = conn();
187 open_task(&c, "t1");
188 let r = assess(&c, "t1", 0).unwrap();
189 assert!(r.gaps.iter().any(|g| g.kind == GapKind::NoGoal));
190 }
191
192 #[test]
193 fn closed_no_outcome_fires() {
194 let (_d, c) = conn();
195 open_task(&c, "t2");
196 c.execute("UPDATE tasks SET goal='ship X' WHERE task_id='t2'", [])
198 .unwrap();
199 c.execute("UPDATE tasks SET status='closed' WHERE task_id='t2'", [])
200 .unwrap();
201 let r = assess(&c, "t2", 0).unwrap();
202 assert!(r.gaps.iter().any(|g| g.kind == GapKind::ClosedNoOutcome));
203 assert!(!r.gaps.iter().any(|g| g.kind == GapKind::NoGoal));
204 }
205
206 #[test]
207 fn unknown_task_is_empty_report() {
208 let (_d, c) = conn();
209 let r = assess(&c, "nope", 0).unwrap();
210 assert!(r.is_complete());
211 }
212
213 #[test]
214 fn decision_without_evidence_fires_then_clears() {
215 use crate::event::EventStatus;
216 let (_d, c) = conn();
217 open_task(&c, "t3");
218 c.execute("UPDATE tasks SET goal='g' WHERE task_id='t3'", [])
219 .unwrap();
220 add_event(&c, "t3", EventType::Decision, EventStatus::Confirmed);
221 let r = assess(&c, "t3", 0).unwrap();
222 assert!(r.gaps.iter().any(|g| g.kind == GapKind::DecisionNoEvidence));
223
224 add_event(&c, "t3", EventType::Evidence, EventStatus::Confirmed);
225 let r2 = assess(&c, "t3", 0).unwrap();
226 assert!(!r2
227 .gaps
228 .iter()
229 .any(|g| g.kind == GapKind::DecisionNoEvidence));
230 }
231
232 #[test]
233 fn suggested_unconfirmed_counts() {
234 use crate::event::EventStatus;
235 let (_d, c) = conn();
236 open_task(&c, "t4");
237 c.execute("UPDATE tasks SET goal='g' WHERE task_id='t4'", [])
238 .unwrap();
239 add_event(&c, "t4", EventType::Finding, EventStatus::Suggested);
240 add_event(&c, "t4", EventType::Finding, EventStatus::Suggested);
241 let r = assess(&c, "t4", 0).unwrap();
242 let g = r
243 .gaps
244 .iter()
245 .find(|g| g.kind == GapKind::SuggestedUnconfirmed)
246 .unwrap();
247 assert!(g.detail.contains('2'));
248 }
249
250 #[test]
251 fn pending_leak_fires_when_count_positive() {
252 let (_d, c) = conn();
253 open_task(&c, "t5");
254 c.execute("UPDATE tasks SET goal='g' WHERE task_id='t5'", [])
255 .unwrap();
256 let r = assess(&c, "t5", 3).unwrap();
257 let g = r
258 .gaps
259 .iter()
260 .find(|g| g.kind == GapKind::PendingLeak)
261 .unwrap();
262 assert!(g.detail.contains('3'));
263
264 let r0 = assess(&c, "t5", 0).unwrap();
265 assert!(!r0.gaps.iter().any(|g| g.kind == GapKind::PendingLeak));
266 }
267
268 #[test]
269 fn pending_count_zero_when_no_dir() {
270 let _ = pending_count();
273 }
274
275 #[test]
276 fn render_section_none_when_complete() {
277 let r = CompletenessReport::default();
278 assert!(render_section(&r).is_none());
279 }
280
281 #[test]
282 fn render_section_lists_gaps() {
283 let r = CompletenessReport {
284 gaps: vec![Gap {
285 kind: GapKind::NoGoal,
286 detail: "no goal recorded".into(),
287 }],
288 };
289 let s = render_section(&r).unwrap();
290 assert!(s.contains("Completeness (1)"));
291 assert!(s.contains("no goal recorded"));
292 }
293}