skillnet 0.3.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
#![allow(dead_code)]

use std::{
    collections::BTreeMap,
    fs,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::Result;
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use walkdir::{DirEntry, WalkDir};

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cache {
    pub stamps: BTreeMap<String, ScopeStamp>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScopeStamp {
    pub last_pulled_at: SystemTime,
    #[serde(with = "u128_string")]
    pub live_source_max_mtime_nanos: u128,
    pub mirror_content_hash: String,
}

pub fn cache_path(mirror_root: &Utf8Path) -> Utf8PathBuf {
    mirror_root.join(".skillnet").join("cache.toml")
}

pub fn load(mirror_root: &Utf8Path) -> Cache {
    let path = cache_path(mirror_root);
    fs::read_to_string(path)
        .ok()
        .and_then(|text| toml::from_str(&text).ok())
        .unwrap_or_default()
}

pub fn save(mirror_root: &Utf8Path, cache: &Cache) -> Result<()> {
    let path = cache_path(mirror_root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let tmp = path.with_extension("toml.tmp");
    fs::write(&tmp, toml::to_string_pretty(cache)?)?;
    fs::rename(tmp, path)?;
    Ok(())
}

pub fn live_source_max_mtime(sources: &[Utf8PathBuf]) -> Result<u128> {
    let mut newest = 0;
    for source in sources {
        if !source.is_dir() {
            continue;
        }

        for entry in WalkDir::new(source)
            .follow_links(false)
            .min_depth(1)
            .into_iter()
            .filter_entry(|entry| !is_dot_entry(entry))
        {
            let entry = entry?;
            if entry.file_type().is_file() || entry.file_type().is_symlink() {
                let modified = entry
                    .metadata()?
                    .modified()
                    .unwrap_or(SystemTime::UNIX_EPOCH);
                let nanos = modified
                    .duration_since(UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_nanos();
                newest = newest.max(nanos);
            }
        }
    }
    Ok(newest)
}

pub fn is_stale(stamp: &ScopeStamp, live_mtime: u128) -> bool {
    live_mtime > stamp.live_source_max_mtime_nanos
}

fn is_dot_entry(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .is_some_and(|name| name.starts_with('.'))
}

mod u128_string {
    use std::fmt;

    use serde::{de, Deserializer, Serializer};

    pub fn serialize<S>(value: &u128, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(&value.to_string())
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<u128, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct U128Visitor;

        impl de::Visitor<'_> for U128Visitor {
            type Value = u128;

            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
                formatter.write_str("a decimal u128 string")
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: de::Error,
            {
                value.parse().map_err(de::Error::custom)
            }
        }

        deserializer.deserialize_str(U128Visitor)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{Duration, UNIX_EPOCH};

    #[test]
    fn cache_toml_schema_round_trips() {
        let mut cache = Cache::default();
        cache.stamps.insert(
            "global".to_string(),
            ScopeStamp {
                last_pulled_at: UNIX_EPOCH + Duration::from_secs(1_700_000_000),
                live_source_max_mtime_nanos: 1_700_000_000_123_456_789,
                mirror_content_hash: "abc123".to_string(),
            },
        );

        let serialized = toml::to_string_pretty(&cache).unwrap();
        assert_eq!(
            serialized,
            "[stamps.global]\nlive_source_max_mtime_nanos = \"1700000000123456789\"\nmirror_content_hash = \"abc123\"\n\n[stamps.global.last_pulled_at]\nsecs_since_epoch = 1700000000\nnanos_since_epoch = 0\n"
        );

        let parsed: Cache = toml::from_str(&serialized).unwrap();
        assert_eq!(parsed, cache);
    }
}