1use 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
19fn 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
40fn 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
61fn 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
82pub 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 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 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 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 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
174pub fn recover_from_recording(server: &str, session_id: Option<&str>, output: Option<&str>) -> Result<()> {
176 println!("[*] Connecting to recording server: {}", server);
177
178 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 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 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 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
248pub 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 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 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 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 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 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
346fn 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 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 if let Ok(text) = String::from_utf8(value.clone()) {
372 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
388pub 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 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
457fn attempt_json_repair(line: &str) -> String {
459 let mut fixed = line.to_string();
460
461 fixed = fixed.replace(",}", "}").replace(",]", "]");
466
467 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
483pub 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 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 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
558pub 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 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) => {} Err(e) => println!(" [!] Failed to convert skeleton: {}", e),
594 }
595 }
596 continue; }
598 }
599
600 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 let has_concatenated = content.contains("}{\"kind\":");
607
608 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 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 if !dry_run {
690 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 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 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 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 }
786 }
787 }
788
789 fs::write(path, output)?;
790 Ok(())
791}
792
793pub 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 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 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 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 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 #[cfg(windows)]
854 {
855 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
889pub 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 let content = fs::read_to_string(input_path)
910 .with_context(|| format!("Failed to read input file: {}", input))?;
911
912 let format_info = detect_session_format(&content);
914
915 let output_format = if let Some(fmt) = format {
917 fmt.to_lowercase()
918 } else if let Some(out) = output {
919 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 match format_info.format {
931 VsCodeSessionFormat::JsonLines => "json".to_string(),
932 VsCodeSessionFormat::LegacyJson => "jsonl".to_string(),
933 }
934 };
935
936 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 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 let output_content = match output_format.as_str() {
971 "json" => {
972 serde_json::to_string_pretty(&session)
974 .with_context(|| "Failed to serialize to JSON")?
975 }
976 "jsonl" => {
977 convert_to_jsonl(&session)
979 .with_context(|| "Failed to serialize to JSONL")?
980 }
981 "md" | "markdown" => {
982 convert_to_markdown(&session)
984 }
985 _ => anyhow::bail!("Unknown output format: {}. Use json, jsonl, or md", output_format),
986 };
987
988 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
998fn 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 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 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 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 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
1048fn 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 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 if let Some(ref response) = request.response {
1077 md.push_str("### Assistant\n\n");
1078 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
1092pub 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 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 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 let normalized_path = canonical_path.display().to_string()
1126 .replace('\\', "/")
1127 .to_lowercase();
1128
1129 println!("[*] Searching for workspace matching: {}", normalized_path);
1131 println!();
1132
1133 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 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 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 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 for (provider, workspace_dir) in &matched_workspaces {
1178 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 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 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 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 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 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_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
1291fn 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
1310fn 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 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
1330pub 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 let content = fs::read_to_string(file_path)
1345 .with_context(|| format!("Failed to read file: {}", file))?;
1346
1347 let format_info = detect_session_format(&content);
1349
1350 let parse_result = parse_session_auto(&content);
1352
1353 if output_json {
1354 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 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 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 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
1482pub 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 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 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 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 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 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 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 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 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 let output_path = if ext != target_format {
1648 path.with_extension(target_format)
1649 } else {
1650 path.clone()
1651 };
1652
1653 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 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 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}