seshat-cli 0.3.1

CLI commands and TUI for Seshat
Documentation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VersionCache {
    pub latest_version: String,
    pub checked_at: String,
    #[serde(default)]
    pub has_assets: Option<bool>,
}

impl VersionCache {
    pub fn cache_dir() -> Option<PathBuf> {
        Some(dirs::data_dir()?.join("seshat"))
    }

    pub fn cache_path() -> Option<PathBuf> {
        Some(Self::cache_dir()?.join("version-check.json"))
    }

    pub fn read_from_path(path: &std::path::Path) -> Option<Self> {
        let content = std::fs::read_to_string(path).ok()?;
        if content.trim().is_empty() {
            return None;
        }
        serde_json::from_str(&content).ok()
    }

    pub fn read() -> Option<Self> {
        Self::read_from_path(&Self::cache_path()?)
    }

    pub fn write(&self) -> Result<(), std::io::Error> {
        let path = Self::cache_path().ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "could not determine cache path",
            )
        })?;
        self.write_to_path(&path)
    }

    pub fn write_to_path(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        std::fs::write(path, json)
    }

    pub fn is_fresh(&self) -> bool {
        chrono::DateTime::parse_from_rfc3339(&self.checked_at)
            .ok()
            .map(|checked_time| {
                let now = chrono::Utc::now();
                let age = now.signed_duration_since(checked_time);
                age.num_hours() < 24
            })
            .unwrap_or(false)
    }

    pub fn new(latest_version: String) -> Self {
        Self {
            latest_version,
            checked_at: chrono::Utc::now().to_rfc3339(),
            has_assets: None,
        }
    }

    pub fn with_assets(latest_version: String, has_assets: bool) -> Self {
        Self {
            latest_version,
            checked_at: chrono::Utc::now().to_rfc3339(),
            has_assets: Some(has_assets),
        }
    }

    pub fn expired_at(version: &str, hours_ago: i64) -> Self {
        Self {
            latest_version: version.to_owned(),
            checked_at: (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339(),
            has_assets: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    fn cache_json(version: &str, rfc3339: &str) -> String {
        format!(
            r#"{{"latest_version":"{}","checked_at":"{}"}}"#,
            version, rfc3339
        )
    }

    #[test]
    fn fresh_cache_is_fresh() {
        let cache = VersionCache::new("1.0.0".to_owned());
        assert!(cache.is_fresh());
    }

    #[test]
    fn old_cache_is_stale() {
        let cache = VersionCache::expired_at("1.0.0", 25);
        assert!(!cache.is_fresh());
    }

    #[test]
    fn exactly_24h_is_stale() {
        let cache = VersionCache::expired_at("1.0.0", 24);
        assert!(!cache.is_fresh());
    }

    #[test]
    fn missing_file_returns_none() {
        let result = VersionCache::read_from_path(std::path::Path::new("/nonexistent/cache.json"));
        assert!(result.is_none());
    }

    #[test]
    fn empty_file_returns_none() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, "").unwrap();
        let result = VersionCache::read_from_path(file.path());
        assert!(result.is_none());
    }

    #[test]
    fn whitespace_only_file_returns_none() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, "   \n  ").unwrap();
        let result = VersionCache::read_from_path(file.path());
        assert!(result.is_none());
    }

    #[test]
    fn corrupted_json_returns_none() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, "not json").unwrap();
        let result = VersionCache::read_from_path(file.path());
        assert!(result.is_none());
    }

    #[test]
    fn missing_required_fields_returns_none() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"{{"wrong_field":"x"}}"#).unwrap();
        let result = VersionCache::read_from_path(file.path());
        assert!(result.is_none());
    }

    #[test]
    fn valid_cache_reads_correctly() {
        let mut file = NamedTempFile::new().unwrap();
        let now_rfc3339 = chrono::Utc::now().to_rfc3339();
        write!(file, "{}", cache_json("2.0.0", &now_rfc3339)).unwrap();
        let result = VersionCache::read_from_path(file.path());
        assert!(result.is_some());
        let cache = result.unwrap();
        assert_eq!(cache.latest_version, "2.0.0");
        assert_eq!(cache.checked_at, now_rfc3339);
    }

    #[test]
    fn write_creates_directories_and_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("subdir").join("nested").join("cache.json");
        let cache = VersionCache::new("3.0.0".to_owned());
        cache.write_to_path(&path).unwrap();
        assert!(path.exists());
        let read_back = VersionCache::read_from_path(&path).unwrap();
        assert_eq!(read_back.latest_version, "3.0.0");
    }

    #[test]
    fn write_then_read_roundtrips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("cache.json");
        let cache = VersionCache::new("4.0.0".to_owned());
        cache.write_to_path(&path).unwrap();
        let read_back = VersionCache::read_from_path(&path).unwrap();
        assert_eq!(read_back.latest_version, "4.0.0");
        assert_eq!(read_back.checked_at, cache.checked_at);
    }

    #[test]
    fn invalid_timestamp_is_stale() {
        let cache = VersionCache {
            latest_version: "1.0.0".to_owned(),
            checked_at: "not-a-timestamp".to_owned(),
            has_assets: None,
        };
        assert!(!cache.is_fresh());
    }

    #[test]
    fn new_creates_with_current_timestamp() {
        let before = chrono::Utc::now() - chrono::Duration::seconds(1);
        let cache = VersionCache::new("1.0.0".to_owned());
        let after = chrono::Utc::now() + chrono::Duration::seconds(1);

        let parsed = chrono::DateTime::parse_from_rfc3339(&cache.checked_at).unwrap();
        assert!(parsed > before);
        assert!(parsed < after);
    }

    #[test]
    fn cache_path_is_in_data_dir() {
        let path = VersionCache::cache_path();
        assert!(path.is_some());
        let p = path.unwrap();
        assert!(p.ends_with("version-check.json"));
        assert!(p.to_string_lossy().contains("seshat"));
    }
}