use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::utils::fs::atomic_write;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StalenessReason {
MissingDependency {
name: String,
resource_type: crate::core::ResourceType,
},
VersionChanged {
name: String,
resource_type: crate::core::ResourceType,
old_version: String,
new_version: String,
},
PathChanged {
name: String,
resource_type: crate::core::ResourceType,
old_path: String,
new_path: String,
},
SourceUrlChanged {
name: String,
old_url: String,
new_url: String,
},
DuplicateEntries {
name: String,
resource_type: crate::core::ResourceType,
count: usize,
},
}
impl std::fmt::Display for StalenessReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDependency {
name,
resource_type,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) is in manifest but missing from lockfile"
)
}
Self::VersionChanged {
name,
resource_type,
old_version,
new_version,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) version changed from '{old_version}' to '{new_version}'"
)
}
Self::PathChanged {
name,
resource_type,
old_path,
new_path,
} => {
write!(
f,
"Dependency '{name}' ({resource_type}) path changed from '{old_path}' to '{new_path}'"
)
}
Self::SourceUrlChanged {
name,
old_url,
new_url,
} => {
write!(f, "Source repository '{name}' URL changed from '{old_url}' to '{new_url}'")
}
Self::DuplicateEntries {
name,
resource_type,
count,
} => {
write!(
f,
"Found {count} duplicate entries for dependency '{name}' ({resource_type})"
)
}
}
}
}
impl std::error::Error for StalenessReason {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<LockedSource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub snippets: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
pub mcp_servers: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<LockedResource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<LockedResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedSource {
pub name: String,
pub url: String,
pub fetched_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedResource {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_commit: Option<String>,
pub checksum: String,
pub installed_at: String,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(skip)]
pub resource_type: crate::core::ResourceType,
#[serde(default = "default_tool", skip_serializing_if = "is_default_tool", rename = "tool")]
pub tool: String,
}
fn default_tool() -> String {
"claude-code".to_string()
}
fn is_default_tool(tool: &str) -> bool {
tool == "claude-code"
}
impl LockFile {
const CURRENT_VERSION: u32 = 1;
#[must_use]
pub const fn new() -> Self {
Self {
version: Self::CURRENT_VERSION,
sources: Vec::new(),
agents: Vec::new(),
snippets: Vec::new(),
commands: Vec::new(),
mcp_servers: Vec::new(),
scripts: Vec::new(),
hooks: Vec::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(path).with_context(|| {
format!(
"Cannot read lockfile: {}\n\n\
Possible causes:\n\
- File doesn't exist (run 'agpm install' to create it)\n\
- Permission denied (check file ownership)\n\
- File is corrupted or locked by another process",
path.display()
)
})?;
if content.trim().is_empty() {
return Ok(Self::new());
}
let mut lockfile: Self = toml::from_str(&content)
.map_err(|e| crate::core::AgpmError::LockfileParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in lockfile: {}\n\n\
The lockfile may be corrupted. You can:\n\
- Delete agpm.lock and run 'agpm install' to regenerate it\n\
- Check for syntax errors if you manually edited the file\n\
- Restore from backup if available",
path.display()
)
})?;
for resource in &mut lockfile.agents {
resource.resource_type = crate::core::ResourceType::Agent;
}
for resource in &mut lockfile.snippets {
resource.resource_type = crate::core::ResourceType::Snippet;
if resource.tool == "claude-code" {
resource.tool = "agpm".to_string();
}
}
for resource in &mut lockfile.commands {
resource.resource_type = crate::core::ResourceType::Command;
}
for resource in &mut lockfile.scripts {
resource.resource_type = crate::core::ResourceType::Script;
}
for resource in &mut lockfile.hooks {
resource.resource_type = crate::core::ResourceType::Hook;
}
for resource in &mut lockfile.mcp_servers {
resource.resource_type = crate::core::ResourceType::McpServer;
}
if lockfile.version > Self::CURRENT_VERSION {
return Err(crate::core::AgpmError::Other {
message: format!(
"Lockfile version {} is newer than supported version {}.\n\n\
This lockfile was created by a newer version of agpm.\n\
Please update agpm to the latest version to use this lockfile.",
lockfile.version,
Self::CURRENT_VERSION
),
}
.into());
}
Ok(lockfile)
}
pub fn save(&self, path: &Path) -> Result<()> {
let mut content = String::from("# Auto-generated lockfile - DO NOT EDIT\n");
content.push_str(&format!("version = {}\n\n", self.version));
if !self.sources.is_empty() {
for source in &self.sources {
content.push_str("[[sources]]\n");
content.push_str(&format!("name = {:?}\n", source.name));
content.push_str(&format!("url = {:?}\n", source.url));
content.push_str(&format!("fetched_at = {:?}\n\n", source.fetched_at));
}
}
let write_resources =
|content: &mut String, resources: &[LockedResource], section: &str| {
for resource in resources {
content.push_str(&format!("[[{section}]]\n"));
content.push_str(&format!("name = {:?}\n", resource.name));
if let Some(source) = &resource.source {
content.push_str(&format!("source = {source:?}\n"));
}
if let Some(url) = &resource.url {
content.push_str(&format!("url = {url:?}\n"));
}
content.push_str(&format!("path = {:?}\n", resource.path));
if let Some(version) = &resource.version {
content.push_str(&format!("version = {version:?}\n"));
}
if let Some(commit) = &resource.resolved_commit {
content.push_str(&format!("resolved_commit = {commit:?}\n"));
}
content.push_str(&format!("checksum = {:?}\n", resource.checksum));
content.push_str(&format!("installed_at = {:?}\n", resource.installed_at));
if !is_default_tool(&resource.tool) {
content.push_str(&format!("tool = {:?}\n", resource.tool));
}
content.push_str("dependencies = [");
if resource.dependencies.is_empty() {
content.push_str("]\n\n");
} else {
content.push('\n');
for dep in &resource.dependencies {
content.push_str(&format!(" {dep:?},\n"));
}
content.push_str("]\n\n");
}
}
};
write_resources(&mut content, &self.agents, "agents");
write_resources(&mut content, &self.snippets, "snippets");
write_resources(&mut content, &self.commands, "commands");
write_resources(&mut content, &self.scripts, "scripts");
write_resources(&mut content, &self.hooks, "hooks");
write_resources(&mut content, &self.mcp_servers, "mcp-servers");
atomic_write(path, content.as_bytes()).with_context(|| {
format!(
"Cannot write lockfile: {}\n\n\
Possible causes:\n\
- Permission denied (try running with elevated permissions)\n\
- Directory doesn't exist\n\
- Disk is full or read-only\n\
- File is locked by another process",
path.display()
)
})?;
Ok(())
}
pub fn add_source(&mut self, name: String, url: String, _commit: String) {
self.sources.retain(|s| s.name != name);
self.sources.push(LockedSource {
name,
url,
fetched_at: chrono::Utc::now().to_rfc3339(),
});
}
pub fn add_resource(&mut self, name: String, resource: LockedResource, is_agent: bool) {
let resources = if is_agent {
&mut self.agents
} else {
&mut self.snippets
};
resources.retain(|r| r.name != name);
resources.push(resource);
}
pub fn add_typed_resource(
&mut self,
name: String,
resource: LockedResource,
resource_type: crate::core::ResourceType,
) {
let resources = match resource_type {
crate::core::ResourceType::Agent => &mut self.agents,
crate::core::ResourceType::Snippet => &mut self.snippets,
crate::core::ResourceType::Command => &mut self.commands,
crate::core::ResourceType::McpServer => {
return;
}
crate::core::ResourceType::Script => &mut self.scripts,
crate::core::ResourceType::Hook => &mut self.hooks,
};
resources.retain(|r| r.name != name);
resources.push(resource);
}
#[must_use]
pub fn get_resource(&self, name: &str) -> Option<&LockedResource> {
self.agents
.iter()
.find(|r| r.name == name)
.or_else(|| self.snippets.iter().find(|r| r.name == name))
.or_else(|| self.commands.iter().find(|r| r.name == name))
.or_else(|| self.scripts.iter().find(|r| r.name == name))
.or_else(|| self.hooks.iter().find(|r| r.name == name))
.or_else(|| self.mcp_servers.iter().find(|r| r.name == name))
}
#[must_use]
pub fn get_resource_by_source(
&self,
name: &str,
source: Option<&str>,
) -> Option<&LockedResource> {
let matches = |r: &&LockedResource| r.name == name && r.source.as_deref() == source;
self.agents
.iter()
.find(matches)
.or_else(|| self.snippets.iter().find(matches))
.or_else(|| self.commands.iter().find(matches))
.or_else(|| self.scripts.iter().find(matches))
.or_else(|| self.hooks.iter().find(matches))
.or_else(|| self.mcp_servers.iter().find(matches))
}
#[must_use]
pub fn get_source(&self, name: &str) -> Option<&LockedSource> {
self.sources.iter().find(|s| s.name == name)
}
#[must_use]
pub fn has_resource(&self, name: &str) -> bool {
self.get_resource(name).is_some()
}
pub fn get_resources(&self, resource_type: crate::core::ResourceType) -> &[LockedResource] {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => &self.agents,
ResourceType::Snippet => &self.snippets,
ResourceType::Command => &self.commands,
ResourceType::Script => &self.scripts,
ResourceType::Hook => &self.hooks,
ResourceType::McpServer => &self.mcp_servers,
}
}
pub const fn get_resources_mut(
&mut self,
resource_type: crate::core::ResourceType,
) -> &mut Vec<LockedResource> {
use crate::core::ResourceType;
match resource_type {
ResourceType::Agent => &mut self.agents,
ResourceType::Snippet => &mut self.snippets,
ResourceType::Command => &mut self.commands,
ResourceType::Script => &mut self.scripts,
ResourceType::Hook => &mut self.hooks,
ResourceType::McpServer => &mut self.mcp_servers,
}
}
#[must_use]
pub fn all_resources(&self) -> Vec<&LockedResource> {
let mut resources = Vec::new();
for resource_type in crate::core::ResourceType::all() {
resources.extend(self.get_resources(*resource_type));
}
resources
}
pub fn clear(&mut self) {
self.sources.clear();
for resource_type in crate::core::ResourceType::all() {
self.get_resources_mut(*resource_type).clear();
}
}
pub fn compute_checksum(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let content = fs::read(path).with_context(|| {
format!(
"Cannot read file for checksum calculation: {}\n\n\
This error occurs when verifying file integrity.\n\
Check that the file exists and is readable.",
path.display()
)
})?;
let mut hasher = Sha256::new();
hasher.update(&content);
let result = hasher.finalize();
Ok(format!("sha256:{}", hex::encode(result)))
}
pub fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
let actual = Self::compute_checksum(path)?;
Ok(actual == expected)
}
pub fn validate_against_manifest(
&self,
manifest: &crate::manifest::Manifest,
strict: bool,
) -> Result<Option<StalenessReason>> {
if let Some(reason) = self.detect_duplicate_entries()? {
return Ok(Some(reason));
}
for (source_name, manifest_url) in &manifest.sources {
if let Some(locked_source) = self.get_source(source_name)
&& &locked_source.url != manifest_url
{
return Ok(Some(StalenessReason::SourceUrlChanged {
name: source_name.clone(),
old_url: locked_source.url.clone(),
new_url: manifest_url.clone(),
}));
}
}
if strict {
for resource_type in crate::core::ResourceType::all() {
if let Some(manifest_deps) = manifest.get_dependencies(*resource_type) {
for (name, dep) in manifest_deps {
let locked_resource = self.get_resource(name);
if locked_resource.is_none() {
return Ok(Some(StalenessReason::MissingDependency {
name: name.clone(),
resource_type: *resource_type,
}));
}
if let Some(locked) = locked_resource {
if let Some(manifest_version) = dep.get_version()
&& let Some(locked_version) = &locked.version
&& manifest_version != locked_version
{
return Ok(Some(StalenessReason::VersionChanged {
name: name.clone(),
resource_type: *resource_type,
old_version: locked_version.clone(),
new_version: manifest_version.to_string(),
}));
}
if dep.get_path() != locked.path {
return Ok(Some(StalenessReason::PathChanged {
name: name.clone(),
resource_type: *resource_type,
old_path: locked.path.clone(),
new_path: dep.get_path().to_string(),
}));
}
}
}
}
}
}
Ok(None)
}
pub fn is_stale(&self, manifest: &crate::manifest::Manifest, strict: bool) -> Result<bool> {
Ok(self.validate_against_manifest(manifest, strict)?.is_some())
}
fn detect_duplicate_entries(&self) -> Result<Option<StalenessReason>> {
use std::collections::HashMap;
for resource_type in crate::core::ResourceType::all() {
let resources = self.get_resources(*resource_type);
let mut seen_names = HashMap::new();
for resource in resources {
if let Some(_first_index) = seen_names.get(&resource.name) {
return Ok(Some(StalenessReason::DuplicateEntries {
name: resource.name.clone(),
resource_type: *resource_type,
count: resources.iter().filter(|r| r.name == resource.name).count(),
}));
}
seen_names.insert(&resource.name, 0);
}
}
Ok(None)
}
pub fn update_resource_checksum(&mut self, name: &str, checksum: &str) -> bool {
for resource in &mut self.agents {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
for resource in &mut self.snippets {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
for resource in &mut self.commands {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
for resource in &mut self.scripts {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
for resource in &mut self.hooks {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
for resource in &mut self.mcp_servers {
if resource.name == name {
resource.checksum = checksum.to_string();
return true;
}
}
false
}
}
impl Default for LockFile {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn find_lockfile() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let lockfile_path = current.join("agpm.lock");
if lockfile_path.exists() {
return Some(lockfile_path);
}
if !current.pop() {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_lockfile_new() {
let lockfile = LockFile::new();
assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
assert!(lockfile.sources.is_empty());
assert!(lockfile.agents.is_empty());
}
#[test]
fn test_lockfile_save_load() {
let temp = tempdir().unwrap();
let lockfile_path = temp.path().join("agpm.lock");
let mut lockfile = LockFile::new();
lockfile.add_source(
"official".to_string(),
"https://github.com/example-org/agpm-official.git".to_string(),
"abc123".to_string(),
);
lockfile.add_resource(
"test-agent".to_string(),
LockedResource {
name: "test-agent".to_string(),
source: Some("official".to_string()),
url: Some("https://github.com/example-org/agpm-official.git".to_string()),
path: "agents/test.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:abcdef".to_string(),
installed_at: "agents/test-agent.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
},
true,
);
lockfile.save(&lockfile_path).unwrap();
assert!(lockfile_path.exists());
let loaded = LockFile::load(&lockfile_path).unwrap();
assert_eq!(loaded.version, LockFile::CURRENT_VERSION);
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.agents.len(), 1);
assert_eq!(
loaded.get_source("official").unwrap().url,
"https://github.com/example-org/agpm-official.git"
);
assert_eq!(loaded.get_resource("test-agent").unwrap().checksum, "sha256:abcdef");
}
#[test]
fn test_staleness_reason_display() {
use crate::core::ResourceType;
let reason = StalenessReason::SourceUrlChanged {
name: "community".to_string(),
old_url: "https://github.com/old/repo.git".to_string(),
new_url: "https://github.com/new/repo.git".to_string(),
};
assert_eq!(
reason.to_string(),
"Source repository 'community' URL changed from 'https://github.com/old/repo.git' to 'https://github.com/new/repo.git'"
);
let reason = StalenessReason::DuplicateEntries {
name: "dup-agent".to_string(),
resource_type: ResourceType::Agent,
count: 3,
};
assert_eq!(
reason.to_string(),
"Found 3 duplicate entries for dependency 'dup-agent' (agent)"
);
}
#[test]
fn test_lockfile_empty_file() {
let temp = tempdir().unwrap();
let lockfile_path = temp.path().join("agpm.lock");
std::fs::write(&lockfile_path, "").unwrap();
let lockfile = LockFile::load(&lockfile_path).unwrap();
assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
assert!(lockfile.sources.is_empty());
}
#[test]
fn test_lockfile_version_check() {
let temp = tempdir().unwrap();
let lockfile_path = temp.path().join("agpm.lock");
let content = "version = 999\n";
std::fs::write(&lockfile_path, content).unwrap();
let result = LockFile::load(&lockfile_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("newer than supported"));
}
#[test]
fn test_resource_operations() {
let mut lockfile = LockFile::new();
lockfile.add_resource(
"agent1".to_string(),
LockedResource {
name: "agent1".to_string(),
source: None,
url: None,
path: "local/agent1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:111".to_string(),
installed_at: "agents/agent1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
},
true, );
lockfile.add_resource(
"snippet1".to_string(),
LockedResource {
name: "snippet1".to_string(),
source: None,
url: None,
path: "local/snippet1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:222".to_string(),
installed_at: "snippets/snippet1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Snippet,
tool: "claude-code".to_string(),
},
false, );
lockfile.add_resource(
"dev-agent1".to_string(),
LockedResource {
name: "dev-agent1".to_string(),
source: None,
url: None,
path: "local/dev-agent1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:333".to_string(),
installed_at: "agents/dev-agent1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
},
true, );
assert!(lockfile.has_resource("agent1"));
assert!(lockfile.has_resource("snippet1"));
assert!(lockfile.has_resource("dev-agent1"));
assert!(!lockfile.has_resource("nonexistent"));
assert_eq!(lockfile.all_resources().len(), 3);
lockfile.clear();
assert!(lockfile.all_resources().is_empty());
}
#[test]
fn test_checksum_computation() {
let temp = tempdir().unwrap();
let file_path = temp.path().join("test.md");
std::fs::write(&file_path, "Hello, World!").unwrap();
let checksum = LockFile::compute_checksum(&file_path).unwrap();
assert!(checksum.starts_with("sha256:"));
assert!(LockFile::verify_checksum(&file_path, &checksum).unwrap());
assert!(!LockFile::verify_checksum(&file_path, "sha256:wrong").unwrap());
}
#[test]
fn test_lockfile_with_commands() {
let mut lockfile = LockFile::new();
lockfile.add_typed_resource(
"build".to_string(),
LockedResource {
name: "build".to_string(),
source: Some("community".to_string()),
url: Some("https://github.com/example/community.git".to_string()),
path: "commands/build.md".to_string(),
version: Some("v1.0.0".to_string()),
resolved_commit: Some("abc123".to_string()),
checksum: "sha256:cmd123".to_string(),
installed_at: ".claude/commands/build.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Command,
tool: "claude-code".to_string(),
},
crate::core::ResourceType::Command,
);
assert_eq!(lockfile.commands.len(), 1);
assert!(lockfile.has_resource("build"));
let resource = lockfile.get_resource("build").unwrap();
assert_eq!(resource.name, "build");
assert_eq!(resource.installed_at, ".claude/commands/build.md");
}
#[test]
fn test_lockfile_all_resources_with_commands() {
let mut lockfile = LockFile::new();
lockfile.add_resource(
"agent1".to_string(),
LockedResource {
name: "agent1".to_string(),
source: None,
url: None,
path: "agent1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:a1".to_string(),
installed_at: "agents/agent1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
},
true,
);
lockfile.add_resource(
"snippet1".to_string(),
LockedResource {
name: "snippet1".to_string(),
source: None,
url: None,
path: "snippet1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:s1".to_string(),
installed_at: "snippets/snippet1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Snippet,
tool: "claude-code".to_string(),
},
false,
);
lockfile.add_typed_resource(
"command1".to_string(),
LockedResource {
name: "command1".to_string(),
source: None,
url: None,
path: "command1.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:c1".to_string(),
installed_at: ".claude/commands/command1.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Command,
tool: "claude-code".to_string(),
},
crate::core::ResourceType::Command,
);
let all = lockfile.all_resources();
assert_eq!(all.len(), 3);
lockfile.clear();
assert!(lockfile.agents.is_empty());
assert!(lockfile.snippets.is_empty());
assert!(lockfile.commands.is_empty());
}
#[test]
fn test_lockfile_save_load_commands() {
let temp = tempdir().unwrap();
let lockfile_path = temp.path().join("agpm.lock");
let mut lockfile = LockFile::new();
lockfile.add_typed_resource(
"deploy".to_string(),
LockedResource {
name: "deploy".to_string(),
source: Some("official".to_string()),
url: Some("https://github.com/example/official.git".to_string()),
path: "commands/deploy.md".to_string(),
version: Some("v2.0.0".to_string()),
resolved_commit: Some("def456".to_string()),
checksum: "sha256:deploy123".to_string(),
installed_at: ".claude/commands/deploy.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Command,
tool: "claude-code".to_string(),
},
crate::core::ResourceType::Command,
);
lockfile.save(&lockfile_path).unwrap();
let loaded = LockFile::load(&lockfile_path).unwrap();
assert_eq!(loaded.commands.len(), 1);
assert!(loaded.has_resource("deploy"));
let cmd = &loaded.commands[0];
assert_eq!(cmd.name, "deploy");
assert_eq!(cmd.version, Some("v2.0.0".to_string()));
assert_eq!(cmd.installed_at, ".claude/commands/deploy.md");
}
#[test]
fn test_lockfile_get_resource_precedence() {
let mut lockfile = LockFile::new();
lockfile.add_resource(
"helper".to_string(),
LockedResource {
name: "helper".to_string(),
source: None,
url: None,
path: "agent_helper.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:agent".to_string(),
installed_at: "agents/helper.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: "claude-code".to_string(),
},
true,
);
lockfile.add_typed_resource(
"helper".to_string(),
LockedResource {
name: "helper".to_string(),
source: None,
url: None,
path: "command_helper.md".to_string(),
version: None,
resolved_commit: None,
checksum: "sha256:command".to_string(),
installed_at: ".claude/commands/helper.md".to_string(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Command,
tool: "claude-code".to_string(),
},
crate::core::ResourceType::Command,
);
let resource = lockfile.get_resource("helper").unwrap();
assert_eq!(resource.installed_at, "agents/helper.md");
}
}