netsky-core 0.1.7

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Durable cron entry storage under `~/.netsky/cron.toml`.

use std::fs;
use std::path::Path;
use std::str::FromStr;

use chrono::{DateTime, Utc};
use croner::Cron;
use serde::{Deserialize, Serialize};

use crate::paths::cron_file_path;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CronFile {
    #[serde(default)]
    pub entries: Vec<CronEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronEntry {
    pub label: String,
    pub schedule: String,
    pub target: String,
    pub prompt: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_fired: Option<String>,
}

impl CronEntry {
    pub fn validate(&self) -> crate::Result<()> {
        self.schedule_handle().map(|_| ())
    }

    pub fn last_fired_at(&self) -> crate::Result<Option<DateTime<Utc>>> {
        self.last_fired
            .as_deref()
            .map(parse_ts)
            .transpose()
            .map_err(|e| crate::anyhow!("cron {label}: {e}", label = self.label))
    }

    pub fn next_after(&self, after: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
        self.schedule_handle()?
            .find_next_occurrence(&after, false)
            .map_err(|e| crate::anyhow!("cron {label}: compute next fire: {e}", label = self.label))
    }

    pub fn next_fire(&self, now: DateTime<Utc>) -> crate::Result<DateTime<Utc>> {
        match self.last_fired_at()? {
            Some(ts) => self.next_after(ts),
            None => self.next_after(now),
        }
    }

    pub fn is_due(&self, now: DateTime<Utc>) -> crate::Result<bool> {
        let Some(last_fired) = self.last_fired_at()? else {
            return Ok(false);
        };
        Ok(self.next_after(last_fired)? <= now)
    }

    pub fn mark_fired(&mut self, when: DateTime<Utc>) {
        self.last_fired = Some(when.to_rfc3339());
    }

    fn schedule_handle(&self) -> crate::Result<Cron> {
        Cron::from_str(&self.schedule)
            .map_err(|e| crate::anyhow!("invalid schedule {:?}: {e}", self.schedule))
    }
}

pub fn load() -> crate::Result<CronFile> {
    load_from(&cron_file_path())
}

pub fn load_from(path: &Path) -> crate::Result<CronFile> {
    let raw = match fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(CronFile::default()),
        Err(e) => return Err(e.into()),
    };
    let file: CronFile =
        toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
    for entry in &file.entries {
        entry.validate()?;
        let _ = entry.last_fired_at()?;
    }
    Ok(file)
}

pub fn save(file: &CronFile) -> crate::Result<()> {
    save_to(&cron_file_path(), file)
}

pub fn save_to(path: &Path, file: &CronFile) -> crate::Result<()> {
    for entry in &file.entries {
        entry.validate()?;
        let _ = entry.last_fired_at()?;
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let tmp = path.with_extension("toml.tmp");
    let body =
        toml::to_string_pretty(file).map_err(|e| crate::anyhow!("serialize cron.toml: {e}"))?;
    fs::write(&tmp, body)?;
    fs::rename(&tmp, path)?;
    Ok(())
}

fn parse_ts(raw: &str) -> crate::Result<DateTime<Utc>> {
    Ok(DateTime::parse_from_rfc3339(raw)
        .map_err(|e| crate::anyhow!("invalid last_fired {:?}: {e}", raw))?
        .with_timezone(&Utc))
}

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

    fn sample_entry() -> CronEntry {
        CronEntry {
            label: "morning-brief".to_string(),
            schedule: "0 7 * * *".to_string(),
            target: "agent0".to_string(),
            prompt: "/morning-brief".to_string(),
            last_fired: None,
        }
    }

    #[test]
    fn validates_five_field_schedule() {
        sample_entry().validate().unwrap();
    }

    #[test]
    fn next_fire_advances_from_last_fired() {
        let mut entry = sample_entry();
        entry.last_fired = Some("2026-04-15T07:00:00Z".to_string());
        let now = Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap();
        assert_eq!(
            entry.next_fire(now).unwrap(),
            Utc.with_ymd_and_hms(2026, 4, 16, 7, 0, 0).unwrap()
        );
        assert!(
            entry
                .is_due(Utc.with_ymd_and_hms(2026, 4, 17, 6, 0, 0).unwrap())
                .unwrap()
        );
    }

    #[test]
    fn save_and_load_round_trip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("cron.toml");
        let mut file = CronFile {
            entries: vec![sample_entry()],
        };
        file.entries[0].mark_fired(Utc.with_ymd_and_hms(2026, 4, 17, 7, 0, 0).unwrap());
        save_to(&path, &file).unwrap();
        let loaded = load_from(&path).unwrap();
        assert_eq!(loaded.entries.len(), 1);
        assert_eq!(loaded.entries[0].label, "morning-brief");
        assert_eq!(loaded.entries[0].last_fired, file.entries[0].last_fired);
    }
}