use std::path::{Path, PathBuf};
use super::entry::{LockEntry, LockfileError};
use super::io::read_lockfile;
use crate::fs::atomic_write;
#[must_use]
pub fn meta_lockfile_path(meta_dir: &Path) -> PathBuf {
meta_dir.join(".grex").join("grex.lock.jsonl")
}
pub fn read_meta_lockfile(meta_dir: &Path) -> Result<Vec<LockEntry>, LockfileError> {
let path = meta_lockfile_path(meta_dir);
let map = read_lockfile(&path)?;
let mut out: Vec<LockEntry> = map.into_values().collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
Ok(out)
}
pub fn write_meta_lockfile(meta_dir: &Path, entries: &[LockEntry]) -> Result<(), LockfileError> {
let dir = meta_dir.join(".grex");
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(LockfileError::Io)?;
}
let path = dir.join("grex.lock.jsonl");
let mut ids: Vec<&LockEntry> = entries.iter().collect();
ids.sort_by(|a, b| a.id.cmp(&b.id));
let mut buf = String::new();
for entry in ids {
let line = serde_json::to_string(entry).map_err(LockfileError::Serialize)?;
buf.push_str(&line);
buf.push('\n');
}
atomic_write(&path, buf.as_bytes())?;
Ok(())
}
pub fn read_lockfile_tree(root_meta: &Path) -> Result<Vec<LockEntry>, LockfileError> {
let mut out: Vec<LockEntry> = Vec::new();
fold_meta_recursive(root_meta, &mut out)?;
Ok(out)
}
fn fold_meta_recursive(meta_dir: &Path, out: &mut Vec<LockEntry>) -> Result<(), LockfileError> {
let entries = read_meta_lockfile(meta_dir)?;
out.extend(entries);
let manifest_path = meta_dir.join(".grex").join("pack.yaml");
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Ok(()),
};
let manifest = match crate::pack::parse(&raw) {
Ok(m) => m,
Err(_) => return Ok(()),
};
for child in &manifest.children {
let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
let child_meta = meta_dir.join(&segment);
if child_meta.join(".grex").join("pack.yaml").is_file() {
fold_meta_recursive(&child_meta, out)?;
}
}
Ok(())
}
pub fn detect_legacy_lockfile(meta_dir: &Path) -> Result<bool, LockfileError> {
let path = meta_lockfile_path(meta_dir);
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(LockfileError::Io(e)),
};
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if !trimmed.contains("\"path\":") {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use tempfile::tempdir;
fn ts() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 29, 10, 0, 0).unwrap()
}
fn entry(id: &str, path: &str) -> LockEntry {
let mut e = LockEntry::new(id, "deadbeef", "main", ts(), "h", "1");
e.path = path.into();
e
}
#[test]
fn test_per_meta_lockfile_path_resolution() {
let dir = tempdir().unwrap();
let meta = dir.path().join("alpha");
let p = meta_lockfile_path(&meta);
assert_eq!(p, meta.join(".grex").join("grex.lock.jsonl"));
}
#[test]
fn test_per_meta_read_empty_meta() {
let dir = tempdir().unwrap();
let entries = read_meta_lockfile(dir.path()).expect("missing → empty");
assert!(entries.is_empty());
}
#[test]
fn test_per_meta_read_v1_2_0_format() {
let dir = tempdir().unwrap();
let entries = vec![entry("alpha", "alpha"), entry("beta", "beta")];
write_meta_lockfile(dir.path(), &entries).unwrap();
let back = read_meta_lockfile(dir.path()).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back[0].id, "alpha");
assert_eq!(back[0].path, "alpha");
assert_eq!(back[1].id, "beta");
}
#[test]
fn test_per_meta_write_atomic() {
let dir = tempdir().unwrap();
let entries = vec![entry("a", "a")];
write_meta_lockfile(dir.path(), &entries).unwrap();
let p = meta_lockfile_path(dir.path());
assert!(p.is_file(), "lockfile must exist after write");
let raw = std::fs::read_to_string(&p).unwrap();
assert!(raw.ends_with('\n'), "atomic write must persist full payload");
assert!(raw.contains("\"id\":\"a\""));
}
#[test]
fn test_lockfile_fold_tree_disjoint_partition() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".grex")).unwrap();
std::fs::write(
root.join(".grex").join("pack.yaml"),
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: https://example.invalid/alpha.git\n path: alpha\n - url: https://example.invalid/beta.git\n path: beta\n",
)
.unwrap();
write_meta_lockfile(root, &[entry("alpha", "alpha"), entry("beta", "beta")]).unwrap();
let alpha = root.join("alpha");
std::fs::create_dir_all(alpha.join(".grex")).unwrap();
std::fs::write(
alpha.join(".grex").join("pack.yaml"),
"schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n - url: https://example.invalid/gamma.git\n path: gamma\n",
)
.unwrap();
write_meta_lockfile(&alpha, &[entry("gamma", "gamma")]).unwrap();
let beta = root.join("beta");
std::fs::create_dir_all(beta.join(".grex")).unwrap();
std::fs::write(
beta.join(".grex").join("pack.yaml"),
"schema_version: \"1\"\nname: beta\ntype: meta\n",
)
.unwrap();
let folded = read_lockfile_tree(root).unwrap();
let ids: Vec<&str> = folded.iter().map(|e| e.id.as_str()).collect();
assert_eq!(
ids.iter().filter(|id| **id == "alpha").count(),
1,
"alpha must appear exactly once",
);
assert_eq!(
ids.iter().filter(|id| **id == "beta").count(),
1,
"beta must appear exactly once",
);
assert_eq!(
ids.iter().filter(|id| **id == "gamma").count(),
1,
"gamma must appear exactly once",
);
assert_eq!(folded.len(), 3, "fold must be disjoint (no doubles)");
}
#[test]
fn test_legacy_v1_1_1_detected() {
let dir = tempdir().unwrap();
let lock_dir = dir.path().join(".grex");
std::fs::create_dir_all(&lock_dir).unwrap();
let v1_1_1_line = r#"{"id":"alpha","sha":"abc","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"h","schema_version":"1"}"#;
std::fs::write(lock_dir.join("grex.lock.jsonl"), format!("{}\n", v1_1_1_line)).unwrap();
assert!(
detect_legacy_lockfile(dir.path()).unwrap(),
"v1.1.1-shaped lockfile must be detected as legacy",
);
}
#[test]
fn test_legacy_detector_clears_v1_2_0_lockfile() {
let dir = tempdir().unwrap();
write_meta_lockfile(dir.path(), &[entry("alpha", "alpha")]).unwrap();
assert!(
!detect_legacy_lockfile(dir.path()).unwrap(),
"v1.2.0 lockfile must not be flagged legacy",
);
}
#[test]
fn test_legacy_detector_missing_file_is_not_legacy() {
let dir = tempdir().unwrap();
assert!(!detect_legacy_lockfile(dir.path()).unwrap());
}
}