1use std::path::Path;
9
10use serde::Deserialize;
11
12use crate::error::CliError;
13
14#[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#[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#[derive(Debug, Default, Deserialize)]
65struct ExtData {
66 #[serde(default)]
67 evidence: Vec<EvidenceRow>,
68}
69
70struct 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 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 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 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 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 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 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 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 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}