netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky status` - compact read-only system summary.

use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};
use std::thread;
use std::time::Duration;

use chrono::{DateTime, Utc};
use owo_colors::OwoColorize;
use serde_json::{Value, json};

use netsky_core::consts::{AGENT0_NAME, AGENTINFINITY_NAME, TICKER_LOG_PATH, TICKER_SESSION};
use netsky_core::paths::home;
use netsky_sh::tmux;

pub fn run(json: bool, watch: bool, interval_secs: u64) -> netsky_core::Result<()> {
    if json && watch {
        netsky_core::bail!("--watch does not support --json");
    }
    if watch {
        return run_watch(interval_secs.max(1));
    }
    let snapshot = gather();
    if json {
        let envelope = build_envelope(&snapshot);
        println!("{}", serde_json::to_string_pretty(&envelope)?);
        return Ok(());
    }
    print!("{}", render_text(&snapshot));
    Ok(())
}

struct Snapshot {
    version: &'static str,
    tmux_sessions: Vec<String>,
    last_tick: Option<DateTime<Utc>>,
    db_bytes: Option<u64>,
}

fn gather() -> Snapshot {
    let mut sessions: Vec<String> = tmux::list_sessions()
        .into_iter()
        .filter(|s| is_status_session(s))
        .collect();
    sessions.sort();
    let last_tick = fs::metadata(Path::new(TICKER_LOG_PATH))
        .and_then(|m| m.modified())
        .ok()
        .map(DateTime::<Utc>::from);
    let db_bytes = fs::metadata(home().join(".netsky/meta.db"))
        .ok()
        .map(|m| m.len());
    Snapshot {
        version: env!("CARGO_PKG_VERSION"),
        tmux_sessions: sessions,
        last_tick,
        db_bytes,
    }
}

fn build_envelope(s: &Snapshot) -> Value {
    let overall = overall_status(s);
    let summary = if s.tmux_sessions.is_empty() {
        "no netsky tmux sessions".to_string()
    } else {
        format!(
            "netsky {} ({} session(s))",
            s.version,
            s.tmux_sessions.len()
        )
    };
    json!({
        "command": "status",
        "status": overall,
        "summary": summary,
        "generated_at": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
        "data": {
            "version": s.version,
            "tmux_sessions": s.tmux_sessions,
            "last_tick_utc": s.last_tick.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
            "meta_db_bytes": s.db_bytes,
        },
    })
}

fn render_text(s: &Snapshot) -> String {
    format!(
        "version: {}\ntmux: {}\nlast tick: {}\nmeta.db: {}\n",
        format!("netsky {}", env!("CARGO_PKG_VERSION")).bold(),
        render_tmux_sessions(s),
        render_last_tick(s),
        render_db_size(s)
    )
}

fn run_watch(interval_secs: u64) -> netsky_core::Result<()> {
    let stop = Arc::new(AtomicBool::new(false));
    let stop_flag = Arc::clone(&stop);
    ctrlc::set_handler(move || {
        stop_flag.store(true, Ordering::SeqCst);
    })
    .map_err(|e| netsky_core::Error::msg(e.to_string()))?;

    let mut stdout = io::stdout().lock();
    loop {
        let snapshot = gather();
        write!(stdout, "\x1b[2J\x1b[H{}", render_text(&snapshot))?;
        stdout.flush()?;
        if stop.load(Ordering::SeqCst) {
            break;
        }
        for _ in 0..(interval_secs * 10) {
            if stop.load(Ordering::SeqCst) {
                break;
            }
            thread::sleep(Duration::from_millis(100));
        }
        if stop.load(Ordering::SeqCst) {
            break;
        }
    }
    writeln!(stdout)?;
    stdout.flush()?;
    Ok(())
}

fn overall_status(s: &Snapshot) -> &'static str {
    if s.tmux_sessions.is_empty() {
        "yellow"
    } else {
        "green"
    }
}

fn render_tmux_sessions(s: &Snapshot) -> String {
    if s.tmux_sessions.is_empty() {
        return "none".yellow().to_string();
    }
    let total = s.tmux_sessions.len();
    let mut shown: Vec<String> = s.tmux_sessions.clone();
    if shown.len() > 8 {
        let tail = shown.len() - 8;
        shown.truncate(8);
        shown.push(format!("+{tail} more"));
    }
    format!("{} active: {}", total.to_string().green(), shown.join(", "))
}

fn is_status_session(name: &str) -> bool {
    name == AGENT0_NAME
        || name == AGENTINFINITY_NAME
        || name == TICKER_SESSION
        || (name.starts_with("agent")
            && name.strip_prefix("agent").is_some_and(|suffix| {
                !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
            }))
}

fn render_last_tick(s: &Snapshot) -> String {
    let Some(dt) = s.last_tick else {
        return "none".yellow().to_string();
    };
    let age = (Utc::now() - dt).num_seconds().max(0) as u64;
    let pretty = format_age(age);
    // Green when fresh, yellow near the 5min edge, red once the watchdog
    // tick window is clearly overdue. Keeps the line scannable without
    // forcing the operator to do the UTC -> delta conversion in their head.
    if age <= 300 {
        pretty.green().to_string()
    } else if age <= 600 {
        format!("{pretty} (stale)").yellow().to_string()
    } else {
        format!("{pretty} (stalled)").red().to_string()
    }
}

fn format_age(secs: u64) -> String {
    if secs < 90 {
        format!("{secs}s ago")
    } else if secs < 5400 {
        format!("{}m ago", secs / 60)
    } else if secs < 86_400 {
        format!("{}h ago", secs / 3600)
    } else {
        format!("{}d ago", secs / 86_400)
    }
}

fn render_db_size(s: &Snapshot) -> String {
    match s.db_bytes {
        Some(n) => human_bytes(n).green().to_string(),
        None => "missing".yellow().to_string(),
    }
}

fn human_bytes(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut value = bytes as f64;
    let mut unit = 0usize;
    while value >= 1024.0 && unit + 1 < UNITS.len() {
        value /= 1024.0;
        unit += 1;
    }
    format!("{value:.1} {}", UNITS[unit])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn recognises_status_sessions() {
        assert!(is_status_session("agent0"));
        assert!(is_status_session("agent13"));
        assert!(is_status_session("agentinfinity"));
        assert!(is_status_session("netsky-ticker"));
        assert!(!is_status_session("agentx"));
    }

    #[test]
    fn human_bytes_scales() {
        assert_eq!(human_bytes(999), "999.0 B");
        assert_eq!(human_bytes(1024), "1.0 KB");
        assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
    }

    #[test]
    fn envelope_shape_and_yellow_on_no_sessions() {
        let s = Snapshot {
            version: "test",
            tmux_sessions: vec![],
            last_tick: None,
            db_bytes: None,
        };
        let v = build_envelope(&s);
        assert_eq!(v["command"], "status");
        assert_eq!(v["status"], "yellow");
        assert!(v["data"]["tmux_sessions"].as_array().unwrap().is_empty());
        assert_eq!(v["data"]["meta_db_bytes"], Value::Null);
    }

    #[test]
    fn envelope_green_on_sessions_and_no_ci() {
        let s = Snapshot {
            version: "v",
            tmux_sessions: vec!["agent0".into()],
            last_tick: None,
            db_bytes: Some(4096),
        };
        let v = build_envelope(&s);
        assert_eq!(v["status"], "green");
        assert_eq!(v["data"]["meta_db_bytes"], 4096);
    }

    #[test]
    fn format_age_buckets_match_human_intuition() {
        assert_eq!(format_age(0), "0s ago");
        assert_eq!(format_age(45), "45s ago");
        assert_eq!(format_age(89), "89s ago");
        assert_eq!(format_age(90), "1m ago");
        assert_eq!(format_age(3600), "60m ago");
        assert_eq!(format_age(5400), "1h ago");
        assert_eq!(format_age(86_400), "1d ago");
    }
}