Skip to main content

sc/cli/commands/
checkpoint.rs

1//! Checkpoint command implementations.
2
3use crate::cli::CheckpointCommands;
4use crate::config::{
5    current_git_branch, default_actor, resolve_db_path, resolve_session_id,
6    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 checkpoint create.
14#[derive(Serialize)]
15struct CheckpointCreateOutput {
16    id: String,
17    name: String,
18    session_id: String,
19    item_count: usize,
20}
21
22/// Output for checkpoint list.
23#[derive(Serialize)]
24struct CheckpointListOutput {
25    checkpoints: Vec<CheckpointInfo>,
26    count: usize,
27}
28
29#[derive(Serialize)]
30struct CheckpointInfo {
31    id: String,
32    name: String,
33    description: Option<String>,
34    item_count: i64,
35    created_at: i64,
36}
37
38/// Execute checkpoint commands.
39pub fn execute(
40    command: &CheckpointCommands,
41    db_path: Option<&PathBuf>,
42    actor: Option<&str>,
43    session_id: Option<&str>,
44    json: bool,
45) -> Result<()> {
46    match command {
47        CheckpointCommands::Create {
48            name,
49            description,
50            include_git,
51        } => create(name, description.as_deref(), *include_git, db_path, actor, session_id, json),
52        CheckpointCommands::List {
53            search,
54            session,
55            project,
56            all_projects,
57            limit,
58            offset,
59        } => list(
60            search.as_deref(),
61            session.as_deref().or(session_id),  // Use CLI flag if provided, otherwise MCP session
62            project.as_deref(),
63            *all_projects,
64            *limit,
65            *offset,
66            db_path,
67            json,
68        ),
69        CheckpointCommands::Show { id } => show(id, db_path, json),
70        CheckpointCommands::Restore { id, categories, tags } => restore(
71            id,
72            categories.as_ref().map(|v| v.as_slice()),
73            tags.as_ref().map(|v| v.as_slice()),
74            db_path,
75            actor,
76            session_id,
77            json,
78        ),
79        CheckpointCommands::Delete { id } => delete(id, db_path, actor, json),
80        CheckpointCommands::AddItems { id, keys } => add_items(id, keys, db_path, actor, session_id, json),
81        CheckpointCommands::RemoveItems { id, keys } => remove_items(id, keys, db_path, actor, json),
82        CheckpointCommands::Items { id } => items(id, db_path, json),
83    }
84}
85
86fn create(
87    name: &str,
88    description: Option<&str>,
89    include_git: bool,
90    db_path: Option<&PathBuf>,
91    actor: Option<&str>,
92    session_id: Option<&str>,
93    json: bool,
94) -> Result<()> {
95    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
96        .ok_or(Error::NotInitialized)?;
97
98    if !db_path.exists() {
99        return Err(Error::NotInitialized);
100    }
101
102    let mut storage = SqliteStorage::open(&db_path)?;
103    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
104
105    let sid = resolve_session_or_suggest(session_id, &storage)?;
106    let session = storage
107        .get_session(&sid)?
108        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
109
110    // Get git info if requested
111    let git_branch = if include_git {
112        current_git_branch()
113    } else {
114        None
115    };
116
117    let git_status = if include_git {
118        get_git_status()
119    } else {
120        None
121    };
122
123    // Generate checkpoint ID
124    let id = format!("ckpt_{}", &uuid::Uuid::new_v4().to_string()[..12]);
125
126    // Get current context items to include
127    let items = storage.get_context_items(&session.id, None, None, Some(1000))?;
128
129    storage.create_checkpoint(
130        &id,
131        &session.id,
132        name,
133        description,
134        git_status.as_deref(),
135        git_branch.as_deref(),
136        &actor,
137    )?;
138
139    // Add items to checkpoint
140    for item in &items {
141        storage.add_checkpoint_item(&id, &item.id, &actor)?;
142    }
143
144    if crate::is_silent() {
145        println!("{id}");
146        return Ok(());
147    }
148
149    if json {
150        let output = CheckpointCreateOutput {
151            id,
152            name: name.to_string(),
153            session_id: session.id.clone(),
154            item_count: items.len(),
155        };
156        println!("{}", serde_json::to_string(&output)?);
157    } else {
158        println!("Created checkpoint: {name}");
159        println!("  Items: {}", items.len());
160        if let Some(ref branch) = git_branch {
161            println!("  Branch: {branch}");
162        }
163    }
164
165    Ok(())
166}
167
168fn list(
169    search: Option<&str>,
170    session_id: Option<&str>,
171    _project: Option<&str>,
172    all_projects: bool,
173    limit: usize,
174    offset: Option<usize>,
175    db_path: Option<&PathBuf>,
176    json: bool,
177) -> Result<()> {
178    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
179        .ok_or(Error::NotInitialized)?;
180
181    if !db_path.exists() {
182        return Err(Error::NotInitialized);
183    }
184
185    let storage = SqliteStorage::open(&db_path)?;
186
187    // Determine session filter
188    let resolved_session_id = if let Some(sid) = session_id {
189        Some(sid.to_string())
190    } else if !all_projects {
191        // Use TTY-keyed status cache; if no session bound, show nothing (not all)
192        resolve_session_id(None).ok()
193    } else {
194        None
195    };
196
197    // Get checkpoints - if we have a session, filter by it; if all_projects, get all
198    #[allow(clippy::cast_possible_truncation)]
199    let mut checkpoints = if let Some(ref sid) = resolved_session_id {
200        storage.list_checkpoints(sid, Some(limit as u32 * 2))?  // Get extra for offset
201    } else if all_projects {
202        storage.get_all_checkpoints()?
203    } else {
204        // No session found and not searching all projects
205        vec![]
206    };
207
208    // Apply search filter
209    if let Some(ref search_term) = search {
210        let s = search_term.to_lowercase();
211        checkpoints.retain(|c| {
212            c.name.to_lowercase().contains(&s)
213                || c.description
214                    .as_ref()
215                    .map(|d| d.to_lowercase().contains(&s))
216                    .unwrap_or(false)
217        });
218    }
219
220    // Apply offset and limit
221    if let Some(off) = offset {
222        if off < checkpoints.len() {
223            checkpoints = checkpoints.into_iter().skip(off).collect();
224        } else {
225            checkpoints = vec![];
226        }
227    }
228    checkpoints.truncate(limit);
229
230    if crate::is_csv() {
231        println!("id,name,items,description");
232        for cp in &checkpoints {
233            let desc = cp.description.as_deref().unwrap_or("");
234            println!("{},{},{},{}", cp.id, crate::csv_escape(&cp.name), cp.item_count, crate::csv_escape(desc));
235        }
236    } else if json {
237        let infos: Vec<CheckpointInfo> = checkpoints
238            .iter()
239            .map(|c| CheckpointInfo {
240                id: c.id.clone(),
241                name: c.name.clone(),
242                description: c.description.clone(),
243                item_count: c.item_count,
244                created_at: c.created_at,
245            })
246            .collect();
247        let output = CheckpointListOutput {
248            count: infos.len(),
249            checkpoints: infos,
250        };
251        println!("{}", serde_json::to_string(&output)?);
252    } else if checkpoints.is_empty() {
253        println!("No checkpoints found.");
254    } else {
255        println!("Checkpoints ({} found):", checkpoints.len());
256        println!();
257        for cp in &checkpoints {
258            println!("• {} ({} items)", cp.name, cp.item_count);
259            println!("  ID: {}", cp.id);
260            if let Some(ref desc) = cp.description {
261                println!("  {desc}");
262            }
263            if let Some(ref branch) = cp.git_branch {
264                println!("  Branch: {branch}");
265            }
266            println!();
267        }
268    }
269
270    Ok(())
271}
272
273fn show(id: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
274    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
275        .ok_or(Error::NotInitialized)?;
276
277    if !db_path.exists() {
278        return Err(Error::NotInitialized);
279    }
280
281    let storage = SqliteStorage::open(&db_path)?;
282
283    let checkpoint = storage
284        .get_checkpoint(id)?
285        .ok_or_else(|| {
286            let all_ids = storage.get_all_checkpoint_ids().unwrap_or_default();
287            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
288            if similar.is_empty() {
289                Error::CheckpointNotFound { id: id.to_string() }
290            } else {
291                Error::CheckpointNotFoundSimilar {
292                    id: id.to_string(),
293                    similar,
294                }
295            }
296        })?;
297
298    if json {
299        println!("{}", serde_json::to_string(&checkpoint)?);
300    } else {
301        println!("Checkpoint: {}", checkpoint.name);
302        println!("  ID: {}", checkpoint.id);
303        println!("  Items: {}", checkpoint.item_count);
304        if let Some(ref desc) = checkpoint.description {
305            println!("  Description: {desc}");
306        }
307        if let Some(ref branch) = checkpoint.git_branch {
308            println!("  Git Branch: {branch}");
309        }
310        if let Some(ref git_status) = checkpoint.git_status {
311            println!("  Git Status:");
312            for line in git_status.lines().take(10) {
313                println!("    {line}");
314            }
315        }
316    }
317
318    Ok(())
319}
320
321fn restore(
322    id: &str,
323    categories: Option<&[String]>,
324    tags: Option<&[String]>,
325    db_path: Option<&PathBuf>,
326    actor: Option<&str>,
327    session_id: Option<&str>,
328    json: bool,
329) -> Result<()> {
330    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
331        .ok_or(Error::NotInitialized)?;
332
333    if !db_path.exists() {
334        return Err(Error::NotInitialized);
335    }
336
337    let mut storage = SqliteStorage::open(&db_path)?;
338    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
339
340    // Get checkpoint to verify it exists
341    let checkpoint = storage
342        .get_checkpoint(id)?
343        .ok_or_else(|| {
344            let all_ids = storage.get_all_checkpoint_ids().unwrap_or_default();
345            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
346            if similar.is_empty() {
347                Error::CheckpointNotFound { id: id.to_string() }
348            } else {
349                Error::CheckpointNotFoundSimilar {
350                    id: id.to_string(),
351                    similar,
352                }
353            }
354        })?;
355
356    // Determine target session via TTY-keyed status cache
357    let target_session_id = resolve_session_or_suggest(session_id, &storage)?;
358
359    // Restore items from checkpoint to target session
360    let restored_count = storage.restore_checkpoint(
361        id,
362        &target_session_id,
363        categories,
364        tags,
365        &actor,
366    )?;
367
368    if json {
369        let output = serde_json::json!({
370            "id": checkpoint.id,
371            "name": checkpoint.name,
372            "restored": true,
373            "item_count": restored_count,
374            "target_session_id": target_session_id
375        });
376        println!("{output}");
377    } else {
378        println!("Restored checkpoint: {}", checkpoint.name);
379        println!("  Items restored: {restored_count}");
380        println!("  Target session: {target_session_id}");
381    }
382
383    Ok(())
384}
385
386fn delete(id: &str, db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
387    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
388        .ok_or(Error::NotInitialized)?;
389
390    if !db_path.exists() {
391        return Err(Error::NotInitialized);
392    }
393
394    let mut storage = SqliteStorage::open(&db_path)?;
395    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
396
397    storage.delete_checkpoint(id, &actor)?;
398
399    if json {
400        let output = serde_json::json!({
401            "id": id,
402            "deleted": true
403        });
404        println!("{output}");
405    } else {
406        println!("Deleted checkpoint: {id}");
407    }
408
409    Ok(())
410}
411
412/// Get current git status output.
413fn get_git_status() -> Option<String> {
414    std::process::Command::new("git")
415        .args(["status", "--porcelain"])
416        .output()
417        .ok()
418        .filter(|output| output.status.success())
419        .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
420}
421
422fn add_items(
423    id: &str,
424    keys: &[String],
425    db_path: Option<&PathBuf>,
426    actor: Option<&str>,
427    session_id: Option<&str>,
428    json: bool,
429) -> Result<()> {
430    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
431        .ok_or(Error::NotInitialized)?;
432
433    if !db_path.exists() {
434        return Err(Error::NotInitialized);
435    }
436
437    let mut storage = SqliteStorage::open(&db_path)?;
438    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
439
440    let sid = resolve_session_or_suggest(session_id, &storage)?;
441    let session = storage
442        .get_session(&sid)?
443        .ok_or_else(|| Error::SessionNotFound { id: sid })?;
444
445    // Verify checkpoint exists
446    let checkpoint = storage
447        .get_checkpoint(id)?
448        .ok_or_else(|| {
449            let all_ids = storage.get_all_checkpoint_ids().unwrap_or_default();
450            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
451            if similar.is_empty() {
452                Error::CheckpointNotFound { id: id.to_string() }
453            } else {
454                Error::CheckpointNotFoundSimilar {
455                    id: id.to_string(),
456                    similar,
457                }
458            }
459        })?;
460
461    let added = storage.add_checkpoint_items_by_keys(id, &session.id, keys, &actor)?;
462
463    if json {
464        let output = serde_json::json!({
465            "checkpoint_id": id,
466            "checkpoint_name": checkpoint.name,
467            "keys_requested": keys.len(),
468            "items_added": added
469        });
470        println!("{output}");
471    } else {
472        println!("Added {} items to checkpoint: {}", added, checkpoint.name);
473        if added < keys.len() {
474            println!("  ({} keys not found in current session)", keys.len() - added);
475        }
476    }
477
478    Ok(())
479}
480
481fn remove_items(
482    id: &str,
483    keys: &[String],
484    db_path: Option<&PathBuf>,
485    actor: Option<&str>,
486    json: bool,
487) -> Result<()> {
488    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
489        .ok_or(Error::NotInitialized)?;
490
491    if !db_path.exists() {
492        return Err(Error::NotInitialized);
493    }
494
495    let mut storage = SqliteStorage::open(&db_path)?;
496    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
497
498    // Verify checkpoint exists
499    let checkpoint = storage
500        .get_checkpoint(id)?
501        .ok_or_else(|| {
502            let all_ids = storage.get_all_checkpoint_ids().unwrap_or_default();
503            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
504            if similar.is_empty() {
505                Error::CheckpointNotFound { id: id.to_string() }
506            } else {
507                Error::CheckpointNotFoundSimilar {
508                    id: id.to_string(),
509                    similar,
510                }
511            }
512        })?;
513
514    let removed = storage.remove_checkpoint_items_by_keys(id, keys, &actor)?;
515
516    if json {
517        let output = serde_json::json!({
518            "checkpoint_id": id,
519            "checkpoint_name": checkpoint.name,
520            "keys_requested": keys.len(),
521            "items_removed": removed
522        });
523        println!("{output}");
524    } else {
525        println!("Removed {} items from checkpoint: {}", removed, checkpoint.name);
526        if removed < keys.len() {
527            println!("  ({} keys not found in checkpoint)", keys.len() - removed);
528        }
529    }
530
531    Ok(())
532}
533
534fn items(id: &str, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
535    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
536        .ok_or(Error::NotInitialized)?;
537
538    if !db_path.exists() {
539        return Err(Error::NotInitialized);
540    }
541
542    let storage = SqliteStorage::open(&db_path)?;
543
544    // Verify checkpoint exists
545    let checkpoint = storage
546        .get_checkpoint(id)?
547        .ok_or_else(|| {
548            let all_ids = storage.get_all_checkpoint_ids().unwrap_or_default();
549            let similar = crate::validate::find_similar_ids(id, &all_ids, 3);
550            if similar.is_empty() {
551                Error::CheckpointNotFound { id: id.to_string() }
552            } else {
553                Error::CheckpointNotFoundSimilar {
554                    id: id.to_string(),
555                    similar,
556                }
557            }
558        })?;
559
560    let items = storage.get_checkpoint_items(id)?;
561
562    if json {
563        let output = serde_json::json!({
564            "checkpoint_id": id,
565            "checkpoint_name": checkpoint.name,
566            "count": items.len(),
567            "items": items
568        });
569        println!("{}", serde_json::to_string(&output)?);
570    } else if items.is_empty() {
571        println!("Checkpoint '{}' has no items.", checkpoint.name);
572    } else {
573        println!("Checkpoint '{}' ({} items):", checkpoint.name, items.len());
574        println!();
575        for item in &items {
576            let priority_icon = match item.priority.as_str() {
577                "high" => "!",
578                "low" => "-",
579                _ => " ",
580            };
581            println!("[{}] {} ({})", priority_icon, item.key, item.category);
582            let display_value = if item.value.len() > 80 {
583                format!("{}...", &item.value[..80])
584            } else {
585                item.value.clone()
586            };
587            println!("    {display_value}");
588            println!();
589        }
590    }
591
592    Ok(())
593}