Skip to main content

seshat_cli/
debug.rs

1//! `seshat debug-snippets` command — dumps every convention from the DB
2//! with full evidence for offline inspection.
3//!
4//! Hidden CLI command (registered with `#[command(hide = true)]`); no
5//! stable user-facing contract. Used to bisect snippet-extraction
6//! regressions on real repositories.
7
8use std::path::Path;
9
10use serde::Deserialize;
11
12use crate::error::CliError;
13
14/// One evidence row as deserialised from `nodes.ext_data` JSON.
15///
16/// `snippet` lives under two historical shapes:
17/// - bare string (legacy)
18/// - `{ "content": "..." }` object (current `CodeSnippet`)
19///
20/// `Snippet`'s custom Deserialize collapses both into a single String.
21#[derive(Debug, Deserialize)]
22struct EvidenceRow {
23    #[serde(default)]
24    file: String,
25    #[serde(default)]
26    line: u64,
27    #[serde(default)]
28    end_line: u64,
29    #[serde(default)]
30    snippet: Snippet,
31    #[serde(default)]
32    snippet_start_line: u64,
33}
34
35/// Wrapper that accepts both the bare-string and the
36/// `{"content": "..."}` snippet shapes the DB has carried at various
37/// schema versions.
38#[derive(Debug, Default)]
39struct Snippet(String);
40
41impl Snippet {
42    fn as_str(&self) -> &str {
43        &self.0
44    }
45}
46
47impl<'de> Deserialize<'de> for Snippet {
48    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
49        #[derive(Deserialize)]
50        #[serde(untagged)]
51        enum Either {
52            Bare(String),
53            Object { content: String },
54        }
55        match Either::deserialize(d)? {
56            Either::Bare(s) => Ok(Snippet(s)),
57            Either::Object { content } => Ok(Snippet(content)),
58        }
59    }
60}
61
62/// Top-level shape of `nodes.ext_data` we care about — only the
63/// evidence array. Other fields are ignored.
64#[derive(Debug, Default, Deserialize)]
65struct ExtData {
66    #[serde(default)]
67    evidence: Vec<EvidenceRow>,
68}
69
70/// One DB row that survives the SELECT projection.
71struct NodeRow {
72    description: String,
73    nature: String,
74    weight: String,
75    confidence: f64,
76    adoption_count: u32,
77    total_count: u32,
78    ext_data: Option<String>,
79}
80
81pub fn run_debug(db_path: &Path, branch_id: &str) -> Result<(), CliError> {
82    let conn = rusqlite::Connection::open(db_path).map_err(|e| CliError::CommandFailed {
83        command: "debug".to_owned(),
84        reason: format!("failed to open database: {e}"),
85    })?;
86
87    let sql = "
88        SELECT description, nature, weight, confidence,
89               adoption_count, total_count, ext_data
90        FROM nodes
91        WHERE nature IN ('convention', 'observation')
92          AND branch_id = ?1
93          AND (json_extract(ext_data, '$.user_rejected') IS NULL
94               OR json_extract(ext_data, '$.user_rejected') != 1)
95          AND (json_extract(ext_data, '$.source') IS NULL
96               OR json_extract(ext_data, '$.source') != 'user')
97        ORDER BY confidence DESC
98    ";
99
100    let mut stmt = conn.prepare(sql).map_err(|e| CliError::CommandFailed {
101        command: "debug".to_owned(),
102        reason: e.to_string(),
103    })?;
104
105    let rows = stmt
106        .query_map(rusqlite::params![branch_id], |row| {
107            Ok(NodeRow {
108                description: row.get(0)?,
109                nature: row.get(1)?,
110                weight: row.get(2)?,
111                confidence: row.get(3)?,
112                adoption_count: row.get(4)?,
113                total_count: row.get(5)?,
114                ext_data: row.get(6)?,
115            })
116        })
117        .map_err(|e| CliError::CommandFailed {
118            command: "debug".to_owned(),
119            reason: e.to_string(),
120        })?;
121
122    // A single malformed row used to abort the entire dump via `?`.
123    // The whole point of debug-snippets is "show what's there"; one
124    // bad ext_data shouldn't blank the rest of the report.
125    for (idx, row_result) in rows.enumerate() {
126        let row = match row_result {
127            Ok(r) => r,
128            Err(e) => {
129                eprintln!("  [warn] row {} skipped: {e}", idx + 1);
130                continue;
131            }
132        };
133        print_node(idx + 1, &row);
134    }
135
136    Ok(())
137}
138
139fn print_node(idx: usize, row: &NodeRow) {
140    let conf_pct = (row.confidence.clamp(0.0, 1.0) * 100.0).round() as u32;
141    let adoption_pct = if row.total_count > 0 {
142        ((f64::from(row.adoption_count) / f64::from(row.total_count)) * 100.0).round() as u32
143    } else {
144        0
145    };
146
147    println!(
148        "═══ {idx}/─ {desc} ═══ {nature}|{weight}|{conf_pct}% | {adopt}/{total} ({adoption_pct}%)",
149        desc = row.description,
150        nature = row.nature,
151        weight = row.weight,
152        adopt = row.adoption_count,
153        total = row.total_count,
154    );
155
156    let ext: ExtData = row
157        .ext_data
158        .as_deref()
159        .and_then(|s| match serde_json::from_str(s) {
160            Ok(d) => Some(d),
161            Err(e) => {
162                eprintln!("  [warn] malformed ext_data: {e}");
163                None
164            }
165        })
166        .unwrap_or_default();
167
168    if ext.evidence.is_empty() {
169        println!("  [no evidence]");
170        return;
171    }
172
173    for (ei, item) in ext.evidence.iter().enumerate() {
174        print_evidence(ei, item);
175    }
176}
177
178fn print_evidence(ei: usize, item: &EvidenceRow) {
179    let file = if item.file.is_empty() {
180        "?"
181    } else {
182        item.file.as_str()
183    };
184    let line = u32::try_from(item.line).unwrap_or(u32::MAX);
185    let end_line = u32::try_from(if item.end_line == 0 {
186        item.line
187    } else {
188        item.end_line
189    })
190    .unwrap_or(u32::MAX);
191    let snippet_start_line = u32::try_from(item.snippet_start_line).unwrap_or(0);
192    let snippet = item.snippet.as_str();
193
194    println!(
195        "  [{ei}] {file}  line={line}..{end_line}  ssl={snippet_start_line}  snippet_len={}",
196        snippet.len(),
197    );
198    if snippet.is_empty() {
199        return;
200    }
201    for (li, l) in snippet.lines().enumerate() {
202        let actual_line = if snippet_start_line > 0 {
203            snippet_start_line as usize + li
204        } else {
205            line as usize + li
206        };
207        let marker = if actual_line >= line as usize && actual_line <= end_line as usize {
208            ">>>"
209        } else {
210            "   "
211        };
212        let numbered_line = actual_line + 1;
213        println!("    {marker} {numbered_line:>4} | {l}");
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn snippet_deserializes_bare_string() {
223        let json = r#""hello world""#;
224        let s: Snippet = serde_json::from_str(json).unwrap();
225        assert_eq!(s.as_str(), "hello world");
226    }
227
228    #[test]
229    fn snippet_deserializes_object_form() {
230        let json = r#"{"content": "hello world"}"#;
231        let s: Snippet = serde_json::from_str(json).unwrap();
232        assert_eq!(s.as_str(), "hello world");
233    }
234
235    #[test]
236    fn snippet_deserializes_empty_string_bare() {
237        let json = r#""""#;
238        let s: Snippet = serde_json::from_str(json).unwrap();
239        assert!(s.as_str().is_empty());
240    }
241
242    #[test]
243    fn snippet_deserializes_empty_object_content() {
244        let json = r#"{"content": ""}"#;
245        let s: Snippet = serde_json::from_str(json).unwrap();
246        assert!(s.as_str().is_empty());
247    }
248
249    #[test]
250    fn snippet_default_is_empty_string() {
251        let s = Snippet::default();
252        assert_eq!(s.as_str(), "");
253    }
254
255    #[test]
256    fn snippet_rejects_unknown_shape() {
257        // Number is neither a bare string nor an object with `content`.
258        let json = "42";
259        let result: Result<Snippet, _> = serde_json::from_str(json);
260        assert!(result.is_err());
261    }
262
263    #[test]
264    fn evidence_row_uses_defaults_for_missing_fields() {
265        let row: EvidenceRow = serde_json::from_str("{}").unwrap();
266        assert!(row.file.is_empty());
267        assert_eq!(row.line, 0);
268        assert_eq!(row.end_line, 0);
269        assert!(row.snippet.as_str().is_empty());
270        assert_eq!(row.snippet_start_line, 0);
271    }
272
273    #[test]
274    fn evidence_row_parses_bare_snippet() {
275        let json =
276            r#"{"file": "src/main.rs", "line": 10, "end_line": 12, "snippet": "fn main() {}"}"#;
277        let row: EvidenceRow = serde_json::from_str(json).unwrap();
278        assert_eq!(row.file, "src/main.rs");
279        assert_eq!(row.line, 10);
280        assert_eq!(row.end_line, 12);
281        assert_eq!(row.snippet.as_str(), "fn main() {}");
282        assert_eq!(row.snippet_start_line, 0);
283    }
284
285    #[test]
286    fn evidence_row_parses_object_snippet() {
287        let json = r#"{
288            "file": "src/lib.rs",
289            "line": 5,
290            "end_line": 7,
291            "snippet": {"content": "pub fn foo() {}"},
292            "snippet_start_line": 3
293        }"#;
294        let row: EvidenceRow = serde_json::from_str(json).unwrap();
295        assert_eq!(row.file, "src/lib.rs");
296        assert_eq!(row.line, 5);
297        assert_eq!(row.end_line, 7);
298        assert_eq!(row.snippet.as_str(), "pub fn foo() {}");
299        assert_eq!(row.snippet_start_line, 3);
300    }
301
302    #[test]
303    fn evidence_row_ignores_unknown_fields() {
304        let json = r#"{"file": "x", "extra_field": 123, "snippet": "code"}"#;
305        let row: EvidenceRow = serde_json::from_str(json).unwrap();
306        assert_eq!(row.file, "x");
307        assert_eq!(row.snippet.as_str(), "code");
308    }
309
310    #[test]
311    fn ext_data_default_has_no_evidence() {
312        let ext = ExtData::default();
313        assert!(ext.evidence.is_empty());
314    }
315
316    #[test]
317    fn ext_data_parses_empty_object() {
318        let ext: ExtData = serde_json::from_str("{}").unwrap();
319        assert!(ext.evidence.is_empty());
320    }
321
322    #[test]
323    fn ext_data_parses_evidence_array_mixed_shapes() {
324        let json = r#"{
325            "evidence": [
326                {"file": "a.rs", "line": 1, "snippet": "x"},
327                {"file": "b.rs", "line": 2, "snippet": {"content": "y"}}
328            ],
329            "other_field": "ignored"
330        }"#;
331        let ext: ExtData = serde_json::from_str(json).unwrap();
332        assert_eq!(ext.evidence.len(), 2);
333        assert_eq!(ext.evidence[0].file, "a.rs");
334        assert_eq!(ext.evidence[0].snippet.as_str(), "x");
335        assert_eq!(ext.evidence[1].file, "b.rs");
336        assert_eq!(ext.evidence[1].snippet.as_str(), "y");
337    }
338
339    #[test]
340    fn run_debug_returns_error_on_missing_database() {
341        let dir = tempfile::tempdir().unwrap();
342        let missing = dir.path().join("does-not-exist").join("nope.db");
343
344        let result = run_debug(&missing, "main");
345        assert!(result.is_err());
346        let msg = result.unwrap_err().to_string();
347        assert!(
348            msg.contains("debug") || msg.contains("database") || msg.contains("failed"),
349            "unexpected error: {msg}"
350        );
351    }
352
353    #[test]
354    fn run_debug_on_empty_db_succeeds_with_no_rows() {
355        let dir = tempfile::tempdir().unwrap();
356        let db_path = dir.path().join("empty.db");
357        let conn = rusqlite::Connection::open(&db_path).unwrap();
358        // Minimal schema mirroring the columns the debug query expects.
359        conn.execute_batch(
360            "CREATE TABLE nodes (
361                id INTEGER PRIMARY KEY AUTOINCREMENT,
362                branch_id TEXT NOT NULL,
363                nature TEXT NOT NULL,
364                weight TEXT NOT NULL,
365                confidence REAL NOT NULL,
366                adoption_count INTEGER NOT NULL,
367                total_count INTEGER NOT NULL,
368                description TEXT NOT NULL,
369                ext_data TEXT
370            );",
371        )
372        .unwrap();
373        drop(conn);
374
375        let result = run_debug(&db_path, "main");
376        assert!(result.is_ok(), "got error: {:?}", result.err());
377    }
378
379    #[test]
380    fn run_debug_walks_convention_rows_and_skips_user_source() {
381        let dir = tempfile::tempdir().unwrap();
382        let db_path = dir.path().join("seeded.db");
383        let conn = rusqlite::Connection::open(&db_path).unwrap();
384        conn.execute_batch(
385            "CREATE TABLE nodes (
386                id INTEGER PRIMARY KEY AUTOINCREMENT,
387                branch_id TEXT NOT NULL,
388                nature TEXT NOT NULL,
389                weight TEXT NOT NULL,
390                confidence REAL NOT NULL,
391                adoption_count INTEGER NOT NULL,
392                total_count INTEGER NOT NULL,
393                description TEXT NOT NULL,
394                ext_data TEXT
395            );",
396        )
397        .unwrap();
398
399        // Auto-detected convention with both snippet shapes in evidence.
400        conn.execute(
401            "INSERT INTO nodes (branch_id, nature, weight, confidence,
402                adoption_count, total_count, description, ext_data)
403             VALUES ('main', 'convention', 'strong', 0.9, 8, 10, 'C1', ?1)",
404            rusqlite::params![
405                serde_json::json!({
406                    "evidence": [
407                        {"file": "a.rs", "line": 1, "snippet": "code"},
408                        {"file": "b.rs", "line": 2, "snippet": {"content": "more"}}
409                    ]
410                })
411                .to_string()
412            ],
413        )
414        .unwrap();
415
416        // User-recorded decision — must be filtered out by run_debug's WHERE clause.
417        conn.execute(
418            "INSERT INTO nodes (branch_id, nature, weight, confidence,
419                adoption_count, total_count, description, ext_data)
420             VALUES ('main', 'convention', 'strong', 0.7, 0, 0, 'user-rec', ?1)",
421            rusqlite::params![serde_json::json!({"source": "user"}).to_string()],
422        )
423        .unwrap();
424
425        // Convention with malformed ext_data — must not crash the dump.
426        conn.execute(
427            "INSERT INTO nodes (branch_id, nature, weight, confidence,
428                adoption_count, total_count, description, ext_data)
429             VALUES ('main', 'convention', 'moderate', 0.5, 1, 1, 'malformed', ?1)",
430            rusqlite::params!["{not valid json"],
431        )
432        .unwrap();
433
434        // Different branch — must be filtered out.
435        conn.execute(
436            "INSERT INTO nodes (branch_id, nature, weight, confidence,
437                adoption_count, total_count, description, ext_data)
438             VALUES ('other', 'convention', 'strong', 0.6, 2, 2, 'other-branch', NULL)",
439            [],
440        )
441        .unwrap();
442
443        // Non-convention nature — also filtered out.
444        conn.execute(
445            "INSERT INTO nodes (branch_id, nature, weight, confidence,
446                adoption_count, total_count, description, ext_data)
447             VALUES ('main', 'fact', 'moderate', 0.5, 1, 1, 'fact-row', NULL)",
448            [],
449        )
450        .unwrap();
451        drop(conn);
452
453        let result = run_debug(&db_path, "main");
454        assert!(result.is_ok(), "got error: {:?}", result.err());
455    }
456}