Skip to main content

outrig_cli/cli/
ls.rs

1//! `outrig ls` -- list sessions newest-first under the session root.
2//!
3//! Output discipline: the table goes to stdout (it's the scriptable thing); a
4//! "no sessions" notice goes to stderr. Symlinked entries (created via
5//! `outrig run --session-dir`) get a trailing `-> <target>` so the user can
6//! see where the real bytes live.
7
8use std::fmt::Write as _;
9use std::path::Path;
10use std::time::SystemTime;
11
12use clap::Parser;
13use tokio::io::{AsyncWrite, AsyncWriteExt};
14
15use crate::error::Result;
16use crate::session::{self, Session, SessionStore};
17
18#[derive(Debug, Parser)]
19pub struct LsArgs {}
20
21pub async fn execute(
22    _args: &LsArgs,
23    session_root_flag: Option<&Path>,
24    repo_cfg_override: Option<&Path>,
25    global_cfg_path: &Path,
26    cwd: &Path,
27) -> Result<i32> {
28    let root = session::resolve_session_root_for_cli(
29        session_root_flag,
30        repo_cfg_override,
31        global_cfg_path,
32        cwd,
33    )?;
34    let store = SessionStore::new(root);
35    let mut stdout = tokio::io::stdout();
36    let mut stderr = tokio::io::stderr();
37    execute_with(&mut stdout, &mut stderr, &store).await
38}
39
40pub async fn execute_with<W, E>(stdout: &mut W, stderr: &mut E, store: &SessionStore) -> Result<i32>
41where
42    W: AsyncWrite + Unpin,
43    E: AsyncWrite + Unpin,
44{
45    let sessions = store.list()?;
46    if sessions.is_empty() {
47        stderr.write_all(b"[outrig] no sessions\n").await?;
48        return Ok(0);
49    }
50    let now = SystemTime::now();
51    let table = render_table(&sessions, now);
52    stdout.write_all(table.as_bytes()).await?;
53    Ok(0)
54}
55
56/// Hand-rolled column padding (per task notes: don't pull in a table crate).
57/// Columns: `ID`, `STARTED`, `DURATION`, `IMAGE`, `EXIT`. The `EXIT`
58/// column trails with `-> <target>` for symlinked entries.
59fn render_table(sessions: &[Session], now: SystemTime) -> String {
60    let rows: Vec<Row> = sessions.iter().map(|s| Row::from_session(s, now)).collect();
61
62    let id_w = max_width("ID", rows.iter().map(|r| r.id.as_str()));
63    let started_w = max_width("STARTED", rows.iter().map(|r| r.started.as_str()));
64    let duration_w = max_width("DURATION", rows.iter().map(|r| r.duration.as_str()));
65    let image_w = max_width("IMAGE", rows.iter().map(|r| r.image.as_str()));
66
67    let mut out = String::new();
68    let _ = writeln!(
69        out,
70        "{:<id_w$}  {:<started_w$}  {:<duration_w$}  {:<image_w$}  EXIT",
71        "ID",
72        "STARTED",
73        "DURATION",
74        "IMAGE",
75        id_w = id_w,
76        started_w = started_w,
77        duration_w = duration_w,
78        image_w = image_w,
79    );
80    for r in &rows {
81        let exit_cell = match &r.link_target {
82            Some(t) => format!("{:<3}  -> {}", r.exit, t),
83            None => r.exit.clone(),
84        };
85        let _ = writeln!(
86            out,
87            "{:<id_w$}  {:<started_w$}  {:<duration_w$}  {:<image_w$}  {}",
88            r.id,
89            r.started,
90            r.duration,
91            r.image,
92            exit_cell,
93            id_w = id_w,
94            started_w = started_w,
95            duration_w = duration_w,
96            image_w = image_w,
97        );
98    }
99    out
100}
101
102struct Row {
103    id: String,
104    started: String,
105    duration: String,
106    image: String,
107    exit: String,
108    link_target: Option<String>,
109}
110
111impl Row {
112    fn from_session(s: &Session, now: SystemTime) -> Self {
113        let started = session::format_started_at(s.started_at);
114        let end = s.ended_at.unwrap_or(now);
115        let duration = end
116            .duration_since(s.started_at)
117            .map(session::format_duration)
118            .unwrap_or_else(|_| "?".to_string());
119        let image = s.image_config_name.clone();
120        let exit = match s.exit_code {
121            Some(c) => c.to_string(),
122            None => "-".to_string(),
123        };
124        let link_target = s.link_target.as_ref().map(|p| p.display().to_string());
125        Self {
126            id: s.id.as_str().to_string(),
127            started,
128            duration,
129            image,
130            exit,
131            link_target,
132        }
133    }
134}
135
136fn max_width<'a, I: IntoIterator<Item = &'a str>>(header: &str, values: I) -> usize {
137    let mut w = header.len();
138    for v in values {
139        if v.len() > w {
140            w = v.len();
141        }
142    }
143    w
144}