1use 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 #[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 #[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}