use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
static COLOR: OnceLock<bool> = OnceLock::new();
static JSON: OnceLock<bool> = OnceLock::new();
pub fn init_format() {
let json = matches!(std::env::var("LOOOP_LOG_FORMAT").as_deref(), Ok("json"));
let _ = JSON.set(json);
unsafe { std::env::set_var("LOOOP_LOG_FORMAT", if json { "json" } else { "human" }) };
}
pub fn is_json() -> bool {
*JSON.get().unwrap_or(&false)
}
pub fn init_color() {
let enabled = if is_json() {
false
} else {
match std::env::var("LOOOP_COLOR") {
Ok(v) if v == "1" => true,
Ok(v) if v == "0" => false,
_ => is_stdout_tty() && std::env::var_os("NO_COLOR").is_none(),
}
};
let _ = COLOR.set(enabled);
unsafe { std::env::set_var("LOOOP_COLOR", if enabled { "1" } else { "0" }) };
}
fn color_on() -> bool {
*COLOR.get().unwrap_or(&false)
}
#[cfg(unix)]
fn is_stdout_tty() -> bool {
unsafe { libc_isatty(1) }
}
#[cfg(not(unix))]
fn is_stdout_tty() -> bool {
false
}
#[cfg(unix)]
unsafe fn libc_isatty(fd: i32) -> bool {
unsafe extern "C" {
fn isatty(fd: i32) -> i32;
}
unsafe { isatty(fd) == 1 }
}
macro_rules! code {
($name:ident, $seq:expr) => {
pub fn $name() -> &'static str {
if color_on() { $seq } else { "" }
}
};
}
code!(rst, "\x1b[0m");
code!(dim, "\x1b[2m");
code!(b, "\x1b[1m");
code!(cyan, "\x1b[36m");
code!(grn, "\x1b[32m");
code!(red, "\x1b[31m");
code!(yel, "\x1b[33m");
#[derive(Clone, Copy)]
pub enum Level {
Info,
Step,
Ok,
Warn,
Error,
}
impl Level {
fn tag(self) -> &'static str {
match self {
Level::Info => "info",
Level::Step => "step",
Level::Ok => "ok",
Level::Warn => "warn",
Level::Error => "error",
}
}
fn color(self) -> &'static str {
match self {
Level::Info => "",
Level::Step => cyan(),
Level::Ok => grn(),
Level::Warn => yel(),
Level::Error => red(),
}
}
fn glyph(self) -> &'static str {
match self {
Level::Info => "·",
Level::Step => "→",
Level::Ok => "✓",
Level::Warn => "⚡",
Level::Error => "✗",
}
}
}
pub fn event(level: Level, event: &str, msg: &str, fields: &[(&str, serde_json::Value)]) {
if is_json() {
let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
println!("{}", json_event_line(&ts, level, event, msg, fields));
return;
}
let glyph = level.glyph();
if matches!(level, Level::Info | Level::Step) {
println!("{}[{}] {} {}{}", dim(), hms(), glyph, msg, rst());
return;
}
let c = level.color();
let bold = if matches!(level, Level::Ok | Level::Error) {
b()
} else {
""
};
let msg_c = if matches!(level, Level::Warn | Level::Error) {
c
} else {
""
};
let msg_rst = if msg_c.is_empty() { "" } else { rst() };
println!(
"{}[{}]{} {}{}{}{} {}{}{}",
dim(),
hms(),
rst(),
bold,
c,
glyph,
rst(),
msg_c,
msg,
msg_rst
);
}
fn json_event_line(
ts: &str,
level: Level,
event: &str,
msg: &str,
fields: &[(&str, serde_json::Value)],
) -> String {
let mut obj = serde_json::Map::new();
obj.insert("ts".into(), serde_json::Value::String(ts.into()));
obj.insert(
"level".into(),
serde_json::Value::String(level.tag().into()),
);
obj.insert("event".into(), serde_json::Value::String(event.into()));
obj.insert("msg".into(), serde_json::Value::String(msg.into()));
for (k, v) in fields {
obj.insert((*k).to_string(), v.clone());
}
serde_json::Value::Object(obj).to_string()
}
pub fn hms() -> String {
chrono::Local::now().format("%H:%M:%S").to_string()
}
pub fn date_fmt(fmt: &str) -> String {
Command::new("date")
.arg(format!("+{fmt}"))
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim_end().to_string())
.unwrap_or_default()
}
pub fn content_hash(input: &[u8]) -> String {
let tool = if on_path("shasum") {
"shasum"
} else if on_path("sha1sum") {
"sha1sum"
} else {
"cksum"
};
let mut child = match Command::new(tool)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => return String::new(),
};
if let Some(mut si) = child.stdin.take() {
let _ = si.write_all(input);
}
let out = match child.wait_with_output() {
Ok(o) => o,
Err(_) => return String::new(),
};
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
pub fn on_path(cmd: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| {
let p = dir.join(cmd);
p.is_file() && is_executable(&p)
})
}
#[cfg(unix)]
fn is_executable(p: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_p: &std::path::Path) -> bool {
true
}
pub fn pid_alive(pid: &str) -> bool {
if pid.is_empty() {
return false;
}
Command::new("kill")
.args(["-0", pid])
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_event_line_is_valid_and_ordered() {
let line = json_event_line(
"2026-01-02T03:04:05Z",
Level::Ok,
"tick.decided",
"decided in 3s",
&[
("secs", serde_json::json!(3)),
("runner", serde_json::json!("claude")),
],
);
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ts"], "2026-01-02T03:04:05Z");
assert_eq!(v["level"], "ok");
assert_eq!(v["event"], "tick.decided");
assert_eq!(v["msg"], "decided in 3s");
assert_eq!(v["secs"], 3);
assert_eq!(v["runner"], "claude");
}
#[test]
fn level_tags_are_stable() {
assert_eq!(Level::Info.tag(), "info");
assert_eq!(Level::Step.tag(), "step");
assert_eq!(Level::Ok.tag(), "ok");
assert_eq!(Level::Warn.tag(), "warn");
assert_eq!(Level::Error.tag(), "error");
}
#[test]
fn level_glyphs_map_importance() {
assert_eq!(Level::Info.glyph(), "·");
assert_eq!(Level::Step.glyph(), "→");
assert_eq!(Level::Ok.glyph(), "✓");
assert_eq!(Level::Warn.glyph(), "⚡");
assert_eq!(Level::Error.glyph(), "✗");
}
}