Skip to main content

sc/cli/commands/
session.rs

1//! Session command implementations.
2
3use crate::cli::SessionCommands;
4use crate::config::{
5    bind_session_to_terminal, clear_status_cache, current_git_branch, current_project_path,
6    default_actor, resolve_db_path, resolve_session_or_suggest,
7};
8use crate::error::{Error, Result};
9use crate::storage::SqliteStorage;
10use serde::Serialize;
11use std::path::PathBuf;
12
13/// Output for session list command.
14#[derive(Serialize)]
15struct SessionListOutput {
16    sessions: Vec<crate::storage::Session>,
17    count: usize,
18}
19
20/// Execute session commands.
21///
22/// # Errors
23///
24/// Returns an error if the database operation fails.
25pub fn execute(
26    command: &SessionCommands,
27    db_path: Option<&PathBuf>,
28    actor: Option<&str>,
29    session_id: Option<&str>,
30    json: bool,
31) -> Result<()> {
32    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
33        .ok_or_else(|| Error::NotInitialized)?;
34
35    if !db_path.exists() {
36        return Err(Error::NotInitialized);
37    }
38
39    let actor = actor
40        .map(ToString::to_string)
41        .unwrap_or_else(default_actor);
42
43    match command {
44        SessionCommands::Start {
45            name,
46            description,
47            project,
48            channel,
49            force_new,
50        } => start(
51            &db_path,
52            name,
53            description.as_deref(),
54            project.as_deref(),
55            channel.as_deref(),
56            *force_new,
57            &actor,
58            json,
59        ),
60        SessionCommands::End => end(&db_path, session_id, &actor, json),
61        SessionCommands::Pause => pause(&db_path, session_id, &actor, json),
62        SessionCommands::Resume { id } => resume(&db_path, id, &actor, json),
63        SessionCommands::List {
64            status,
65            limit,
66            search,
67            project,
68            all_projects,
69            include_completed,
70        } => list(
71            &db_path,
72            status,
73            *limit,
74            search.as_deref(),
75            project.as_deref(),
76            *all_projects,
77            *include_completed,
78            json,
79        ),
80        SessionCommands::Switch { id } => switch(&db_path, id, &actor, json),
81        SessionCommands::Rename { name } => rename(&db_path, session_id, name, &actor, json),
82        SessionCommands::Delete { id, force } => delete(&db_path, id, *force, &actor, json),
83        SessionCommands::AddPath { id, path } => {
84            add_path(&db_path, id.as_deref(), path.as_deref(), &actor, json)
85        }
86        SessionCommands::RemovePath { id, path } => {
87            remove_path(&db_path, id.as_deref(), path, &actor, json)
88        }
89    }
90}
91
92/// Start a new session.
93fn start(
94    db_path: &PathBuf,
95    name: &str,
96    description: Option<&str>,
97    project: Option<&str>,
98    channel: Option<&str>,
99    force_new: bool,
100    actor: &str,
101    json: bool,
102) -> Result<()> {
103    let mut storage = SqliteStorage::open(db_path)?;
104
105    // Use provided project path or fall back to current directory
106    let project_path = match project {
107        Some(p) => {
108            // Canonicalize if possible for consistent paths
109            std::path::PathBuf::from(p)
110                .canonicalize()
111                .map(|p| p.to_string_lossy().to_string())
112                .unwrap_or_else(|_| p.to_string())
113        }
114        None => current_project_path()
115            .map(|p| p.to_string_lossy().to_string())
116            .unwrap_or_else(|| ".".to_string()),
117    };
118    let branch = current_git_branch();
119
120    // Use provided channel or derive from git branch
121    let resolved_channel = channel
122        .map(ToString::to_string)
123        .or_else(|| branch.clone());
124
125    // Check for existing session to resume (unless force_new)
126    if !force_new {
127        // Look for a session with matching name + project that can be resumed
128        let existing = storage.list_sessions(Some(&project_path), Some("paused"), Some(10))?;
129        if let Some(session) = existing.iter().find(|s| s.name == name) {
130            // Resume the existing session
131            storage.update_session_status(&session.id, "active", actor)?;
132
133            // Bind terminal to this session
134            bind_session_to_terminal(&session.id, &session.name, &project_path, "active");
135
136            if crate::is_silent() {
137                println!("{}", session.id);
138                return Ok(());
139            }
140
141            if json {
142                let output = serde_json::json!({
143                    "id": session.id,
144                    "name": session.name,
145                    "status": "active",
146                    "project_path": session.project_path,
147                    "branch": branch,
148                    "resumed": true
149                });
150                println!("{output}");
151            } else {
152                println!("Resumed session: {name}");
153                println!("  ID: {}", session.id);
154                println!("  Project: {project_path}");
155                if let Some(ref branch) = branch {
156                    println!("  Branch: {branch}");
157                }
158            }
159            return Ok(());
160        }
161    }
162
163    // Generate session ID
164    let id = format!("sess_{}", &uuid::Uuid::new_v4().to_string()[..12]);
165
166    storage.create_session(
167        &id,
168        name,
169        description,
170        Some(&project_path),
171        resolved_channel.as_deref(),
172        actor,
173    )?;
174
175    // Bind terminal to new session
176    bind_session_to_terminal(&id, name, &project_path, "active");
177
178    if crate::is_silent() {
179        println!("{id}");
180        return Ok(());
181    }
182
183    if json {
184        let output = serde_json::json!({
185            "id": id,
186            "name": name,
187            "status": "active",
188            "project_path": project_path,
189            "branch": branch,
190            "resumed": false
191        });
192        println!("{output}");
193    } else {
194        println!("Started session: {name}");
195        println!("  ID: {id}");
196        println!("  Project: {project_path}");
197        if let Some(ref branch) = branch {
198            println!("  Branch: {branch}");
199        }
200    }
201
202    Ok(())
203}
204
205/// End (complete) the current session.
206fn end(db_path: &PathBuf, session_id: Option<&str>, actor: &str, json: bool) -> Result<()> {
207    let mut storage = SqliteStorage::open(db_path)?;
208
209    let sid = resolve_session_or_suggest(session_id, &storage)?;
210    let session = storage
211        .get_session(&sid)?
212        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
213
214    storage.update_session_status(&session.id, "completed", actor)?;
215
216    // Unbind terminal from this session
217    clear_status_cache();
218
219    if json {
220        let output = serde_json::json!({
221            "id": session.id,
222            "name": session.name,
223            "status": "completed"
224        });
225        println!("{output}");
226    } else {
227        println!("Completed session: {}", session.name);
228    }
229
230    Ok(())
231}
232
233/// Pause the current session.
234fn pause(db_path: &PathBuf, session_id: Option<&str>, actor: &str, json: bool) -> Result<()> {
235    let mut storage = SqliteStorage::open(db_path)?;
236
237    let sid = resolve_session_or_suggest(session_id, &storage)?;
238    let session = storage
239        .get_session(&sid)?
240        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
241
242    storage.update_session_status(&session.id, "paused", actor)?;
243
244    // Unbind terminal from this session
245    clear_status_cache();
246
247    if json {
248        let output = serde_json::json!({
249            "id": session.id,
250            "name": session.name,
251            "status": "paused"
252        });
253        println!("{output}");
254    } else {
255        println!("Paused session: {}", session.name);
256    }
257
258    Ok(())
259}
260
261/// Resume a paused, completed, or even active session.
262/// Active sessions are allowed because the user may be resuming in a new terminal instance.
263fn resume(db_path: &PathBuf, id: &str, actor: &str, json: bool) -> Result<()> {
264    let mut storage = SqliteStorage::open(db_path)?;
265
266    // Get the session to verify it exists
267    let session = storage
268        .get_session(id)?
269        .ok_or_else(|| {
270            let all_ids = storage.get_all_session_ids().unwrap_or_default();
271            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
272            if similar.is_empty() {
273                Error::SessionNotFound { id: id.to_string() }
274            } else {
275                Error::SessionNotFoundSimilar {
276                    id: id.to_string(),
277                    similar,
278                }
279            }
280        })?;
281
282    // Allow resuming any session including active ones (for new terminal instances)
283    // This matches the MCP server behavior where resumeSession() doesn't check status
284
285    // Set to active and clear ended_at (matching MCP server behavior)
286    storage.update_session_status(id, "active", actor)?;
287
288    // Bind terminal to this session
289    let project_path = session
290        .project_path
291        .as_deref()
292        .unwrap_or(".");
293    bind_session_to_terminal(&session.id, &session.name, project_path, "active");
294
295    if json {
296        let output = serde_json::json!({
297            "id": session.id,
298            "name": session.name,
299            "status": "active"
300        });
301        println!("{output}");
302    } else {
303        println!("Resumed session: {}", session.name);
304    }
305
306    Ok(())
307}
308
309/// List sessions.
310#[allow(clippy::too_many_arguments)]
311fn list(
312    db_path: &PathBuf,
313    status: &str,
314    limit: usize,
315    search: Option<&str>,
316    project: Option<&str>,
317    all_projects: bool,
318    include_completed: bool,
319    json: bool,
320) -> Result<()> {
321    let storage = SqliteStorage::open(db_path)?;
322
323    // Determine project path filter:
324    // - If all_projects is true, don't filter by project
325    // - If project is provided, use that
326    // - Otherwise, use current directory
327    let project_path = if all_projects {
328        None
329    } else {
330        project.map(ToString::to_string).or_else(|| {
331            current_project_path().map(|p| p.to_string_lossy().to_string())
332        })
333    };
334
335    // Determine status filter
336    // - "all" means no status filter
337    // - include_completed means we fetch more and filter client-side
338    let status_filter = if status == "all" {
339        None
340    } else if include_completed {
341        // Fetch all statuses and filter client-side to include both the requested status and completed
342        None
343    } else {
344        Some(status)
345    };
346
347    #[allow(clippy::cast_possible_truncation)]
348    let mut sessions = storage.list_sessions_with_search(
349        project_path.as_deref(),
350        status_filter,
351        Some(limit as u32 * 2), // Fetch extra to allow filtering
352        search,
353    )?;
354
355    // If we're not fetching "all" status and include_completed is set,
356    // filter to only include the requested status OR completed
357    if status != "all" && include_completed {
358        sessions.retain(|s| s.status == status || s.status == "completed");
359    }
360
361    // Apply limit after filtering
362    sessions.truncate(limit);
363
364    if crate::is_csv() {
365        println!("id,name,status,project_path");
366        for s in &sessions {
367            let path = s.project_path.as_deref().unwrap_or("");
368            println!("{},{},{},{}", s.id, crate::csv_escape(&s.name), s.status, crate::csv_escape(path));
369        }
370    } else if json {
371        let output = SessionListOutput {
372            count: sessions.len(),
373            sessions,
374        };
375        println!("{}", serde_json::to_string(&output)?);
376    } else if sessions.is_empty() {
377        println!("No sessions found.");
378    } else {
379        println!("Sessions ({} found):", sessions.len());
380        println!();
381        for session in &sessions {
382            let status_icon = match session.status.as_str() {
383                "active" => "●",
384                "paused" => "◐",
385                "completed" => "○",
386                _ => "?",
387            };
388            println!("{} {} [{}]", status_icon, session.name, session.status);
389            println!("  ID: {}", session.id);
390            if let Some(ref path) = session.project_path {
391                println!("  Project: {path}");
392            }
393            if let Some(ref branch) = session.branch {
394                println!("  Branch: {branch}");
395            }
396            println!();
397        }
398    }
399
400    Ok(())
401}
402
403/// Switch to a different session.
404fn switch(db_path: &PathBuf, id: &str, actor: &str, json: bool) -> Result<()> {
405    let mut storage = SqliteStorage::open(db_path)?;
406
407    // Pause the currently bound session (if any) via status cache
408    if let Some(current_sid) = crate::config::current_session_id() {
409        if current_sid != id {
410            // Only pause if it's a different session
411            if let Ok(Some(_)) = storage.get_session(&current_sid) {
412                storage.update_session_status(&current_sid, "paused", actor)?;
413            }
414        }
415    }
416
417    // Get the target session
418    let target = storage
419        .get_session(id)?
420        .ok_or_else(|| {
421            let all_ids = storage.get_all_session_ids().unwrap_or_default();
422            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
423            if similar.is_empty() {
424                Error::SessionNotFound { id: id.to_string() }
425            } else {
426                Error::SessionNotFoundSimilar {
427                    id: id.to_string(),
428                    similar,
429                }
430            }
431        })?;
432
433    // Activate the target session if not already active
434    if target.status != "active" {
435        storage.update_session_status(id, "active", actor)?;
436    }
437
438    // Bind terminal to the new session
439    let project_path = target
440        .project_path
441        .as_deref()
442        .unwrap_or(".");
443    bind_session_to_terminal(&target.id, &target.name, project_path, "active");
444
445    if json {
446        let output = serde_json::json!({
447            "id": target.id,
448            "name": target.name,
449            "status": "active"
450        });
451        println!("{output}");
452    } else {
453        println!("Switched to session: {}", target.name);
454    }
455
456    Ok(())
457}
458
459/// Rename the current session.
460fn rename(db_path: &PathBuf, session_id: Option<&str>, new_name: &str, actor: &str, json: bool) -> Result<()> {
461    let mut storage = SqliteStorage::open(db_path)?;
462
463    let sid = resolve_session_or_suggest(session_id, &storage)?;
464    let session = storage
465        .get_session(&sid)?
466        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
467
468    storage.rename_session(&session.id, new_name, actor)?;
469
470    // Update the status cache with the new name
471    if let Some(ref path) = session.project_path {
472        bind_session_to_terminal(&session.id, new_name, path, &session.status);
473    }
474
475    if json {
476        let output = serde_json::json!({
477            "id": session.id,
478            "name": new_name,
479            "old_name": session.name
480        });
481        println!("{output}");
482    } else {
483        println!("Renamed session to: {new_name}");
484    }
485
486    Ok(())
487}
488
489/// Delete a session permanently.
490fn delete(db_path: &PathBuf, id: &str, force: bool, actor: &str, json: bool) -> Result<()> {
491    let mut storage = SqliteStorage::open(db_path)?;
492
493    // Get the session to verify it exists and show info
494    let session = storage
495        .get_session(id)?
496        .ok_or_else(|| {
497            let all_ids = storage.get_all_session_ids().unwrap_or_default();
498            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
499            if similar.is_empty() {
500                Error::SessionNotFound { id: id.to_string() }
501            } else {
502                Error::SessionNotFoundSimilar {
503                    id: id.to_string(),
504                    similar,
505                }
506            }
507        })?;
508
509    // Cannot delete active session without force
510    if session.status == "active" && !force {
511        return Err(Error::InvalidSessionStatus {
512            expected: "paused or completed (use --force to delete active session)".to_string(),
513            actual: session.status.clone(),
514        });
515    }
516
517    // Perform deletion
518    storage.delete_session(id, actor)?;
519
520    if json {
521        let output = serde_json::json!({
522            "id": session.id,
523            "name": session.name,
524            "deleted": true
525        });
526        println!("{output}");
527    } else {
528        println!("Deleted session: {}", session.name);
529    }
530
531    Ok(())
532}
533
534/// Add a project path to a session.
535fn add_path(
536    db_path: &PathBuf,
537    id: Option<&str>,
538    path: Option<&str>,
539    actor: &str,
540    json: bool,
541) -> Result<()> {
542    let mut storage = SqliteStorage::open(db_path)?;
543
544    // Resolve session ID: explicit -i flag first, then standard resolution
545    let session_id = resolve_session_or_suggest(id, &storage)?;
546
547    // Resolve path (use provided or current directory)
548    let project_path = match path {
549        Some(p) => std::path::PathBuf::from(p)
550            .canonicalize()
551            .map(|p| p.to_string_lossy().to_string())
552            .unwrap_or_else(|_| p.to_string()),
553        None => std::env::current_dir()
554            .map(|p| p.to_string_lossy().to_string())
555            .map_err(|e| Error::Io(e))?,
556    };
557
558    // Get session info for output
559    let session = storage
560        .get_session(&session_id)?
561        .ok_or_else(|| Error::SessionNotFound {
562            id: session_id.clone(),
563        })?;
564
565    // Add the path
566    storage.add_session_path(&session_id, &project_path, actor)?;
567
568    if json {
569        let output = serde_json::json!({
570            "session_id": session.id,
571            "session_name": session.name,
572            "path_added": project_path
573        });
574        println!("{output}");
575    } else {
576        println!("Added path to session: {}", session.name);
577        println!("  Path: {project_path}");
578    }
579
580    Ok(())
581}
582
583/// Remove a project path from a session.
584fn remove_path(
585    db_path: &PathBuf,
586    id: Option<&str>,
587    path: &str,
588    actor: &str,
589    json: bool,
590) -> Result<()> {
591    let mut storage = SqliteStorage::open(db_path)?;
592
593    // Resolve session ID: explicit -i flag first, then standard resolution
594    let session_id = resolve_session_or_suggest(id, &storage)?;
595
596    // Get session info for output
597    let session = storage
598        .get_session(&session_id)?
599        .ok_or_else(|| Error::SessionNotFound {
600            id: session_id.clone(),
601        })?;
602
603    // Canonicalize path if possible (to match stored paths)
604    let project_path = std::path::PathBuf::from(path)
605        .canonicalize()
606        .map(|p| p.to_string_lossy().to_string())
607        .unwrap_or_else(|_| path.to_string());
608
609    // Remove the path
610    storage.remove_session_path(&session_id, &project_path, actor)?;
611
612    if json {
613        let output = serde_json::json!({
614            "session_id": session.id,
615            "session_name": session.name,
616            "path_removed": project_path
617        });
618        println!("{output}");
619    } else {
620        println!("Removed path from session: {}", session.name);
621        println!("  Path: {project_path}");
622    }
623
624    Ok(())
625}