use std::collections::BTreeMap;
use std::path::PathBuf;
#[cfg(test)]
use std::sync::Mutex;
use algocline_core::AppDir;
use serde::{Deserialize, Serialize};
use super::source::PackageSource;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct ManifestEntry {
pub version: Option<String>,
#[serde(default)]
pub source: PackageSource,
pub installed_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub(crate) struct Manifest {
pub packages: BTreeMap<String, ManifestEntry>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum InstalledManifestStoreError {
#[error("failed to read installed manifest at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse installed manifest at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to serialize installed manifest: {source}")]
Serialize {
#[source]
source: serde_json::Error,
},
#[error("failed to create installed manifest directory at {path}: {source}")]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write installed manifest temp file at {path}: {source}")]
WriteTmp {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to atomically rename installed manifest onto {path}: {source}")]
Rename {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("installed manifest lock failed at {path}: {message}")]
Lock { path: PathBuf, message: String },
}
impl From<InstalledManifestStoreError> for String {
fn from(e: InstalledManifestStoreError) -> Self {
e.to_string()
}
}
pub(crate) trait InstalledManifestStore: Send + Sync {
fn load(&self) -> Result<Manifest, InstalledManifestStoreError>;
fn record_install(
&self,
name: &str,
version: Option<&str>,
source: PackageSource,
) -> Result<(), InstalledManifestStoreError>;
fn record_install_batch(
&self,
names: &[String],
source: PackageSource,
) -> Result<(), InstalledManifestStoreError>;
fn record_remove(&self, name: &str) -> Result<(), InstalledManifestStoreError>;
}
#[derive(Clone)]
pub(crate) struct FsInstalledManifestStore {
app_dir: AppDir,
}
impl FsInstalledManifestStore {
pub(crate) fn new(app_dir: AppDir) -> Self {
Self { app_dir }
}
fn manifest_path(&self) -> PathBuf {
self.app_dir.installed_json()
}
fn manifest_lock_path(&self) -> PathBuf {
self.app_dir.installed_json().with_extension("json.lock")
}
fn with_lock<F, R>(&self, f: F) -> Result<R, InstalledManifestStoreError>
where
F: FnOnce() -> Result<R, InstalledManifestStoreError>,
{
let lock_path = self.manifest_lock_path();
crate::service::lock::with_exclusive_lock(&lock_path, || f().map_err(|e| e.to_string()))
.map_err(|message| InstalledManifestStoreError::Lock {
path: lock_path.clone(),
message,
})
}
fn save(&self, manifest: &Manifest) -> Result<(), InstalledManifestStoreError> {
let path = self.manifest_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| {
InstalledManifestStoreError::CreateDir {
path: parent.to_path_buf(),
source,
}
})?;
}
let content = serde_json::to_string_pretty(manifest)
.map_err(|source| InstalledManifestStoreError::Serialize { source })?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &content).map_err(|source| InstalledManifestStoreError::WriteTmp {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, &path).map_err(|source| {
let _ = std::fs::remove_file(&tmp);
InstalledManifestStoreError::Rename {
path: path.clone(),
source,
}
})
}
}
impl InstalledManifestStore for FsInstalledManifestStore {
fn load(&self) -> Result<Manifest, InstalledManifestStoreError> {
let path = self.manifest_path();
if !path.exists() {
return Ok(Manifest::default());
}
let content =
std::fs::read_to_string(&path).map_err(|source| InstalledManifestStoreError::Read {
path: path.clone(),
source,
})?;
serde_json::from_str(&content)
.map_err(|source| InstalledManifestStoreError::Parse { path, source })
}
fn record_install(
&self,
name: &str,
version: Option<&str>,
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
self.with_lock(|| {
let mut manifest = self.load()?;
let now = now_iso8601();
manifest
.packages
.entry(name.to_string())
.and_modify(|e| {
if let Some(v) = version {
e.version = Some(v.to_string());
}
e.source = source.clone();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: version.map(String::from),
source: source.clone(),
installed_at: now.clone(),
updated_at: now,
});
self.save(&manifest)
})
}
fn record_install_batch(
&self,
names: &[String],
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
if names.is_empty() {
return Ok(());
}
self.with_lock(|| {
let mut manifest = self.load()?;
let now = now_iso8601();
for name in names {
manifest
.packages
.entry(name.clone())
.and_modify(|e| {
e.source = source.clone();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: None,
source: source.clone(),
installed_at: now.clone(),
updated_at: now.clone(),
});
}
self.save(&manifest)
})
}
fn record_remove(&self, name: &str) -> Result<(), InstalledManifestStoreError> {
self.with_lock(|| {
let mut manifest = self.load()?;
manifest.packages.remove(name);
self.save(&manifest)
})
}
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct InMemoryInstalledManifestStore {
data: Mutex<Manifest>,
}
#[cfg(test)]
impl InstalledManifestStore for InMemoryInstalledManifestStore {
fn load(&self) -> Result<Manifest, InstalledManifestStoreError> {
Ok(self.data.lock().unwrap_or_else(|e| e.into_inner()).clone())
}
fn record_install(
&self,
name: &str,
version: Option<&str>,
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
let mut guard = self.data.lock().unwrap_or_else(|e| e.into_inner());
let now = now_iso8601();
guard
.packages
.entry(name.to_string())
.and_modify(|e| {
if let Some(v) = version {
e.version = Some(v.to_string());
}
e.source = source.clone();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: version.map(String::from),
source: source.clone(),
installed_at: now.clone(),
updated_at: now,
});
Ok(())
}
fn record_install_batch(
&self,
names: &[String],
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
if names.is_empty() {
return Ok(());
}
let mut guard = self.data.lock().unwrap_or_else(|e| e.into_inner());
let now = now_iso8601();
for name in names {
guard
.packages
.entry(name.clone())
.and_modify(|e| {
e.source = source.clone();
e.updated_at = now.clone();
})
.or_insert_with(|| ManifestEntry {
version: None,
source: source.clone(),
installed_at: now.clone(),
updated_at: now.clone(),
});
}
Ok(())
}
fn record_remove(&self, name: &str) -> Result<(), InstalledManifestStoreError> {
let mut guard = self.data.lock().unwrap_or_else(|e| e.into_inner());
guard.packages.remove(name);
Ok(())
}
}
pub(crate) fn load_manifest(app_dir: &AppDir) -> Result<Manifest, InstalledManifestStoreError> {
FsInstalledManifestStore::new(app_dir.clone()).load()
}
pub(crate) 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(
app_dir: &AppDir,
name: &str,
version: Option<&str>,
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
FsInstalledManifestStore::new(app_dir.clone()).record_install(name, version, source)
}
pub(crate) fn record_install_batch(
app_dir: &AppDir,
names: &[String],
source: PackageSource,
) -> Result<(), InstalledManifestStoreError> {
FsInstalledManifestStore::new(app_dir.clone()).record_install_batch(names, source)
}
pub(crate) fn record_remove(
app_dir: &AppDir,
name: &str,
) -> Result<(), InstalledManifestStoreError> {
FsInstalledManifestStore::new(app_dir.clone()).record_remove(name)
}
#[cfg(test)]
pub(crate) fn load_manifest_from(
path: &std::path::Path,
) -> Result<Manifest, InstalledManifestStoreError> {
if !path.exists() {
return Ok(Manifest::default());
}
let content =
std::fs::read_to_string(path).map_err(|source| InstalledManifestStoreError::Read {
path: path.to_path_buf(),
source,
})?;
serde_json::from_str(&content).map_err(|source| InstalledManifestStoreError::Parse {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_installed_manifest_store_roundtrip() {
let repo = InMemoryInstalledManifestStore::default();
let git_src = || PackageSource::Git {
url: "https://example.test/mock".to_string(),
rev: None,
};
repo.record_install("alpha", Some("1.0.0"), git_src())
.unwrap();
repo.record_install("alpha", None, git_src()).unwrap();
repo.record_install_batch(&["beta".to_string(), "gamma".to_string()], git_src())
.unwrap();
repo.record_remove("beta").unwrap();
let loaded = repo.load().unwrap();
assert_eq!(
loaded.packages.get("alpha").unwrap().version.as_deref(),
Some("1.0.0"),
"version=None should preserve existing entry"
);
assert!(loaded.packages.contains_key("gamma"));
assert!(!loaded.packages.contains_key("beta"));
}
#[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: PackageSource::Git {
url: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
rev: None,
},
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_backward_compat_legacy_string_sources() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("installed.json");
let legacy_json = r#"{
"packages": {
"cot": {
"version": "0.1.0",
"source": "https://github.com/ynishi/algocline-bundled-packages",
"installed_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
"ucb": {
"version": null,
"source": "bundled",
"installed_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
"local_pkg": {
"version": null,
"source": "/abs/local/pkg",
"installed_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
"legacy_empty": {
"version": null,
"source": "",
"installed_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
}
}"#;
std::fs::write(&path, legacy_json).unwrap();
let loaded = load_manifest_from(&path).expect("must parse legacy manifest");
assert_eq!(
loaded.packages.get("cot").unwrap().source,
PackageSource::Git {
url: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
rev: None,
},
);
assert_eq!(
loaded.packages.get("ucb").unwrap().source,
PackageSource::Bundled { collection: None },
);
assert_eq!(
loaded.packages.get("local_pkg").unwrap().source,
PackageSource::Installed,
);
assert_eq!(
loaded.packages.get("legacy_empty").unwrap().source,
PackageSource::Unknown,
);
}
#[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());
}
#[test]
fn record_install_none_preserves_existing_version() {
let tmp = tempfile::tempdir().unwrap();
let app_dir = AppDir::new(tmp.path().to_path_buf());
let git_src = || PackageSource::Git {
url: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
rev: None,
};
record_install(&app_dir, "cot", Some("0.1.0"), git_src()).unwrap();
let before = load_manifest(&app_dir).unwrap();
assert_eq!(
before.packages.get("cot").unwrap().version.as_deref(),
Some("0.1.0")
);
record_install(&app_dir, "cot", None, git_src()).unwrap();
let after_none = load_manifest(&app_dir).unwrap();
assert_eq!(
after_none.packages.get("cot").unwrap().version.as_deref(),
Some("0.1.0"),
"version=None must preserve existing version"
);
record_install(&app_dir, "cot", Some("0.2.0"), git_src()).unwrap();
let after_some = load_manifest(&app_dir).unwrap();
assert_eq!(
after_some.packages.get("cot").unwrap().version.as_deref(),
Some("0.2.0"),
"version=Some(_) must overwrite"
);
}
}