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