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