1use 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);
23const 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 pub session: Option<String>,
38 pub server: Option<String>,
40 #[arg(short = 'f', long = "follow")]
42 pub follow: bool,
43 #[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
172fn 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
206async 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 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}