use std::io::{IsTerminal, Write};
use std::path::Path;
use std::pin::pin;
use std::time::Duration;
use anyhow::{Context, anyhow};
use base64::Engine as _;
use chrono::{DateTime, SecondsFormat, Utc};
use clap::{Args, ValueEnum};
use console::style;
use futures::StreamExt;
use microsandbox::MicrosandboxError;
use microsandbox::logs::{
self, LogEntry as EngineLogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
};
use microsandbox_runtime::boot_error::BootError;
use microsandbox_utils::log_text::{base64_decode, 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 = logs::log_dir_for(&args.name);
if !log_dir.exists() {
return Err(anyhow!(
"no logs directory for sandbox {:?} (sandbox not found?)",
&args.name
));
}
let mask = resolve_sources(&args.source);
let engine_sources = mask.to_engine_sources();
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 snapshot_opts = LogOptions {
tail: args.tail,
since,
until,
sources: engine_sources.clone(),
};
let snapshot = logs::read_logs_snapshot(&args.name, &snapshot_opts)
.await
.context("reading logs")?;
for entry in &snapshot.entries {
let cli_entry = engine_entry_to_cli(entry);
if grep_matches(grep_re.as_ref(), &cli_entry.d) {
render_entry(&cli_entry, &args, color_policy)?;
}
}
if !args.follow {
return Ok(());
}
let stream_opts = LogStreamOptions {
sources: engine_sources,
start: LogStreamStart::From(snapshot.cursor),
until,
follow: true,
};
let mut stream = pin!(
logs::log_stream(&args.name, &stream_opts)
.await
.context("starting log stream")?
);
while let Some(item) = stream.next().await {
match item {
Ok(entry) => {
let cli_entry = engine_entry_to_cli(&entry);
if grep_matches(grep_re.as_ref(), &cli_entry.d) {
render_entry(&cli_entry, &args, color_policy)?;
}
}
Err(MicrosandboxError::MissedRotation {
dropped_from_offset,
}) => {
eprintln!(
"log follower fell behind: missed rotation at offset {dropped_from_offset}. \
restart `msb logs -f` to resume."
);
return Ok(());
}
Err(e) => return Err(anyhow!(e)),
}
}
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 to_engine_sources(self) -> Vec<LogSource> {
let mut out = Vec::with_capacity(4);
if self.stdout {
out.push(LogSource::Stdout);
}
if self.stderr {
out.push(LogSource::Stderr);
}
if self.output {
out.push(LogSource::Output);
}
if self.system {
out.push(LogSource::System);
}
out
}
}
fn render_boot_error_if_present(log_dir: &Path, name: &str, json_mode: bool) -> anyhow::Result<()> {
let boot_err = match 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 engine_entry_to_cli(entry: &EngineLogEntry) -> LogEntry {
let s = match entry.source {
LogSource::Stdout => "stdout",
LogSource::Stderr => "stderr",
LogSource::Output => "output",
LogSource::System => "system",
};
let (d, e) = match std::str::from_utf8(&entry.data) {
Ok(text) => (text.to_string(), None),
Err(_) => (
base64::engine::general_purpose::STANDARD.encode(&entry.data),
Some("b64".to_string()),
),
};
LogEntry {
t: entry.timestamp.to_rfc3339_opts(SecondsFormat::Millis, true),
s: s.to_string(),
d,
id: entry.session_id,
e,
}
}
fn grep_matches(re: Option<&Regex>, body: &str) -> bool {
re.is_none_or(|r| r.is_match(body))
}
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 render_entry(entry: &LogEntry, args: &LogsArgs, color: ColorMode) -> anyhow::Result<()> {
if args.json {
let line = serde_json::to_string(&serde_json::json!({
"t": entry.t,
"s": entry.s,
"d": entry.d,
"id": entry.id,
"e": entry.e,
}))?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "{line}")?;
return Ok(());
}
render_one(entry, args, color)
}
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
}
#[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 grep_matches_returns_true_when_no_pattern() {
assert!(grep_matches(None, "anything"));
}
#[test]
fn grep_matches_filters_by_pattern() {
let re = Regex::new("err").unwrap();
assert!(grep_matches(Some(&re), "error: bad"));
assert!(!grep_matches(Some(&re), "ok"));
}
#[test]
fn source_mask_to_engine_sources_maps_each_flag() {
let mask = SourceMask {
stdout: true,
stderr: false,
output: true,
system: true,
};
assert_eq!(
mask.to_engine_sources(),
vec![LogSource::Stdout, LogSource::Output, LogSource::System],
);
}
#[test]
fn engine_entry_to_cli_preserves_utf8_body() {
let entry = EngineLogEntry {
timestamp: DateTime::parse_from_rfc3339("2026-04-30T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
source: LogSource::Stdout,
session_id: Some(7),
data: bytes::Bytes::from_static(b"hello world"),
cursor: microsandbox::logs::LogCursor::empty(),
};
let cli = engine_entry_to_cli(&entry);
assert_eq!(cli.s, "stdout");
assert_eq!(cli.id, Some(7));
assert_eq!(cli.d, "hello world");
assert!(cli.e.is_none());
}
#[test]
fn engine_entry_to_cli_base64s_non_utf8_body() {
let entry = EngineLogEntry {
timestamp: DateTime::parse_from_rfc3339("2026-04-30T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc),
source: LogSource::Output,
session_id: None,
data: bytes::Bytes::from_static(&[0xff, 0xfe, 0xfd]),
cursor: microsandbox::logs::LogCursor::empty(),
};
let cli = engine_entry_to_cli(&entry);
assert_eq!(cli.e.as_deref(), Some("b64"));
assert_eq!(cli.d, "//79");
}
}