Skip to main content

agg/cmds/sessions/
delete.rs

1use 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    // Outcomes are 1:1 with `selected` / `targets` order; pair by index so duplicate
25    // Cursor ids still confirm the row whose path was deleted.
26    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}