use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
static SAVE_GUARD: Mutex<()> = Mutex::new(());
use super::source::PackageSource;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct LockFile {
pub version: u32,
#[serde(default, rename = "package")]
pub packages: Vec<LockPackage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct LockPackage {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub source: PackageSource,
}
pub(crate) fn lockfile_path(project_root: &Path) -> PathBuf {
project_root.join("alc.lock")
}
pub(crate) fn load_lockfile(project_root: &Path) -> Result<Option<LockFile>, String> {
let path = lockfile_path(project_root);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read alc.lock at {}: {e}", path.display()))?;
let lock: LockFile = toml::from_str(&content)
.map_err(|e| format!("Failed to parse alc.lock at {}: {e}", path.display()))?;
if lock.version != 1 {
return Err(format!(
"unsupported alc.lock version {}: expected 1",
lock.version
));
}
Ok(Some(lock))
}
pub(crate) fn save_lockfile(project_root: &Path, lock: &LockFile) -> Result<(), String> {
let _guard = SAVE_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let path = lockfile_path(project_root);
let parent = path.parent().ok_or_else(|| {
format!(
"Cannot determine parent directory for alc.lock at {}",
path.display()
)
})?;
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory for alc.lock: {e}"))?;
let content =
toml::to_string_pretty(lock).map_err(|e| format!("Failed to serialize alc.lock: {e}"))?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.map_err(|e| format!("Failed to create temp file for alc.lock: {e}"))?;
{
use std::io::Write;
tmp.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write alc.lock staging: {e}"))?;
tmp.as_file()
.sync_all()
.map_err(|e| format!("Failed to fsync alc.lock staging: {e}"))?;
}
tmp.persist(&path)
.map_err(|e| format!("Failed to persist alc.lock at {}: {e}", path.display()))?;
Ok(())
}
pub(crate) fn resolve_path_entries(
project_root: &Path,
lock: &LockFile,
) -> (Vec<PathBuf>, Vec<String>) {
let mut paths = Vec::new();
let mut warnings = Vec::new();
for pkg in &lock.packages {
let PackageSource::Path { path: ref raw } = pkg.source else {
continue;
};
let resolved = {
let p = Path::new(raw);
if p.is_absolute() {
p.to_path_buf()
} else {
project_root.join(p)
}
};
if !resolved.exists() {
warnings.push(format!(
"alc.lock: path entry for '{}' does not exist, skipping: {}",
pkg.name,
resolved.display()
));
continue;
}
paths.push(resolved);
}
(paths, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::source::PackageSource;
fn make_path_lock(path: &str) -> LockFile {
LockFile {
version: 1,
packages: vec![LockPackage {
name: "test_pkg".to_string(),
version: None,
source: PackageSource::Path {
path: path.to_string(),
},
}],
}
}
#[test]
fn lockfile_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let original = LockFile {
version: 1,
packages: vec![LockPackage {
name: "head_agent".to_string(),
version: Some("0.3.0".to_string()),
source: PackageSource::Path {
path: "packages/head_agent".to_string(),
},
}],
};
save_lockfile(project_root, &original).unwrap();
let loaded = load_lockfile(project_root).unwrap();
assert_eq!(loaded, Some(original));
}
#[test]
fn lockfile_roundtrip_no_version() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let original = LockFile {
version: 1,
packages: vec![LockPackage {
name: "my_pkg".to_string(),
version: None,
source: PackageSource::Installed,
}],
};
save_lockfile(project_root, &original).unwrap();
let loaded = load_lockfile(project_root).unwrap();
assert_eq!(loaded, Some(original));
}
#[test]
fn lockfile_version_mismatch() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("alc.lock");
std::fs::write(
&path,
r#"version = 2
[[package]]
name = "foo"
[package.source]
type = "path"
path = "packages/foo"
"#,
)
.unwrap();
let result = load_lockfile(tmp.path());
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("unsupported alc.lock version 2"), "{msg}");
}
#[test]
fn lockfile_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = load_lockfile(tmp.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn resolve_path_relative() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let pkg_dir = project_root.join("packages").join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let lock = make_path_lock("packages/my_pkg");
let (paths, warnings) = resolve_path_entries(project_root, &lock);
let expected = project_root.join("packages").join("my_pkg");
assert_eq!(paths, vec![expected]);
assert!(warnings.is_empty());
}
#[test]
fn resolve_path_absolute_inside_project() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let pkg_dir = project_root.join("abs_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let lock = make_path_lock(pkg_dir.to_str().unwrap());
let (paths, warnings) = resolve_path_entries(project_root, &lock);
assert_eq!(paths, vec![pkg_dir]);
assert!(warnings.is_empty());
}
#[test]
fn resolve_path_absolute_outside_project_accepted() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let abs_tmp = tempfile::tempdir().unwrap();
let abs_path = abs_tmp.path().to_path_buf();
let lock = make_path_lock(abs_path.to_str().unwrap());
let (paths, warnings) = resolve_path_entries(project_root, &lock);
assert_eq!(paths, vec![abs_path]);
assert!(warnings.is_empty());
}
#[test]
fn resolve_path_relative_traversal_accepted() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path().join("project");
std::fs::create_dir_all(&project_root).unwrap();
let sibling = tmp.path().join("sibling");
std::fs::create_dir_all(&sibling).unwrap();
let lock = make_path_lock("../sibling");
let (paths, warnings) = resolve_path_entries(&project_root, &lock);
let expected = project_root.join("../sibling");
assert_eq!(paths, vec![expected]);
assert!(warnings.is_empty());
}
#[test]
fn resolve_path_skip_missing() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let lock = make_path_lock("nonexistent/path");
let (paths, warnings) = resolve_path_entries(project_root, &lock);
assert!(paths.is_empty());
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].contains("test_pkg"),
"warning should mention the package name: {warnings:?}"
);
assert!(
warnings[0].contains("nonexistent"),
"warning should mention the missing path: {warnings:?}"
);
}
#[test]
fn resolve_path_existing_no_warnings() {
let tmp = tempfile::tempdir().unwrap();
let project_root = tmp.path();
let pkg_dir = project_root.join("packages").join("my_pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let lock = make_path_lock("packages/my_pkg");
let (paths, warnings) = resolve_path_entries(project_root, &lock);
assert_eq!(paths.len(), 1);
assert!(
warnings.is_empty(),
"existing path should not warn: {warnings:?}"
);
}
}