use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct ManifestEntry {
pub version: Option<String>,
pub source: String,
pub installed_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub(crate) struct Manifest {
pub packages: BTreeMap<String, ManifestEntry>,
}
fn manifest_path() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
Ok(home.join(".algocline").join("installed.json"))
}
pub(crate) fn load_manifest() -> Result<Manifest, String> {
let path = manifest_path()?;
if !path.exists() {
return Ok(Manifest::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read manifest: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest: {e}"))
}
fn save_manifest(manifest: &Manifest) -> Result<(), String> {
let path = manifest_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create manifest dir: {e}"))?;
}
let content = serde_json::to_string_pretty(manifest)
.map_err(|e| format!("Failed to serialize manifest: {e}"))?;
std::fs::write(&path, content).map_err(|e| format!("Failed to write manifest: {e}"))
}
fn now_iso8601() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let s = secs as i64;
let days = s / 86400;
let time_of_day = s % 86400;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let sec = time_of_day % 60;
let (y, mo, d) = days_to_ymd(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{sec:02}Z")
}
fn days_to_ymd(days: i64) -> (i64, i64, i64) {
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
pub(crate) fn record_install(
name: &str,
version: Option<&str>,
source: &str,
) -> Result<(), String> {
let mut manifest = load_manifest()?;
let now = now_iso8601();
let entry = manifest
.packages
.entry(name.to_string())
.and_modify(|e| {
e.version = version.map(String::from);
e.source = source.to_string();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: version.map(String::from),
source: source.to_string(),
installed_at: now.clone(),
updated_at: now,
});
let _ = entry;
save_manifest(&manifest)
}
pub(crate) fn record_install_batch(names: &[String], source: &str) -> Result<(), String> {
if names.is_empty() {
return Ok(());
}
let mut manifest = load_manifest()?;
let now = now_iso8601();
for name in names {
manifest
.packages
.entry(name.clone())
.and_modify(|e| {
e.source = source.to_string();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: None, source: source.to_string(),
installed_at: now.clone(),
updated_at: now.clone(),
});
}
save_manifest(&manifest)
}
pub(crate) fn record_remove(name: &str) -> Result<(), String> {
let mut manifest = load_manifest()?;
manifest.packages.remove(name);
save_manifest(&manifest)
}
#[cfg(test)]
pub(crate) fn load_manifest_from(path: &std::path::Path) -> Result<Manifest, String> {
if !path.exists() {
return Ok(Manifest::default());
}
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_to_ymd_epoch() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}
#[test]
fn days_to_ymd_known_date() {
assert_eq!(days_to_ymd(19723), (2024, 1, 1));
}
#[test]
fn manifest_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("installed.json");
let mut manifest = Manifest::default();
manifest.packages.insert(
"cot".to_string(),
ManifestEntry {
version: Some("0.1.0".to_string()),
source: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
installed_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
let content = serde_json::to_string_pretty(&manifest).unwrap();
std::fs::write(&path, &content).unwrap();
let loaded = load_manifest_from(&path).unwrap();
assert_eq!(loaded, manifest);
}
#[test]
fn manifest_empty_file_missing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nonexistent.json");
let loaded = load_manifest_from(&path).unwrap();
assert!(loaded.packages.is_empty());
}
}