Skip to main content

outrig_cli/cli/
logs.rs

1//! `outrig logs` -- print or `tail -F` an MCP server's captured stderr.
2//!
3//! Three modes:
4//! - `outrig logs <sid>` (no server) -- list available log files with sizes.
5//! - `outrig logs <sid> <server>` -- cat that server's `.stderr` file.
6//! - `outrig logs <sid> <server> --follow` -- polling tail.
7//!
8//! `--session-dir <path>` substitutes for `<sid>` when the user already knows
9//! the on-disk path (avoids the id lookup). The two are mutually exclusive
10//! per the [`ArgGroup`].
11
12use std::fmt::Write as FmtWrite;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use clap::{ArgGroup, Parser};
17use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt};
18
19use crate::error::{OutrigError, Result};
20use crate::session::{self, SessionStore};
21
22const FOLLOW_POLL: Duration = Duration::from_millis(200);
23/// MCP server stderr captures land at `<session>/logs/<server>.stderr` (see
24/// `src/mcp.rs`). Used both to build the file name and to strip the suffix
25/// for display.
26const LOG_SUFFIX: &str = ".stderr";
27
28#[derive(Debug, Parser)]
29#[command(group(
30    ArgGroup::new("logs_target")
31        .args(["session", "session_dir"])
32        .multiple(false)
33        .required(false)
34))]
35pub struct LogsArgs {
36    /// Session id (substring match if unambiguous).
37    pub session: Option<String>,
38    /// MCP server name (omit to list available logs).
39    pub server: Option<String>,
40    /// Tail the file; continue reading as new lines arrive.
41    #[arg(short = 'f', long = "follow")]
42    pub follow: bool,
43    /// Read directly from this session dir, skipping the id lookup.
44    #[arg(long = "session-dir", value_name = "PATH")]
45    pub session_dir: Option<PathBuf>,
46}
47
48pub async fn execute(
49    args: &LogsArgs,
50    session_root_flag: Option<&Path>,
51    repo_cfg_override: Option<&Path>,
52    global_cfg_path: &Path,
53    cwd: &Path,
54) -> Result<i32> {
55    let logs_dir = resolve_logs_dir(
56        args,
57        session_root_flag,
58        repo_cfg_override,
59        global_cfg_path,
60        cwd,
61    )?;
62    let mut stdout = tokio::io::stdout();
63    let mut stderr = tokio::io::stderr();
64    execute_with(
65        &mut stdout,
66        &mut stderr,
67        &logs_dir,
68        args.server.as_deref(),
69        args.follow,
70    )
71    .await
72}
73
74pub async fn execute_with<W, E>(
75    stdout: &mut W,
76    stderr: &mut E,
77    logs_dir: &Path,
78    server: Option<&str>,
79    follow: bool,
80) -> Result<i32>
81where
82    W: AsyncWrite + Unpin,
83    E: AsyncWrite + Unpin,
84{
85    match server {
86        None => {
87            list_logs(stdout, stderr, logs_dir).await?;
88            Ok(0)
89        }
90        Some(s) => {
91            let path = logs_dir.join(format!("{s}{LOG_SUFFIX}"));
92            cat_file(stdout, &path).await?;
93            if follow {
94                follow_file(stdout, &path).await?;
95            }
96            Ok(0)
97        }
98    }
99}
100
101fn resolve_logs_dir(
102    args: &LogsArgs,
103    session_root_flag: Option<&Path>,
104    repo_cfg_override: Option<&Path>,
105    global_cfg_path: &Path,
106    cwd: &Path,
107) -> Result<PathBuf> {
108    if let Some(dir) = args.session_dir.as_deref() {
109        return Ok(dir.join("logs"));
110    }
111    let Some(query) = args.session.as_deref() else {
112        return Err(OutrigError::Configuration(
113            "outrig logs requires either a session id or --session-dir".to_string(),
114        )
115        .into());
116    };
117    let root = session::resolve_session_root_for_cli(
118        session_root_flag,
119        repo_cfg_override,
120        global_cfg_path,
121        cwd,
122    )?;
123    let store = SessionStore::new(root);
124    let (dir, _) = super::resolve_session_arg(&store, query)?;
125    Ok(dir.join("logs"))
126}
127
128async fn list_logs<W, E>(stdout: &mut W, stderr: &mut E, logs_dir: &Path) -> Result<()>
129where
130    W: AsyncWrite + Unpin,
131    E: AsyncWrite + Unpin,
132{
133    let mut entries: Vec<(String, u64)> = Vec::new();
134    let mut rd = match tokio::fs::read_dir(logs_dir).await {
135        Ok(rd) => rd,
136        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
137            stderr
138                .write_all(b"[outrig] no logs directory for this session\n")
139                .await?;
140            return Ok(());
141        }
142        Err(e) => return Err(e.into()),
143    };
144    while let Some(ent) = rd.next_entry().await? {
145        let meta = ent.metadata().await?;
146        if !meta.is_file() {
147            continue;
148        }
149        let raw = ent.file_name().to_string_lossy().into_owned();
150        let display = raw.strip_suffix(LOG_SUFFIX).unwrap_or(&raw).to_string();
151        entries.push((display, meta.len()));
152    }
153    entries.sort();
154
155    let header = format!("[outrig] logs in {}:\n", logs_dir.display());
156    stderr.write_all(header.as_bytes()).await?;
157
158    if entries.is_empty() {
159        stderr.write_all(b"  (none)\n").await?;
160        return Ok(());
161    }
162
163    let pad = entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
164    let mut out = String::new();
165    for (name, size) in &entries {
166        let _ = writeln!(out, "  {:<pad$}  ({})", name, format_size(*size), pad = pad);
167    }
168    stdout.write_all(out.as_bytes()).await?;
169    Ok(())
170}
171
172/// Sizes formatted like `1.2 KiB`, `3.4 MiB` -- one decimal, IEC prefixes.
173fn format_size(bytes: u64) -> String {
174    const KIB: f64 = 1024.0;
175    const MIB: f64 = KIB * 1024.0;
176    const GIB: f64 = MIB * 1024.0;
177    let b = bytes as f64;
178    if b < KIB {
179        format!("{bytes} B")
180    } else if b < MIB {
181        format!("{:.1} KiB", b / KIB)
182    } else if b < GIB {
183        format!("{:.1} MiB", b / MIB)
184    } else {
185        format!("{:.1} GiB", b / GIB)
186    }
187}
188
189async fn cat_file<W: AsyncWrite + Unpin>(stdout: &mut W, path: &Path) -> Result<u64> {
190    let mut file = match tokio::fs::File::open(path).await {
191        Ok(f) => f,
192        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
193            return Err(OutrigError::Configuration(format!(
194                "log file {} does not exist",
195                path.display()
196            ))
197            .into());
198        }
199        Err(e) => return Err(e.into()),
200    };
201    let n = tokio::io::copy(&mut file, stdout).await?;
202    stdout.flush().await?;
203    Ok(n)
204}
205
206/// Polling tail. Caller has already cat'd the file once via [`cat_file`].
207/// Loop: stat, if size grew read+write delta, if size shrank reopen from
208/// byte 0 (file was truncated/rotated). Terminates on `ctrl_c`.
209async fn follow_file<W: AsyncWrite + Unpin>(stdout: &mut W, path: &Path) -> Result<()> {
210    let mut pos: u64 = tokio::fs::metadata(path).await?.len();
211    let mut buf = [0u8; 8192];
212    loop {
213        tokio::select! {
214            _ = tokio::signal::ctrl_c() => return Ok(()),
215            _ = tokio::time::sleep(FOLLOW_POLL) => {}
216        }
217        let len = match tokio::fs::metadata(path).await {
218            Ok(m) => m.len(),
219            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
220            Err(e) => return Err(e.into()),
221        };
222        if len < pos {
223            // Truncation/rotation: re-read from the start.
224            pos = 0;
225        }
226        if len > pos {
227            let mut file = tokio::fs::File::open(path).await?;
228            file.seek(std::io::SeekFrom::Start(pos)).await?;
229            loop {
230                let n = file.read(&mut buf).await?;
231                if n == 0 {
232                    break;
233                }
234                stdout.write_all(&buf[..n]).await?;
235                pos += n as u64;
236            }
237            stdout.flush().await?;
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::format_size;
245
246    #[test]
247    fn size_formatting() {
248        assert_eq!(format_size(0), "0 B");
249        assert_eq!(format_size(512), "512 B");
250        assert_eq!(format_size(1228), "1.2 KiB");
251        assert_eq!(format_size(3 * 1024 * 1024 + 400 * 1024), "3.4 MiB");
252    }
253}