use std::collections::HashMap;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{AugentError, Result};
#[derive(Debug, Clone, Default)]
pub struct Lockfile {
pub bundles: Vec<LockedBundle>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LockedBundle {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
pub source: LockedSource,
pub files: Vec<String>,
}
impl Serialize for LockedBundle {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("LockedBundle", 9)?;
state.serialize_field("name", &self.name)?;
if let Some(ref description) = self.description {
state.serialize_field("description", description)?;
}
if let Some(ref version) = self.version {
state.serialize_field("version", version)?;
}
if let Some(ref author) = self.author {
state.serialize_field("author", author)?;
}
if let Some(ref license) = self.license {
state.serialize_field("license", license)?;
}
if let Some(ref homepage) = self.homepage {
state.serialize_field("homepage", homepage)?;
}
state.serialize_field("source", &self.source)?;
let mut sorted_files = self.files.clone();
sorted_files.sort();
state.serialize_field("files", &sorted_files)?;
state.end()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum LockedSource {
Dir {
#[serde(default = "default_dot_path")]
path: String,
hash: String,
},
Git {
url: String,
#[serde(skip_serializing_if = "std::option::Option::is_none")]
path: Option<String>,
#[serde(rename = "ref")]
git_ref: Option<String>,
sha: String,
hash: String,
},
}
fn default_dot_path() -> String {
".".to_string()
}
impl Serialize for Lockfile {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut state = serializer.serialize_struct("Lockfile", 2)?;
state.serialize_field("name", "")?;
state.serialize_field("bundles", &self.bundles)?;
state.end()
}
}
impl<'de> Deserialize<'de> for Lockfile {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::MapAccess;
use serde::de::Visitor;
use std::fmt;
struct LockfileVisitor;
impl<'de> Visitor<'de> for LockfileVisitor {
type Value = Lockfile;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a Lockfile")
}
fn visit_map<M>(self, mut map: M) -> std::result::Result<Lockfile, M::Error>
where
M: MapAccess<'de>,
{
let mut bundles = Vec::new();
while let Some(key) = map.next_key()? {
let key: String = key;
match key.as_str() {
"name" => {
let _: serde::de::IgnoredAny = map.next_value()?;
}
"bundles" => {
bundles = map.next_value()?;
}
_ => {
let _: serde::de::IgnoredAny = map.next_value()?;
}
}
}
Ok(Lockfile { bundles })
}
}
deserializer.deserialize_map(LockfileVisitor)
}
}
impl Lockfile {
pub fn new() -> Self {
Self {
bundles: Vec::new(),
}
}
pub fn from_json(json: &str) -> Result<Self> {
let mut lockfile: Self =
serde_json::from_str(json).map_err(|e| AugentError::ConfigParseFailed {
path: "augent.lock".to_string(),
reason: e.to_string(),
})?;
lockfile.normalize_git_refs();
Ok(lockfile)
}
fn normalize_git_refs(&mut self) {
for bundle in &mut self.bundles {
if let LockedSource::Git { git_ref, .. } = &mut bundle.source {
if git_ref.is_none() {
*git_ref = Some("main".to_string());
}
}
}
}
pub fn to_json(&self, workspace_name: &str) -> Result<String> {
let mut json =
serde_json::to_string_pretty(self).map_err(|e| AugentError::ConfigParseFailed {
path: "augent.lock".to_string(),
reason: e.to_string(),
})?;
json = json.replace(
"\"name\": \"\"",
&format!("\"name\": \"{}\"", workspace_name),
);
Ok(json)
}
pub fn reorganize(&mut self, workspace_bundle_name: Option<&str>) {
let mut git_bundles = Vec::new();
let mut dir_bundles = Vec::new();
let mut workspace_bundle = None;
for bundle in self.bundles.drain(..) {
if let Some(ws_name) = workspace_bundle_name {
if bundle.name == ws_name {
workspace_bundle = Some(bundle);
continue;
}
}
if matches!(bundle.source, LockedSource::Dir { .. }) {
dir_bundles.push(bundle);
} else {
git_bundles.push(bundle);
}
}
self.bundles = git_bundles; self.bundles.extend(dir_bundles); if let Some(ws_bundle) = workspace_bundle {
self.bundles.push(ws_bundle); }
}
pub fn add_bundle(&mut self, bundle: LockedBundle) {
let is_dir_bundle = matches!(bundle.source, LockedSource::Dir { .. });
if is_dir_bundle {
self.bundles.push(bundle);
} else {
if let Some(pos) = self
.bundles
.iter()
.position(|b| matches!(b.source, LockedSource::Dir { .. }))
{
self.bundles.insert(pos, bundle);
} else {
self.bundles.push(bundle);
}
}
}
pub fn reorder_from_bundle_config(
&mut self,
bundle_config_deps: &[crate::config::BundleDependency],
workspace_bundle_name: Option<&str>,
) {
let mut bundle_map: HashMap<String, LockedBundle> = self
.bundles
.drain(..)
.map(|b| (b.name.clone(), b))
.collect();
let workspace_bundle = workspace_bundle_name.and_then(|name| bundle_map.remove(name));
let mut reordered = Vec::new();
for dep in bundle_config_deps {
if let Some(bundle) = bundle_map.remove(&dep.name) {
reordered.push(bundle);
}
}
reordered.extend(bundle_map.into_values());
if let Some(ws_bundle) = workspace_bundle {
reordered.push(ws_bundle);
}
self.bundles = reordered;
}
pub fn find_bundle(&self, name: &str) -> Option<&LockedBundle> {
self.bundles.iter().find(|b| b.name == name)
}
pub fn remove_bundle(&mut self, name: &str) -> Option<LockedBundle> {
if let Some(pos) = self.bundles.iter().position(|b| b.name == name) {
Some(self.bundles.remove(pos))
} else {
None
}
}
pub fn equals(&self, other: &Lockfile) -> bool {
if self.bundles.len() != other.bundles.len() {
return false;
}
self.bundles.iter().zip(other.bundles.iter()).all(|(a, b)| {
a.name == b.name
&& match (&a.source, &b.source) {
(
LockedSource::Dir { path: pa, hash: ha },
LockedSource::Dir { path: pb, hash: hb },
) => pa == pb && ha == hb,
(
LockedSource::Git {
url: ua,
sha: sa,
hash: ha,
path: pa,
git_ref: ra,
},
LockedSource::Git {
url: ub,
sha: sb,
hash: hb,
path: pb,
git_ref: rb,
},
) => ua == ub && sa == sb && ha == hb && pa == pb && ra == rb,
_ => false,
}
})
}
}
impl LockedBundle {
#[allow(dead_code)] pub fn dir(
name: impl Into<String>,
path: impl Into<String>,
hash: impl Into<String>,
files: Vec<String>,
) -> Self {
Self {
name: name.into(),
description: None,
version: None,
author: None,
license: None,
homepage: None,
source: LockedSource::Dir {
path: path.into(),
hash: hash.into(),
},
files,
}
}
#[allow(dead_code)] pub fn git(
name: impl Into<String>,
url: impl Into<String>,
sha: impl Into<String>,
hash: impl Into<String>,
files: Vec<String>,
) -> Self {
Self {
name: name.into(),
description: None,
version: None,
author: None,
license: None,
homepage: None,
source: LockedSource::Git {
url: url.into(),
git_ref: None,
sha: sha.into(),
path: None,
hash: hash.into(),
},
files,
}
}
#[allow(dead_code)] pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(AugentError::ConfigInvalid {
message: "Bundle name cannot be empty".to_string(),
});
}
match &self.source {
LockedSource::Dir { path, hash } => {
if path.is_empty() {
return Err(AugentError::ConfigInvalid {
message: format!("Bundle '{}' has empty path", self.name),
});
}
if !hash.starts_with("blake3:") {
return Err(AugentError::ConfigInvalid {
message: format!("Bundle '{}' has invalid hash format", self.name),
});
}
}
LockedSource::Git { url, sha, hash, .. } => {
if url.is_empty() {
return Err(AugentError::ConfigInvalid {
message: format!("Bundle '{}' has empty URL", self.name),
});
}
if sha.is_empty() {
return Err(AugentError::ConfigInvalid {
message: format!("Bundle '{}' has empty SHA", self.name),
});
}
if !hash.starts_with("blake3:") {
return Err(AugentError::ConfigInvalid {
message: format!("Bundle '{}' has invalid hash format", self.name),
});
}
}
}
Ok(())
}
#[allow(dead_code)] pub fn hash(&self) -> &str {
match &self.source {
LockedSource::Dir { hash, .. } => hash,
LockedSource::Git { hash, .. } => hash,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lockfile_new() {
let lockfile = Lockfile::new();
assert!(lockfile.bundles.is_empty());
}
#[test]
fn test_lockfile_from_json() {
let json = r#"{
"name": "@author/my-bundle",
"bundles": [
{
"name": "my-debug-bundle",
"source": {
"type": "dir",
"path": "local-bundles/my-debug-bundle",
"hash": "blake3:abc123"
},
"files": ["commands/debug.md"]
},
{
"name": "code-documentation",
"source": {
"type": "git",
"url": "https://github.com/wshobson/agents.git",
"ref": "main",
"sha": "abc123def456",
"path": "plugins/code-documentation",
"hash": "blake3:def456"
},
"files": ["commands/code-explain.md"]
}
]
}"#;
let lockfile = Lockfile::from_json(json).unwrap();
assert_eq!(lockfile.bundles.len(), 2);
let bundle = lockfile.find_bundle("my-debug-bundle").unwrap();
assert!(matches!(bundle.source, LockedSource::Dir { .. }));
let bundle = lockfile.find_bundle("code-documentation").unwrap();
assert!(matches!(bundle.source, LockedSource::Git { .. }));
}
#[test]
fn test_lockfile_to_json() {
let mut lockfile = Lockfile::new();
lockfile.add_bundle(LockedBundle::dir(
"dep1",
"local-bundles/dep1",
"blake3:abc123",
vec!["file1.md".to_string()],
));
let json = lockfile.to_json("@test/bundle").unwrap();
assert!(json.contains("@test/bundle"));
assert!(json.contains("dep1"));
assert!(json.contains("blake3:abc123"));
}
#[test]
fn test_lockfile_operations() {
let mut lockfile = Lockfile::new();
assert!(lockfile.find_bundle("dep1").is_none());
lockfile.add_bundle(LockedBundle::dir("dep1", "path", "blake3:hash", vec![]));
assert!(lockfile.find_bundle("dep1").is_some());
let removed = lockfile.remove_bundle("dep1");
assert!(removed.is_some());
assert!(lockfile.find_bundle("dep1").is_none());
}
#[test]
fn test_locked_bundle_dir() {
let bundle = LockedBundle::dir(
"test",
"path/to/test",
"blake3:abc123",
vec!["file.md".to_string()],
);
assert_eq!(bundle.name, "test");
assert_eq!(bundle.hash(), "blake3:abc123");
assert_eq!(bundle.files, vec!["file.md"]);
}
#[test]
fn test_locked_bundle_git() {
let bundle = LockedBundle::git(
"test",
"https://github.com/test/repo.git",
"sha123",
"blake3:abc123",
vec!["file.md".to_string()],
);
assert_eq!(bundle.name, "test");
assert_eq!(bundle.hash(), "blake3:abc123");
}
#[test]
fn test_locked_bundle_validation() {
let bundle = LockedBundle::dir("test", "path", "blake3:hash", vec![]);
assert!(bundle.validate().is_ok());
let bundle = LockedBundle::dir("", "path", "blake3:hash", vec![]);
assert!(bundle.validate().is_err());
let bundle = LockedBundle::dir("test", "path", "sha256:hash", vec![]);
assert!(bundle.validate().is_err());
}
#[test]
fn test_lockfile_equals_identical() {
let mut lockfile1 = Lockfile::new();
lockfile1.add_bundle(LockedBundle::dir(
"bundle1",
"path1",
"blake3:hash1",
vec!["file1.md".to_string()],
));
let mut lockfile2 = Lockfile::new();
lockfile2.add_bundle(LockedBundle::dir(
"bundle1",
"path1",
"blake3:hash1",
vec!["file1.md".to_string()],
));
assert!(lockfile1.equals(&lockfile2));
}
#[test]
fn test_lockfile_equals_different_order() {
let mut lockfile1 = Lockfile::new();
lockfile1.add_bundle(LockedBundle::dir("bundle1", "p1", "blake3:h1", vec![]));
lockfile1.add_bundle(LockedBundle::dir("bundle2", "p2", "blake3:h2", vec![]));
let mut lockfile2 = Lockfile::new();
lockfile2.add_bundle(LockedBundle::dir("bundle2", "p2", "blake3:h2", vec![]));
lockfile2.add_bundle(LockedBundle::dir("bundle1", "p1", "blake3:h1", vec![]));
assert!(!lockfile1.equals(&lockfile2));
}
#[test]
fn test_lockfile_equals_different_content() {
let mut lockfile1 = Lockfile::new();
lockfile1.add_bundle(LockedBundle::dir(
"bundle1",
"path1",
"blake3:hash1",
vec![],
));
let mut lockfile2 = Lockfile::new();
lockfile2.add_bundle(LockedBundle::dir(
"bundle1",
"path1",
"blake3:hash2",
vec![],
));
assert!(!lockfile1.equals(&lockfile2));
}
#[test]
fn test_lockfile_equals_git_source() {
let mut lockfile1 = Lockfile::new();
lockfile1.add_bundle(LockedBundle::git(
"bundle1",
"https://github.com/test/repo.git",
"abc123",
"blake3:hash1",
vec!["file.md".to_string()],
));
let mut lockfile2 = Lockfile::new();
lockfile2.add_bundle(LockedBundle::git(
"bundle1",
"https://github.com/test/repo.git",
"abc123",
"blake3:hash1",
vec!["file.md".to_string()],
));
assert!(lockfile1.equals(&lockfile2));
}
#[test]
fn test_lockfile_equals_different_sha() {
let mut lockfile1 = Lockfile::new();
lockfile1.add_bundle(LockedBundle::git(
"bundle1",
"https://github.com/test/repo.git",
"abc123",
"blake3:hash1",
vec![],
));
let mut lockfile2 = Lockfile::new();
lockfile2.add_bundle(LockedBundle::git(
"bundle1",
"https://github.com/test/repo.git",
"def456",
"blake3:hash1",
vec![],
));
assert!(!lockfile1.equals(&lockfile2));
}
#[test]
fn test_lockfile_files_serialized_alphabetically() {
let mut lockfile = Lockfile::new();
let bundle = LockedBundle::git(
"test-bundle",
"https://github.com/test/repo.git",
"abc123",
"blake3:hash1",
vec![
"commands/zebra.md".to_string(),
"agents/alpha.md".to_string(),
"commands/apple.md".to_string(),
"agents/beta.md".to_string(),
],
);
lockfile.add_bundle(bundle);
let workspace_name = "@test/workspace";
let json = lockfile.to_json(workspace_name).unwrap();
let alpha_pos = json.find("agents/alpha.md").unwrap();
let beta_pos = json.find("agents/beta.md").unwrap();
let apple_pos = json.find("commands/apple.md").unwrap();
let zebra_pos = json.find("commands/zebra.md").unwrap();
assert!(alpha_pos < beta_pos, "alpha should come before beta");
assert!(beta_pos < apple_pos, "beta should come before apple");
assert!(apple_pos < zebra_pos, "apple should come before zebra");
}
#[test]
fn test_bundle_ordering_dir_bundles_last() {
let mut lockfile = Lockfile::new();
lockfile.add_bundle(LockedBundle::git(
"git-bundle-1",
"https://github.com/test/repo1.git",
"sha123",
"blake3:hash1",
vec!["file1.md".to_string()],
));
lockfile.add_bundle(LockedBundle::dir(
"local-bundle-1",
".augent/local-bundle-1",
"blake3:hash2",
vec!["file2.md".to_string()],
));
lockfile.add_bundle(LockedBundle::git(
"git-bundle-2",
"https://github.com/test/repo2.git",
"sha456",
"blake3:hash3",
vec!["file3.md".to_string()],
));
lockfile.add_bundle(LockedBundle::dir(
"local-bundle-2",
".augent/local-bundle-2",
"blake3:hash4",
vec!["file4.md".to_string()],
));
assert_eq!(lockfile.bundles.len(), 4);
assert_eq!(lockfile.bundles[0].name, "git-bundle-1");
assert!(matches!(
lockfile.bundles[0].source,
LockedSource::Git { .. }
));
assert_eq!(lockfile.bundles[1].name, "git-bundle-2");
assert!(matches!(
lockfile.bundles[1].source,
LockedSource::Git { .. }
));
assert_eq!(lockfile.bundles[2].name, "local-bundle-1");
assert!(matches!(
lockfile.bundles[2].source,
LockedSource::Dir { .. }
));
assert_eq!(lockfile.bundles[3].name, "local-bundle-2");
assert!(matches!(
lockfile.bundles[3].source,
LockedSource::Dir { .. }
));
}
#[test]
fn test_lockfile_reorganize() {
let mut lockfile = Lockfile::new();
lockfile.bundles.push(LockedBundle::dir(
"local-bundle-1",
".augent/local-bundle-1",
"blake3:hash1",
vec!["file1.md".to_string()],
));
lockfile.bundles.push(LockedBundle::git(
"git-bundle-1",
"https://github.com/test/repo1.git",
"sha123",
"blake3:hash2",
vec!["file2.md".to_string()],
));
lockfile.bundles.push(LockedBundle::dir(
"local-bundle-2",
".augent/local-bundle-2",
"blake3:hash3",
vec!["file3.md".to_string()],
));
lockfile.bundles.push(LockedBundle::git(
"git-bundle-2",
"https://github.com/test/repo2.git",
"sha456",
"blake3:hash4",
vec!["file4.md".to_string()],
));
lockfile.bundles.push(LockedBundle::dir(
"@test/bundle",
".augent",
"blake3:hash5",
vec!["agents/ai.md".to_string()],
));
lockfile.reorganize(Some("@test/bundle"));
assert_eq!(lockfile.bundles.len(), 5);
assert_eq!(lockfile.bundles[0].name, "git-bundle-1");
assert!(matches!(
lockfile.bundles[0].source,
LockedSource::Git { .. }
));
assert_eq!(lockfile.bundles[1].name, "git-bundle-2");
assert!(matches!(
lockfile.bundles[1].source,
LockedSource::Git { .. }
));
assert_eq!(lockfile.bundles[2].name, "local-bundle-1");
assert!(matches!(
lockfile.bundles[2].source,
LockedSource::Dir { .. }
));
assert_eq!(lockfile.bundles[3].name, "local-bundle-2");
assert!(matches!(
lockfile.bundles[3].source,
LockedSource::Dir { .. }
));
assert_eq!(lockfile.bundles[4].name, "@test/bundle");
assert!(matches!(
lockfile.bundles[4].source,
LockedSource::Dir { .. }
));
}
}