chasm_cli/commands/
export_import.rs

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