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