#[cfg(unix)]
use std::os::fd::AsRawFd;
use std::{
io::IsTerminal,
time::{Duration, Instant},
};
use console::style;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use microsandbox_image::PullProgress;
const ACTION_LABEL_WIDTH: usize = 18;
const DETAIL_LABEL_WIDTH: usize = 20;
const BRAILLE_TICKS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋"];
pub struct Spinner {
pb: Option<ProgressBar>,
start: Instant,
target: String,
quiet: bool,
_echo_guard: Option<EchoGuard>,
}
#[cfg(unix)]
struct EchoGuard {
original: libc::termios,
fd: i32,
}
#[cfg(windows)]
struct EchoGuard;
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
#[derive(Debug)]
pub struct AlreadyRenderedError;
impl Spinner {
pub fn start(label: &str, target: &str) -> Self {
let is_tty = std::io::stderr().is_terminal();
let (pb, echo_guard) = if is_tty {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(BRAILLE_TICKS)
.template(" {spinner} {msg}")
.unwrap(),
);
pb.set_message(action_message(label, target));
pb.enable_steady_tick(Duration::from_millis(80));
(Some(pb), EchoGuard::acquire())
} else {
(None, None)
};
Self {
pb,
start: Instant::now(),
target: target.to_string(),
quiet: false,
_echo_guard: echo_guard,
}
}
pub fn quiet() -> Self {
Self {
pb: None,
start: Instant::now(),
target: String::new(),
quiet: true,
_echo_guard: None,
}
}
pub fn finish_success(self, past_tense: &str) {
if let Some(pb) = self.pb {
pb.finish_and_clear();
}
if !self.quiet {
let elapsed = self.start.elapsed();
let duration = if elapsed.as_millis() > 500 {
format!(
" ({})",
microsandbox_utils::format::format_duration(elapsed)
)
} else {
String::new()
};
success(
past_tense,
&format!("{}{}", self.target, style(duration).dim()),
);
}
}
pub fn finish_fail(self, label: &str) {
if let Some(pb) = self.pb {
pb.finish_and_clear();
}
if !self.quiet {
failure(label, &self.target);
}
}
pub fn finish_clear(self) {
if let Some(pb) = self.pb {
pb.finish_and_clear();
}
}
}
impl EchoGuard {
#[cfg(unix)]
fn acquire() -> Option<Self> {
if !std::io::stdin().is_terminal() {
return None;
}
let fd = std::io::stdin().as_raw_fd();
let mut original: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut original) } != 0 {
return None;
}
let mut modified = original;
modified.c_lflag &= !libc::ECHO;
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &modified) } != 0 {
return None;
}
Some(Self { original, fd })
}
#[cfg(windows)]
fn acquire() -> Option<Self> {
None
}
}
#[cfg(unix)]
impl Drop for EchoGuard {
fn drop(&mut self) {
unsafe {
libc::tcflush(self.fd, libc::TCIFLUSH);
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
impl Table {
pub fn new(headers: &[&str]) -> Self {
Self {
headers: headers.iter().map(|h| h.to_string()).collect(),
rows: Vec::new(),
}
}
pub fn add_row(&mut self, row: Vec<String>) {
self.rows.push(row);
}
pub fn print(&self) {
let rendered = self.render();
if !rendered.is_empty() {
print!("{rendered}");
}
}
pub fn render(&self) -> String {
if self.rows.is_empty() {
return String::new();
}
let col_count = self.headers.len();
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.len()).collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count {
widths[i] = widths[i].max(console::measure_text_width(cell));
}
}
}
let mut out = String::new();
let header: String = self
.headers
.iter()
.enumerate()
.map(|(i, h)| {
if i < col_count - 1 {
format!("{:<width$} ", h, width = widths[i])
} else {
h.to_string()
}
})
.collect();
out.push_str(&style(header).cyan().bold().to_string());
out.push('\n');
for row in &self.rows {
let line: String = row
.iter()
.enumerate()
.map(|(i, cell)| {
if i < col_count - 1 {
let vis = console::measure_text_width(cell);
let padding = widths[i].saturating_sub(vis) + 4;
format!("{cell}{:padding$}", "", padding = padding)
} else {
cell.clone()
}
})
.collect();
out.push_str(&line);
out.push('\n');
}
out
}
}
impl std::fmt::Display for AlreadyRenderedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("command failed")
}
}
impl std::error::Error for AlreadyRenderedError {}
pub fn install_panic_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
#[cfg(unix)]
if std::io::stdin().is_terminal() {
let fd = std::io::stdin().as_raw_fd();
let mut termios: libc::termios = unsafe { std::mem::zeroed() };
if unsafe { libc::tcgetattr(fd, &mut termios) } == 0 {
termios.c_lflag |= libc::ECHO;
unsafe {
libc::tcsetattr(fd, libc::TCSANOW, &termios);
}
}
}
default(info);
}));
}
pub fn error(msg: &str) {
eprintln!("{} {msg}", style("error:").red().bold());
}
pub fn error_context(msg: &str, context: &[&str]) {
error(msg);
for line in context {
eprintln!("{}", context_line(line));
}
}
#[derive(Debug, Clone, Copy)]
pub enum ErrorLine<'a> {
Cause(&'a str),
Hint(&'a str),
}
pub fn error_with_lines(msg: &str, lines: &[ErrorLine<'_>]) {
error(msg);
for line in lines {
let text = match line {
ErrorLine::Cause(t) | ErrorLine::Hint(t) => t,
};
eprintln!("{}", context_line(text));
}
}
pub fn warn(msg: &str) {
eprintln!("{} {msg}", style("warn:").yellow().bold());
}
pub fn notice(label: &str, detail: &str) {
eprintln!(" {} {}", style("•").cyan(), action_message(label, detail));
}
pub fn warn_with_lines(msg: &str, lines: &[ErrorLine<'_>]) {
warn(msg);
for line in lines {
let text = match line {
ErrorLine::Cause(t) | ErrorLine::Hint(t) => t,
};
eprintln!("{}", context_line(text));
}
}
pub fn success(verb: &str, target: &str) {
eprintln!(" {} {}", style("✓").green(), action_message(verb, target));
}
pub fn failure(label: &str, detail: &str) {
eprintln!(" {} {}", style("✗").red(), action_message(label, detail));
}
pub fn format_status(status: &str) -> String {
match status {
"Created" => format!("{}", style("created").dim()),
"Starting" => format!("{}", style("starting").yellow().bold()),
"Running" => format!("{}", style("running").green().bold()),
"Stopped" => format!("{}", style("stopped").dim()),
"Paused" => format!("{}", style("paused").yellow().bold()),
"Draining" => format!("{}", style("draining").yellow().bold()),
"Crashed" => format!("{}", style("crashed").red().bold()),
other => other.to_lowercase(),
}
}
pub fn format_disposition(disposition: &str) -> String {
match disposition {
"live" => format!("{}", style("live").green().bold()),
"requires restart" => format!("{}", style("requires restart").yellow().bold()),
"next start" => format!("{}", style("next start").dim()),
"unsupported" => format!("{}", style("unsupported").red().bold()),
other => other.to_lowercase(),
}
}
pub fn detail_header(title: &str) {
println!();
println!("{}", style(title).bold());
}
pub fn detail_kv(key: &str, value: &str) {
println!("{}", detail_line(key, value, false));
}
pub fn detail_kv_indent(key: &str, value: &str) {
println!("{}", detail_line(key, value, true));
}
pub fn parse_size_mib(s: &str) -> Result<u32, String> {
let s = s.trim();
if let Some(n) = s.strip_suffix('G').or_else(|| s.strip_suffix('g')) {
let val: f64 = n.trim().parse().map_err(|e| format!("invalid size: {e}"))?;
if val.is_nan() || val.is_infinite() || val < 0.0 {
return Err("size must be a finite positive number".into());
}
let mib = val * 1024.0;
if mib > u32::MAX as f64 {
return Err("size too large".into());
}
Ok(mib as u32)
} else if let Some(n) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) {
n.trim()
.parse::<u32>()
.map_err(|e| format!("invalid size: {e}"))
} else {
s.parse::<u32>()
.map_err(|e| format!("invalid size (expected e.g. 512M, 1G): {e}"))
}
}
pub fn parse_size_bytes(s: &str) -> Result<u64, String> {
let s = s.trim();
let (digits, multiplier) = if let Some(n) = s.strip_suffix('K').or_else(|| s.strip_suffix('k'))
{
(n, 1024u64)
} else if let Some(n) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) {
(n, 1024 * 1024)
} else if let Some(n) = s.strip_suffix('G').or_else(|| s.strip_suffix('g')) {
(n, 1024 * 1024 * 1024)
} else {
(s, 1)
};
let value: u64 = digits
.trim()
.parse()
.map_err(|e| format!("invalid size `{s}` (expected raw bytes or K/M/G suffix): {e}"))?;
value
.checked_mul(multiplier)
.ok_or_else(|| format!("size `{s}` overflows u64 bytes"))
}
pub fn parse_env(s: &str) -> Result<(String, String), String> {
if let Some(eq_pos) = s.find('=') {
Ok((s[..eq_pos].to_string(), s[eq_pos + 1..].to_string()))
} else {
match std::env::var(s) {
Ok(val) => Ok((s.to_string(), val)),
Err(_) => Err(format!("environment variable '{s}' not set")),
}
}
}
pub fn parse_label(s: &str) -> (String, String) {
match s.find('=') {
Some(eq_pos) => (s[..eq_pos].to_string(), s[eq_pos + 1..].to_string()),
None => (s.to_string(), String::new()),
}
}
pub fn generate_name() -> String {
use rand::RngExt;
let id: u32 = rand::rng().random();
format!("msb-{id:08x}")
}
pub fn format_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
}
pub fn format_json_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
dt.to_rfc3339()
}
pub fn format_rfc3339_datetime(s: &str) -> Result<String, chrono::ParseError> {
chrono::DateTime::parse_from_rfc3339(s).map(|dt| {
dt.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
}
fn padded_pair(label: &str, value: &str, width: usize) -> String {
let padding = width.saturating_sub(console::measure_text_width(label)) + 2;
format!("{label}{:padding$}{value}", "")
}
fn action_message(label: &str, detail: &str) -> String {
if label.is_empty() {
detail.to_string()
} else if detail.is_empty() {
label.to_string()
} else {
padded_pair(label, detail, ACTION_LABEL_WIDTH)
}
}
fn detail_line(key: &str, value: &str, indented: bool) -> String {
let label = format!("{key}:");
if indented {
format!(
" {}",
padded_pair(
&style(label).dim().to_string(),
value,
DETAIL_LABEL_WIDTH - 2
)
)
} else {
padded_pair(&style(label).cyan().to_string(), value, DETAIL_LABEL_WIDTH)
}
}
fn context_line(text: &str) -> String {
format!(" {} {text}", style("→").dim())
}
pub struct PullProgressDisplay {
mp: MultiProgress,
header: ProgressBar,
layer_bars: Vec<ProgressBar>,
reference: String,
download_style: ProgressStyle,
materialize_style: ProgressStyle,
done_style: ProgressStyle,
verb: &'static str,
_echo_guard: Option<EchoGuard>,
}
impl PullProgressDisplay {
pub fn handle_creation_event(&mut self, event: microsandbox::CreationProgress) {
use microsandbox::{CreationProgress, StartupPhase};
match event {
CreationProgress::Pull(event) => self.handle_event(event),
CreationProgress::Startup(event) => {
let phase = match event.phase {
StartupPhase::PreparingSnapshot => "Preparing sandbox",
StartupPhase::WaitingForMemoryBacking => "Waiting for RAM backing",
StartupPhase::PreparingMemoryBacking => "Preparing RAM backing",
StartupPhase::ReusingMemoryBacking => "Reusing RAM backing",
StartupPhase::SyncingMemoryBacking => "Syncing RAM backing",
StartupPhase::Activating => "Activating sandbox",
};
let detail = event
.total_bytes
.map(|total| {
format!(
" — {} / {} MiB",
event.completed_bytes / 1_048_576,
total / 1_048_576
)
})
.unwrap_or_default();
self.header.set_message(format!("{phase}{detail}"));
}
}
}
pub fn new(reference: &str) -> Self {
Self::new_inner(reference, false, "Pulling")
}
pub fn quiet(reference: &str) -> Self {
Self::new_inner(reference, true, "Pulling")
}
pub fn load(reference: &str, quiet: bool) -> Self {
Self::new_inner(reference, quiet, "Loading")
}
fn new_inner(reference: &str, quiet: bool, verb: &'static str) -> Self {
let is_tty = !quiet && std::io::stderr().is_terminal();
let mp = MultiProgress::new();
if is_tty {
mp.set_draw_target(ProgressDrawTarget::stderr_with_hz(10));
} else {
mp.set_draw_target(ProgressDrawTarget::hidden());
}
let header = mp.add(ProgressBar::new_spinner());
header.set_style(
ProgressStyle::default_spinner()
.tick_strings(BRAILLE_TICKS)
.template(" {spinner} {msg}")
.unwrap(),
);
header.set_message(action_message(verb, reference));
header.enable_steady_tick(Duration::from_millis(80));
Self {
mp,
header,
layer_bars: Vec::new(),
reference: reference.to_string(),
verb,
_echo_guard: if is_tty { EchoGuard::acquire() } else { None },
download_style: ProgressStyle::default_bar()
.template(
" {prefix} {bar:36.magenta/238} {bytes}/{total_bytes} {msg:.magenta}",
)
.unwrap()
.progress_chars("━━╌"),
materialize_style: ProgressStyle::default_bar()
.template(" {prefix} {bar:36.blue/238} {bytes}/{total_bytes} {msg:.blue}")
.unwrap()
.progress_chars("━━╌"),
done_style: ProgressStyle::default_bar()
.template(" {prefix} {msg}")
.unwrap(),
}
}
fn ensure_layer_bar(&mut self, index: usize) {
while self.layer_bars.len() <= index {
let n = self.layer_bars.len() + 1;
let pb = self.mp.add(ProgressBar::new(1));
pb.set_style(self.materialize_style.clone());
pb.set_prefix(format!("layer {n}"));
pb.set_message("materializing");
self.layer_bars.push(pb);
}
}
pub fn handle_event(&mut self, event: PullProgress) {
match event {
PullProgress::Resolving { .. } => {
self.header.set_message(action_message(
"Resolving",
&format!("{}...", self.reference),
));
}
PullProgress::Resolved { layer_count, .. } => {
self.header.set_message(action_message(
self.verb,
&format!(
"{} ({} layer{})",
self.reference,
layer_count,
if layer_count == 1 { "" } else { "s" }
),
));
let width = layer_count.to_string().len();
for i in 0..layer_count {
let pb = self.mp.add(ProgressBar::new(1));
pb.set_style(self.download_style.clone());
pb.set_prefix(format!("layer {:>width$}/{layer_count}", i + 1));
pb.set_message("downloading");
self.layer_bars.push(pb);
}
}
PullProgress::LayerDownloadProgress {
layer_index,
downloaded_bytes,
total_bytes,
..
} => {
if let Some(pb) = self.layer_bars.get(layer_index) {
if let Some(total) = total_bytes {
pb.set_length(total);
}
pb.set_position(downloaded_bytes);
}
}
PullProgress::LayerDownloadComplete {
layer_index,
downloaded_bytes,
..
} => {
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_length(downloaded_bytes);
pb.set_position(downloaded_bytes);
}
}
PullProgress::LayerDownloadVerifying { layer_index, .. } => {
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_message("verifying");
}
}
PullProgress::LayerMaterializeStarted { layer_index, .. } => {
self.ensure_layer_bar(layer_index);
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_style(self.materialize_style.clone());
pb.set_position(0);
pb.set_length(1);
pb.set_message("materializing");
}
}
PullProgress::LayerMaterializeProgress {
layer_index,
bytes_read,
total_bytes,
} => {
self.ensure_layer_bar(layer_index);
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_length(total_bytes);
pb.set_position(bytes_read);
}
}
PullProgress::LayerMaterializeWriting { layer_index } => {
self.ensure_layer_bar(layer_index);
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_position(pb.length().unwrap_or(0));
pb.set_message("writing image");
}
}
PullProgress::LayerMaterializeComplete { layer_index, .. } => {
self.ensure_layer_bar(layer_index);
if let Some(pb) = self.layer_bars.get(layer_index) {
pb.set_position(pb.length().unwrap_or(0));
pb.set_style(self.done_style.clone());
pb.set_message(format!("{}", style("✓").green()));
pb.tick();
}
}
PullProgress::StitchMergingTrees { layer_count } => {
self.header.set_message(action_message(
"Merging",
&format!(
"{} ({} layer{})",
self.reference,
layer_count,
if layer_count == 1 { "" } else { "s" }
),
));
}
PullProgress::StitchWritingFsmeta => {
self.header
.set_message(action_message("Writing fsmeta", &self.reference));
}
PullProgress::StitchWritingVmdk => {
self.header
.set_message(action_message("Writing vmdk", &self.reference));
}
PullProgress::StitchComplete => {
self.header
.set_message(action_message("Stitched", &self.reference));
}
PullProgress::Complete { .. } => {}
}
}
pub fn finish(self) {
let _ = self.mp.clear();
}
}
#[cfg(test)]
mod tests {
#[test]
fn detail_values_align_without_touching_long_keys() {
for (key, indented) in [
("Name", false),
("Deployment Profile", false),
("Checkpoint Manifest", false),
("CPU Placement", true),
] {
let line = super::detail_line(key, "VALUE", indented);
let plain = console::strip_ansi_codes(&line);
let before_value = plain.strip_suffix("VALUE").unwrap();
assert_eq!(console::measure_text_width(before_value), 22, "{key}");
assert!(before_value.ends_with(" "), "{key}");
assert_eq!(plain.starts_with(" "), indented);
}
let line = super::detail_line("A much longer detail label", "VALUE", false);
assert!(console::strip_ansi_codes(&line).ends_with("label: VALUE"));
}
#[test]
fn action_columns_use_display_width_and_handle_sentences() {
for label in [
"Pulled",
"Writing fsmeta",
"Scheduled removal",
"Already authorized",
"镜像",
] {
let styled = console::style(label)
.green()
.force_styling(true)
.to_string();
let line = super::action_message(&styled, "TARGET");
let plain = console::strip_ansi_codes(&line);
assert_eq!(
console::measure_text_width(plain.strip_suffix("TARGET").unwrap()),
20
);
}
assert_eq!(super::action_message("", "Skipped."), "Skipped.");
assert_eq!(
super::action_message("Host setup is ready.", ""),
"Host setup is ready."
);
assert_eq!(
super::action_message("A much longer action label", "TARGET"),
"A much longer action label TARGET"
);
}
#[test]
fn pull_target_stays_in_the_same_column_through_stitching() {
use microsandbox_image::PullProgress;
let mut display = super::PullProgressDisplay::quiet("alpine");
let column = display.header.message().find("alpine").unwrap();
for event in [
PullProgress::StitchMergingTrees { layer_count: 2 },
PullProgress::StitchWritingFsmeta,
PullProgress::StitchWritingVmdk,
PullProgress::StitchComplete,
] {
display.handle_event(event);
assert_eq!(display.header.message().find("alpine"), Some(column));
}
assert_eq!(
super::action_message("Pulled", "alpine").find("alpine"),
Some(column)
);
display.finish();
}
#[test]
fn parse_size_bytes_accepts_raw_bytes_and_binary_suffixes() {
assert_eq!(super::parse_size_bytes("1048576").unwrap(), 1024 * 1024);
assert_eq!(super::parse_size_bytes("512K").unwrap(), 512 * 1024);
assert_eq!(super::parse_size_bytes("1M").unwrap(), 1024 * 1024);
assert_eq!(
super::parse_size_bytes("2G").unwrap(),
2 * 1024 * 1024 * 1024
);
assert_eq!(
super::parse_size_bytes("2g").unwrap(),
2 * 1024 * 1024 * 1024
);
assert!(super::parse_size_bytes("1.5M").is_err());
assert!(super::parse_size_bytes("abc").is_err());
assert!(super::parse_size_bytes(&format!("{}G", u64::MAX)).is_err());
}
#[test]
fn json_datetime_uses_rfc3339_utc() {
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-31T09:09:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
assert_eq!(
super::format_json_datetime(&dt),
"2026-05-31T09:09:00+00:00"
);
}
#[test]
fn display_datetime_uses_local_timezone() {
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-31T09:09:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let expected = dt
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string();
assert_eq!(super::format_datetime(&dt), expected);
assert_eq!(
super::format_rfc3339_datetime("2026-05-31T09:09:00Z").unwrap(),
expected
);
}
}