Skip to main content

outrig_cli/cli/
discard.rs

1//! `outrig discard` -- delete a session's on-disk record.
2//!
3//! Refuses if the session's container is still running (would race the
4//! `outrig run` writer). The running-check is closure-injected so tests can
5//! drive both branches without a podman dependency.
6
7use std::future::Future;
8use std::path::{Path, PathBuf};
9
10use clap::{ArgGroup, Parser};
11use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
12
13use crate::error::{OutrigError, Result};
14use crate::session::{self, Session, SessionStore};
15use outrig::container::Container;
16
17#[derive(Debug, Parser)]
18#[command(group(
19    ArgGroup::new("discard_target")
20        .args(["session", "session_dir"])
21        .multiple(false)
22        .required(false)
23))]
24pub struct DiscardArgs {
25    /// Session id (substring match if unambiguous).
26    pub session: Option<String>,
27    /// Skip the interactive `[y/N]` confirmation.
28    #[arg(short = 'y', long = "yes")]
29    pub yes: bool,
30    /// Discard exactly this session dir, skipping the id lookup.
31    #[arg(long = "session-dir", value_name = "PATH")]
32    pub session_dir: Option<PathBuf>,
33}
34
35pub async fn execute(
36    args: &DiscardArgs,
37    session_root_flag: Option<&Path>,
38    repo_cfg_override: Option<&Path>,
39    global_cfg_path: &Path,
40    cwd: &Path,
41) -> Result<i32> {
42    // `--session-dir` skips the id lookup entirely, so the root is unused.
43    // Resolving it anyway would surface a config-parse error on a path
44    // that doesn't need the config at all.
45    let root = if args.session_dir.is_some() {
46        PathBuf::new()
47    } else {
48        session::resolve_session_root_for_cli(
49            session_root_flag,
50            repo_cfg_override,
51            global_cfg_path,
52            cwd,
53        )?
54    };
55    let store = SessionStore::new(root);
56    let stdin = tokio::io::BufReader::new(tokio::io::stdin());
57    let mut stderr = tokio::io::stderr();
58    execute_with(&mut stderr, stdin, &store, args, podman_is_running).await
59}
60
61/// Inner form: every effect is injected so the test suite can drive both
62/// the "container running" and "user typed n" branches deterministically.
63/// `is_running` takes `String` (not `&str`) so its returned future is
64/// `'static` -- avoids the HRTB friction with `FnOnce(&str) -> impl Future`.
65pub async fn execute_with<E, R, F, Fut>(
66    stderr: &mut E,
67    stdin: R,
68    store: &SessionStore,
69    args: &DiscardArgs,
70    is_running: F,
71) -> Result<i32>
72where
73    E: AsyncWrite + Unpin,
74    R: AsyncBufRead + Unpin,
75    F: FnOnce(String) -> Fut,
76    Fut: Future<Output = Result<bool>>,
77{
78    let target = resolve_target(args, store)?;
79
80    if is_running(target.session.container_name.clone()).await? {
81        return Err(OutrigError::Configuration(format!(
82            "session {} is still running (container {}); stop it before discarding",
83            target.session.id, target.session.container_name
84        ))
85        .into());
86    }
87
88    if !args.yes && !confirm(stderr, stdin, &target.dir).await? {
89        stderr.write_all(b"[outrig] aborted\n").await?;
90        return Ok(0);
91    }
92
93    if args.session_dir.is_some() {
94        store.remove_by_path(&target.dir)?;
95    } else {
96        store.remove_by_id(&target.session.id)?;
97    }
98
99    let dir_msg = format!("[outrig] removed {}\n", target.dir.display());
100    stderr.write_all(dir_msg.as_bytes()).await?;
101    if args.session_dir.is_none() && target.session.link_target.is_some() {
102        let link_path = store.symlink_path(&target.session.id);
103        let link_msg = format!("[outrig] removed {} (symlink)\n", link_path.display());
104        stderr.write_all(link_msg.as_bytes()).await?;
105    }
106    Ok(0)
107}
108
109async fn confirm<E, R>(stderr: &mut E, mut stdin: R, dir: &Path) -> Result<bool>
110where
111    E: AsyncWrite + Unpin,
112    R: AsyncBufRead + Unpin,
113{
114    let prompt = format!("Discard {}? [y/N]: ", dir.display());
115    stderr.write_all(prompt.as_bytes()).await?;
116    stderr.flush().await?;
117    let mut line = String::new();
118    stdin.read_line(&mut line).await?;
119    let answer = line.trim().to_ascii_lowercase();
120    Ok(answer == "y" || answer == "yes")
121}
122
123struct Target {
124    dir: PathBuf,
125    session: Session,
126}
127
128fn resolve_target(args: &DiscardArgs, store: &SessionStore) -> Result<Target> {
129    if let Some(dir) = args.session_dir.as_deref() {
130        let session = store.get_by_path(dir)?;
131        return Ok(Target {
132            dir: dir.to_path_buf(),
133            session,
134        });
135    }
136    let Some(query) = args.session.as_deref() else {
137        return Err(OutrigError::Configuration(
138            "outrig discard requires either a session id or --session-dir".to_string(),
139        )
140        .into());
141    };
142    let (dir, session) = super::resolve_session_arg(store, query)?;
143    Ok(Target { dir, session })
144}
145
146async fn podman_is_running(name: String) -> Result<bool> {
147    Ok(Container::is_running(&name).await?)
148}