use std::io::{IsTerminal, Write};
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, anyhow};
use chrono::{DateTime, Utc};
use clap::{Args, ValueEnum};
use console::style;
use microsandbox_utils::log_text::{base64_decode, split_leading_timestamp, strip_ansi};
use regex::Regex;
use serde::Deserialize;
#[derive(Debug, Args)]
pub struct LogsArgs {
pub name: String,
#[arg(long)]
pub tail: Option<usize>,
#[arg(long)]
pub since: Option<String>,
#[arg(long)]
pub until: Option<String>,
#[arg(short = 'f', long)]
pub follow: bool,
#[arg(long)]
pub timestamps: bool,
#[arg(long, value_enum, value_delimiter = ',')]
pub source: Vec<SourceFilter>,
#[arg(long)]
pub grep: Option<String>,
#[arg(long)]
pub json: bool,
#[arg(long, value_enum, default_value = "auto")]
pub color: ColorMode,
#[arg(long, conflicts_with = "color")]
pub no_color: bool,
#[arg(long)]
pub show_id: bool,
#[arg(long)]
pub color_sessions: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum SourceFilter {
Stdout,
Stderr,
Output,
System,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Debug, Deserialize)]
struct LogEntry {
t: String,
s: String,
d: String,
#[serde(default)]
id: Option<u64>,
#[serde(default)]
e: Option<String>,
}
pub async fn run(args: LogsArgs) -> anyhow::Result<()> {
let log_dir = microsandbox::sandbox::logs::log_dir_for(&args.name);
if !log_dir.exists() {
return Err(anyhow!(
"no logs directory for sandbox {:?} (sandbox not found?)",
&args.name
));
}
let sources = resolve_sources(&args.source);
let since = parse_time_arg(args.since.as_deref())?;
let until = parse_time_arg(args.until.as_deref())?;
let grep_re = match args.grep.as_deref() {
Some(pat) => Some(Regex::new(pat).context("invalid --grep regex")?),
None => None,
};
let color_policy = if args.no_color || std::env::var_os("NO_COLOR").is_some() {
ColorMode::Never
} else {
args.color
};
render_boot_error_if_present(&log_dir, &args.name, args.json)?;
let mut entries = read_all_entries(&log_dir, sources)?;
apply_filters(&mut entries, since, until, grep_re.as_ref(), args.tail);
render_entries(&entries, &args, color_policy)?;
if args.follow {
let last_t = entries.last().map(|e| e.t.clone());
follow_loop(
&log_dir,
sources,
&args,
color_policy,
last_t,
grep_re.as_ref(),
)
.await?;
}
Ok(())
}
fn resolve_sources(picked: &[SourceFilter]) -> SourceMask {
if picked.is_empty() {
return SourceMask {
stdout: true,
stderr: true,
output: true,
system: false,
};
}
let mut mask = SourceMask::default();
for s in picked {
match s {
SourceFilter::Stdout => mask.stdout = true,
SourceFilter::Stderr => mask.stderr = true,
SourceFilter::Output => mask.output = true,
SourceFilter::System => mask.system = true,
SourceFilter::All => {
mask.stdout = true;
mask.stderr = true;
mask.output = true;
mask.system = true;
}
}
}
mask
}
#[derive(Debug, Clone, Copy, Default)]
struct SourceMask {
stdout: bool,
stderr: bool,
output: bool,
system: bool,
}
impl SourceMask {
fn includes_exec_sources(&self) -> bool {
self.stdout || self.stderr || self.output
}
}
fn render_boot_error_if_present(log_dir: &Path, name: &str, json_mode: bool) -> anyhow::Result<()> {
let boot_err = match microsandbox_runtime::boot_error::BootError::read(log_dir) {
Ok(Some(b)) => b,
Ok(None) => return Ok(()),
Err(_) => return Ok(()),
};
if json_mode {
let payload = serde_json::to_string(&boot_err).unwrap_or_default();
let line = serde_json::json!({
"t": boot_err.t,
"s": "boot-error",
"d": payload,
});
println!("{line}");
return Ok(());
}
crate::boot_error_render::render(name, &boot_err);
eprintln!();
Ok(())
}
fn read_all_entries(log_dir: &Path, sources: SourceMask) -> anyhow::Result<Vec<LogEntry>> {
let mut entries: Vec<LogEntry> = Vec::new();
if sources.includes_exec_sources() {
for suffix in [".3", ".2", ".1", ""].iter() {
let path = if suffix.is_empty() {
log_dir.join("exec.log")
} else {
log_dir.join(format!("exec.log{suffix}"))
};
if !path.exists() {
continue;
}
append_jsonl_entries(&path, &mut entries, sources)?;
}
}
if sources.system {
append_text_log_as_system(&log_dir.join("runtime.log"), &mut entries);
append_text_log_as_system(&log_dir.join("kernel.log"), &mut entries);
entries.sort_by_key(|e| parse_entry_time(&e.t).unwrap_or(DateTime::<Utc>::MIN_UTC));
}
Ok(entries)
}
fn append_jsonl_entries(
path: &Path,
out: &mut Vec<LogEntry>,
sources: SourceMask,
) -> anyhow::Result<()> {
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let text = String::from_utf8_lossy(&bytes);
for line in text.lines() {
if line.is_empty() {
continue;
}
match serde_json::from_str::<LogEntry>(line) {
Ok(entry) => {
if entry_passes_source_mask(&entry, sources) {
out.push(entry);
}
}
Err(_) => {
}
}
}
Ok(())
}
fn entry_passes_source_mask(entry: &LogEntry, mask: SourceMask) -> bool {
match entry.s.as_str() {
"stdout" => mask.stdout,
"stderr" => mask.stderr,
"output" => mask.output,
"system" => mask.system,
_ => true, }
}
fn append_text_log_as_system(path: &Path, out: &mut Vec<LogEntry>) {
if !path.exists() {
return;
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return,
};
let text = String::from_utf8_lossy(&bytes);
let mtime_iso = file_mtime_rfc3339(path).unwrap_or_else(now_rfc3339);
for line in text.lines() {
if line.is_empty() {
continue;
}
let stripped = strip_ansi(line);
let (t, body) = match split_leading_timestamp(&stripped) {
Some((t, body)) => (t.to_string(), body.to_string()),
None => (mtime_iso.clone(), stripped.clone()),
};
out.push(LogEntry {
t,
s: "system".into(),
d: format!("{}\n", body),
id: None,
e: None,
});
}
}
fn file_mtime_rfc3339(path: &Path) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?;
let dt: DateTime<Utc> = modified.into();
Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
fn apply_filters(
entries: &mut Vec<LogEntry>,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
grep: Option<&Regex>,
tail: Option<usize>,
) {
if let Some(s) = since {
entries.retain(|e| match parse_entry_time(&e.t) {
Some(t) => t >= s,
None => true,
});
}
if let Some(u) = until {
entries.retain(|e| match parse_entry_time(&e.t) {
Some(t) => t < u,
None => true,
});
}
if let Some(re) = grep {
entries.retain(|e| re.is_match(&e.d));
}
if let Some(n) = tail
&& entries.len() > n
{
let drop = entries.len() - n;
entries.drain(0..drop);
}
}
fn parse_time_arg(input: Option<&str>) -> anyhow::Result<Option<DateTime<Utc>>> {
let Some(raw) = input else {
return Ok(None);
};
if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
return Ok(Some(dt.with_timezone(&Utc)));
}
let dur = parse_duration(raw).with_context(|| {
format!("could not parse time {raw:?} (expected RFC 3339 or `5m` etc.)")
})?;
Ok(Some(Utc::now() - chrono::Duration::from_std(dur)?))
}
fn parse_duration(raw: &str) -> Option<Duration> {
if raw.is_empty() {
return None;
}
let (num_str, unit) = raw.split_at(raw.len() - 1);
let n: u64 = num_str.parse().ok()?;
let secs = match unit {
"s" => n,
"m" => n * 60,
"h" => n * 60 * 60,
"d" => n * 60 * 60 * 24,
_ => return None,
};
Some(Duration::from_secs(secs))
}
fn parse_entry_time(t: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(t)
.ok()
.map(|d| d.with_timezone(&Utc))
}
fn render_entries(entries: &[LogEntry], args: &LogsArgs, color: ColorMode) -> anyhow::Result<()> {
if args.json {
let stdout = std::io::stdout();
let mut out = stdout.lock();
for entry in entries {
let line = serde_json::to_string(&serde_json::json!({
"t": entry.t,
"s": entry.s,
"d": entry.d,
"id": entry.id,
"e": entry.e,
}))?;
writeln!(out, "{line}")?;
}
return Ok(());
}
for entry in entries {
render_one(entry, args, color)?;
}
Ok(())
}
fn render_one(entry: &LogEntry, args: &LogsArgs, color: ColorMode) -> anyhow::Result<()> {
let body = decode_body(entry);
let body = apply_color_policy(&body, color);
let want_id_prefix = args.show_id || args.color_sessions;
let want_session_color = args.color_sessions && color_active(color);
let body = if want_session_color && let Some(id) = entry.id {
wrap_in_session_color(&body, id)
} else {
body
};
let id_prefix = if want_id_prefix {
Some(format_id_prefix(entry.id, want_session_color))
} else {
None
};
let final_text = if args.timestamps {
prefix_with_timestamp(&entry.t, id_prefix.as_deref(), &body)
} else if let Some(prefix) = id_prefix {
prefix_each_line(&prefix, &body)
} else {
body
};
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(final_text.as_bytes())?;
Ok(())
}
fn color_active(mode: ColorMode) -> bool {
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
}
}
const SESSION_PALETTE: &[u8] = &[
36, 35, 32, 34, 96, 95, 92, 94, ];
fn session_color_code(id: u64) -> u8 {
SESSION_PALETTE[(id as usize) % SESSION_PALETTE.len()]
}
fn wrap_in_session_color(body: &str, id: u64) -> String {
let code = session_color_code(id);
let mut out = String::with_capacity(body.len() + 16);
for line in body.split_inclusive('\n') {
if line == "\n" {
out.push('\n');
continue;
}
out.push_str(&format!("\x1b[{code}m"));
if let Some(stripped) = line.strip_suffix('\n') {
out.push_str(stripped);
out.push_str("\x1b[0m");
out.push('\n');
} else {
out.push_str(line);
out.push_str("\x1b[0m");
}
}
out
}
fn format_id_prefix(id: Option<u64>, colored: bool) -> String {
match id {
Some(id) => {
if colored {
let code = session_color_code(id);
format!("\x1b[{code}m[id:{id:>3}]\x1b[0m ")
} else {
format!("[id:{id:>3}] ")
}
}
None => "[id:sys] ".to_string(),
}
}
fn prefix_each_line(prefix: &str, body: &str) -> String {
if body.is_empty() {
return body.to_string();
}
let mut out = String::with_capacity(body.len() + prefix.len() * 2);
let mut first = true;
for line in body.split_inclusive('\n') {
if first {
out.push_str(prefix);
first = false;
} else if !line.is_empty() && line != "\n" {
out.push_str(prefix);
}
out.push_str(line);
}
out
}
fn decode_body(entry: &LogEntry) -> String {
match entry.e.as_deref() {
Some("b64") => base64_decode(&entry.d)
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_else(|| entry.d.clone()),
_ => entry.d.clone(),
}
}
fn apply_color_policy(body: &str, mode: ColorMode) -> String {
let strip = match mode {
ColorMode::Always => false,
ColorMode::Never => true,
ColorMode::Auto => !std::io::stdout().is_terminal(),
};
if strip {
strip_ansi(body)
} else {
body.to_string()
}
}
fn prefix_with_timestamp(t: &str, id_prefix: Option<&str>, body: &str) -> String {
if body.is_empty() {
return body.to_string();
}
let ts = style(t).dim().to_string();
let id_prefix = id_prefix.unwrap_or("");
let mut out = String::with_capacity(body.len() + t.len() + id_prefix.len() + 4);
let mut first = true;
for line in body.split_inclusive('\n') {
if first {
out.push_str(&ts);
out.push('\t');
out.push_str(id_prefix);
first = false;
} else if !line.is_empty() && line != "\n" {
out.push_str(&" ".repeat(t.len()));
out.push('\t');
out.push_str(id_prefix);
}
out.push_str(line);
}
out
}
async fn follow_loop(
log_dir: &Path,
sources: SourceMask,
args: &LogsArgs,
color: ColorMode,
mut last_t: Option<String>,
grep_re: Option<&Regex>,
) -> anyhow::Result<()> {
let path = log_dir.join("exec.log");
let (mut last_size, mut last_inode) = match std::fs::metadata(&path) {
Ok(m) => (m.len(), inode_from_meta(&m)),
Err(_) => (0u64, 0u64),
};
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
let Ok(meta) = std::fs::metadata(&path) else {
break;
};
let inode = inode_from_meta(&meta);
let size = meta.len();
let need_full_reread = inode != last_inode || size < last_size;
if !need_full_reread && size == last_size {
continue;
}
let mut new_entries: Vec<LogEntry> = Vec::new();
if sources.includes_exec_sources() {
append_jsonl_entries(&path, &mut new_entries, sources)?;
}
let cutoff = last_t.clone();
new_entries.retain(|e| match cutoff.as_deref() {
Some(c) => e.t.as_str() > c,
None => true,
});
if let Some(re) = grep_re {
new_entries.retain(|e| re.is_match(&e.d));
}
for entry in &new_entries {
render_one(entry, args, color)?;
last_t = Some(entry.t.clone());
}
last_size = size;
last_inode = inode;
}
Ok(())
}
#[cfg(unix)]
fn inode_from_meta(meta: &std::fs::Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
meta.ino()
}
#[cfg(not(unix))]
fn inode_from_meta(_meta: &std::fs::Metadata) -> u64 {
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_duration_basic() {
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
assert_eq!(parse_duration("xyz"), None);
assert_eq!(parse_duration(""), None);
}
#[test]
fn parse_time_accepts_rfc3339() {
let parsed = parse_time_arg(Some("2026-04-30T20:32:59.690Z"))
.unwrap()
.unwrap();
let expected = DateTime::parse_from_rfc3339("2026-04-30T20:32:59.690Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(parsed, expected);
}
#[test]
fn parse_time_accepts_relative() {
let parsed = parse_time_arg(Some("5m")).unwrap().unwrap();
let now = Utc::now();
let diff = (now - parsed).num_seconds();
assert!((290..=310).contains(&diff), "diff was {diff}");
}
#[test]
fn strip_ansi_removes_color_and_cursor() {
let s = "\x1b[31merror\x1b[0m\x1b[2J\x1b[H text";
let stripped = strip_ansi(s);
assert_eq!(stripped, "error text");
}
#[test]
fn strip_ansi_preserves_plain_text() {
let s = "hello\nworld\n";
assert_eq!(strip_ansi(s), s);
}
#[test]
fn source_mask_default_excludes_system() {
let mask = resolve_sources(&[]);
assert!(mask.stdout && mask.stderr && mask.output && !mask.system);
}
#[test]
fn source_mask_all() {
let mask = resolve_sources(&[SourceFilter::All]);
assert!(mask.stdout && mask.stderr && mask.output && mask.system);
}
#[test]
fn source_mask_output_only() {
let mask = resolve_sources(&[SourceFilter::Output]);
assert!(mask.output && !mask.stdout && !mask.stderr && !mask.system);
}
#[test]
fn apply_filters_tail_keeps_last_n() {
let mut entries: Vec<LogEntry> = (0..5)
.map(|i| LogEntry {
t: format!("2026-04-30T00:00:0{i}.000Z"),
s: "stdout".into(),
d: format!("line {i}"),
id: Some(1),
e: None,
})
.collect();
apply_filters(&mut entries, None, None, None, Some(2));
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].d, "line 3");
assert_eq!(entries[1].d, "line 4");
}
#[test]
fn apply_filters_grep() {
let mut entries: Vec<LogEntry> = vec![
LogEntry {
t: "1".into(),
s: "stdout".into(),
d: "ok".into(),
id: Some(1),
e: None,
},
LogEntry {
t: "2".into(),
s: "stdout".into(),
d: "error: bad".into(),
id: Some(1),
e: None,
},
];
let re = Regex::new("error").unwrap();
apply_filters(&mut entries, None, None, Some(&re), None);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].d, "error: bad");
}
}