netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky watchdog events` reads the durable watchdog JSONL trail.

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

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

use netsky_core::paths::{logs_dir, watchdog_event_log_path};

const DEFAULT_LIMIT: usize = 50;
const EVENT_PREFIX: &str = "watchdog-events-";
const EVENT_SUFFIX: &str = ".jsonl";

#[derive(Debug, Clone)]
pub struct WatchdogEvent {
    ts: DateTime<Utc>,
    kind: String,
    detail: Value,
    raw: Value,
}

#[derive(Debug, Clone)]
pub struct EventLogStatus {
    pub records: usize,
    pub last_ts: Option<DateTime<Utc>>,
}

pub fn run(since: Option<&str>, limit: Option<usize>, json: bool) -> netsky_core::Result<()> {
    let since_ts = match since {
        Some(s) => Some(Utc::now() - parse_duration(s)?),
        None => None,
    };
    let paths = event_paths(since_ts)?;
    let mut events = Vec::new();
    for path in paths {
        if !path.exists() {
            continue;
        }
        events.extend(read_events_from(&path)?);
    }
    if let Some(cutoff) = since_ts {
        events.retain(|e| e.ts >= cutoff);
    }
    events.sort_by_key(|e| e.ts);

    let effective_limit = match (since, limit) {
        (_, Some(n)) => Some(n),
        (None, None) => Some(DEFAULT_LIMIT),
        (Some(_), None) => None,
    };
    if let Some(n) = effective_limit
        && events.len() > n
    {
        events = events.split_off(events.len() - n);
    }

    if json {
        let raw = events.into_iter().map(|e| e.raw).collect::<Vec<_>>();
        println!("{}", serde_json::to_string_pretty(&raw)?);
        return Ok(());
    }

    if events.is_empty() {
        println!("no watchdog events");
        return Ok(());
    }
    for event in events {
        println!(
            "{}  {:<24}  {}",
            event.ts.format("%Y-%m-%dT%H:%M:%SZ"),
            event.kind,
            compact_json(&event.detail)?
        );
    }
    Ok(())
}

pub fn parse_status(path: &Path) -> netsky_core::Result<EventLogStatus> {
    let events = read_events_from(path)?;
    Ok(EventLogStatus {
        records: events.len(),
        last_ts: events.last().map(|e| e.ts),
    })
}

pub fn read_events_from(path: &Path) -> netsky_core::Result<Vec<WatchdogEvent>> {
    let mut out = Vec::new();
    for (idx, raw) in netsky_core::jsonl::read_records(path)?.enumerate() {
        let raw = raw.map_err(|e| {
            netsky_core::Error::Invalid(format!(
                "{}:{}: {e}",
                path.display(),
                idx.saturating_add(1)
            ))
        })?;
        out.push(parse_raw(raw)?);
    }
    Ok(out)
}

fn parse_raw(raw: Value) -> netsky_core::Result<WatchdogEvent> {
    let ts = raw
        .get("ts")
        .and_then(Value::as_str)
        .ok_or_else(|| netsky_core::Error::Invalid("missing ts".to_string()))?;
    let ts = DateTime::parse_from_rfc3339(ts)
        .map_err(|e| netsky_core::Error::Invalid(format!("bad ts: {e}")))?
        .with_timezone(&Utc);
    let kind = raw
        .get("kind")
        .and_then(Value::as_str)
        .ok_or_else(|| netsky_core::Error::Invalid("missing kind".to_string()))?
        .to_string();
    let detail = raw
        .get("detail")
        .cloned()
        .ok_or_else(|| netsky_core::Error::Invalid("missing detail".to_string()))?;
    Ok(WatchdogEvent {
        ts,
        kind,
        detail,
        raw,
    })
}

fn event_paths(since: Option<DateTime<Utc>>) -> netsky_core::Result<Vec<PathBuf>> {
    let mut paths = Vec::new();
    let dir = logs_dir();
    if let Ok(entries) = fs::read_dir(&dir) {
        for entry in entries.filter_map(|e| e.ok()) {
            let path = entry.path();
            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
                continue;
            };
            if !name.starts_with(EVENT_PREFIX) || !name.ends_with(EVENT_SUFFIX) {
                continue;
            }
            if let Some(cutoff) = since
                && let Some(day) = event_day(name)
                && day < cutoff.date_naive()
            {
                continue;
            }
            paths.push(path);
        }
    }
    paths.sort();
    if paths.is_empty() {
        let today = watchdog_event_log_path();
        if today.exists() || since.is_none() {
            paths.push(today);
        }
    }
    Ok(paths)
}

fn event_day(name: &str) -> Option<chrono::NaiveDate> {
    let day = name
        .strip_prefix(EVENT_PREFIX)?
        .strip_suffix(EVENT_SUFFIX)?;
    chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()
}

fn parse_duration(s: &str) -> netsky_core::Result<Duration> {
    let s = s.trim();
    if s.is_empty() {
        return Err(netsky_core::Error::Invalid(
            "empty duration — expected `<N><unit>` (e.g. 30m, 6h, 2d)".to_string(),
        ));
    }
    let (digits, unit) = s.split_at(s.find(|ch: char| !ch.is_ascii_digit()).unwrap_or(s.len()));
    if digits.is_empty() {
        return Err(netsky_core::Error::Invalid(format!(
            "bad duration '{s}' — expected `<N><unit>` with N an integer (e.g. 30m, 6h, 2d)"
        )));
    }
    let n: i64 = digits.parse()?;
    match unit {
        "" | "s" | "sec" | "secs" => Ok(Duration::seconds(n)),
        "m" | "min" | "mins" => Ok(Duration::minutes(n)),
        "h" | "hr" | "hrs" => Ok(Duration::hours(n)),
        "d" | "day" | "days" => Ok(Duration::days(n)),
        _ => Err(netsky_core::Error::Invalid(format!(
            "bad duration unit '{unit}' — expected one of m|min|mins, h|hr|hrs, d|day|days"
        ))),
    }
}

fn compact_json(value: &Value) -> netsky_core::Result<String> {
    Ok(serde_json::to_string(value)?)
}

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

    #[test]
    fn reader_parses_sample_jsonl() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
        fs::write(
            &path,
            concat!(
                r#"{"ts":"2026-04-17T00:00:00Z","kind":"ticker stopped","detail":{"message":"gap"}}"#,
                "\n",
                r#"{"ts":"2026-04-17T00:01:00Z","kind":"failed-revive","detail":{"pid":42}}"#,
                "\n"
            ),
        )
        .unwrap();

        let events = read_events_from(&path).unwrap();
        assert_eq!(events.len(), 2);
        assert_eq!(events[0].kind, "ticker stopped");
        assert_eq!(events[1].detail["pid"], 42);
    }

    #[test]
    fn reader_rejects_bad_jsonl() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
        fs::write(&path, r#"{"ts":"nope","kind":"x","detail":{}}"#).unwrap();
        let err = read_events_from(&path).unwrap_err();
        assert!(format!("{err}").contains("bad ts"));
    }

    #[test]
    fn reader_skips_malformed_trailing_jsonl_record() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
        fs::write(
            &path,
            concat!(
                r#"{"ts":"2026-04-17T00:00:00Z","kind":"ticker stopped","detail":{"message":"gap"}}"#,
                "\n",
                r#"{"ts":"2026-04-17T00:01:00Z","kind":"failed-revive","detail":{"pid":42}}"#,
                "\n",
                r#"{"ts":"2026-04-17T00:02:00Z","kind":"partial""#
            ),
        )
        .unwrap();

        let events = read_events_from(&path).unwrap();
        assert_eq!(events.len(), 2);
        assert_eq!(events[0].kind, "ticker stopped");
        assert_eq!(events[1].kind, "failed-revive");
    }

    #[test]
    fn reader_keeps_valid_final_jsonl_record_without_newline() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
        fs::write(
            &path,
            concat!(
                r#"{"ts":"2026-04-17T00:00:00Z","kind":"ticker stopped","detail":{"message":"gap"}}"#,
                "\n",
                r#"{"ts":"2026-04-17T00:01:00Z","kind":"failed-revive","detail":{"pid":42}}"#
            ),
        )
        .unwrap();

        let events = read_events_from(&path).unwrap();
        assert_eq!(events.len(), 2);
        assert_eq!(events[1].kind, "failed-revive");
    }

    #[test]
    fn reader_rejects_malformed_middle_jsonl_record() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("watchdog-events-2026-04-17.jsonl");
        fs::write(
            &path,
            concat!(
                r#"{"ts":"2026-04-17T00:00:00Z","kind":"ticker stopped","detail":{"message":"gap"}}"#,
                "\n",
                r#"{"ts":"2026-04-17T00:01:00Z","kind":"broken""#,
                "\n",
                r#"{"ts":"2026-04-17T00:02:00Z","kind":"failed-revive","detail":{"pid":42}}"#,
                "\n"
            ),
        )
        .unwrap();

        let err = read_events_from(&path).unwrap_err();
        assert!(format!("{err}").contains(&path.display().to_string()));
    }

    #[test]
    fn duration_accepts_operator_units() {
        assert_eq!(parse_duration("30m").unwrap(), Duration::minutes(30));
        assert_eq!(parse_duration("6h").unwrap(), Duration::hours(6));
        assert_eq!(parse_duration("2d").unwrap(), Duration::days(2));
        assert!(parse_duration("forty").is_err());
    }
}