use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::TemplateError;
pub const LOCKFILE_NAME: &str = ".ironflow-templates.lock";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LockFile {
#[serde(default)]
installed: Vec<InstalledEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledEntry {
pub name: String,
pub version: String,
pub repo: String,
pub installed_at: String,
pub path: String,
}
impl LockFile {
pub fn load(path: &Path) -> Result<Self, TemplateError> {
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path).map_err(TemplateError::Io)?;
toml::from_str(&content)
.map_err(|e| TemplateError::Lockfile(format!("failed to parse lockfile: {e}")))
}
pub fn save(&self, path: &Path) -> Result<(), TemplateError> {
let content = toml::to_string_pretty(self)
.map_err(|e| TemplateError::Lockfile(format!("failed to serialize lockfile: {e}")))?;
fs::write(path, content).map_err(TemplateError::Io)
}
pub fn record_install(&mut self, entry: InstalledEntry) {
self.installed.retain(|e| e.name != entry.name);
self.installed.push(entry);
}
pub fn find_installed(&self, name: &str) -> Option<&InstalledEntry> {
self.installed.iter().find(|e| e.name == name)
}
pub fn installed(&self) -> &[InstalledEntry] {
&self.installed
}
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn sample_entry(name: &str, version: &str) -> InstalledEntry {
InstalledEntry {
name: name.to_string(),
version: version.to_string(),
repo: "https://example.com/templates".to_string(),
installed_at: "2026-09-10".to_string(),
path: format!("src/workflows/{name}"),
}
}
#[test]
fn round_trip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
let mut lock = LockFile::default();
lock.record_install(sample_entry("hello", "1.0.0"));
lock.record_install(sample_entry("deploy", "2.0.0"));
lock.save(&path).unwrap();
let loaded = LockFile::load(&path).unwrap();
assert_eq!(loaded.installed().len(), 2);
assert_eq!(loaded.find_installed("hello").unwrap().version, "1.0.0");
assert_eq!(loaded.find_installed("deploy").unwrap().version, "2.0.0");
}
#[test]
fn record_install_replaces_existing() {
let mut lock = LockFile::default();
lock.record_install(sample_entry("hello", "1.0.0"));
lock.record_install(sample_entry("hello", "2.0.0"));
assert_eq!(lock.installed().len(), 1);
assert_eq!(lock.find_installed("hello").unwrap().version, "2.0.0");
}
#[test]
fn load_missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let lock = LockFile::load(&tmp.path().join("nope.lock")).unwrap();
assert!(lock.installed().is_empty());
}
#[test]
fn load_invalid_toml_returns_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
fs::write(&path, "not valid toml [[[").unwrap();
let err = LockFile::load(&path).unwrap_err();
assert!(err.to_string().contains("lockfile"));
}
#[test]
fn find_installed_returns_none_for_unknown() {
let lock = LockFile::default();
assert!(lock.find_installed("unknown").is_none());
}
#[test]
fn record_install_preserves_other_entries() {
let mut lock = LockFile::default();
lock.record_install(sample_entry("a", "1.0.0"));
lock.record_install(sample_entry("b", "1.0.0"));
lock.record_install(sample_entry("a", "2.0.0"));
assert_eq!(lock.installed().len(), 2);
assert_eq!(lock.find_installed("a").unwrap().version, "2.0.0");
assert_eq!(lock.find_installed("b").unwrap().version, "1.0.0");
}
}