Skip to main content

outrig_cli/cli/
clean.rs

1//! `outrig clean` -- bulk-remove old session records.
2//!
3//! This is intentionally a session-store command, not a container lifecycle
4//! command: it removes old metadata/log directories and refuses to touch
5//! sessions whose container is still running.
6
7use std::future::Future;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime};
10
11use clap::Parser;
12use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
13
14use crate::error::Result;
15use crate::session::{self, Session, SessionStore};
16use outrig::container::Container;
17
18const DAY: u64 = 24 * 60 * 60;
19pub const DEFAULT_OLDER_THAN: Duration = Duration::from_secs(30 * DAY);
20
21#[derive(Debug, Parser)]
22pub struct CleanArgs {
23    /// Remove sessions older than this duration. Supports s, m, h, and d.
24    #[arg(
25        long = "older-than",
26        value_name = "DURATION",
27        default_value = "30d",
28        value_parser = parse_duration
29    )]
30    pub older_than: Duration,
31    /// Skip the interactive `[y/N]` confirmation.
32    #[arg(short = 'y', long = "yes")]
33    pub yes: bool,
34}
35
36pub async fn execute(
37    args: &CleanArgs,
38    session_root_flag: Option<&Path>,
39    repo_cfg_override: Option<&Path>,
40    global_cfg_path: &Path,
41    cwd: &Path,
42) -> Result<i32> {
43    let root = session::resolve_session_root_for_cli(
44        session_root_flag,
45        repo_cfg_override,
46        global_cfg_path,
47        cwd,
48    )?;
49    let store = SessionStore::new(root);
50    let stdin = tokio::io::BufReader::new(tokio::io::stdin());
51    let mut stderr = tokio::io::stderr();
52    execute_with(
53        &mut stderr,
54        stdin,
55        &store,
56        args,
57        SystemTime::now(),
58        podman_is_running,
59    )
60    .await
61}
62
63pub async fn execute_with<E, R, F, Fut>(
64    stderr: &mut E,
65    stdin: R,
66    store: &SessionStore,
67    args: &CleanArgs,
68    now: SystemTime,
69    mut is_running: F,
70) -> Result<i32>
71where
72    E: AsyncWrite + Unpin,
73    R: AsyncBufRead + Unpin,
74    F: FnMut(String) -> Fut,
75    Fut: Future<Output = Result<bool>>,
76{
77    let mut targets = Vec::new();
78    let mut skipped_running = Vec::new();
79
80    for session in store.list()? {
81        if !older_than(&session, args.older_than, now) {
82            continue;
83        }
84        if is_running(session.container_name.clone()).await? {
85            skipped_running.push(session);
86            continue;
87        }
88        targets.push(CleanTarget {
89            dir: session.session_dir.clone(),
90            session,
91        });
92    }
93
94    write_skipped_running(stderr, &skipped_running).await?;
95
96    let retention = format_retention(args.older_than);
97    if targets.is_empty() {
98        let msg = format!("[outrig] no stopped sessions older than {retention}\n");
99        stderr.write_all(msg.as_bytes()).await?;
100        return Ok(0);
101    }
102
103    write_preview(stderr, &targets, &retention).await?;
104
105    if !args.yes && !confirm(stderr, stdin, targets.len()).await? {
106        stderr.write_all(b"[outrig] aborted\n").await?;
107        return Ok(0);
108    }
109
110    let mut removed = 0usize;
111    for target in &targets {
112        store.remove_by_id(&target.session.id)?;
113        removed += 1;
114
115        let dir_msg = format!("[outrig] removed {}\n", target.dir.display());
116        stderr.write_all(dir_msg.as_bytes()).await?;
117        if target.session.link_target.is_some() {
118            let link_path = store.symlink_path(&target.session.id);
119            let link_msg = format!("[outrig] removed {} (symlink)\n", link_path.display());
120            stderr.write_all(link_msg.as_bytes()).await?;
121        }
122    }
123
124    let summary = format!("[outrig] cleaned {}\n", session_count(removed));
125    stderr.write_all(summary.as_bytes()).await?;
126    Ok(0)
127}
128
129pub fn parse_duration(value: &str) -> std::result::Result<Duration, String> {
130    let raw = value.trim();
131    if raw.len() < 2 {
132        return Err("expected a duration like 30d, 12h, 45m, or 10s".to_string());
133    }
134
135    let (amount, unit) = raw.split_at(raw.len() - 1);
136    if amount.is_empty() || !amount.bytes().all(|b| b.is_ascii_digit()) {
137        return Err("duration amount must be a positive integer".to_string());
138    }
139
140    let amount: u64 = amount
141        .parse()
142        .map_err(|_| "duration amount is too large".to_string())?;
143    if amount == 0 {
144        return Err("duration amount must be greater than zero".to_string());
145    }
146
147    let unit_secs = match unit {
148        "s" => 1,
149        "m" => 60,
150        "h" => 60 * 60,
151        "d" => DAY,
152        _ => return Err("duration unit must be one of s, m, h, or d".to_string()),
153    };
154
155    let secs = amount
156        .checked_mul(unit_secs)
157        .ok_or_else(|| "duration is too large".to_string())?;
158    Ok(Duration::from_secs(secs))
159}
160
161struct CleanTarget {
162    session: Session,
163    dir: PathBuf,
164}
165
166fn older_than(session: &Session, cutoff: Duration, now: SystemTime) -> bool {
167    let age_basis = session.ended_at.unwrap_or(session.started_at);
168    now.duration_since(age_basis)
169        .map(|age| age >= cutoff)
170        .unwrap_or(false)
171}
172
173async fn write_skipped_running<E>(stderr: &mut E, skipped: &[Session]) -> Result<()>
174where
175    E: AsyncWrite + Unpin,
176{
177    if skipped.is_empty() {
178        return Ok(());
179    }
180
181    stderr
182        .write_all(b"[outrig] skipped running sessions:\n")
183        .await?;
184    for session in skipped {
185        let line = format!("  {}  {}\n", session.id, session.container_name);
186        stderr.write_all(line.as_bytes()).await?;
187    }
188    Ok(())
189}
190
191async fn write_preview<E>(stderr: &mut E, targets: &[CleanTarget], retention: &str) -> Result<()>
192where
193    E: AsyncWrite + Unpin,
194{
195    let header = format!(
196        "[outrig] will remove {} older than {retention}:\n",
197        session_count(targets.len())
198    );
199    stderr.write_all(header.as_bytes()).await?;
200    for target in targets {
201        let (label, timestamp) = match target.session.ended_at {
202            Some(t) => ("ended", t),
203            None => ("started", target.session.started_at),
204        };
205        let line = format!(
206            "  {}  {} {}  {}\n",
207            target.session.id,
208            label,
209            session::format_started_at(timestamp),
210            target.dir.display()
211        );
212        stderr.write_all(line.as_bytes()).await?;
213    }
214    Ok(())
215}
216
217async fn confirm<E, R>(stderr: &mut E, mut stdin: R, count: usize) -> Result<bool>
218where
219    E: AsyncWrite + Unpin,
220    R: AsyncBufRead + Unpin,
221{
222    let prompt = format!("Clean {}? [y/N]: ", session_count(count));
223    stderr.write_all(prompt.as_bytes()).await?;
224    stderr.flush().await?;
225    let mut line = String::new();
226    stdin.read_line(&mut line).await?;
227    let answer = line.trim().to_ascii_lowercase();
228    Ok(answer == "y" || answer == "yes")
229}
230
231fn session_count(count: usize) -> String {
232    if count == 1 {
233        "1 session".to_string()
234    } else {
235        format!("{count} sessions")
236    }
237}
238
239fn format_retention(duration: Duration) -> String {
240    let secs = duration.as_secs();
241    if secs.is_multiple_of(DAY) {
242        format!("{}d", secs / DAY)
243    } else if secs.is_multiple_of(60 * 60) {
244        format!("{}h", secs / (60 * 60))
245    } else if secs.is_multiple_of(60) {
246        format!("{}m", secs / 60)
247    } else {
248        format!("{secs}s")
249    }
250}
251
252async fn podman_is_running(name: String) -> Result<bool> {
253    Ok(Container::is_running(&name).await?)
254}