netsky 0.1.5

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

use std::fs;
use std::path::Path;
use std::process::Command;

use chrono::{DateTime, Utc};
use owo_colors::OwoColorize;
use serde::Deserialize;

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

const GITHUB_REPO: &str = "lostmygithubaccount/netsky";
const MAIN_BRANCH: &str = "main";

#[derive(Debug, Deserialize)]
struct GhRun {
    conclusion: Option<String>,
    #[serde(rename = "createdAt")]
    created_at: String,
    #[serde(rename = "headSha")]
    head_sha: String,
    status: String,
    url: String,
}

pub fn run() -> netsky_core::Result<()> {
    println!(
        "version: {}",
        format!("netsky {}", env!("CARGO_PKG_VERSION")).bold()
    );
    println!("tmux: {}", render_tmux_sessions());
    println!("last tick: {}", render_last_tick());
    println!("main CI: {}", render_ci_status());
    println!("meta.db: {}", render_db_size());
    Ok(())
}

fn render_tmux_sessions() -> String {
    let mut sessions: Vec<String> = tmux::list_sessions()
        .into_iter()
        .filter(|s| is_status_session(s))
        .collect();
    sessions.sort();
    if sessions.is_empty() {
        return "none".yellow().to_string();
    }
    let total = sessions.len();
    if sessions.len() > 8 {
        let tail = sessions.len() - 8;
        sessions.truncate(8);
        sessions.push(format!("+{tail} more"));
    }
    format!(
        "{} active: {}",
        total.to_string().green(),
        sessions.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() -> String {
    let log = Path::new(TICKER_LOG_PATH);
    let modified = match fs::metadata(log).and_then(|m| m.modified()) {
        Ok(ts) => ts,
        Err(_) => return "none".yellow().to_string(),
    };
    let dt: DateTime<Utc> = modified.into();
    dt.format("%Y-%m-%dT%H:%M:%SZ")
        .to_string()
        .green()
        .to_string()
}

fn render_ci_status() -> String {
    match latest_main_run() {
        Ok(Some(run)) => render_ci_run(run),
        Ok(None) => "pending".yellow().to_string(),
        Err(_) => "pending (offline)".yellow().to_string(),
    }
}

fn render_ci_run(run: GhRun) -> String {
    let created = DateTime::parse_from_rfc3339(&run.created_at)
        .map(|dt| dt.with_timezone(&Utc))
        .ok();
    let age = created
        .map(|dt| Utc::now().signed_duration_since(dt))
        .unwrap_or_else(|| chrono::Duration::hours(25));
    let sha = short_sha(&run.head_sha);
    match (run.status.as_str(), run.conclusion.as_deref()) {
        ("completed", Some("success")) => format!("green {sha} {}", run.url).green().to_string(),
        ("completed", Some("failure" | "cancelled" | "timed_out")) => {
            format!("red {sha} {}", run.url).red().to_string()
        }
        ("completed", _) => format!("pending {sha} {}", run.url).yellow().to_string(),
        _ => {
            let stale = if age.num_hours() >= 24 { " stale" } else { "" };
            format!("pending {sha}{stale} {}", run.url)
                .yellow()
                .to_string()
        }
    }
}

fn latest_main_run() -> netsky_core::Result<Option<GhRun>> {
    let output = match Command::new("gh")
        .args([
            "run",
            "list",
            "--repo",
            GITHUB_REPO,
            "--branch",
            MAIN_BRANCH,
            "--limit",
            "1",
            "--json",
            "conclusion,headSha,createdAt,status,url",
        ])
        .output()
    {
        Ok(o) if o.status.success() => o,
        Ok(_) => return Ok(None),
        Err(_) => return Ok(None),
    };
    let runs: Vec<GhRun> = serde_json::from_slice(&output.stdout)?;
    Ok(runs.into_iter().next())
}

fn render_db_size() -> String {
    let path = home().join(".netsky/meta.db");
    let Ok(meta) = fs::metadata(&path) else {
        return "missing".yellow().to_string();
    };
    human_bytes(meta.len()).green().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])
}

fn short_sha(sha: &str) -> String {
    sha.chars().take(7).collect()
}

#[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");
    }
}