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());
}
}