agg/cmds/sessions/
delete.rs1use std::path::Path;
2
3use owo_colors::OwoColorize;
4use rootcause::report;
5use ytil_agents::agent::session::SessionKey;
6use ytil_agents::agent::session_deletion::DeletionOutcome;
7use ytil_agents::agent::session_deletion::DeletionTarget;
8
9use super::list::RenderableSession;
10
11pub(super) fn delete_selected_sessions(selected: &[RenderableSession], home_dir: &Path) -> rootcause::Result<()> {
12 let targets = selected
13 .iter()
14 .map(|session| {
15 DeletionTarget::new(
16 SessionKey::new(session.session.agent, &session.session.id),
17 session.session.path.clone(),
18 )
19 })
20 .collect::<Vec<_>>();
21 let report = ytil_agents::agent::session_deletion::delete_session_targets(home_dir, &targets);
22 let mut failures = Vec::new();
23
24 for (index, outcome) in report.outcomes().iter().enumerate() {
27 match outcome {
28 DeletionOutcome::Deleted {
29 key,
30 related_session_count,
31 } => {
32 if let Some(session) = selected.get(index) {
33 println!("{}", deleted_session_output(session, key, *related_session_count));
34 }
35 }
36 DeletionOutcome::Failed { key, error } => failures.push(format!("{key}: {error}")),
37 }
38 }
39 for skipped_path in report.skipped_paths() {
40 println!(
41 "{} {}",
42 "Skipped unreadable session file".yellow().bold(),
43 skipped_path.display()
44 );
45 }
46
47 if failures.is_empty() {
48 Ok(())
49 } else {
50 Err(report!("session deletion failed").attach(failures.join("\n")))
51 }
52}
53
54fn deleted_session_output(session: &RenderableSession, key: &SessionKey, related_session_count: usize) -> String {
55 let related_sessions = if related_session_count > 0 {
56 format!(" ({related_session_count} related sessions)")
57 } else {
58 String::new()
59 };
60 format!(
61 "{} {session}\n └─ {}{related_sessions}",
62 "Deleted".red().bold(),
63 key.id().white().bold()
64 )
65}
66
67#[cfg(test)]
68mod tests {
69 use std::path::PathBuf;
70
71 use jiff::Timestamp;
72 use test_that::prelude::*;
73 use ytil_agents::agent::Agent;
74 use ytil_agents::agent::session::Session;
75 use ytil_agents::agent::session::SessionKey;
76
77 use super::super::list::RenderableSession;
78 use super::*;
79
80 #[test]
81 fn test_render_deleted_session_when_no_related_sessions_prints_id_without_count() {
82 let session = render_test_session();
83 let key = SessionKey::new(Agent::Claude, "session-id");
84
85 let output = rendered_output(&session, &key, 0);
86
87 assert_that!(
88 output,
89 eq("Deleted cl /workspace 01/01/1970-00:00 01/01/1970-00:00\n └─ session-id")
90 );
91 }
92
93 #[test]
94 fn test_render_deleted_session_when_related_sessions_exist_prints_id_and_count() {
95 let session = render_test_session();
96 let key = SessionKey::new(Agent::Claude, "session-id");
97
98 let output = rendered_output(&session, &key, 2);
99
100 assert_that!(
101 output,
102 eq("Deleted cl /workspace 01/01/1970-00:00 01/01/1970-00:00\n └─ session-id (2 related sessions)")
103 );
104 }
105
106 #[test]
107 fn test_session_for_deletion_outcome_when_cursor_ids_duplicate_uses_outcome_index_path() {
108 let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
109 let session_id = "24ba5086-7cca-419c-85c7-e9d636670fbe";
110 let first_path = PathBuf::from("/tmp/chats/hash-a").join(session_id);
111 let second_path = PathBuf::from("/tmp/chats/hash-b").join(session_id);
112 let home_dir = PathBuf::from("/tmp");
113 let selected = [
114 RenderableSession::for_test(
115 Session {
116 id: session_id.to_owned(),
117 agent: Agent::Cursor,
118 name: "Code Review Comments".to_owned(),
119 last_user_prompt: None,
120 search_text: "Code Review Comments".to_owned(),
121 workspace: PathBuf::from("/"),
122 path: first_path.clone(),
123 created_at,
124 updated_at: created_at,
125 },
126 home_dir.clone(),
127 ),
128 RenderableSession::for_test(
129 Session {
130 id: session_id.to_owned(),
131 agent: Agent::Cursor,
132 name: "KPay B2B Denormalized".to_owned(),
133 last_user_prompt: None,
134 search_text: "KPay B2B Denormalized".to_owned(),
135 workspace: PathBuf::from("/tmp/conversions"),
136 path: second_path.clone(),
137 created_at,
138 updated_at: created_at,
139 },
140 home_dir,
141 ),
142 ];
143
144 let first = selected.first().expect("first outcome should resolve");
145 let second = selected.get(1).expect("second outcome should resolve");
146
147 assert_that!(first.session.path, eq(first_path));
148 assert_that!(second.session.path, eq(second_path));
149 assert_that!(first.session.id.as_str(), eq(second.session.id.as_str()));
150 }
151
152 fn rendered_output(session: &RenderableSession, key: &SessionKey, related_session_count: usize) -> String {
153 strip_ansi(&deleted_session_output(session, key, related_session_count))
154 }
155
156 fn strip_ansi(output: &str) -> String {
157 let mut plain = String::new();
158 let mut chars = output.chars();
159 while let Some(character) = chars.next() {
160 if character != '\x1b' {
161 plain.push(character);
162 continue;
163 }
164 for escaped in chars.by_ref() {
165 if escaped.is_ascii_alphabetic() {
166 break;
167 }
168 }
169 }
170 plain
171 }
172
173 fn render_test_session() -> RenderableSession {
174 let created_at = Timestamp::from_millisecond(1).expect("test timestamp should be valid");
175 RenderableSession::for_test(
176 Session {
177 id: "session-id".to_owned(),
178 agent: Agent::Claude,
179 name: "session-name".to_owned(),
180 last_user_prompt: None,
181 search_text: "session-name".to_owned(),
182 workspace: PathBuf::from("/workspace"),
183 path: PathBuf::from("/sessions/session.jsonl"),
184 created_at,
185 updated_at: created_at,
186 },
187 PathBuf::from("/"),
188 )
189 }
190}