1use 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#[derive(Serialize)]
15struct CheckpointCreateOutput {
16 id: String,
17 name: String,
18 session_id: String,
19 item_count: usize,
20}
21
22#[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
38pub 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), 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 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 let id = format!("ckpt_{}", &uuid::Uuid::new_v4().to_string()[..12]);
125
126 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 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 let resolved_session_id = if let Some(sid) = session_id {
189 Some(sid.to_string())
190 } else if !all_projects {
191 resolve_session_id(None).ok()
193 } else {
194 None
195 };
196
197 #[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))? } else if all_projects {
202 storage.get_all_checkpoints()?
203 } else {
204 vec![]
206 };
207
208 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 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 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 let target_session_id = resolve_session_or_suggest(session_id, &storage)?;
358
359 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
412fn 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 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 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 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}