1use 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#[derive(Serialize)]
15struct SessionListOutput {
16 sessions: Vec<crate::storage::Session>,
17 count: usize,
18}
19
20pub 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
92fn 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 let project_path = match project {
107 Some(p) => {
108 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 let resolved_channel = channel
122 .map(ToString::to_string)
123 .or_else(|| branch.clone());
124
125 if !force_new {
127 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 storage.update_session_status(&session.id, "active", actor)?;
132
133 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 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_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
205fn 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 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
233fn 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 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
261fn resume(db_path: &PathBuf, id: &str, actor: &str, json: bool) -> Result<()> {
264 let mut storage = SqliteStorage::open(db_path)?;
265
266 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 storage.update_session_status(id, "active", actor)?;
287
288 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#[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 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 let status_filter = if status == "all" {
339 None
340 } else if include_completed {
341 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), search,
353 )?;
354
355 if status != "all" && include_completed {
358 sessions.retain(|s| s.status == status || s.status == "completed");
359 }
360
361 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
403fn switch(db_path: &PathBuf, id: &str, actor: &str, json: bool) -> Result<()> {
405 let mut storage = SqliteStorage::open(db_path)?;
406
407 if let Some(current_sid) = crate::config::current_session_id() {
409 if current_sid != id {
410 if let Ok(Some(_)) = storage.get_session(¤t_sid) {
412 storage.update_session_status(¤t_sid, "paused", actor)?;
413 }
414 }
415 }
416
417 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 if target.status != "active" {
435 storage.update_session_status(id, "active", actor)?;
436 }
437
438 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
459fn 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 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
489fn delete(db_path: &PathBuf, id: &str, force: bool, actor: &str, json: bool) -> Result<()> {
491 let mut storage = SqliteStorage::open(db_path)?;
492
493 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 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 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
534fn 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 let session_id = resolve_session_or_suggest(id, &storage)?;
546
547 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 let session = storage
560 .get_session(&session_id)?
561 .ok_or_else(|| Error::SessionNotFound {
562 id: session_id.clone(),
563 })?;
564
565 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
583fn 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 let session_id = resolve_session_or_suggest(id, &storage)?;
595
596 let session = storage
598 .get_session(&session_id)?
599 .ok_or_else(|| Error::SessionNotFound {
600 id: session_id.clone(),
601 })?;
602
603 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 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}