Skip to main content

tj_core/
completeness.rs

1//! Capture completeness: deterministic, read-only detection of structural
2//! gaps in a task's captured history. Measure + flag only — no mutation.
3
4use 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
32/// Assess a task's captured history for structural gaps. Deterministic and
33/// read-only. `pending_count` (project-level unprocessed entries) is injected
34/// so this fn stays filesystem-free and unit-testable.
35pub 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    // Metadata rules: read status/goal/outcome from the tasks row.
43    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        // Unknown task → empty report (no panic).
53        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    // Event rules: tally types and statuses for this task.
73    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
119/// Best-effort count of unprocessed pending entries for the cwd's project.
120/// Returns 0 on any resolution/IO error — the PendingLeak rule then stays
121/// silent rather than failing the whole assessment.
122pub 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            // Count live .json chunks; skip .dead and non-json.
139            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
148/// Render the Completeness section, or None when there are no gaps.
149pub 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        // Set a goal, then close without outcome.
197        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        // Best-effort contract: resolution may succeed or fail, but it must
271        // never panic. In a clean env with no pending dir the count is 0.
272        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}