chasm_cli/commands/
history.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! History commands (show, fetch, merge)
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use colored::*;
8use std::path::Path;
9use uuid::Uuid;
10
11use crate::models::{ChatRequest, ChatSession};
12use crate::storage::{
13    add_session_to_index, backup_workspace_sessions, get_workspace_storage_db, is_vscode_running,
14    register_all_sessions_from_directory,
15};
16use crate::workspace::{
17    discover_workspaces, find_all_workspaces_for_project, find_workspace_by_path,
18    get_chat_sessions_from_workspace, normalize_path,
19};
20
21/// Show all chat sessions across workspaces for current project
22pub fn history_show(project_path: Option<&str>) -> Result<()> {
23    // Resolve the project path, handling "." specially
24    let project_path = match project_path {
25        Some(".") | None => std::env::current_dir()
26            .map(|p| p.to_string_lossy().to_string())
27            .unwrap_or_else(|_| ".".to_string()),
28        Some(p) => {
29            // If it's a relative path, try to resolve it
30            let path = Path::new(p);
31            if path.is_relative() {
32                std::env::current_dir()
33                    .map(|cwd| cwd.join(path).to_string_lossy().to_string())
34                    .unwrap_or_else(|_| p.to_string())
35            } else {
36                p.to_string()
37            }
38        }
39    };
40
41    let project_name = Path::new(&project_path)
42        .file_name()
43        .map(|n| n.to_string_lossy().to_string())
44        .unwrap_or_else(|| project_path.clone());
45
46    println!(
47        "\n{} Chat History for: {}",
48        "[*]".blue(),
49        project_name.cyan()
50    );
51    println!("{}", "=".repeat(70));
52
53    // Find all workspaces for this project
54    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
55
56    if all_workspaces.is_empty() {
57        println!(
58            "\n{} No workspaces found matching '{}'",
59            "[!]".yellow(),
60            project_name
61        );
62        return Ok(());
63    }
64
65    // Find current workspace
66    let current_ws = find_workspace_by_path(&project_path)?;
67    let current_ws_id = current_ws.as_ref().map(|(id, _, _)| id.clone());
68
69    let mut total_sessions = 0;
70    let mut total_requests = 0;
71
72    for (ws_id, ws_dir, folder_path, last_mod) in &all_workspaces {
73        let is_current = current_ws_id.as_ref() == Some(ws_id);
74        let marker = if is_current { "-> " } else { "   " };
75        let label = if is_current {
76            " (current)".green().to_string()
77        } else {
78            "".to_string()
79        };
80
81        let mod_date: DateTime<Utc> = (*last_mod).into();
82        let mod_str = mod_date.format("%Y-%m-%d %H:%M").to_string();
83
84        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
85
86        println!(
87            "\n{}Workspace: {}...{}",
88            marker.cyan(),
89            &ws_id[..16.min(ws_id.len())],
90            label
91        );
92        println!("   Path: {}", folder_path.as_deref().unwrap_or("(none)"));
93        println!("   Modified: {}", mod_str);
94        println!("   Sessions: {}", sessions.len());
95
96        if !sessions.is_empty() {
97            for session_with_path in &sessions {
98                let session = &session_with_path.session;
99                let title = session.title();
100                let request_count = session.request_count();
101
102                // Get timestamp range
103                let date_range = if let Some((first, last)) = session.timestamp_range() {
104                    let first_date = timestamp_to_date(first);
105                    let last_date = timestamp_to_date(last);
106                    if first_date == last_date {
107                        first_date
108                    } else {
109                        format!("{} -> {}", first_date, last_date)
110                    }
111                } else {
112                    "empty".to_string()
113                };
114
115                println!(
116                    "     {} {:<40} ({:3} msgs) [{}]",
117                    "[-]".blue(),
118                    truncate(&title, 40),
119                    request_count,
120                    date_range
121                );
122
123                total_requests += request_count;
124                total_sessions += 1;
125            }
126        }
127    }
128
129    println!("\n{}", "=".repeat(70));
130    println!(
131        "Total: {} sessions, {} messages across {} workspace(s)",
132        total_sessions,
133        total_requests,
134        all_workspaces.len()
135    );
136
137    Ok(())
138}
139
140/// Fetch chat sessions from other workspaces into current workspace
141pub fn history_fetch(project_path: Option<&str>, force: bool, no_register: bool) -> Result<()> {
142    let project_path = match project_path {
143        Some(p) => {
144            let path = Path::new(p);
145            path.canonicalize()
146                .map(|p| p.to_string_lossy().to_string())
147                .unwrap_or_else(|_| p.to_string())
148        }
149        None => std::env::current_dir()
150            .map(|p| p.to_string_lossy().to_string())
151            .unwrap_or_else(|_| ".".to_string()),
152    };
153
154    let project_name = Path::new(&project_path)
155        .file_name()
156        .map(|n| n.to_string_lossy().to_string())
157        .unwrap_or_else(|| project_path.clone());
158
159    println!(
160        "\n{} Fetching Chat History for: {}",
161        "[<]".blue(),
162        project_name.cyan()
163    );
164    println!("{}", "=".repeat(70));
165
166    // Find current workspace
167    let current_ws = find_workspace_by_path(&project_path)?
168        .context("Current workspace not found. Make sure the project is opened in VS Code")?;
169    let (current_ws_id, current_ws_dir, _) = current_ws;
170
171    // Find all workspaces for this project
172    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
173    let historical_workspaces: Vec<_> = all_workspaces
174        .into_iter()
175        .filter(|(id, _, _, _)| *id != current_ws_id)
176        .collect();
177
178    if historical_workspaces.is_empty() {
179        println!(
180            "{} No historical workspaces found for '{}'",
181            "[!]".yellow(),
182            project_name
183        );
184        println!("   Only the current workspace exists.");
185        return Ok(());
186    }
187
188    println!(
189        "Found {} historical workspace(s)\n",
190        historical_workspaces.len()
191    );
192
193    // Create chatSessions directory
194    let chat_sessions_dir = current_ws_dir.join("chatSessions");
195    std::fs::create_dir_all(&chat_sessions_dir)?;
196
197    let mut fetched_count = 0;
198    let mut skipped_count = 0;
199
200    for (_, ws_dir, _, _) in &historical_workspaces {
201        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
202
203        for session_with_path in sessions {
204            // Get session ID from filename if not in data
205            let session_id = session_with_path
206                .session
207                .session_id
208                .clone()
209                .unwrap_or_else(|| {
210                    session_with_path
211                        .path
212                        .file_stem()
213                        .map(|s| s.to_string_lossy().to_string())
214                        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
215                });
216            let dest_file = chat_sessions_dir.join(format!("{}.json", session_id));
217
218            if dest_file.exists() && !force {
219                println!(
220                    "   {} Skipped (exists): {}...",
221                    "[>]".yellow(),
222                    &session_id[..16.min(session_id.len())]
223                );
224                skipped_count += 1;
225            } else {
226                std::fs::copy(&session_with_path.path, &dest_file)?;
227                let title = session_with_path.session.title();
228                println!(
229                    "   {} Fetched: {} ({}...)",
230                    "[OK]".green(),
231                    truncate(&title, 40),
232                    &session_id[..16.min(session_id.len())]
233                );
234                fetched_count += 1;
235            }
236        }
237    }
238
239    println!("\n{}", "=".repeat(70));
240    println!("Fetched: {} sessions", fetched_count);
241    if skipped_count > 0 {
242        println!("Skipped: {} (use --force to overwrite)", skipped_count);
243    }
244
245    // Register sessions in VS Code index
246    if fetched_count > 0 && !no_register {
247        println!(
248            "\n{} Registering sessions in VS Code index...",
249            "[#]".blue()
250        );
251
252        if is_vscode_running() && !force {
253            println!(
254                "{} VS Code is running. Sessions may not appear until restart.",
255                "[!]".yellow()
256            );
257            println!("   Run 'csm history fetch --force' after closing VS Code to register.");
258        } else {
259            let registered =
260                register_all_sessions_from_directory(&current_ws_id, &chat_sessions_dir, true)?;
261            println!(
262                "{} Registered {} sessions in index",
263                "[OK]".green(),
264                registered
265            );
266        }
267    }
268
269    println!(
270        "\n{} Reload VS Code (Ctrl+R) and check Chat history dropdown",
271        "[i]".cyan()
272    );
273
274    Ok(())
275}
276
277/// Merge all chat sessions into a single unified chat ordered by timestamp
278pub fn history_merge(
279    project_path: Option<&str>,
280    title: Option<&str>,
281    force: bool,
282    no_backup: bool,
283) -> Result<()> {
284    let project_path = match project_path {
285        Some(p) => {
286            let path = Path::new(p);
287            path.canonicalize()
288                .map(|p| p.to_string_lossy().to_string())
289                .unwrap_or_else(|_| p.to_string())
290        }
291        None => std::env::current_dir()
292            .map(|p| p.to_string_lossy().to_string())
293            .unwrap_or_else(|_| ".".to_string()),
294    };
295
296    let project_name = Path::new(&project_path)
297        .file_name()
298        .map(|n| n.to_string_lossy().to_string())
299        .unwrap_or_else(|| project_path.clone());
300
301    println!(
302        "\n{} Merging Chat History for: {}",
303        "[M]".blue(),
304        project_name.cyan()
305    );
306    println!("{}", "=".repeat(70));
307
308    // Find current workspace
309    let current_ws = find_workspace_by_path(&project_path)?
310        .context("Current workspace not found. Make sure the project is opened in VS Code")?;
311    let (current_ws_id, current_ws_dir, _) = current_ws;
312
313    // Find all workspaces for this project
314    let all_workspaces = find_all_workspaces_for_project(&project_name)?;
315
316    // Collect ALL sessions from ALL workspaces
317    println!(
318        "\n{} Collecting sessions from {} workspace(s)...",
319        "[D]".blue(),
320        all_workspaces.len()
321    );
322
323    let mut all_sessions = Vec::new();
324    for (ws_id, ws_dir, _, _) in &all_workspaces {
325        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
326        if !sessions.is_empty() {
327            println!(
328                "   {} {}... ({} sessions)",
329                "[d]".blue(),
330                &ws_id[..16.min(ws_id.len())],
331                sessions.len()
332            );
333            all_sessions.extend(sessions);
334        }
335    }
336
337    if all_sessions.is_empty() {
338        println!("\n{} No chat sessions found in any workspace", "[X]".red());
339        return Ok(());
340    }
341
342    println!("\n   Total: {} sessions collected", all_sessions.len());
343
344    // Collect all requests with timestamps
345    println!("\n{} Extracting and sorting messages...", "[*]".blue());
346
347    let mut all_requests: Vec<ChatRequest> = Vec::new();
348    for session_with_path in &all_sessions {
349        let session = &session_with_path.session;
350        let session_title = session.title();
351
352        for req in &session.requests {
353            let mut req = req.clone();
354            // Add source session info
355            req.source_session = Some(session_title.clone());
356            if req.timestamp.is_some() {
357                all_requests.push(req);
358            }
359        }
360    }
361
362    if all_requests.is_empty() {
363        println!("\n{} No messages found in any session", "[X]".red());
364        return Ok(());
365    }
366
367    // Sort by timestamp
368    all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
369
370    // Get timeline info
371    let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
372    let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
373
374    let first_date = timestamp_to_date(first_time);
375    let last_date = timestamp_to_date(last_time);
376    let days_span = if first_time > 0 && last_time > 0 {
377        (last_time - first_time) / (1000 * 60 * 60 * 24)
378    } else {
379        0
380    };
381
382    println!("   Messages: {}", all_requests.len());
383    println!(
384        "   Timeline: {} -> {} ({} days)",
385        first_date, last_date, days_span
386    );
387
388    // Create merged session
389    println!("\n{} Creating merged session...", "[+]".blue());
390
391    let merged_session_id = Uuid::new_v4().to_string();
392    let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
393        format!(
394            "Merged History ({} sessions, {} days)",
395            all_sessions.len(),
396            days_span
397        )
398    });
399
400    let merged_session = ChatSession {
401        version: 3,
402        session_id: Some(merged_session_id.clone()),
403        creation_date: first_time,
404        last_message_date: last_time,
405        is_imported: false,
406        initial_location: "panel".to_string(),
407        custom_title: Some(merged_title.clone()),
408        requester_username: Some("User".to_string()),
409        requester_avatar_icon_uri: None, // Optional - VS Code will use default
410        responder_username: Some("GitHub Copilot".to_string()),
411        responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
412        requests: all_requests.clone(),
413    };
414
415    // Create backup if requested
416    let chat_sessions_dir = current_ws_dir.join("chatSessions");
417
418    if !no_backup {
419        if let Some(backup_dir) = backup_workspace_sessions(&current_ws_dir)? {
420            println!(
421                "   {} Backup: {}",
422                "[B]".blue(),
423                backup_dir.file_name().unwrap().to_string_lossy()
424            );
425        }
426    }
427
428    // Write merged session
429    std::fs::create_dir_all(&chat_sessions_dir)?;
430    let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
431
432    let json = serde_json::to_string_pretty(&merged_session)?;
433    std::fs::write(&merged_file, json)?;
434
435    println!(
436        "   {} File: {}",
437        "[F]".blue(),
438        merged_file.file_name().unwrap().to_string_lossy()
439    );
440
441    // Register in VS Code index
442    println!("\n{} Registering in VS Code index...", "[#]".blue());
443
444    if is_vscode_running() && !force {
445        println!(
446            "{} VS Code is running. Close it and run again, or use --force",
447            "[!]".yellow()
448        );
449    } else {
450        let db_path = get_workspace_storage_db(&current_ws_id)?;
451        add_session_to_index(
452            &db_path,
453            &merged_session_id,
454            &merged_title,
455            last_time,
456            false,
457            "panel",
458            false,
459        )?;
460        println!("   {} Registered in index", "[OK]".green());
461    }
462
463    println!("\n{}", "=".repeat(70));
464    println!("{} MERGE COMPLETE!", "[OK]".green().bold());
465    println!("\n{} Summary:", "[=]".blue());
466    println!("   - Sessions merged: {}", all_sessions.len());
467    println!("   - Total messages: {}", all_requests.len());
468    println!("   - Timeline: {} days", days_span);
469    println!("   - Title: {}", merged_title);
470
471    println!("\n{} Next Steps:", "[i]".cyan());
472    println!("   1. Reload VS Code (Ctrl+R)");
473    println!("   2. Open Chat history dropdown");
474    println!("   3. Select: '{}'", merged_title);
475
476    Ok(())
477}
478
479/// Merge chat sessions from workspaces matching a name pattern
480pub fn merge_by_workspace_name(
481    workspace_name: &str,
482    title: Option<&str>,
483    target_path: Option<&str>,
484    force: bool,
485    no_backup: bool,
486) -> Result<()> {
487    println!(
488        "\n{} Merging Sessions by Workspace Name: {}",
489        "[M]".blue(),
490        workspace_name.cyan()
491    );
492    println!("{}", "=".repeat(70));
493
494    // Find all workspaces matching the pattern
495    let all_workspaces = find_all_workspaces_for_project(workspace_name)?;
496
497    if all_workspaces.is_empty() {
498        println!(
499            "\n{} No workspaces found matching '{}'",
500            "[X]".red(),
501            workspace_name
502        );
503        return Ok(());
504    }
505
506    println!(
507        "\n{} Found {} workspace(s) matching pattern:",
508        "[D]".blue(),
509        all_workspaces.len()
510    );
511    for (ws_id, _, folder_path, _) in &all_workspaces {
512        println!(
513            "   {} {}... -> {}",
514            "[*]".blue(),
515            &ws_id[..16.min(ws_id.len())],
516            folder_path.as_deref().unwrap_or("(unknown)")
517        );
518    }
519
520    // Determine target workspace
521    let target_path = match target_path {
522        Some(p) => {
523            let path = Path::new(p);
524            path.canonicalize()
525                .map(|p| p.to_string_lossy().to_string())
526                .unwrap_or_else(|_| p.to_string())
527        }
528        None => std::env::current_dir()
529            .map(|p| p.to_string_lossy().to_string())
530            .unwrap_or_else(|_| ".".to_string()),
531    };
532
533    let target_ws = find_workspace_by_path(&target_path)?
534        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
535    let (target_ws_id, target_ws_dir, _) = target_ws;
536
537    println!(
538        "\n{} Target workspace: {}...",
539        "[>]".blue(),
540        &target_ws_id[..16.min(target_ws_id.len())]
541    );
542
543    // Collect sessions from all matching workspaces
544    println!("\n{} Collecting sessions...", "[D]".blue());
545
546    let mut all_sessions = Vec::new();
547    for (ws_id, ws_dir, _, _) in &all_workspaces {
548        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
549        if !sessions.is_empty() {
550            println!(
551                "   {} {}... ({} sessions)",
552                "[d]".blue(),
553                &ws_id[..16.min(ws_id.len())],
554                sessions.len()
555            );
556            all_sessions.extend(sessions);
557        }
558    }
559
560    if all_sessions.is_empty() {
561        println!(
562            "\n{} No chat sessions found in matching workspaces",
563            "[X]".red()
564        );
565        return Ok(());
566    }
567
568    // Use the common merge logic
569    merge_sessions_internal(
570        all_sessions,
571        title,
572        &target_ws_id,
573        &target_ws_dir,
574        force,
575        no_backup,
576        &format!("Workspace: {}", workspace_name),
577    )
578}
579
580/// Merge specific chat sessions by their IDs or filenames
581pub fn merge_sessions_by_list(
582    session_ids: &[String],
583    title: Option<&str>,
584    target_path: Option<&str>,
585    force: bool,
586    no_backup: bool,
587) -> Result<()> {
588    println!("\n{} Merging Specific Sessions", "[M]".blue());
589    println!("{}", "=".repeat(70));
590
591    println!(
592        "\n{} Looking for {} session(s):",
593        "[D]".blue(),
594        session_ids.len()
595    );
596    for id in session_ids {
597        println!("   {} {}", "[?]".blue(), id);
598    }
599
600    // Determine target workspace
601    let target_path = match target_path {
602        Some(p) => {
603            let path = Path::new(p);
604            path.canonicalize()
605                .map(|p| p.to_string_lossy().to_string())
606                .unwrap_or_else(|_| p.to_string())
607        }
608        None => std::env::current_dir()
609            .map(|p| p.to_string_lossy().to_string())
610            .unwrap_or_else(|_| ".".to_string()),
611    };
612
613    let target_ws = find_workspace_by_path(&target_path)?
614        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
615    let (target_ws_id, target_ws_dir, _) = target_ws;
616
617    // Find and collect requested sessions from all workspaces
618    println!("\n{} Searching all workspaces...", "[D]".blue());
619
620    let all_workspaces = crate::workspace::discover_workspaces()?;
621    let mut found_sessions = Vec::new();
622    let mut found_ids: Vec<String> = Vec::new();
623
624    // Normalize session IDs for comparison (remove .json extension if present)
625    let normalized_ids: Vec<String> = session_ids
626        .iter()
627        .map(|id| {
628            let id = id.trim();
629            if id.to_lowercase().ends_with(".json") {
630                id[..id.len() - 5].to_string()
631            } else {
632                id.to_string()
633            }
634        })
635        .collect();
636
637    for ws in &all_workspaces {
638        if !ws.has_chat_sessions {
639            continue;
640        }
641
642        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
643
644        for session_with_path in sessions {
645            let session_id = session_with_path
646                .session
647                .session_id
648                .clone()
649                .unwrap_or_else(|| {
650                    session_with_path
651                        .path
652                        .file_stem()
653                        .map(|s| s.to_string_lossy().to_string())
654                        .unwrap_or_default()
655                });
656
657            // Check if this session matches any requested ID
658            let matches = normalized_ids.iter().any(|req_id| {
659                session_id.starts_with(req_id)
660                    || req_id.starts_with(&session_id)
661                    || session_id == *req_id
662            });
663
664            if matches && !found_ids.contains(&session_id) {
665                println!(
666                    "   {} Found: {} in workspace {}...",
667                    "[OK]".green(),
668                    truncate(&session_with_path.session.title(), 40),
669                    &ws.hash[..16.min(ws.hash.len())]
670                );
671                found_ids.push(session_id);
672                found_sessions.push(session_with_path);
673            }
674        }
675    }
676
677    if found_sessions.is_empty() {
678        println!("\n{} No matching sessions found", "[X]".red());
679        println!(
680            "\n{} Tip: Use 'csm list sessions' or 'csm find session <pattern>' to find session IDs",
681            "[i]".cyan()
682        );
683        return Ok(());
684    }
685
686    // Report any sessions that weren't found
687    let not_found: Vec<_> = normalized_ids
688        .iter()
689        .filter(|id| {
690            !found_ids
691                .iter()
692                .any(|found| found.starts_with(*id) || id.starts_with(found))
693        })
694        .collect();
695
696    if !not_found.is_empty() {
697        println!("\n{} Sessions not found:", "[!]".yellow());
698        for id in not_found {
699            println!("   {} {}", "[X]".red(), id);
700        }
701    }
702
703    println!("\n   Total: {} sessions found", found_sessions.len());
704
705    // Use the common merge logic
706    merge_sessions_internal(
707        found_sessions,
708        title,
709        &target_ws_id,
710        &target_ws_dir,
711        force,
712        no_backup,
713        &format!("{} selected sessions", session_ids.len()),
714    )
715}
716
717/// Internal function to merge sessions and write to target workspace
718fn merge_sessions_internal(
719    sessions: Vec<crate::models::SessionWithPath>,
720    title: Option<&str>,
721    target_ws_id: &str,
722    target_ws_dir: &Path,
723    force: bool,
724    no_backup: bool,
725    source_description: &str,
726) -> Result<()> {
727    // Collect all requests with timestamps
728    println!("\n{} Extracting and sorting messages...", "[*]".blue());
729
730    let mut all_requests: Vec<ChatRequest> = Vec::new();
731    for session_with_path in &sessions {
732        let session = &session_with_path.session;
733        let session_title = session.title();
734
735        for req in &session.requests {
736            let mut req = req.clone();
737            req.source_session = Some(session_title.clone());
738            if req.timestamp.is_some() {
739                all_requests.push(req);
740            }
741        }
742    }
743
744    if all_requests.is_empty() {
745        println!("\n{} No messages found in selected sessions", "[X]".red());
746        return Ok(());
747    }
748
749    // Sort by timestamp
750    all_requests.sort_by_key(|r| r.timestamp.unwrap_or(0));
751
752    // Get timeline info
753    let first_time = all_requests.first().and_then(|r| r.timestamp).unwrap_or(0);
754    let last_time = all_requests.last().and_then(|r| r.timestamp).unwrap_or(0);
755
756    let first_date = timestamp_to_date(first_time);
757    let last_date = timestamp_to_date(last_time);
758    let days_span = if first_time > 0 && last_time > 0 {
759        (last_time - first_time) / (1000 * 60 * 60 * 24)
760    } else {
761        0
762    };
763
764    println!("   Messages: {}", all_requests.len());
765    println!(
766        "   Timeline: {} -> {} ({} days)",
767        first_date, last_date, days_span
768    );
769
770    // Create merged session
771    println!("\n{} Creating merged session...", "[+]".blue());
772
773    let merged_session_id = Uuid::new_v4().to_string();
774    let merged_title = title.map(|t| t.to_string()).unwrap_or_else(|| {
775        format!(
776            "Merged: {} ({} sessions, {} days)",
777            source_description,
778            sessions.len(),
779            days_span
780        )
781    });
782
783    let merged_session = ChatSession {
784        version: 3,
785        session_id: Some(merged_session_id.clone()),
786        creation_date: first_time,
787        last_message_date: last_time,
788        is_imported: false,
789        initial_location: "panel".to_string(),
790        custom_title: Some(merged_title.clone()),
791        requester_username: Some("User".to_string()),
792        requester_avatar_icon_uri: None,
793        responder_username: Some("GitHub Copilot".to_string()),
794        responder_avatar_icon_uri: Some(serde_json::json!({"id": "copilot"})),
795        requests: all_requests.clone(),
796    };
797
798    // Create backup if requested
799    let chat_sessions_dir = target_ws_dir.join("chatSessions");
800
801    if !no_backup {
802        if let Some(backup_dir) = backup_workspace_sessions(target_ws_dir)? {
803            println!(
804                "   {} Backup: {}",
805                "[B]".blue(),
806                backup_dir.file_name().unwrap().to_string_lossy()
807            );
808        }
809    }
810
811    // Write merged session
812    std::fs::create_dir_all(&chat_sessions_dir)?;
813    let merged_file = chat_sessions_dir.join(format!("{}.json", merged_session_id));
814
815    let json = serde_json::to_string_pretty(&merged_session)?;
816    std::fs::write(&merged_file, json)?;
817
818    println!(
819        "   {} File: {}",
820        "[F]".blue(),
821        merged_file.file_name().unwrap().to_string_lossy()
822    );
823
824    // Register in VS Code index
825    println!("\n{} Registering in VS Code index...", "[#]".blue());
826
827    if is_vscode_running() && !force {
828        println!(
829            "{} VS Code is running. Close it and run again, or use --force",
830            "[!]".yellow()
831        );
832    } else {
833        let db_path = get_workspace_storage_db(target_ws_id)?;
834        add_session_to_index(
835            &db_path,
836            &merged_session_id,
837            &merged_title,
838            last_time,
839            false,
840            "panel",
841            false,
842        )?;
843        println!("   {} Registered in index", "[OK]".green());
844    }
845
846    println!("\n{}", "=".repeat(70));
847    println!("{} MERGE COMPLETE!", "[OK]".green().bold());
848    println!("\n{} Summary:", "[=]".blue());
849    println!("   - Sessions merged: {}", sessions.len());
850    println!("   - Total messages: {}", all_requests.len());
851    println!("   - Timeline: {} days", days_span);
852    println!("   - Title: {}", merged_title);
853
854    println!("\n{} Next Steps:", "[i]".cyan());
855    println!("   1. Reload VS Code (Ctrl+R)");
856    println!("   2. Open Chat history dropdown");
857    println!("   3. Select: '{}'", merged_title);
858
859    Ok(())
860}
861
862/// Convert millisecond timestamp to date string
863fn timestamp_to_date(timestamp: i64) -> String {
864    if timestamp == 0 {
865        return "unknown".to_string();
866    }
867
868    // Handle both milliseconds and seconds
869    let secs = if timestamp > 1_000_000_000_000 {
870        timestamp / 1000
871    } else {
872        timestamp
873    };
874
875    DateTime::from_timestamp(secs, 0)
876        .map(|dt| dt.format("%Y-%m-%d").to_string())
877        .unwrap_or_else(|| "unknown".to_string())
878}
879
880/// Truncate string to max length
881fn truncate(s: &str, max_len: usize) -> String {
882    if s.len() <= max_len {
883        s.to_string()
884    } else {
885        format!("{}...", &s[..max_len - 3])
886    }
887}
888
889/// Fetch sessions from workspaces matching a name pattern
890pub fn fetch_by_workspace(
891    workspace_name: &str,
892    target_path: Option<&str>,
893    force: bool,
894    no_register: bool,
895) -> Result<()> {
896    use colored::Colorize;
897    use std::fs;
898
899    println!("\n{}", "=".repeat(70));
900    println!("{} FETCH BY WORKSPACE", "[*]".cyan().bold());
901    println!("{}", "=".repeat(70));
902
903    // Find target workspace
904    let target_dir = match target_path {
905        Some(p) => {
906            let path = std::path::PathBuf::from(p);
907            path.canonicalize().unwrap_or(path)
908        }
909        None => std::env::current_dir().unwrap_or_default(),
910    };
911    let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
912
913    println!("\n{} Target: {}", "[>]".blue(), target_normalized);
914    println!("{} Pattern: {}", "[>]".blue(), workspace_name);
915
916    // Find all workspaces
917    let all_workspaces = discover_workspaces()?;
918    let pattern_lower = workspace_name.to_lowercase();
919
920    // Find source workspaces matching pattern
921    let source_workspaces: Vec<_> = all_workspaces
922        .iter()
923        .filter(|ws| {
924            ws.project_path
925                .as_ref()
926                .map(|p| p.to_lowercase().contains(&pattern_lower))
927                .unwrap_or(false)
928        })
929        .filter(|ws| ws.has_chat_sessions)
930        .collect();
931
932    if source_workspaces.is_empty() {
933        println!(
934            "\n{} No workspaces found matching '{}'",
935            "[X]".red(),
936            workspace_name
937        );
938        return Ok(());
939    }
940
941    println!(
942        "\n{} Found {} matching workspace(s)",
943        "[OK]".green(),
944        source_workspaces.len()
945    );
946
947    // Find target workspace
948    let target_ws = all_workspaces.iter().find(|ws| {
949        ws.project_path
950            .as_ref()
951            .map(|p| normalize_path(p) == target_normalized)
952            .unwrap_or(false)
953    });
954
955    let target_ws_dir = match target_ws {
956        Some(ws) => ws.workspace_path.join("workspaceState"),
957        None => {
958            println!(
959                "{} Target workspace not found, creating new...",
960                "[!]".yellow()
961            );
962            // Would need to create workspace - for now return error
963            anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
964        }
965    };
966
967    // Collect all sessions from matching workspaces
968    let mut fetched_count = 0;
969
970    for ws in source_workspaces {
971        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
972
973        for session_with_path in sessions {
974            let src_file = &session_with_path.path;
975            let filename = src_file
976                .file_name()
977                .map(|n| n.to_string_lossy().to_string())
978                .unwrap_or_default();
979
980            let dest_file = target_ws_dir.join(&filename);
981
982            if dest_file.exists() && !force {
983                println!("   {} Skipping (exists): {}", "[!]".yellow(), filename);
984                continue;
985            }
986
987            fs::copy(src_file, &dest_file)?;
988            fetched_count += 1;
989            println!(
990                "   {} Fetched: {}",
991                "[OK]".green(),
992                session_with_path.session.title()
993            );
994        }
995    }
996
997    println!(
998        "\n{} Fetched {} session(s)",
999        "[OK]".green().bold(),
1000        fetched_count
1001    );
1002
1003    if !no_register {
1004        println!(
1005            "{} Sessions will appear in VS Code after reload",
1006            "[i]".cyan()
1007        );
1008    }
1009
1010    Ok(())
1011}
1012
1013/// Fetch specific sessions by their IDs
1014pub fn fetch_sessions(
1015    session_ids: &[String],
1016    target_path: Option<&str>,
1017    force: bool,
1018    no_register: bool,
1019) -> Result<()> {
1020    use colored::Colorize;
1021    use std::fs;
1022
1023    println!("\n{}", "=".repeat(70));
1024    println!("{} FETCH SESSIONS BY ID", "[*]".cyan().bold());
1025    println!("{}", "=".repeat(70));
1026
1027    if session_ids.is_empty() {
1028        println!("{} No session IDs provided", "[X]".red());
1029        return Ok(());
1030    }
1031
1032    // Find target workspace
1033    let target_dir = match target_path {
1034        Some(p) => {
1035            let path = std::path::PathBuf::from(p);
1036            path.canonicalize().unwrap_or(path)
1037        }
1038        None => std::env::current_dir().unwrap_or_default(),
1039    };
1040    let target_normalized = normalize_path(target_dir.to_str().unwrap_or(""));
1041
1042    println!("\n{} Target: {}", "[>]".blue(), target_normalized);
1043    println!("{} Sessions: {:?}", "[>]".blue(), session_ids);
1044
1045    let all_workspaces = discover_workspaces()?;
1046
1047    // Find target workspace
1048    let target_ws = all_workspaces.iter().find(|ws| {
1049        ws.project_path
1050            .as_ref()
1051            .map(|p| normalize_path(p) == target_normalized)
1052            .unwrap_or(false)
1053    });
1054
1055    let target_ws_dir = match target_ws {
1056        Some(ws) => ws.workspace_path.join("workspaceState"),
1057        None => {
1058            anyhow::bail!("Target workspace not found. Please open the folder in VS Code first.");
1059        }
1060    };
1061
1062    // Normalize session IDs
1063    let normalized_ids: Vec<String> = session_ids
1064        .iter()
1065        .flat_map(|s| s.split(',').map(|p| p.trim().to_lowercase()))
1066        .filter(|s| !s.is_empty())
1067        .collect();
1068
1069    let mut fetched_count = 0;
1070    let mut found_ids = Vec::new();
1071
1072    for ws in &all_workspaces {
1073        if !ws.has_chat_sessions {
1074            continue;
1075        }
1076
1077        let sessions = get_chat_sessions_from_workspace(&ws.workspace_path)?;
1078
1079        for session_with_path in sessions {
1080            let session_id = session_with_path
1081                .session
1082                .session_id
1083                .clone()
1084                .unwrap_or_else(|| {
1085                    session_with_path
1086                        .path
1087                        .file_stem()
1088                        .map(|s| s.to_string_lossy().to_string())
1089                        .unwrap_or_default()
1090                });
1091
1092            let matches = normalized_ids.iter().any(|req_id| {
1093                session_id.to_lowercase().contains(req_id)
1094                    || req_id.contains(&session_id.to_lowercase())
1095            });
1096
1097            if matches && !found_ids.contains(&session_id) {
1098                let src_file = &session_with_path.path;
1099                let filename = src_file
1100                    .file_name()
1101                    .map(|n| n.to_string_lossy().to_string())
1102                    .unwrap_or_default();
1103
1104                let dest_file = target_ws_dir.join(&filename);
1105
1106                if dest_file.exists() && !force {
1107                    println!("   {} Skipping (exists): {}", "[!]".yellow(), filename);
1108                    found_ids.push(session_id);
1109                    continue;
1110                }
1111
1112                fs::copy(src_file, &dest_file)?;
1113                fetched_count += 1;
1114                found_ids.push(session_id);
1115                println!(
1116                    "   {} Fetched: {}",
1117                    "[OK]".green(),
1118                    session_with_path.session.title()
1119                );
1120            }
1121        }
1122    }
1123
1124    // Report not found
1125    let not_found: Vec<_> = normalized_ids
1126        .iter()
1127        .filter(|id| {
1128            !found_ids
1129                .iter()
1130                .any(|found| found.to_lowercase().contains(*id))
1131        })
1132        .collect();
1133
1134    if !not_found.is_empty() {
1135        println!("\n{} Sessions not found:", "[!]".yellow());
1136        for id in not_found {
1137            println!("   {} {}", "[X]".red(), id);
1138        }
1139    }
1140
1141    println!(
1142        "\n{} Fetched {} session(s)",
1143        "[OK]".green().bold(),
1144        fetched_count
1145    );
1146
1147    if !no_register {
1148        println!(
1149            "{} Sessions will appear in VS Code after reload",
1150            "[i]".cyan()
1151        );
1152    }
1153
1154    Ok(())
1155}
1156
1157/// Merge chat sessions from multiple workspace name patterns
1158pub fn merge_by_workspace_names(
1159    workspace_names: &[String],
1160    title: Option<&str>,
1161    target_path: Option<&str>,
1162    force: bool,
1163    no_backup: bool,
1164) -> Result<()> {
1165    println!(
1166        "\n{} Merging Sessions from Multiple Workspaces",
1167        "[M]".blue().bold()
1168    );
1169    println!("{}", "=".repeat(70));
1170
1171    println!("\n{} Workspace patterns:", "[D]".blue());
1172    for name in workspace_names {
1173        println!("   {} {}", "[*]".blue(), name.cyan());
1174    }
1175
1176    // Collect all matching workspaces
1177    let mut all_matching_workspaces = Vec::new();
1178    let mut seen_ws_ids = std::collections::HashSet::new();
1179
1180    for pattern in workspace_names {
1181        let workspaces = find_all_workspaces_for_project(pattern)?;
1182        for ws in workspaces {
1183            if !seen_ws_ids.contains(&ws.0) {
1184                seen_ws_ids.insert(ws.0.clone());
1185                all_matching_workspaces.push(ws);
1186            }
1187        }
1188    }
1189
1190    if all_matching_workspaces.is_empty() {
1191        println!(
1192            "\n{} No workspaces found matching any of the patterns",
1193            "[X]".red()
1194        );
1195        return Ok(());
1196    }
1197
1198    println!(
1199        "\n{} Found {} unique workspace(s):",
1200        "[D]".blue(),
1201        all_matching_workspaces.len()
1202    );
1203    for (ws_id, _, folder_path, _) in &all_matching_workspaces {
1204        println!(
1205            "   {} {}... -> {}",
1206            "[*]".blue(),
1207            &ws_id[..16.min(ws_id.len())],
1208            folder_path.as_deref().unwrap_or("(unknown)")
1209        );
1210    }
1211
1212    // Determine target workspace
1213    let target_path = match target_path {
1214        Some(p) => {
1215            let path = Path::new(p);
1216            path.canonicalize()
1217                .map(|p| p.to_string_lossy().to_string())
1218                .unwrap_or_else(|_| p.to_string())
1219        }
1220        None => std::env::current_dir()
1221            .map(|p| p.to_string_lossy().to_string())
1222            .unwrap_or_else(|_| ".".to_string()),
1223    };
1224
1225    let target_ws = find_workspace_by_path(&target_path)?
1226        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1227    let (target_ws_id, target_ws_dir, _) = target_ws;
1228
1229    println!(
1230        "\n{} Target workspace: {}...",
1231        "[>]".blue(),
1232        &target_ws_id[..16.min(target_ws_id.len())]
1233    );
1234
1235    // Collect sessions from all matching workspaces
1236    println!("\n{} Collecting sessions...", "[D]".blue());
1237
1238    let mut all_sessions = Vec::new();
1239    for (ws_id, ws_dir, _, _) in &all_matching_workspaces {
1240        let sessions = get_chat_sessions_from_workspace(ws_dir)?;
1241        if !sessions.is_empty() {
1242            println!(
1243                "   {} {}... ({} sessions)",
1244                "[d]".blue(),
1245                &ws_id[..16.min(ws_id.len())],
1246                sessions.len()
1247            );
1248            all_sessions.extend(sessions);
1249        }
1250    }
1251
1252    if all_sessions.is_empty() {
1253        println!(
1254            "\n{} No chat sessions found in matching workspaces",
1255            "[X]".red()
1256        );
1257        return Ok(());
1258    }
1259
1260    // Generate title from workspace names if not provided
1261    let auto_title = format!("Merged: {}", workspace_names.join(" + "));
1262    let merge_title = title.unwrap_or(&auto_title);
1263
1264    // Use the common merge logic
1265    merge_sessions_internal(
1266        all_sessions,
1267        Some(merge_title),
1268        &target_ws_id,
1269        &target_ws_dir,
1270        force,
1271        no_backup,
1272        &format!("{} workspaces", workspace_names.len()),
1273    )
1274}
1275
1276/// Merge chat sessions from an LLM provider
1277pub fn merge_from_provider(
1278    provider_name: &str,
1279    title: Option<&str>,
1280    target_path: Option<&str>,
1281    session_ids: Option<&[String]>,
1282    force: bool,
1283    no_backup: bool,
1284) -> Result<()> {
1285    use crate::providers::{ProviderRegistry, ProviderType};
1286
1287    println!(
1288        "\n{} Merging Sessions from Provider: {}",
1289        "[M]".blue().bold(),
1290        provider_name.cyan()
1291    );
1292    println!("{}", "=".repeat(70));
1293
1294    // Parse provider name
1295    let provider_type = match provider_name.to_lowercase().as_str() {
1296        "copilot" | "github-copilot" | "vscode" => ProviderType::Copilot,
1297        "cursor" => ProviderType::Cursor,
1298        "ollama" => ProviderType::Ollama,
1299        "vllm" => ProviderType::Vllm,
1300        "foundry" | "azure" | "azure-foundry" => ProviderType::Foundry,
1301        "lm-studio" | "lmstudio" => ProviderType::LmStudio,
1302        "localai" | "local-ai" => ProviderType::LocalAI,
1303        "text-gen-webui" | "textgenwebui" | "oobabooga" => ProviderType::TextGenWebUI,
1304        "jan" | "jan-ai" => ProviderType::Jan,
1305        "gpt4all" => ProviderType::Gpt4All,
1306        "llamafile" => ProviderType::Llamafile,
1307        _ => {
1308            println!("{} Unknown provider: {}", "[X]".red(), provider_name);
1309            println!("\n{} Available providers:", "[i]".cyan());
1310            println!("   copilot, cursor, ollama, vllm, foundry, lm-studio,");
1311            println!("   localai, text-gen-webui, jan, gpt4all, llamafile");
1312            return Ok(());
1313        }
1314    };
1315
1316    // Get provider sessions
1317    let registry = ProviderRegistry::new();
1318    let provider = registry
1319        .get_provider(provider_type)
1320        .context(format!("Provider '{}' not available", provider_name))?;
1321
1322    if !provider.is_available() {
1323        println!(
1324            "{} Provider '{}' is not available or not configured",
1325            "[X]".red(),
1326            provider_name
1327        );
1328        return Ok(());
1329    }
1330
1331    println!(
1332        "{} Provider: {} ({})",
1333        "[*]".blue(),
1334        provider.name(),
1335        provider_type.display_name()
1336    );
1337
1338    // Get sessions from provider
1339    let provider_sessions = provider
1340        .list_sessions()
1341        .context("Failed to list sessions from provider")?;
1342
1343    if provider_sessions.is_empty() {
1344        println!("{} No sessions found in provider", "[X]".red());
1345        return Ok(());
1346    }
1347
1348    println!(
1349        "{} Found {} session(s) in provider",
1350        "[D]".blue(),
1351        provider_sessions.len()
1352    );
1353
1354    // Filter sessions if specific IDs provided
1355    let sessions_to_merge: Vec<_> = if let Some(ids) = session_ids {
1356        let ids_lower: Vec<String> = ids.iter().map(|s| s.to_lowercase()).collect();
1357        provider_sessions
1358            .into_iter()
1359            .filter(|s| {
1360                let session_id = s
1361                    .session_id
1362                    .as_ref()
1363                    .unwrap_or(&String::new())
1364                    .to_lowercase();
1365                let title = s.title().to_lowercase();
1366                ids_lower
1367                    .iter()
1368                    .any(|id| session_id.contains(id) || title.contains(id))
1369            })
1370            .collect()
1371    } else {
1372        provider_sessions
1373    };
1374
1375    if sessions_to_merge.is_empty() {
1376        println!("{} No matching sessions found", "[X]".red());
1377        return Ok(());
1378    }
1379
1380    println!(
1381        "{} Merging {} session(s):",
1382        "[D]".blue(),
1383        sessions_to_merge.len()
1384    );
1385    for s in &sessions_to_merge {
1386        println!(
1387            "   {} {} ({} messages)",
1388            "[*]".blue(),
1389            truncate(&s.title(), 50),
1390            s.request_count()
1391        );
1392    }
1393
1394    // Determine target workspace
1395    let target_path = match target_path {
1396        Some(p) => {
1397            let path = Path::new(p);
1398            path.canonicalize()
1399                .map(|p| p.to_string_lossy().to_string())
1400                .unwrap_or_else(|_| p.to_string())
1401        }
1402        None => std::env::current_dir()
1403            .map(|p| p.to_string_lossy().to_string())
1404            .unwrap_or_else(|_| ".".to_string()),
1405    };
1406
1407    let target_ws = find_workspace_by_path(&target_path)?
1408        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1409    let (target_ws_id, target_ws_dir, _) = target_ws;
1410
1411    println!(
1412        "\n{} Target workspace: {}...",
1413        "[>]".blue(),
1414        &target_ws_id[..16.min(target_ws_id.len())]
1415    );
1416
1417    // Convert to SessionWithPath format (create temporary entries)
1418    let sessions_with_path: Vec<crate::models::SessionWithPath> = sessions_to_merge
1419        .into_iter()
1420        .map(|session| crate::models::SessionWithPath {
1421            session,
1422            path: std::path::PathBuf::new(), // Provider sessions don't have a file path
1423        })
1424        .collect();
1425
1426    // Generate title
1427    let auto_title = format!("Imported from {}", provider.name());
1428    let merge_title = title.unwrap_or(&auto_title);
1429
1430    // Use the common merge logic
1431    merge_sessions_internal(
1432        sessions_with_path,
1433        Some(merge_title),
1434        &target_ws_id,
1435        &target_ws_dir,
1436        force,
1437        no_backup,
1438        &format!("Provider: {}", provider.name()),
1439    )
1440}
1441
1442/// Merge chat sessions from multiple providers (cross-provider merge)
1443pub fn merge_cross_provider(
1444    provider_names: &[String],
1445    title: Option<&str>,
1446    target_path: Option<&str>,
1447    workspace_filter: Option<&str>,
1448    force: bool,
1449    no_backup: bool,
1450) -> Result<()> {
1451    use crate::models::ChatSession;
1452    use crate::providers::{ProviderRegistry, ProviderType};
1453
1454    println!("\n{} Cross-Provider Merge", "[M]".blue().bold());
1455    println!("{}", "=".repeat(70));
1456    println!(
1457        "{} Providers: {}",
1458        "[*]".blue(),
1459        provider_names.join(", ").cyan()
1460    );
1461
1462    if let Some(ws) = workspace_filter {
1463        println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1464    }
1465
1466    let registry = ProviderRegistry::new();
1467    let mut all_sessions: Vec<(String, ChatSession)> = Vec::new(); // (provider_name, session)
1468
1469    // Parse and collect sessions from each provider
1470    for provider_name in provider_names {
1471        let provider_type = match provider_name.to_lowercase().as_str() {
1472            "copilot" | "github-copilot" | "vscode" => Some(ProviderType::Copilot),
1473            "cursor" => Some(ProviderType::Cursor),
1474            "ollama" => Some(ProviderType::Ollama),
1475            "vllm" => Some(ProviderType::Vllm),
1476            "foundry" | "azure" | "azure-foundry" => Some(ProviderType::Foundry),
1477            "lm-studio" | "lmstudio" => Some(ProviderType::LmStudio),
1478            "localai" | "local-ai" => Some(ProviderType::LocalAI),
1479            "text-gen-webui" | "textgenwebui" | "oobabooga" => Some(ProviderType::TextGenWebUI),
1480            "jan" | "jan-ai" => Some(ProviderType::Jan),
1481            "gpt4all" => Some(ProviderType::Gpt4All),
1482            "llamafile" => Some(ProviderType::Llamafile),
1483            _ => {
1484                println!(
1485                    "{} Unknown provider: {} (skipping)",
1486                    "[!]".yellow(),
1487                    provider_name
1488                );
1489                None
1490            }
1491        };
1492
1493        if let Some(pt) = provider_type {
1494            if let Some(provider) = registry.get_provider(pt) {
1495                if provider.is_available() {
1496                    match provider.list_sessions() {
1497                        Ok(sessions) => {
1498                            let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1499                                let pattern = ws_filter.to_lowercase();
1500                                sessions
1501                                    .into_iter()
1502                                    .filter(|s| s.title().to_lowercase().contains(&pattern))
1503                                    .collect()
1504                            } else {
1505                                sessions
1506                            };
1507
1508                            println!(
1509                                "{} {} ({}): {} session(s)",
1510                                "[D]".blue(),
1511                                provider.name(),
1512                                provider_type
1513                                    .as_ref()
1514                                    .map(|p| p.display_name())
1515                                    .unwrap_or("?"),
1516                                filtered.len()
1517                            );
1518
1519                            for session in filtered {
1520                                all_sessions.push((provider.name().to_string(), session));
1521                            }
1522                        }
1523                        Err(e) => {
1524                            println!(
1525                                "{} Failed to get sessions from {}: {}",
1526                                "[!]".yellow(),
1527                                provider.name(),
1528                                e
1529                            );
1530                        }
1531                    }
1532                } else {
1533                    println!(
1534                        "{} Provider {} not available",
1535                        "[!]".yellow(),
1536                        provider.name()
1537                    );
1538                }
1539            }
1540        }
1541    }
1542
1543    if all_sessions.is_empty() {
1544        println!("{} No sessions found across providers", "[X]".red());
1545        return Ok(());
1546    }
1547
1548    println!(
1549        "\n{} Total: {} sessions from {} provider(s)",
1550        "[*]".green().bold(),
1551        all_sessions.len(),
1552        provider_names.len()
1553    );
1554
1555    // Sort all sessions by timestamp
1556    all_sessions.sort_by(|(_, a), (_, b)| {
1557        let a_time = a
1558            .requests
1559            .first()
1560            .map(|r| r.timestamp.unwrap_or(0))
1561            .unwrap_or(0);
1562        let b_time = b
1563            .requests
1564            .first()
1565            .map(|r| r.timestamp.unwrap_or(0))
1566            .unwrap_or(0);
1567        a_time.cmp(&b_time)
1568    });
1569
1570    // Print sessions being merged
1571    println!("\n{} Sessions to merge:", "[D]".blue());
1572    for (provider_name, session) in &all_sessions {
1573        println!(
1574            "   {} [{}] {} ({} messages)",
1575            "[*]".blue(),
1576            provider_name.cyan(),
1577            truncate(&session.title(), 40),
1578            session.request_count()
1579        );
1580    }
1581
1582    // Determine target workspace
1583    let target_path = match target_path {
1584        Some(p) => {
1585            let path = Path::new(p);
1586            path.canonicalize()
1587                .map(|p| p.to_string_lossy().to_string())
1588                .unwrap_or_else(|_| p.to_string())
1589        }
1590        None => std::env::current_dir()
1591            .map(|p| p.to_string_lossy().to_string())
1592            .unwrap_or_else(|_| ".".to_string()),
1593    };
1594
1595    let target_ws = find_workspace_by_path(&target_path)?
1596        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1597    let (target_ws_id, target_ws_dir, _) = target_ws;
1598
1599    println!(
1600        "\n{} Target workspace: {}...",
1601        "[>]".blue(),
1602        &target_ws_id[..16.min(target_ws_id.len())]
1603    );
1604
1605    // Convert to SessionWithPath format
1606    let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1607        .into_iter()
1608        .map(|(_, session)| crate::models::SessionWithPath {
1609            session,
1610            path: std::path::PathBuf::new(),
1611        })
1612        .collect();
1613
1614    // Generate title
1615    let auto_title = format!("Cross-provider merge: {}", provider_names.join(", "));
1616    let merge_title = title.unwrap_or(&auto_title);
1617
1618    merge_sessions_internal(
1619        sessions_with_path,
1620        Some(merge_title),
1621        &target_ws_id,
1622        &target_ws_dir,
1623        force,
1624        no_backup,
1625        &format!("{} providers", provider_names.len()),
1626    )
1627}
1628
1629/// Merge all sessions from all available providers
1630pub fn merge_all_providers(
1631    title: Option<&str>,
1632    target_path: Option<&str>,
1633    workspace_filter: Option<&str>,
1634    force: bool,
1635    no_backup: bool,
1636) -> Result<()> {
1637    use crate::models::ChatSession;
1638    use crate::providers::{ProviderRegistry, ProviderType};
1639
1640    println!("\n{} Merge All Providers", "[M]".blue().bold());
1641    println!("{}", "=".repeat(70));
1642
1643    if let Some(ws) = workspace_filter {
1644        println!("{} Workspace filter: {}", "[*]".blue(), ws.cyan());
1645    }
1646
1647    let registry = ProviderRegistry::new();
1648    let mut all_sessions: Vec<(String, ChatSession)> = Vec::new();
1649    let mut providers_found = 0;
1650
1651    // List of all provider types to check
1652    let all_provider_types = vec![
1653        ProviderType::Copilot,
1654        ProviderType::Cursor,
1655        ProviderType::Ollama,
1656        ProviderType::Vllm,
1657        ProviderType::Foundry,
1658        ProviderType::LmStudio,
1659        ProviderType::LocalAI,
1660        ProviderType::TextGenWebUI,
1661        ProviderType::Jan,
1662        ProviderType::Gpt4All,
1663        ProviderType::Llamafile,
1664    ];
1665
1666    println!("{} Scanning providers...", "[*]".blue());
1667
1668    for provider_type in all_provider_types {
1669        if let Some(provider) = registry.get_provider(provider_type) {
1670            if provider.is_available() {
1671                match provider.list_sessions() {
1672                    Ok(sessions) if !sessions.is_empty() => {
1673                        let filtered: Vec<_> = if let Some(ws_filter) = workspace_filter {
1674                            let pattern = ws_filter.to_lowercase();
1675                            sessions
1676                                .into_iter()
1677                                .filter(|s| s.title().to_lowercase().contains(&pattern))
1678                                .collect()
1679                        } else {
1680                            sessions
1681                        };
1682
1683                        if !filtered.is_empty() {
1684                            println!(
1685                                "   {} {}: {} session(s)",
1686                                "[+]".green(),
1687                                provider.name(),
1688                                filtered.len()
1689                            );
1690                            providers_found += 1;
1691
1692                            for session in filtered {
1693                                all_sessions.push((provider.name().to_string(), session));
1694                            }
1695                        }
1696                    }
1697                    Ok(_) => {
1698                        // Empty sessions, skip silently
1699                    }
1700                    Err(_) => {
1701                        // Failed to list, skip silently
1702                    }
1703                }
1704            }
1705        }
1706    }
1707
1708    if all_sessions.is_empty() {
1709        println!("{} No sessions found across any providers", "[X]".red());
1710        return Ok(());
1711    }
1712
1713    println!(
1714        "\n{} Found {} sessions across {} provider(s)",
1715        "[*]".green().bold(),
1716        all_sessions.len(),
1717        providers_found
1718    );
1719
1720    // Sort all sessions by timestamp
1721    all_sessions.sort_by(|(_, a), (_, b)| {
1722        let a_time = a
1723            .requests
1724            .first()
1725            .map(|r| r.timestamp.unwrap_or(0))
1726            .unwrap_or(0);
1727        let b_time = b
1728            .requests
1729            .first()
1730            .map(|r| r.timestamp.unwrap_or(0))
1731            .unwrap_or(0);
1732        a_time.cmp(&b_time)
1733    });
1734
1735    // Print sessions being merged (limit to first 20)
1736    println!("\n{} Sessions to merge:", "[D]".blue());
1737    for (i, (provider_name, session)) in all_sessions.iter().enumerate() {
1738        if i >= 20 {
1739            println!(
1740                "   {} ... and {} more",
1741                "[*]".blue(),
1742                all_sessions.len() - 20
1743            );
1744            break;
1745        }
1746        println!(
1747            "   {} [{}] {} ({} messages)",
1748            "[*]".blue(),
1749            provider_name.cyan(),
1750            truncate(&session.title(), 40),
1751            session.request_count()
1752        );
1753    }
1754
1755    // Determine target workspace
1756    let target_path = match target_path {
1757        Some(p) => {
1758            let path = Path::new(p);
1759            path.canonicalize()
1760                .map(|p| p.to_string_lossy().to_string())
1761                .unwrap_or_else(|_| p.to_string())
1762        }
1763        None => std::env::current_dir()
1764            .map(|p| p.to_string_lossy().to_string())
1765            .unwrap_or_else(|_| ".".to_string()),
1766    };
1767
1768    let target_ws = find_workspace_by_path(&target_path)?
1769        .context("Target workspace not found. Make sure the project is opened in VS Code")?;
1770    let (target_ws_id, target_ws_dir, _) = target_ws;
1771
1772    println!(
1773        "\n{} Target workspace: {}...",
1774        "[>]".blue(),
1775        &target_ws_id[..16.min(target_ws_id.len())]
1776    );
1777
1778    // Convert to SessionWithPath format
1779    let sessions_with_path: Vec<crate::models::SessionWithPath> = all_sessions
1780        .into_iter()
1781        .map(|(_, session)| crate::models::SessionWithPath {
1782            session,
1783            path: std::path::PathBuf::new(),
1784        })
1785        .collect();
1786
1787    // Generate title
1788    let auto_title = format!("All providers merge ({})", providers_found);
1789    let merge_title = title.unwrap_or(&auto_title);
1790
1791    merge_sessions_internal(
1792        sessions_with_path,
1793        Some(merge_title),
1794        &target_ws_id,
1795        &target_ws_dir,
1796        force,
1797        no_backup,
1798        &format!("{} providers (all)", providers_found),
1799    )
1800}