netsky 0.1.7

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky handoffs [WHICH] [ARG] [--json]` — inspect the handoff archive.
//!
//! Subcommand grammar (preferred): `list | show [N|last] | prune`.
//! Freeform forms (`last`, `<N>`, blank) stay supported as aliases.

use std::fs;
use std::path::{Path, PathBuf};

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

use netsky_core::paths::handoff_archive_dir;

pub fn run(which: &str, arg: Option<&str>, limit: usize, json: bool) -> netsky_core::Result<()> {
    let dir = handoff_archive_dir();
    if !dir.exists() {
        return render_empty(
            &format!(
                "archive not present: {} (no restart since the archive-feature shipped)",
                dir.display()
            ),
            &dir,
            json,
        );
    }

    let files = newest_first_jsons(&dir)?;
    if files.is_empty() {
        return render_empty("(archive empty)", &dir, json);
    }

    match which {
        "list" => list(&files, limit, json),
        "last" => dump(&files[0], json),
        "show" => {
            let target = arg.unwrap_or("last");
            if target == "last" {
                dump(&files[0], json)
            } else if target.chars().all(|c| c.is_ascii_digit()) {
                let idx: usize = target.parse()?;
                if idx == 0 || idx > files.len() {
                    netsky_core::bail!("index {idx} out of range (have {} entries)", files.len());
                }
                dump(&files[idx - 1], json)
            } else {
                netsky_core::bail!("show takes `last` or a 1-based index, got '{target}'")
            }
        }
        "prune" => prune(&files, limit, json),
        s if s.chars().all(|c| c.is_ascii_digit()) => {
            let idx: usize = s.parse()?;
            if idx == 0 || idx > files.len() {
                netsky_core::bail!("index {idx} out of range (have {} entries)", files.len());
            }
            dump(&files[idx - 1], json)
        }
        other => netsky_core::bail!(
            "unknown selector '{other}' (use: list | show [N|last] | prune | <N>)"
        ),
    }
}

fn prune(files: &[PathBuf], keep: usize, json: bool) -> netsky_core::Result<()> {
    let to_drop: Vec<&PathBuf> = files.iter().skip(keep).collect();
    let mut removed = Vec::with_capacity(to_drop.len());
    for path in &to_drop {
        if let Err(err) = fs::remove_file(path) {
            netsky_core::bail!("remove {}: {err}", path.display());
        }
        removed.push(path.display().to_string());
    }
    if json {
        let envelope = json!({
            "command": "handoffs",
            "status": "green",
            "summary": format!("pruned {} (kept {})", removed.len(), keep),
            "generated_at": now_utc(),
            "data": {
                "archive_dir": handoff_archive_dir().display().to_string(),
                "kept": files.len().min(keep),
                "removed_count": removed.len(),
                "removed": removed,
            },
        });
        println!("{}", serde_json::to_string_pretty(&envelope)?);
    } else {
        println!(
            "pruned {} entries; kept newest {}",
            removed.len(),
            files.len().min(keep)
        );
    }
    Ok(())
}

fn render_empty(message: &str, dir: &Path, json: bool) -> netsky_core::Result<()> {
    if json {
        let envelope = envelope_list(message, dir, &[]);
        println!("{}", serde_json::to_string_pretty(&envelope)?);
    } else {
        println!("{message}");
    }
    Ok(())
}

fn newest_first_jsons(dir: &PathBuf) -> netsky_core::Result<Vec<PathBuf>> {
    let mut entries: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
    for e in fs::read_dir(dir)? {
        let e = e?;
        let p = e.path();
        if p.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let mtime = e.metadata()?.modified()?;
        entries.push((mtime, p));
    }
    entries.sort_by(|a, b| b.0.cmp(&a.0));
    Ok(entries.into_iter().map(|(_, p)| p).collect())
}

fn list(files: &[PathBuf], limit: usize, json: bool) -> netsky_core::Result<()> {
    if json {
        let items = files
            .iter()
            .take(limit)
            .map(handoff_item)
            .collect::<netsky_core::Result<Vec<_>>>()?;
        let envelope = envelope_list(
            &format!("{} handoff(s) archived", files.len()),
            &handoff_archive_dir(),
            &items,
        );
        println!("{}", serde_json::to_string_pretty(&envelope)?);
        return Ok(());
    }
    for f in files.iter().take(limit) {
        let md = fs::metadata(f)?;
        let ts: DateTime<Utc> = md.modified()?.into();
        let size = md.len();
        let preview = preview_of(f).unwrap_or_else(|| "(preview unavailable)".to_string());
        println!(
            "  {}  {:>5}B  {preview}",
            ts.format("%Y-%m-%dT%H:%M:%SZ"),
            size
        );
    }
    Ok(())
}

fn dump(path: &PathBuf, json: bool) -> netsky_core::Result<()> {
    let content = fs::read_to_string(path)?;
    let parsed: Option<Value> = serde_json::from_str(&content).ok();
    if json {
        let envelope = json!({
            "command": "handoffs",
            "status": "green",
            "summary": format!("handoff at {}", path.display()),
            "generated_at": now_utc(),
            "data": {
                "path": path.display().to_string(),
                "envelope": parsed.clone().unwrap_or_else(|| Value::String(content.clone())),
            },
        });
        println!("{}", serde_json::to_string_pretty(&envelope)?);
        return Ok(());
    }
    match parsed {
        Some(v) => println!("{}", serde_json::to_string_pretty(&v)?),
        None => println!("{content}"),
    }
    Ok(())
}

fn handoff_item(path: &PathBuf) -> netsky_core::Result<Value> {
    let md = fs::metadata(path)?;
    let ts: DateTime<Utc> = md.modified()?.into();
    Ok(json!({
        "path": path.display().to_string(),
        "ts_utc": ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
        "size_bytes": md.len(),
        "preview": preview_of(path),
    }))
}

fn envelope_list(summary: &str, dir: &Path, items: &[Value]) -> Value {
    json!({
        "command": "handoffs",
        "status": "green",
        "summary": summary,
        "generated_at": now_utc(),
        "data": {
            "archive_dir": dir.display().to_string(),
            "items": items,
            "count": items.len(),
        },
    })
}

fn now_utc() -> String {
    Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}

fn preview_of(path: &PathBuf) -> Option<String> {
    let content = fs::read_to_string(path).ok()?;
    let v: Value = serde_json::from_str(&content).ok()?;
    let text = v.get("text")?.as_str()?;
    let line: String = text.chars().take(70).collect::<String>().replace('\n', " ");
    Some(line)
}