Skip to main content

chasm/commands/
export_import.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Export and import commands
4
5use anyhow::{Context, Result};
6use colored::*;
7use std::path::Path;
8
9use crate::models::Workspace;
10use crate::workspace::{get_workspace_by_hash, get_workspace_by_path};
11
12/// Check if a path has a session file extension (.json or .jsonl)
13fn is_session_file(path: &Path) -> bool {
14    path.extension()
15        .map(|e| e == "json" || e == "jsonl")
16        .unwrap_or(false)
17}
18
19/// Export chat sessions from a workspace
20pub fn export_sessions(destination: &str, hash: Option<&str>, path: Option<&str>) -> Result<()> {
21    let workspace = if let Some(h) = hash {
22        get_workspace_by_hash(h)?.context(format!("Workspace not found with hash: {}", h))?
23    } else if let Some(p) = path {
24        get_workspace_by_path(p)?.context(format!("Workspace not found for path: {}", p))?
25    } else {
26        anyhow::bail!("Must specify either --hash or --path");
27    };
28
29    if !workspace.has_chat_sessions {
30        println!("No chat sessions to export.");
31        return Ok(());
32    }
33
34    // Create destination directory
35    let dest_path = Path::new(destination);
36    std::fs::create_dir_all(dest_path)?;
37
38    // Copy all session files
39    let mut exported_count = 0;
40    for entry in std::fs::read_dir(&workspace.chat_sessions_path)? {
41        let entry = entry?;
42        let src_path = entry.path();
43
44        if is_session_file(&src_path) {
45            let dest_file = dest_path.join(entry.file_name());
46            std::fs::copy(&src_path, &dest_file)?;
47            exported_count += 1;
48        }
49    }
50
51    println!(
52        "{} Exported {} chat session(s) to {}",
53        "[OK]".green(),
54        exported_count,
55        destination
56    );
57
58    Ok(())
59}
60
61/// Export chat sessions from multiple project paths (batch operation)
62pub fn export_batch(destination: &str, project_paths: &[String]) -> Result<()> {
63    let dest_base = Path::new(destination);
64    std::fs::create_dir_all(dest_base)?;
65
66    let mut total_exported = 0;
67    let mut total_projects = 0;
68    let mut projects_with_sessions = 0;
69
70    println!(
71        "\n{} Batch Exporting Sessions",
72        "=".repeat(60).dimmed()
73    );
74    println!("{}", "=".repeat(60).dimmed());
75
76    for project_path in project_paths {
77        total_projects += 1;
78        let project_name = Path::new(project_path)
79            .file_name()
80            .map(|n| n.to_string_lossy().to_string())
81            .unwrap_or_else(|| "unknown".to_string());
82
83        print!("  {} {} ... ", "→".blue(), project_name);
84
85        match get_workspace_by_path(project_path) {
86            Ok(Some(workspace)) => {
87                if !workspace.has_chat_sessions {
88                    println!("{}", "no sessions".dimmed());
89                    continue;
90                }
91
92                // Create project-specific subdirectory
93                let project_dest = dest_base.join(&project_name);
94                std::fs::create_dir_all(&project_dest)?;
95
96                // Copy all session files
97                let mut exported_count = 0;
98                for entry in std::fs::read_dir(&workspace.chat_sessions_path)? {
99                    let entry = entry?;
100                    let src_path = entry.path();
101
102                    if is_session_file(&src_path) {
103                        let dest_file = project_dest.join(entry.file_name());
104                        std::fs::copy(&src_path, &dest_file)?;
105                        exported_count += 1;
106                    }
107                }
108
109                if exported_count > 0 {
110                    projects_with_sessions += 1;
111                    total_exported += exported_count;
112                    println!("{} {} session(s)", "[OK]".green(), exported_count);
113                } else {
114                    println!("{}", "no sessions".dimmed());
115                }
116            }
117            Ok(None) => {
118                println!("{}", "workspace not found".yellow());
119            }
120            Err(e) => {
121                println!("{} {}", "[ERR]".red(), e);
122            }
123        }
124    }
125
126    println!("{}", "=".repeat(60).dimmed());
127    println!(
128        "\n{} Exported {} session(s) from {}/{} project(s) to {}",
129        "[DONE]".green().bold(),
130        total_exported,
131        projects_with_sessions,
132        total_projects,
133        destination
134    );
135
136    Ok(())
137}
138
139/// Import chat sessions into a workspace
140pub fn import_sessions(
141    source: &str,
142    hash: Option<&str>,
143    path: Option<&str>,
144    force: bool,
145) -> Result<()> {
146    let src_path = Path::new(source);
147    if !src_path.exists() {
148        anyhow::bail!("Source path not found: {}", source);
149    }
150
151    let workspace = if let Some(h) = hash {
152        get_workspace_by_hash(h)?.context(format!("Workspace not found with hash: {}", h))?
153    } else if let Some(p) = path {
154        get_workspace_by_path(p)?.context(format!("Workspace not found for path: {}", p))?
155    } else {
156        anyhow::bail!("Must specify either --hash or --path");
157    };
158
159    // Create chatSessions directory if it doesn't exist
160    std::fs::create_dir_all(&workspace.chat_sessions_path)?;
161
162    // Import all JSON files
163    let mut imported_count = 0;
164    let mut skipped_count = 0;
165
166    for entry in std::fs::read_dir(src_path)? {
167        let entry = entry?;
168        let src_file = entry.path();
169
170        if is_session_file(&src_file) {
171            let dest_file = workspace.chat_sessions_path.join(entry.file_name());
172
173            if dest_file.exists() && !force {
174                skipped_count += 1;
175            } else {
176                std::fs::copy(&src_file, &dest_file)?;
177                imported_count += 1;
178            }
179        }
180    }
181
182    println!(
183        "{} Imported {} chat session(s)",
184        "[OK]".green(),
185        imported_count
186    );
187    if skipped_count > 0 {
188        println!(
189            "{} Skipped {} existing session(s). Use --force to overwrite.",
190            "[!]".yellow(),
191            skipped_count
192        );
193    }
194
195    Ok(())
196}
197
198/// Move chat sessions from one workspace to another (by path lookup)
199#[allow(dead_code)]
200pub fn move_sessions(source_hash: &str, target_path: &str) -> Result<()> {
201    let source_ws = get_workspace_by_hash(source_hash)?
202        .context(format!("Source workspace not found: {}", source_hash))?;
203
204    let target_ws = get_workspace_by_path(target_path)?.context(format!(
205        "Target workspace not found for path: {}",
206        target_path
207    ))?;
208
209    // Prevent moving to self
210    if source_ws.workspace_path == target_ws.workspace_path {
211        println!(
212            "{} Source and target are the same workspace",
213            "[!]".yellow()
214        );
215        return Ok(());
216    }
217
218    move_sessions_internal(&source_ws, &target_ws, target_path)
219}
220
221/// Move chat sessions from one workspace to another (with explicit target workspace)
222fn move_sessions_to_workspace(source_ws: &Workspace, target_ws: &Workspace) -> Result<()> {
223    let target_path: &str = target_ws
224        .project_path
225        .as_deref()
226        .unwrap_or("target workspace");
227    move_sessions_internal(source_ws, target_ws, target_path)
228}
229
230/// Internal function to move sessions between workspaces
231fn move_sessions_internal(
232    source_ws: &Workspace,
233    target_ws: &Workspace,
234    display_path: &str,
235) -> Result<()> {
236    if !source_ws.has_chat_sessions {
237        println!("No chat sessions to move.");
238        return Ok(());
239    }
240
241    // Prevent moving to self
242    if source_ws.workspace_path == target_ws.workspace_path {
243        println!(
244            "{} Source and target are the same workspace",
245            "[!]".yellow()
246        );
247        return Ok(());
248    }
249
250    // Create chatSessions directory in target if needed
251    std::fs::create_dir_all(&target_ws.chat_sessions_path)?;
252
253    // Move all session files
254    let mut moved_count = 0;
255    let mut skipped_count = 0;
256    for entry in std::fs::read_dir(&source_ws.chat_sessions_path)? {
257        let entry = entry?;
258        let src_file = entry.path();
259
260        if is_session_file(&src_file) {
261            let dest_file = target_ws.chat_sessions_path.join(entry.file_name());
262
263            // Skip if file already exists with same name (don't overwrite)
264            if dest_file.exists() {
265                skipped_count += 1;
266                continue;
267            }
268
269            std::fs::rename(&src_file, &dest_file)?;
270            moved_count += 1;
271        }
272    }
273
274    println!(
275        "{} Moved {} chat session(s) to {}",
276        "[OK]".green(),
277        moved_count,
278        display_path
279    );
280
281    if skipped_count > 0 {
282        println!(
283            "{} Skipped {} session(s) that already exist in target",
284            "[!]".yellow(),
285            skipped_count
286        );
287    }
288
289    Ok(())
290}
291
292/// Export specific sessions by ID
293pub fn export_specific_sessions(
294    destination: &str,
295    session_ids: &[String],
296    project_path: Option<&str>,
297) -> Result<()> {
298    use crate::workspace::{discover_workspaces, get_chat_sessions_from_workspace, normalize_path};
299
300    let dest_path = Path::new(destination);
301    std::fs::create_dir_all(dest_path)?;
302
303    let workspaces = discover_workspaces()?;
304
305    // Filter workspaces by project path if provided
306    let filtered: Vec<_> = if let Some(path) = project_path {
307        let normalized = normalize_path(path);
308        workspaces
309            .into_iter()
310            .filter(|ws| {
311                ws.project_path
312                    .as_ref()
313                    .map(|p| normalize_path(p) == normalized)
314                    .unwrap_or(false)
315            })
316            .collect()
317    } else {
318        workspaces
319    };
320
321    let normalized_ids: Vec<String> = session_ids
322        .iter()
323        .flat_map(|s| s.split(',').map(|p| p.trim().to_lowercase()))
324        .filter(|s| !s.is_empty())
325        .collect();
326
327    let mut exported_count = 0;
328    let mut found_ids = Vec::new();
329
330    for ws in filtered {
331        if !ws.has_chat_sessions {
332            continue;
333        }
334
335        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
336
337        for session in sessions {
338            let session_id = session.session.session_id.clone().unwrap_or_else(|| {
339                session
340                    .path
341                    .file_stem()
342                    .map(|s| s.to_string_lossy().to_string())
343                    .unwrap_or_default()
344            });
345
346            let matches = normalized_ids.iter().any(|req_id| {
347                session_id.to_lowercase().contains(req_id)
348                    || req_id.contains(&session_id.to_lowercase())
349            });
350
351            if matches && !found_ids.contains(&session_id) {
352                let filename = session
353                    .path
354                    .file_name()
355                    .map(|n| n.to_string_lossy().to_string())
356                    .unwrap_or_default();
357
358                let dest_file = dest_path.join(&filename);
359                std::fs::copy(&session.path, &dest_file)?;
360                exported_count += 1;
361                found_ids.push(session_id);
362                println!(
363                    "   {} Exported: {}",
364                    "[OK]".green(),
365                    session.session.title()
366                );
367            }
368        }
369    }
370
371    println!(
372        "\n{} Exported {} session(s) to {}",
373        "[OK]".green().bold(),
374        exported_count,
375        destination
376    );
377
378    Ok(())
379}
380
381/// Import specific session files
382pub fn import_specific_sessions(
383    session_files: &[String],
384    target_path: Option<&str>,
385    force: bool,
386) -> Result<()> {
387    let target_ws = if let Some(path) = target_path {
388        get_workspace_by_path(path)?.context(format!("Workspace not found for path: {}", path))?
389    } else {
390        let cwd = std::env::current_dir()?;
391        get_workspace_by_path(cwd.to_str().unwrap_or(""))?
392            .context("Current directory is not a VS Code workspace")?
393    };
394
395    std::fs::create_dir_all(&target_ws.chat_sessions_path)?;
396
397    let mut imported_count = 0;
398    let mut skipped_count = 0;
399
400    for file_path in session_files {
401        let src_path = Path::new(file_path);
402
403        if !src_path.exists() {
404            println!("{} File not found: {}", "[!]".yellow(), file_path);
405            continue;
406        }
407
408        let filename = src_path
409            .file_name()
410            .map(|n| n.to_string_lossy().to_string())
411            .unwrap_or_default();
412
413        let dest_file = target_ws.chat_sessions_path.join(&filename);
414
415        if dest_file.exists() && !force {
416            println!("   {} Skipping (exists): {}", "[!]".yellow(), filename);
417            skipped_count += 1;
418        } else {
419            std::fs::copy(src_path, &dest_file)?;
420            imported_count += 1;
421            println!("   {} Imported: {}", "[OK]".green(), filename);
422        }
423    }
424
425    println!(
426        "\n{} Imported {} session(s)",
427        "[OK]".green().bold(),
428        imported_count
429    );
430    if skipped_count > 0 {
431        println!(
432            "{} Skipped {} existing. Use --force to overwrite.",
433            "[!]".yellow(),
434            skipped_count
435        );
436    }
437
438    Ok(())
439}
440
441/// Move all sessions from one workspace to another (by hash)
442pub fn move_workspace(source_hash: &str, target: &str) -> Result<()> {
443    // Get source workspace
444    let source_ws = get_workspace_by_hash(source_hash)?
445        .context(format!("Source workspace not found: {}", source_hash))?;
446
447    // Try target as hash first, then as path
448    // This prevents ambiguity when multiple workspaces share the same path
449    let target_ws = get_workspace_by_hash(target)?
450        .or_else(|| get_workspace_by_path(target).ok().flatten())
451        .context(format!("Target workspace not found: {}", target))?;
452
453    move_sessions_to_workspace(&source_ws, &target_ws)
454}
455
456/// Move specific sessions by ID
457pub fn move_specific_sessions(session_ids: &[String], target_path: &str) -> Result<()> {
458    use crate::workspace::{discover_workspaces, get_chat_sessions_from_workspace, normalize_path};
459
460    let target_ws = get_workspace_by_path(target_path)?
461        .context(format!("Target workspace not found: {}", target_path))?;
462
463    std::fs::create_dir_all(&target_ws.chat_sessions_path)?;
464
465    let workspaces = discover_workspaces()?;
466
467    let normalized_ids: Vec<String> = session_ids
468        .iter()
469        .flat_map(|s| s.split(',').map(|p| p.trim().to_lowercase()))
470        .filter(|s| !s.is_empty())
471        .collect();
472
473    let mut moved_count = 0;
474    let mut found_ids = Vec::new();
475
476    for ws in workspaces {
477        if !ws.has_chat_sessions {
478            continue;
479        }
480
481        // Skip target workspace
482        if ws
483            .project_path
484            .as_ref()
485            .map(|p| normalize_path(p) == normalize_path(target_path))
486            .unwrap_or(false)
487        {
488            continue;
489        }
490
491        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
492
493        for session in sessions {
494            let session_id = session.session.session_id.clone().unwrap_or_else(|| {
495                session
496                    .path
497                    .file_stem()
498                    .map(|s| s.to_string_lossy().to_string())
499                    .unwrap_or_default()
500            });
501
502            let matches = normalized_ids.iter().any(|req_id| {
503                session_id.to_lowercase().contains(req_id)
504                    || req_id.contains(&session_id.to_lowercase())
505            });
506
507            if matches && !found_ids.contains(&session_id) {
508                let filename = session
509                    .path
510                    .file_name()
511                    .map(|n| n.to_string_lossy().to_string())
512                    .unwrap_or_default();
513
514                let dest_file = target_ws.chat_sessions_path.join(&filename);
515                std::fs::rename(&session.path, &dest_file)?;
516                moved_count += 1;
517                found_ids.push(session_id);
518                println!("   {} Moved: {}", "[OK]".green(), session.session.title());
519            }
520        }
521    }
522
523    println!(
524        "\n{} Moved {} session(s) to {}",
525        "[OK]".green().bold(),
526        moved_count,
527        target_path
528    );
529
530    Ok(())
531}
532
533/// Move sessions from one path to another
534pub fn move_by_path(source_path: &str, target_path: &str) -> Result<()> {
535    let source_ws = get_workspace_by_path(source_path)?
536        .context(format!("Source workspace not found: {}", source_path))?;
537
538    let target_ws = get_workspace_by_path(target_path)?
539        .context(format!("Target workspace not found: {}", target_path))?;
540
541    if !source_ws.has_chat_sessions {
542        println!("No chat sessions to move.");
543        return Ok(());
544    }
545
546    std::fs::create_dir_all(&target_ws.chat_sessions_path)?;
547
548    let mut moved_count = 0;
549    for entry in std::fs::read_dir(&source_ws.chat_sessions_path)? {
550        let entry = entry?;
551        let src_file = entry.path();
552
553        if is_session_file(&src_file) {
554            let dest_file = target_ws.chat_sessions_path.join(entry.file_name());
555            std::fs::rename(&src_file, &dest_file)?;
556            moved_count += 1;
557        }
558    }
559
560    println!(
561        "{} Moved {} chat session(s) from {} to {}",
562        "[OK]".green(),
563        moved_count,
564        source_path,
565        target_path
566    );
567
568    Ok(())
569}