Skip to main content

chasm/commands/
recover.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Session Recovery Commands
4//!
5//! This module provides commands for recovering lost, corrupted, or orphaned
6//! chat sessions from various sources including:
7//! - Recording API server state
8//! - SQLite database backups
9//! - Corrupted JSONL files
10//! - Orphaned files in workspaceStorage
11
12use anyhow::{Context, Result};
13use colored::Colorize;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use crate::storage::{detect_session_format, parse_session_auto, VsCodeSessionFormat};
18
19/// Get workspace storage path for a provider
20fn get_provider_storage_path(provider: &str) -> Option<PathBuf> {
21    let base = match std::env::consts::OS {
22        "windows" => std::env::var("APPDATA").ok().map(PathBuf::from),
23        "macos" => dirs::home_dir().map(|p| p.join("Library/Application Support")),
24        _ => dirs::home_dir().map(|p| p.join(".config")),
25    }?;
26
27    let path = match provider {
28        "vscode" => base.join("Code/User/workspaceStorage"),
29        "cursor" => base.join("Cursor/User/workspaceStorage"),
30        _ => return None,
31    };
32
33    if path.exists() {
34        Some(path)
35    } else {
36        None
37    }
38}
39
40/// Get state database path for a provider
41fn get_provider_state_db(provider: &str) -> Option<PathBuf> {
42    let base = match std::env::consts::OS {
43        "windows" => std::env::var("APPDATA").ok().map(PathBuf::from),
44        "macos" => dirs::home_dir().map(|p| p.join("Library/Application Support")),
45        _ => dirs::home_dir().map(|p| p.join(".config")),
46    }?;
47
48    let path = match provider {
49        "vscode" => base.join("Code/User/globalStorage/state.vscdb"),
50        "cursor" => base.join("Cursor/User/globalStorage/state.vscdb"),
51        _ => return None,
52    };
53
54    if path.exists() {
55        Some(path)
56    } else {
57        None
58    }
59}
60
61/// Get copilot chat history path
62fn get_copilot_history_path(provider: &str) -> Option<PathBuf> {
63    let base = match std::env::consts::OS {
64        "windows" => std::env::var("APPDATA").ok().map(PathBuf::from),
65        "macos" => dirs::home_dir().map(|p| p.join("Library/Application Support")),
66        _ => dirs::home_dir().map(|p| p.join(".config")),
67    }?;
68
69    let path = match provider {
70        "vscode" => base.join("Code/User/History/copilot-chat"),
71        "cursor" => base.join("Cursor/User/History"),
72        _ => return None,
73    };
74
75    if path.exists() {
76        Some(path)
77    } else {
78        None
79    }
80}
81
82/// Scan for recoverable sessions from various sources
83pub fn recover_scan(provider: &str, verbose: bool, _include_old: bool) -> Result<()> {
84    println!("╔═══════════════════════════════════════════════════════════════════╗");
85    println!("║               Session Recovery Scanner v1.3.2                     ║");
86    println!("╚═══════════════════════════════════════════════════════════════════╝\n");
87
88    let providers_to_scan = if provider == "all" {
89        vec!["vscode", "cursor"]
90    } else {
91        vec![provider]
92    };
93
94    let mut total_recoverable = 0;
95    let mut total_corrupted = 0;
96
97    for prov in &providers_to_scan {
98        println!("[*] Scanning {} workspaces...", prov);
99
100        // Scan workspace storage
101        if let Some(storage_path) = get_provider_storage_path(prov) {
102            let mut count = 0;
103            if let Ok(entries) = fs::read_dir(&storage_path) {
104                for entry in entries.flatten() {
105                    let path = entry.path();
106                    if path.is_dir() {
107                        // Look for session files
108                        let sessions_dir = path.join("state.vscdb");
109                        let history_dir = path.join("history");
110                        
111                        if sessions_dir.exists() || history_dir.exists() {
112                            count += 1;
113                            if verbose {
114                                println!("    [+] Found workspace: {}", path.display());
115                            }
116                        }
117                    }
118                }
119            }
120            println!("    Found {} workspace directories", count);
121            total_recoverable += count;
122        }
123
124        // Scan for JSONL files with parse errors
125        if let Some(copilot_path) = get_copilot_history_path(prov) {
126            let mut corrupted_count = 0;
127            if let Ok(entries) = fs::read_dir(&copilot_path) {
128                for entry in entries.flatten() {
129                    let path = entry.path();
130                    if path.extension().is_some_and(|e| e == "jsonl") {
131                        // Try to parse the file
132                        if let Ok(content) = fs::read_to_string(&path) {
133                            let lines: Vec<&str> = content.lines().collect();
134                            let mut errors = 0;
135                            for line in &lines {
136                                if !line.is_empty()
137                                    && serde_json::from_str::<serde_json::Value>(line).is_err() {
138                                        errors += 1;
139                                    }
140                            }
141                            if errors > 0 {
142                                corrupted_count += 1;
143                                if verbose {
144                                    println!("    [!] Corrupted JSONL: {} ({} bad lines)", path.display(), errors);
145                                }
146                            }
147                        }
148                    }
149                }
150            }
151            if corrupted_count > 0 {
152                println!("    Found {} potentially corrupted JSONL files", corrupted_count);
153                total_corrupted += corrupted_count;
154            }
155        }
156    }
157
158    println!();
159    println!("╔═══════════════════════════════════════════════════════════════════╗");
160    println!("║                       Recovery Summary                            ║");
161    println!("╠═══════════════════════════════════════════════════════════════════╣");
162    println!("║  Workspace directories found: {:>5}                              ║", total_recoverable);
163    println!("║  Corrupted files:             {:>5}                              ║", total_corrupted);
164    println!("╚═══════════════════════════════════════════════════════════════════╝");
165
166    if total_corrupted > 0 {
167        println!();
168        println!("[i] Use 'chasm recover jsonl <file>' to attempt repair of corrupted files");
169    }
170
171    Ok(())
172}
173
174/// Recover sessions from the recording API server
175pub fn recover_from_recording(server: &str, session_id: Option<&str>, output: Option<&str>) -> Result<()> {
176    println!("[*] Connecting to recording server: {}", server);
177
178    // Build the recovery URL
179    let url = if let Some(sid) = session_id {
180        format!("{}/recording/session/{}/recovery", server, sid)
181    } else {
182        format!("{}/recording/sessions", server)
183    };
184
185    // Make HTTP request using blocking reqwest
186    let client = reqwest::blocking::Client::builder()
187        .timeout(std::time::Duration::from_secs(10))
188        .build()?;
189    
190    let response = client.get(&url)
191        .send()
192        .context("Failed to connect to recording server")?;
193
194    if !response.status().is_success() {
195        anyhow::bail!("Server returned error: {}", response.status());
196    }
197
198    let body = response.text()?;
199    
200    if let Some(sid) = session_id {
201        // Single session recovery
202        let output_path = output
203            .map(PathBuf::from)
204            .unwrap_or_else(|| PathBuf::from(format!("{}_recovered.json", sid)));
205
206        fs::write(&output_path, &body)?;
207        println!("[+] Recovered session saved to: {}", output_path.display());
208    } else {
209        // List all sessions
210        let sessions: serde_json::Value = serde_json::from_str(&body)?;
211        
212        if let Some(arr) = sessions.get("active_sessions").and_then(|v| v.as_array()) {
213            println!();
214            println!("╔═══════════════════════════════════════════════════════════════════╗");
215            println!("║                    Active Recording Sessions                      ║");
216            println!("╠═══════════════════════════════════════════════════════════════════╣");
217            
218            for session in arr {
219                let id = session.get("session_id").and_then(|v| v.as_str()).unwrap_or("?");
220                let provider = session.get("provider").and_then(|v| v.as_str()).unwrap_or("?");
221                let msgs = session.get("message_count").and_then(|v| v.as_i64()).unwrap_or(0);
222                let title = session.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled");
223                
224                println!("║ {:36} {:10} {:>4} msgs  ║", 
225                    &id[..id.len().min(36)],
226                    provider,
227                    msgs
228                );
229                if title != "Untitled" {
230                    println!("║   └─ {}{}║", 
231                        &title[..title.len().min(55)],
232                        " ".repeat(55 - title.len().min(55))
233                    );
234                }
235            }
236            
237            println!("╚═══════════════════════════════════════════════════════════════════╝");
238            println!();
239            println!("[i] Use 'chasm recover recording --session <ID>' to recover a specific session");
240        } else {
241            println!("[!] No active sessions found on recording server");
242        }
243    }
244
245    Ok(())
246}
247
248/// Recover sessions from a SQLite database backup
249pub fn recover_from_database(backup_path: &str, session_id: Option<&str>, output: Option<&str>, format: &str) -> Result<()> {
250    println!("[*] Opening database backup: {}", backup_path);
251
252    let conn = rusqlite::Connection::open(backup_path)?;
253
254    // Check for sessions table
255    let table_exists: bool = conn.query_row(
256        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='sessions')",
257        [],
258        |row| row.get(0),
259    )?;
260
261    if !table_exists {
262        // Try VS Code state.vscdb format
263        let state_format: bool = conn.query_row(
264            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='ItemTable')",
265            [],
266            |row| row.get(0),
267        )?;
268
269        if state_format {
270            return recover_from_vscdb(&conn, session_id, output, format);
271        }
272        
273        anyhow::bail!("Database does not contain recognized session tables");
274    }
275
276    // Query sessions
277    let query = if let Some(sid) = session_id {
278        format!("SELECT id, title, provider, created_at, data FROM sessions WHERE id = '{}'", sid)
279    } else {
280        "SELECT id, title, provider, created_at, data FROM sessions ORDER BY created_at DESC LIMIT 50".to_string()
281    };
282
283    let mut stmt = conn.prepare(&query)?;
284    let sessions: Vec<(String, String, String, String, String)> = stmt
285        .query_map([], |row| {
286            Ok((
287                row.get(0)?,
288                row.get::<_, Option<String>>(1)?.unwrap_or_default(),
289                row.get::<_, Option<String>>(2)?.unwrap_or_default(),
290                row.get::<_, Option<String>>(3)?.unwrap_or_default(),
291                row.get::<_, Option<String>>(4)?.unwrap_or_default(),
292            ))
293        })?
294        .collect::<Result<Vec<_>, _>>()?;
295
296    if sessions.is_empty() {
297        println!("[!] No sessions found in database");
298        return Ok(());
299    }
300
301    if let Some(sid) = session_id {
302        // Export single session
303        let session = &sessions[0];
304        let output_path = output
305            .map(PathBuf::from)
306            .unwrap_or_else(|| PathBuf::from(format!("{}_recovered.{}", sid, format)));
307
308        let content = match format {
309            "json" => session.4.clone(),
310            "jsonl" => session.4.lines().collect::<Vec<_>>().join("\n"),
311            _ => session.4.clone(),
312        };
313
314        fs::write(&output_path, content)?;
315        println!("[+] Session recovered to: {}", output_path.display());
316    } else {
317        // List sessions
318        println!();
319        println!("╔═══════════════════════════════════════════════════════════════════╗");
320        println!("║                    Sessions in Database Backup                    ║");
321        println!("╠═══════════════════════════════════════════════════════════════════╣");
322        
323        for (id, title, provider, created, _) in &sessions {
324            let title_display = if title.is_empty() { "Untitled" } else { title };
325            println!("║ {:36} {:10} {:16}  ║",
326                &id[..id.len().min(36)],
327                &provider[..provider.len().min(10)],
328                &created[..created.len().min(16)]
329            );
330            if !title.is_empty() {
331                println!("║   └─ {}{}║",
332                    &title_display[..title_display.len().min(55)],
333                    " ".repeat(55 - title_display.len().min(55))
334                );
335            }
336        }
337        
338        println!("╚═══════════════════════════════════════════════════════════════════╝");
339        println!();
340        println!("[i] Use 'chasm recover database {} --session <ID>' to export a session", backup_path);
341    }
342
343    Ok(())
344}
345
346/// Recover from VS Code state.vscdb format
347fn recover_from_vscdb(conn: &rusqlite::Connection, _session_id: Option<&str>, output: Option<&str>, _format: &str) -> Result<()> {
348    println!("[*] Detected VS Code state.vscdb format");
349
350    // Query for chat history keys
351    let mut stmt = conn.prepare(
352        "SELECT key, value FROM ItemTable WHERE key LIKE '%chat%' OR key LIKE '%copilot%'"
353    )?;
354
355    let items: Vec<(String, Vec<u8>)> = stmt
356        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
357        .collect::<Result<Vec<_>, _>>()?;
358
359    if items.is_empty() {
360        println!("[!] No chat-related data found in state database");
361        return Ok(());
362    }
363
364    println!("[+] Found {} chat-related entries", items.len());
365
366    let output_dir = output.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("recovered_vscdb"));
367    fs::create_dir_all(&output_dir)?;
368
369    for (key, value) in &items {
370        // Try to parse as UTF-8
371        if let Ok(text) = String::from_utf8(value.clone()) {
372            // Check if it's JSON
373            if text.starts_with('{') || text.starts_with('[') {
374                let safe_key = key.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
375                let output_path = output_dir.join(format!("{}.json", safe_key));
376                fs::write(&output_path, &text)?;
377                println!("  [+] Extracted: {}", output_path.display());
378            }
379        }
380    }
381
382    println!();
383    println!("[+] Recovery output written to: {}", output_dir.display());
384
385    Ok(())
386}
387
388/// Recover sessions from corrupted JSONL files
389pub fn recover_jsonl(file_path: &str, output: Option<&str>, aggressive: bool) -> Result<()> {
390    println!("[*] Attempting to recover JSONL file: {}", file_path);
391
392    let content = fs::read_to_string(file_path)?;
393    let lines: Vec<&str> = content.lines().collect();
394
395    let mut recovered_objects: Vec<serde_json::Value> = Vec::new();
396    let mut errors = 0;
397    let mut recovered = 0;
398
399    for (i, line) in lines.iter().enumerate() {
400        if line.is_empty() {
401            continue;
402        }
403
404        match serde_json::from_str::<serde_json::Value>(line) {
405            Ok(obj) => {
406                recovered_objects.push(obj);
407                recovered += 1;
408            }
409            Err(e) => {
410                errors += 1;
411                if aggressive {
412                    // Try to fix common issues
413                    let fixed = attempt_json_repair(line);
414                    if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&fixed) {
415                        recovered_objects.push(obj);
416                        recovered += 1;
417                        println!("  [+] Repaired line {}", i + 1);
418                    } else {
419                        println!("  [!] Could not repair line {}: {}", i + 1, e);
420                    }
421                } else {
422                    println!("  [!] Error on line {}: {}", i + 1, e);
423                }
424            }
425        }
426    }
427
428    println!();
429    println!("╔═══════════════════════════════════════════════════════════════════╗");
430    println!("║                    JSONL Recovery Summary                         ║");
431    println!("╠═══════════════════════════════════════════════════════════════════╣");
432    println!("║  Total lines:     {:>5}                                          ║", lines.len());
433    println!("║  Recovered:       {:>5}                                          ║", recovered);
434    println!("║  Errors:          {:>5}                                          ║", errors);
435    println!("╚═══════════════════════════════════════════════════════════════════╝");
436
437    if recovered > 0 {
438        let output_path = output.map(PathBuf::from).unwrap_or_else(|| {
439            let p = Path::new(file_path);
440            p.with_extension("recovered.jsonl")
441        });
442
443        let mut output_content = String::new();
444        for obj in &recovered_objects {
445            output_content.push_str(&serde_json::to_string(obj)?);
446            output_content.push('\n');
447        }
448
449        fs::write(&output_path, output_content)?;
450        println!();
451        println!("[+] Recovered data written to: {}", output_path.display());
452    }
453
454    Ok(())
455}
456
457/// Attempt to repair malformed JSON
458fn attempt_json_repair(line: &str) -> String {
459    let mut fixed = line.to_string();
460
461    // Fix unescaped quotes
462    // This is a simple heuristic - real repair would need more sophisticated parsing
463    
464    // Fix trailing commas
465    fixed = fixed.replace(",}", "}").replace(",]", "]");
466
467    // Fix missing closing braces/brackets
468    let open_braces = fixed.matches('{').count();
469    let close_braces = fixed.matches('}').count();
470    if open_braces > close_braces {
471        fixed.push_str(&"}".repeat(open_braces - close_braces));
472    }
473
474    let open_brackets = fixed.matches('[').count();
475    let close_brackets = fixed.matches(']').count();
476    if open_brackets > close_brackets {
477        fixed.push_str(&"]".repeat(open_brackets - close_brackets));
478    }
479
480    fixed
481}
482
483/// List orphaned sessions in workspaceStorage
484pub fn recover_orphans(provider: &str, unindexed: bool, _verify: bool) -> Result<()> {
485    println!("[*] Scanning for orphaned sessions...");
486
487    let providers_to_scan = if provider == "all" {
488        vec!["vscode", "cursor"]
489    } else {
490        vec![provider]
491    };
492
493    let mut total_orphans = 0;
494
495    for prov in &providers_to_scan {
496        println!("\n[*] Checking {}...", prov);
497
498        if let Some(storage_path) = get_provider_storage_path(prov) {
499            // Get list of workspaces from database if checking for unindexed
500            let indexed_workspaces: std::collections::HashSet<String> = if unindexed {
501                if let Some(db_path) = get_provider_state_db(prov) {
502                    if let Ok(conn) = rusqlite::Connection::open(&db_path) {
503                        if let Ok(mut stmt) = conn.prepare("SELECT key FROM ItemTable WHERE key LIKE 'workspaceStorage/%'") {
504                            stmt.query_map([], |row| row.get::<_, String>(0))
505                                .ok()
506                                .map(|iter| iter.flatten().collect())
507                                .unwrap_or_default()
508                        } else {
509                            std::collections::HashSet::new()
510                        }
511                    } else {
512                        std::collections::HashSet::new()
513                    }
514                } else {
515                    std::collections::HashSet::new()
516                }
517            } else {
518                std::collections::HashSet::new()
519            };
520
521            if let Ok(entries) = fs::read_dir(&storage_path) {
522                for entry in entries.flatten() {
523                    let path = entry.path();
524                    if path.is_dir() {
525                        let dir_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
526                        
527                        // Check if this workspace has session data
528                        let has_sessions = path.join("state.vscdb").exists() 
529                            || path.join("history").exists();
530
531                        if !has_sessions {
532                            continue;
533                        }
534
535                        let is_indexed = !unindexed || indexed_workspaces.contains(&dir_name);
536                        
537                        if !is_indexed {
538                            total_orphans += 1;
539                            println!("  [?] Unindexed: {}", dir_name);
540                        }
541                    }
542                }
543            }
544        }
545    }
546
547    println!();
548    if total_orphans > 0 {
549        println!("[i] Found {} potentially orphaned workspace(s)", total_orphans);
550        println!("[i] Use 'chasm register all' to re-index these workspaces");
551    } else {
552        println!("[+] No orphaned sessions found");
553    }
554
555    Ok(())
556}
557
558/// Repair corrupted session files in place
559pub fn recover_repair(path: &str, create_backup: bool, dry_run: bool) -> Result<()> {
560    use crate::storage::{is_skeleton_json, convert_skeleton_json_to_jsonl, fix_cancelled_model_state};
561
562    let path = Path::new(path);
563
564    if dry_run {
565        println!("[*] DRY RUN - no changes will be made");
566    }
567
568    if path.is_dir() {
569        println!("[*] Scanning directory for repairable files: {}", path.display());
570        
571        let mut repaired = 0;
572        let mut skeletons_converted = 0;
573        let mut cancelled_fixed = 0;
574
575        for entry in walkdir::WalkDir::new(path).into_iter().flatten() {
576            let file_path = entry.path();
577            if file_path.extension().is_some_and(|e| e == "jsonl" || e == "json") {
578                if let Ok(content) = fs::read_to_string(file_path) {
579                    // Check for skeleton .json files (corrupted, only structural chars remain)
580                    if file_path.extension().is_some_and(|e| e == "json")
581                        && !file_path.to_string_lossy().ends_with(".bak")
582                        && !file_path.to_string_lossy().ends_with(".corrupt")
583                    {
584                        if is_skeleton_json(&content) {
585                            println!("  [!] Skeleton .json: {} — corrupt, only structural chars", file_path.display());
586                            if !dry_run {
587                                match convert_skeleton_json_to_jsonl(file_path, None, None) {
588                                    Ok(Some(_)) => {
589                                        println!("  [+] Converted to .jsonl, original renamed to .json.corrupt");
590                                        skeletons_converted += 1;
591                                    }
592                                    Ok(None) => {} // Skipped (e.g., .jsonl already exists)
593                                    Err(e) => println!("  [!] Failed to convert skeleton: {}", e),
594                                }
595                            }
596                            continue; // Don't try to repair skeleton content
597                        }
598                    }
599
600                    // Check for corrupted JSON lines
601                    let has_corrupt_lines = content.lines().any(|line| {
602                        !line.is_empty() && serde_json::from_str::<serde_json::Value>(line).is_err()
603                    });
604
605                    // Check for concatenated JSONL (}{"kind": pattern without newlines)
606                    let has_concatenated = content.contains("}{\"kind\":");
607
608                    // Check for missing VS Code-required fields in kind:0 lines
609                    let missing_fields = if file_path.extension().is_some_and(|e| e == "jsonl") {
610                        content
611                            .lines()
612                            .next()
613                            .and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
614                            .and_then(|obj| {
615                                if obj.get("kind")?.as_u64()? == 0 {
616                                    let v = obj.get("v")?;
617                                    let missing = !v.get("hasPendingEdits").is_some()
618                                        || !v.get("pendingRequests").is_some()
619                                        || !v.get("inputState").is_some()
620                                        || !v.get("sessionId").is_some();
621                                    Some(missing)
622                                } else {
623                                    None
624                                }
625                            })
626                            .unwrap_or(false)
627                    } else {
628                        false
629                    };
630
631                    let needs_repair = has_corrupt_lines || has_concatenated || missing_fields;
632
633                    if needs_repair {
634                        let reasons: Vec<&str> = [
635                            if has_corrupt_lines { Some("corrupt JSON") } else { None },
636                            if has_concatenated { Some("concatenated lines") } else { None },
637                            if missing_fields { Some("missing VS Code fields") } else { None },
638                        ]
639                        .into_iter()
640                        .flatten()
641                        .collect();
642
643                        println!("  [!] Needs repair: {} ({})", file_path.display(), reasons.join(", "));
644                        if !dry_run {
645                            repair_file(file_path, create_backup)?;
646                            repaired += 1;
647                        }
648                    }
649
650                    // Check for cancelled modelState in JSONL files (after other repairs)
651                    if file_path.extension().is_some_and(|e| e == "jsonl") && !dry_run {
652                        match fix_cancelled_model_state(file_path) {
653                            Ok(true) => {
654                                println!("  [+] Fixed cancelled modelState: {}", file_path.display());
655                                cancelled_fixed += 1;
656                            }
657                            Ok(false) => {}
658                            Err(e) => {
659                                println!("  [!] Failed to fix modelState for {}: {}", file_path.display(), e);
660                            }
661                        }
662                    }
663                }
664            }
665        }
666
667        println!();
668        if dry_run {
669            println!("[i] {} file(s) would be repaired", repaired);
670        } else {
671            let mut parts = Vec::new();
672            if repaired > 0 {
673                parts.push(format!("{} repaired", repaired));
674            }
675            if skeletons_converted > 0 {
676                parts.push(format!("{} skeletons converted", skeletons_converted));
677            }
678            if cancelled_fixed > 0 {
679                parts.push(format!("{} cancelled states fixed", cancelled_fixed));
680            }
681            if parts.is_empty() {
682                println!("[+] No issues found");
683            } else {
684                println!("[+] {}", parts.join(", "));
685            }
686        }
687    } else {
688        // Single file
689        if !dry_run {
690            // Check for skeleton .json first
691            if path.extension().is_some_and(|e| e == "json")
692                && !path.to_string_lossy().ends_with(".bak")
693                && !path.to_string_lossy().ends_with(".corrupt")
694            {
695                if let Ok(content) = fs::read_to_string(path) {
696                    if is_skeleton_json(&content) {
697                        match convert_skeleton_json_to_jsonl(path, None, None) {
698                            Ok(Some(jsonl_path)) => {
699                                println!("[+] Converted skeleton .json → {}", jsonl_path.display());
700                                println!("    Original renamed to .json.corrupt");
701                                return Ok(());
702                            }
703                            Ok(None) => println!("[i] Skeleton detected but .jsonl already exists"),
704                            Err(e) => println!("[!] Failed to convert skeleton: {}", e),
705                        }
706                        return Ok(());
707                    }
708                }
709            }
710
711            repair_file(path, create_backup)?;
712            println!("[+] File repaired: {}", path.display());
713
714            // Fix cancelled modelState for JSONL files
715            if path.extension().is_some_and(|e| e == "jsonl") {
716                match fix_cancelled_model_state(path) {
717                    Ok(true) => println!("[+] Fixed cancelled modelState"),
718                    Ok(false) => {}
719                    Err(e) => println!("[!] Failed to fix modelState: {}", e),
720                }
721            }
722        } else {
723            println!("[i] Would repair: {}", path.display());
724        }
725    }
726
727    Ok(())
728}
729
730fn repair_file(path: &Path, create_backup: bool) -> Result<()> {
731    use crate::storage::{ensure_vscode_compat_fields, split_concatenated_jsonl};
732
733    if create_backup {
734        let backup_path = path.with_extension("backup");
735        fs::copy(path, &backup_path)?;
736    }
737
738    let content = fs::read_to_string(path)?;
739
740    // Pre-process: split concatenated JSON objects that lack newline separators
741    let content = if path.extension().is_some_and(|e| e == "jsonl") {
742        split_concatenated_jsonl(&content)
743    } else {
744        content
745    };
746
747    let session_id = path
748        .file_stem()
749        .and_then(|s| s.to_str())
750        .map(|s| s.to_string());
751    let mut output = String::new();
752
753    for line in content.lines() {
754        if line.is_empty() {
755            output.push('\n');
756            continue;
757        }
758
759        match serde_json::from_str::<serde_json::Value>(line) {
760            Ok(mut parsed) => {
761                // For kind:0 lines, ensure all VS Code-required fields are present
762                let is_kind_0 = parsed
763                    .get("kind")
764                    .and_then(|k| k.as_u64())
765                    .map(|k| k == 0)
766                    .unwrap_or(false);
767
768                if is_kind_0 {
769                    if let Some(v) = parsed.get_mut("v") {
770                        ensure_vscode_compat_fields(v, session_id.as_deref());
771                    }
772                    output.push_str(&serde_json::to_string(&parsed).unwrap_or_default());
773                } else {
774                    output.push_str(line);
775                }
776                output.push('\n');
777            }
778            Err(_) => {
779                let fixed = attempt_json_repair(line);
780                if serde_json::from_str::<serde_json::Value>(&fixed).is_ok() {
781                    output.push_str(&fixed);
782                    output.push('\n');
783                }
784                // Skip unrecoverable lines
785            }
786        }
787    }
788
789    fs::write(path, output)?;
790    Ok(())
791}
792
793/// Show recovery status and recommendations
794pub fn recover_status(provider: &str, check_system: bool) -> Result<()> {
795    println!("╔═══════════════════════════════════════════════════════════════════╗");
796    println!("║                    Recovery Status Report                         ║");
797    println!("╚═══════════════════════════════════════════════════════════════════╝\n");
798
799    let providers_to_check = if provider == "all" {
800        vec!["vscode", "cursor"]
801    } else {
802        vec![provider]
803    };
804
805    for name in &providers_to_check {
806        println!("[*] {} Status:", name.to_uppercase());
807
808        // Check state database
809        if let Some(db_path) = get_provider_state_db(name) {
810            let size = fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
811            println!("    Database: {} ({:.1} MB)", db_path.display(), size as f64 / 1024.0 / 1024.0);
812            
813            // Check if database is accessible
814            match rusqlite::Connection::open(&db_path) {
815                Ok(conn) => {
816                    if let Ok(count) = conn.query_row::<i64, _, _>(
817                        "SELECT COUNT(*) FROM ItemTable", [], |r| r.get(0)
818                    ) {
819                        println!("    Items in database: {}", count);
820                    }
821                }
822                Err(e) => {
823                    println!("    [!] Database error: {}", e);
824                }
825            }
826        } else {
827            println!("    Database: Not found");
828        }
829
830        // Check workspace storage
831        if let Some(storage_path) = get_provider_storage_path(name) {
832            let count = fs::read_dir(&storage_path)
833                .map(|r| r.count())
834                .unwrap_or(0);
835            println!("    Workspace folders: {}", count);
836        }
837
838        // Check copilot history
839        if let Some(history_path) = get_copilot_history_path(name) {
840            let count = fs::read_dir(&history_path)
841                .map(|r| r.filter(|e| e.as_ref().map(|e| e.path().extension().is_some_and(|ext| ext == "jsonl")).unwrap_or(false)).count())
842                .unwrap_or(0);
843            println!("    JSONL session files: {}", count);
844        }
845
846        println!();
847    }
848
849    if check_system {
850        println!("[*] System Status:");
851        
852        // Get available disk space
853        #[cfg(windows)]
854        {
855            // Windows: check C: drive
856            if let Ok(output) = std::process::Command::new("wmic")
857                .args(["logicaldisk", "get", "freespace,size"])
858                .output()
859            {
860                if let Ok(text) = String::from_utf8(output.stdout) {
861                    println!("    Disk space: {}", text.lines().nth(1).unwrap_or("Unknown"));
862                }
863            }
864        }
865
866        #[cfg(not(windows))]
867        {
868            if let Ok(output) = std::process::Command::new("df")
869                .args(["-h", "/"])
870                .output()
871            {
872                if let Ok(text) = String::from_utf8(output.stdout) {
873                    if let Some(line) = text.lines().nth(1) {
874                        println!("    Disk space: {}", line);
875                    }
876                }
877            }
878        }
879    }
880
881    println!("[*] Recommendations:");
882    println!("    1. Run 'chasm recover scan' to find recoverable sessions");
883    println!("    2. Use 'chasm harvest run' to consolidate all sessions");
884    println!("    3. Consider setting up the recording API for crash protection");
885
886    Ok(())
887}
888
889// ============================================================================
890// Convert Command - Convert between JSON and JSONL formats
891// ============================================================================
892
893/// Convert session files between JSON and JSONL formats
894pub fn recover_convert(
895    input: &str,
896    output: Option<&str>,
897    format: Option<&str>,
898    compat: &str,
899) -> Result<()> {
900    use crate::storage::{parse_session_auto, detect_session_format, VsCodeSessionFormat};
901    
902
903    let input_path = Path::new(input);
904    if !input_path.exists() {
905        anyhow::bail!("Input file does not exist: {}", input);
906    }
907
908    // Read content first for auto-detection
909    let content = fs::read_to_string(input_path)
910        .with_context(|| format!("Failed to read input file: {}", input))?;
911
912    // Auto-detect format from content (not just extension)
913    let format_info = detect_session_format(&content);
914    
915    // Determine output format
916    let output_format = if let Some(fmt) = format {
917        fmt.to_lowercase()
918    } else if let Some(out) = output {
919        // Infer from output extension
920        Path::new(out)
921            .extension()
922            .and_then(|e| e.to_str())
923            .unwrap_or(match format_info.format {
924                VsCodeSessionFormat::JsonLines => "json",
925                VsCodeSessionFormat::LegacyJson => "jsonl",
926            })
927            .to_lowercase()
928    } else {
929        // Default to opposite of detected input format
930        match format_info.format {
931            VsCodeSessionFormat::JsonLines => "json".to_string(),
932            VsCodeSessionFormat::LegacyJson => "jsonl".to_string(),
933        }
934    };
935
936    // Determine output path
937    let output_path = if let Some(out) = output {
938        PathBuf::from(out)
939    } else {
940        let stem = input_path.file_stem()
941            .and_then(|s| s.to_str())
942            .unwrap_or("converted");
943        input_path.with_file_name(format!("{}.{}", stem, output_format))
944    };
945
946    println!("[*] Session Format Converter");
947    println!("    Input:  {}", input);
948    println!("    Output: {} ({})", output_path.display(), output_format.to_uppercase());
949    println!("    Compat: {}", compat);
950    println!();
951    println!("[*] Auto-detected source format:");
952    println!("    Format:     {} ({})", format_info.format.short_name(), format_info.format);
953    println!("    Schema:     {}", format_info.schema_version);
954    println!("    Confidence: {:.0}%", format_info.confidence * 100.0);
955    println!("    Method:     {}", format_info.detection_method);
956    println!();
957
958    // Parse using auto-detection
959    let (session, _) = parse_session_auto(&content)
960        .with_context(|| "Failed to parse session")?;
961
962    println!("[+] Parsed session:");
963    println!("    Session ID: {}", session.session_id.as_deref().unwrap_or("none"));
964    println!("    Version:    {}", session.version);
965    println!("    Requests:   {}", session.requests.len());
966    println!("    Created:    {}", format_timestamp(session.creation_date));
967    println!();
968
969    // Convert to output format
970    let output_content = match output_format.as_str() {
971        "json" => {
972            // Convert to legacy JSON format (VS Code < 1.109.0)
973            serde_json::to_string_pretty(&session)
974                .with_context(|| "Failed to serialize to JSON")?
975        }
976        "jsonl" => {
977            // Convert to JSONL format (VS Code >= 1.109.0)
978            convert_to_jsonl(&session)
979                .with_context(|| "Failed to serialize to JSONL")?
980        }
981        "md" | "markdown" => {
982            // Convert to readable markdown
983            convert_to_markdown(&session)
984        }
985        _ => anyhow::bail!("Unknown output format: {}. Use json, jsonl, or md", output_format),
986    };
987
988    // Write output
989    fs::write(&output_path, &output_content)
990        .with_context(|| format!("Failed to write output file: {}", output_path.display()))?;
991
992    println!("[+] Converted successfully!");
993    println!("    Output size: {} bytes", output_content.len());
994
995    Ok(())
996}
997
998/// Convert ChatSession to JSONL format (VS Code 1.109.0+)
999fn convert_to_jsonl(session: &crate::models::ChatSession) -> Result<String> {
1000    use crate::storage::ensure_vscode_compat_fields;
1001
1002    let mut lines = Vec::new();
1003
1004    // Line 1: kind 0 - Initial session state with all VS Code-required fields
1005    let mut initial = serde_json::json!({
1006        "kind": 0,
1007        "v": {
1008            "version": session.version,
1009            "sessionId": session.session_id,
1010            "creationDate": session.creation_date,
1011            "initialLocation": session.initial_location,
1012            "responderUsername": session.responder_username,
1013            "requests": session.requests
1014        }
1015    });
1016
1017    // Inject any missing fields that VS Code's latest format requires
1018    // (hasPendingEdits, pendingRequests, inputState, etc.)
1019    if let Some(v) = initial.get_mut("v") {
1020        ensure_vscode_compat_fields(v, session.session_id.as_deref());
1021    }
1022
1023    lines.push(serde_json::to_string(&initial)?);
1024
1025    // Line 2: kind 1 - lastMessageDate delta
1026    if session.last_message_date > 0 {
1027        let delta = serde_json::json!({
1028            "kind": 1,
1029            "k": ["lastMessageDate"],
1030            "v": session.last_message_date
1031        });
1032        lines.push(serde_json::to_string(&delta)?);
1033    }
1034
1035    // Line 3: kind 1 - customTitle delta (if set)
1036    if let Some(ref title) = session.custom_title {
1037        let delta = serde_json::json!({
1038            "kind": 1,
1039            "k": ["customTitle"],
1040            "v": title
1041        });
1042        lines.push(serde_json::to_string(&delta)?);
1043    }
1044
1045    Ok(lines.join("\n"))
1046}
1047
1048/// Convert ChatSession to readable markdown
1049fn convert_to_markdown(session: &crate::models::ChatSession) -> String {
1050    let mut md = String::new();
1051
1052    md.push_str("# Chat Session\n\n");
1053    
1054    if let Some(ref title) = session.custom_title {
1055        md.push_str(&format!("**Title:** {}\n\n", title));
1056    }
1057    
1058    if let Some(ref session_id) = session.session_id {
1059        md.push_str(&format!("**Session ID:** `{}`\n\n", session_id));
1060    }
1061    
1062    md.push_str(&format!("**Created:** {}\n\n", format_timestamp(session.creation_date)));
1063    md.push_str(&format!("**Messages:** {}\n\n", session.requests.len()));
1064    md.push_str("---\n\n");
1065
1066    for (i, request) in session.requests.iter().enumerate() {
1067        md.push_str(&format!("## Turn {}\n\n", i + 1));
1068        
1069        // User message
1070        md.push_str("### User\n\n");
1071        if let Some(ref msg) = request.message {
1072            md.push_str(&format!("{}\n\n", msg.text.as_deref().unwrap_or("")));
1073        }
1074
1075        // Assistant response  
1076        if let Some(ref response) = request.response {
1077            md.push_str("### Assistant\n\n");
1078            // Response is a serde_json::Value - extract text from 'value' or 'text' field
1079            let response_text = response.get("value")
1080                .or_else(|| response.get("text"))
1081                .and_then(|v| v.as_str())
1082                .unwrap_or("");
1083            md.push_str(&format!("{}\n\n", response_text));
1084        }
1085
1086        md.push_str("---\n\n");
1087    }
1088
1089    md
1090}
1091
1092// ============================================================================
1093// Extract Command - Extract sessions from a project path
1094// ============================================================================
1095
1096/// Extract sessions from a VS Code workspace by project path
1097pub fn recover_extract(
1098    project_path: &str,
1099    output: Option<&str>,
1100    all_formats: bool,
1101    include_edits: bool,
1102) -> Result<()> {
1103    let project_path = Path::new(project_path);
1104    
1105    // Normalize the path
1106    let canonical_path = if project_path.exists() {
1107        let p = project_path.canonicalize()
1108            .with_context(|| format!("Failed to canonicalize path: {}", project_path.display()))?;
1109        // Strip Windows extended path prefix (\\?\) if present
1110        let path_str = p.to_string_lossy();
1111        if path_str.starts_with("\\\\?\\") {
1112            PathBuf::from(&path_str[4..])
1113        } else {
1114            p
1115        }
1116    } else {
1117        PathBuf::from(project_path)
1118    };
1119
1120    println!("[*] Session Extractor");
1121    println!("    Project: {}", canonical_path.display());
1122    println!();
1123
1124    // Normalize the path for comparison
1125    let normalized_path = canonical_path.display().to_string()
1126        .replace('\\', "/")
1127        .to_lowercase();
1128
1129    // Search for matching workspace directories by reading workspace.json files
1130    println!("[*] Searching for workspace matching: {}", normalized_path);
1131    println!();
1132
1133    // Search in all providers
1134    let providers = ["vscode", "cursor"];
1135    let mut found_sessions = Vec::new();
1136    let mut matched_workspaces = Vec::new();
1137
1138    for provider in &providers {
1139        if let Some(storage_path) = get_provider_storage_path(provider) {
1140            // Iterate through all workspace directories and check workspace.json
1141            if let Ok(entries) = fs::read_dir(&storage_path) {
1142                for entry in entries.flatten() {
1143                    let workspace_dir = entry.path();
1144                    if !workspace_dir.is_dir() {
1145                        continue;
1146                    }
1147                    
1148                    let workspace_json = workspace_dir.join("workspace.json");
1149                    if let Ok(content) = fs::read_to_string(&workspace_json) {
1150                        // Parse workspace.json to get folder URI
1151                        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
1152                            if let Some(folder) = json.get("folder").and_then(|f| f.as_str()) {
1153                                // Normalize the folder URI for comparison
1154                                // file:///c%3A/path -> c:/path
1155                                let folder_path = folder
1156                                    .trim_start_matches("file:///")
1157                                    .trim_start_matches("file://")
1158                                    .replace("%3A", ":")
1159                                    .replace("%3a", ":")
1160                                    .to_lowercase();
1161                                
1162                                if folder_path == normalized_path ||
1163                                   folder_path.trim_end_matches('/') == normalized_path.trim_end_matches('/') {
1164                                    matched_workspaces.push((provider.to_string(), workspace_dir.clone()));
1165                                    println!("[+] Found {} workspace: {}", provider, workspace_dir.display());
1166                                    println!("    Folder: {}", folder);
1167                                }
1168                            }
1169                        }
1170                    }
1171                }
1172            }
1173        }
1174    }
1175
1176    // Now collect sessions from all matched workspaces
1177    for (provider, workspace_dir) in &matched_workspaces {
1178        // Check for JSONL sessions (modern format)
1179        if let Some(history_path) = get_copilot_history_path(provider) {
1180            if let Ok(entries) = fs::read_dir(&history_path) {
1181                for entry in entries.flatten() {
1182                    let path = entry.path();
1183                    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1184                    if ext == "jsonl" {
1185                        found_sessions.push((provider.to_string(), path, "jsonl".to_string()));
1186                    } else if all_formats && ext == "json" {
1187                        found_sessions.push((provider.to_string(), path, "json".to_string()));
1188                    }
1189                }
1190            }
1191        }
1192
1193        // Check workspace-specific state
1194        let state_db = workspace_dir.join("state.vscdb");
1195        if state_db.exists() {
1196            found_sessions.push((provider.to_string(), state_db.clone(), "sqlite".to_string()));
1197        }
1198
1199        // Check for editing sessions if requested
1200        if include_edits {
1201            let edits_dir = workspace_dir.join("workspaceEditingSessions");
1202            if edits_dir.exists() {
1203                if let Ok(entries) = fs::read_dir(&edits_dir) {
1204                    for entry in entries.flatten() {
1205                        let path = entry.path();
1206                        found_sessions.push((provider.to_string(), path, "edit".to_string()));
1207                    }
1208                }
1209            }
1210        }
1211    }
1212
1213    if found_sessions.is_empty() {
1214        println!("[-] No sessions found for this project");
1215        println!();
1216        println!("[*] Tips:");
1217        println!("    - Make sure the path matches exactly what VS Code opened");
1218        println!("    - Try 'chasm recover scan' to see all available sessions");
1219        return Ok(());
1220    }
1221
1222    // Determine output directory
1223    let output_dir = if let Some(out) = output {
1224        PathBuf::from(out)
1225    } else {
1226        canonical_path.join(".chasm_recovery")
1227    };
1228
1229    fs::create_dir_all(&output_dir)
1230        .with_context(|| format!("Failed to create output directory: {}", output_dir.display()))?;
1231
1232    println!();
1233    println!("[*] Extracting {} items to: {}", found_sessions.len(), output_dir.display());
1234    println!();
1235
1236    let mut total_size = 0u64;
1237    let mut file_count = 0;
1238    let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1239
1240    for (provider, source_path, format_type) in &found_sessions {
1241        // Generate unique filename including workspace hash if needed
1242        let mut dest_name = format!("{}_{}_{}",
1243            provider,
1244            format_type,
1245            source_path.file_name().unwrap_or_default().to_string_lossy()
1246        );
1247        
1248        // If we've seen this name, add the parent directory name (workspace hash) to make it unique
1249        if seen_names.contains(&dest_name) {
1250            if let Some(parent) = source_path.parent() {
1251                if let Some(parent_name) = parent.file_name() {
1252                    dest_name = format!("{}_{}_{}_{}",
1253                        provider,
1254                        format_type,
1255                        parent_name.to_string_lossy(),
1256                        source_path.file_name().unwrap_or_default().to_string_lossy()
1257                    );
1258                }
1259            }
1260        }
1261        seen_names.insert(dest_name.clone());
1262        let dest_path = output_dir.join(&dest_name);
1263
1264        if source_path.is_file() {
1265            if let Ok(metadata) = fs::metadata(source_path) {
1266                total_size += metadata.len();
1267            }
1268            
1269            fs::copy(source_path, &dest_path)
1270                .with_context(|| format!("Failed to copy: {}", source_path.display()))?;
1271            
1272            file_count += 1;
1273            println!("    [+] {} -> {}", source_path.display(), dest_name);
1274        } else if source_path.is_dir() {
1275            // Copy directory recursively
1276            copy_dir_recursive(source_path, &dest_path)?;
1277            file_count += 1;
1278            println!("    [+] {} (directory)", dest_name);
1279        }
1280    }
1281
1282    println!();
1283    println!("[+] Extraction complete!");
1284    println!("    Files:      {}", file_count);
1285    println!("    Total size: {} bytes", total_size);
1286    println!("    Output:     {}", output_dir.display());
1287
1288    Ok(())
1289}
1290
1291/// Recursively copy a directory
1292fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
1293    fs::create_dir_all(dst)?;
1294    
1295    for entry in fs::read_dir(src)? {
1296        let entry = entry?;
1297        let src_path = entry.path();
1298        let dst_path = dst.join(entry.file_name());
1299        
1300        if src_path.is_dir() {
1301            copy_dir_recursive(&src_path, &dst_path)?;
1302        } else {
1303            fs::copy(&src_path, &dst_path)?;
1304        }
1305    }
1306    
1307    Ok(())
1308}
1309
1310/// Format a Unix timestamp for display
1311fn format_timestamp(ts: i64) -> String {
1312    use std::time::{Duration, UNIX_EPOCH};
1313    
1314    if ts <= 0 {
1315        return "Unknown".to_string();
1316    }
1317    
1318    // Handle both seconds and milliseconds
1319    let ts_secs = if ts > 10_000_000_000 { ts / 1000 } else { ts };
1320    
1321    match UNIX_EPOCH.checked_add(Duration::from_secs(ts_secs as u64)) {
1322        Some(time) => {
1323            let datetime: chrono::DateTime<chrono::Utc> = time.into();
1324            datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
1325        }
1326        None => format!("{}", ts),
1327    }
1328}
1329
1330// ============================================================================
1331// Detect Command - Detect session format and version
1332// ============================================================================
1333
1334/// Detect and display session format and version information
1335pub fn recover_detect(file: &str, verbose: bool, output_json: bool) -> Result<()> {
1336    use crate::storage::{detect_session_format, parse_session_auto, VsCodeSessionFormat};
1337
1338    let file_path = Path::new(file);
1339    if !file_path.exists() {
1340        anyhow::bail!("File does not exist: {}", file);
1341    }
1342
1343    // Read file content
1344    let content = fs::read_to_string(file_path)
1345        .with_context(|| format!("Failed to read file: {}", file))?;
1346
1347    // Detect format
1348    let format_info = detect_session_format(&content);
1349
1350    // Try to parse the session
1351    let parse_result = parse_session_auto(&content);
1352
1353    if output_json {
1354        // JSON output
1355        let mut result = serde_json::json!({
1356            "file": file,
1357            "file_size": content.len(),
1358            "format": {
1359                "type": format_info.format.short_name(),
1360                "description": format_info.format.description(),
1361                "min_vscode_version": format_info.format.min_vscode_version(),
1362            },
1363            "schema": {
1364                "version": format_info.schema_version.version_number(),
1365                "description": format_info.schema_version.description(),
1366            },
1367            "detection": {
1368                "confidence": format_info.confidence,
1369                "method": format_info.detection_method,
1370            },
1371        });
1372
1373        if let Ok((session, _)) = &parse_result {
1374            result["session"] = serde_json::json!({
1375                "id": session.session_id,
1376                "version": session.version,
1377                "requests": session.requests.len(),
1378                "creation_date": session.creation_date,
1379                "last_message_date": session.last_message_date,
1380                "title": session.custom_title,
1381                "responder": session.responder_username,
1382            });
1383            result["parse_success"] = serde_json::json!(true);
1384        } else {
1385            result["parse_success"] = serde_json::json!(false);
1386            if let Err(e) = &parse_result {
1387                result["parse_error"] = serde_json::json!(e.to_string());
1388            }
1389        }
1390
1391        println!("{}", serde_json::to_string_pretty(&result)?);
1392    } else {
1393        // Human-readable output
1394        println!("[*] Session Format Detection");
1395        println!("    File: {}", file);
1396        println!("    Size: {} bytes", content.len());
1397        println!();
1398        
1399        println!("[*] Detected Format:");
1400        println!("    Type:        {} ({})", format_info.format.short_name().to_uppercase(), format_info.format);
1401        println!("    Min VS Code: {}", format_info.format.min_vscode_version());
1402        println!();
1403        
1404        println!("[*] Schema Version:");
1405        println!("    Version:     {}", format_info.schema_version);
1406        println!("    Confidence:  {:.0}%", format_info.confidence * 100.0);
1407        if verbose {
1408            println!("    Method:      {}", format_info.detection_method);
1409        }
1410        println!();
1411
1412        match &parse_result {
1413            Ok((session, _)) => {
1414                println!("[+] Session Parsed Successfully:");
1415                println!("    Session ID:  {}", session.session_id.as_deref().unwrap_or("none"));
1416                println!("    Version:     {}", session.version);
1417                println!("    Requests:    {}", session.requests.len());
1418                println!("    Created:     {}", format_timestamp(session.creation_date));
1419                if session.last_message_date > 0 {
1420                    println!("    Last Msg:    {}", format_timestamp(session.last_message_date));
1421                }
1422                if let Some(ref title) = session.custom_title {
1423                    println!("    Title:       {}", title);
1424                }
1425                if let Some(ref responder) = session.responder_username {
1426                    println!("    Responder:   {}", responder);
1427                }
1428                
1429                if verbose && !session.requests.is_empty() {
1430                    println!();
1431                    println!("[*] Request Summary:");
1432                    for (i, req) in session.requests.iter().take(5).enumerate() {
1433                        let msg_preview = req.message
1434                            .as_ref()
1435                            .and_then(|m| m.text.as_ref())
1436                            .map(|t| {
1437                                let preview: String = t.chars().take(50).collect();
1438                                if t.len() > 50 { format!("{}...", preview) } else { preview }
1439                            })
1440                            .unwrap_or_else(|| "[no message]".to_string());
1441                        println!("    {}. {}", i + 1, msg_preview);
1442                    }
1443                    if session.requests.len() > 5 {
1444                        println!("    ... and {} more requests", session.requests.len() - 5);
1445                    }
1446                }
1447            }
1448            Err(e) => {
1449                println!("[-] Parse Error:");
1450                println!("    {}", e);
1451                if verbose {
1452                    // Show first few lines of content for debugging
1453                    println!();
1454                    println!("[*] File Preview:");
1455                    for (i, line) in content.lines().take(5).enumerate() {
1456                        let preview: String = line.chars().take(100).collect();
1457                        println!("    {}: {}{}", i + 1, preview, if line.len() > 100 { "..." } else { "" });
1458                    }
1459                }
1460            }
1461        }
1462        
1463        // Show conversion recommendations
1464        println!();
1465        println!("[*] Recommendations:");
1466        match format_info.format {
1467            VsCodeSessionFormat::LegacyJson => {
1468                println!("    - This is legacy JSON format (VS Code < 1.109.0)");
1469                println!("    - Convert to JSONL: chasm recover convert \"{}\" --format jsonl", file);
1470            }
1471            VsCodeSessionFormat::JsonLines => {
1472                println!("    - This is modern JSONL format (VS Code >= 1.109.0)");
1473                println!("    - Convert to JSON: chasm recover convert \"{}\" --format json", file);
1474            }
1475        }
1476        println!("    - Export to Markdown: chasm recover convert \"{}\" --format md", file);
1477    }
1478
1479    Ok(())
1480}
1481
1482// ============================================================================
1483// Upgrade Command - Upgrade session files to current provider format
1484// ============================================================================
1485
1486/// Upgrade session files for multiple projects to the current provider format
1487pub fn recover_upgrade(
1488    project_paths: &[String],
1489    provider: &str,
1490    target_format: &str,
1491    no_backup: bool,
1492    dry_run: bool,
1493) -> Result<()> {
1494    use crate::workspace::get_workspace_by_path;
1495
1496    println!();
1497    println!("{} Session Format Upgrade", "=".repeat(60).dimmed());
1498    println!("{}", "=".repeat(60).dimmed());
1499    println!();
1500    println!("  Provider:      {}", if provider == "auto" { "auto-detect".cyan() } else { provider.cyan() });
1501    println!("  Target format: {}", target_format.cyan());
1502    println!("  Backup:        {}", if no_backup { "disabled".yellow() } else { "enabled".green() });
1503    println!("  Mode:          {}", if dry_run { "DRY RUN".yellow().bold() } else { "LIVE".green().bold() });
1504    println!();
1505    println!("{}", "=".repeat(60).dimmed());
1506
1507    let mut total_upgraded = 0;
1508    let mut total_skipped = 0;
1509    let mut total_errors = 0;
1510    let mut total_projects = 0;
1511
1512    for project_path in project_paths {
1513        total_projects += 1;
1514        let project_name = Path::new(project_path)
1515            .file_name()
1516            .map(|n| n.to_string_lossy().to_string())
1517            .unwrap_or_else(|| "unknown".to_string());
1518
1519        println!();
1520        println!("  {} {}", "→".blue().bold(), project_name.bold());
1521
1522        // Get workspace for this project
1523        let workspace = match get_workspace_by_path(project_path) {
1524            Ok(Some(ws)) => ws,
1525            Ok(None) => {
1526                println!("    {} Workspace not found", "⚠".yellow());
1527                continue;
1528            }
1529            Err(e) => {
1530                println!("    {} Error: {}", "✗".red(), e);
1531                total_errors += 1;
1532                continue;
1533            }
1534        };
1535
1536        if !workspace.has_chat_sessions {
1537            println!("    {} No chat sessions", "○".dimmed());
1538            continue;
1539        }
1540
1541        // Process each session file
1542        let sessions_path = Path::new(&workspace.chat_sessions_path);
1543        let entries = match std::fs::read_dir(sessions_path) {
1544            Ok(e) => e,
1545            Err(e) => {
1546                println!("    {} Cannot read sessions: {}", "✗".red(), e);
1547                total_errors += 1;
1548                continue;
1549            }
1550        };
1551
1552        for entry in entries {
1553            let entry = match entry {
1554                Ok(e) => e,
1555                Err(_) => continue,
1556            };
1557
1558            let path = entry.path();
1559            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1560            
1561            // Only process session files
1562            if ext != "json" && ext != "jsonl" {
1563                continue;
1564            }
1565
1566            let file_name = path.file_name()
1567                .map(|n| n.to_string_lossy().to_string())
1568                .unwrap_or_default();
1569
1570            // Read and detect current format
1571            let content = match std::fs::read_to_string(&path) {
1572                Ok(c) => c,
1573                Err(e) => {
1574                    println!("    {} {} - read error: {}", "✗".red(), file_name, e);
1575                    total_errors += 1;
1576                    continue;
1577                }
1578            };
1579
1580            let format_info = detect_session_format(&content);
1581            
1582            // Determine if upgrade is needed
1583            let needs_upgrade = match target_format {
1584                "jsonl" => matches!(format_info.format, VsCodeSessionFormat::LegacyJson),
1585                "json" => matches!(format_info.format, VsCodeSessionFormat::JsonLines),
1586                _ => false,
1587            };
1588
1589            if !needs_upgrade {
1590                println!("    {} {} - already {}", "○".dimmed(), file_name, target_format);
1591                total_skipped += 1;
1592                continue;
1593            }
1594
1595            // Parse the session
1596            let session = match parse_session_auto(&content) {
1597                Ok((s, _)) => s,
1598                Err(e) => {
1599                    println!("    {} {} - parse error: {}", "✗".red(), file_name, e);
1600                    total_errors += 1;
1601                    continue;
1602                }
1603            };
1604
1605            // Convert to target format
1606            let output_content = match target_format {
1607                "jsonl" => match convert_to_jsonl(&session) {
1608                    Ok(c) => c,
1609                    Err(e) => {
1610                        println!("    {} {} - conversion error: {}", "✗".red(), file_name, e);
1611                        total_errors += 1;
1612                        continue;
1613                    }
1614                },
1615                "json" => match serde_json::to_string_pretty(&session) {
1616                    Ok(c) => c,
1617                    Err(e) => {
1618                        println!("    {} {} - serialization error: {}", "✗".red(), file_name, e);
1619                        total_errors += 1;
1620                        continue;
1621                    }
1622                },
1623                _ => {
1624                    println!("    {} {} - unsupported target format: {}", "✗".red(), file_name, target_format);
1625                    total_errors += 1;
1626                    continue;
1627                }
1628            };
1629
1630            if dry_run {
1631                println!("    {} {} - would upgrade ({} → {})", "◉".cyan(), file_name, ext, target_format);
1632                total_upgraded += 1;
1633                continue;
1634            }
1635
1636            // Create backup if requested
1637            if !no_backup {
1638                let backup_path = path.with_extension(format!("{}.backup", ext));
1639                if let Err(e) = std::fs::copy(&path, &backup_path) {
1640                    println!("    {} {} - backup failed: {}", "✗".red(), file_name, e);
1641                    total_errors += 1;
1642                    continue;
1643                }
1644            }
1645
1646            // Determine output file path (change extension if needed)
1647            let output_path = if ext != target_format {
1648                path.with_extension(target_format)
1649            } else {
1650                path.clone()
1651            };
1652
1653            // Write upgraded content
1654            if let Err(e) = std::fs::write(&output_path, &output_content) {
1655                println!("    {} {} - write error: {}", "✗".red(), file_name, e);
1656                total_errors += 1;
1657                continue;
1658            }
1659
1660            // Remove old file if extension changed
1661            if ext != target_format && path != output_path {
1662                let _ = std::fs::remove_file(&path);
1663            }
1664
1665            println!("    {} {} → .{}", "✓".green(), file_name, target_format);
1666            total_upgraded += 1;
1667        }
1668    }
1669
1670    // Summary
1671    println!();
1672    println!("{}", "=".repeat(60).dimmed());
1673    println!();
1674    if dry_run {
1675        println!(
1676            "{} Would upgrade {} session(s), skip {} (already {}), {} error(s) across {} project(s)",
1677            "[DRY RUN]".yellow().bold(),
1678            total_upgraded,
1679            total_skipped,
1680            target_format,
1681            total_errors,
1682            total_projects
1683        );
1684    } else {
1685        println!(
1686            "{} Upgraded {} session(s), skipped {} (already {}), {} error(s) across {} project(s)",
1687            "[DONE]".green().bold(),
1688            total_upgraded,
1689            total_skipped,
1690            target_format,
1691            total_errors,
1692            total_projects
1693        );
1694    }
1695
1696    Ok(())
1697}