use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::manifest::ResourceDependency;
use crate::utils::fs::atomic_write;
#[derive(Debug, Clone, PartialEq)]
pub enum StalenessReason {
MissingDependency {
name: String,
resource_type: crate::core::ResourceType,
},
RemovedDependency {
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,
},
SourceChanged {
name: String,
resource_type: crate::core::ResourceType,
old_source: String,
new_source: 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 {
StalenessReason::MissingDependency {
name,
resource_type,
} => {
write!(
f,
"Dependency '{}' ({}) is in manifest but missing from lockfile",
name, resource_type
)
}
StalenessReason::RemovedDependency {
name,
resource_type,
} => {
write!(
f,
"Dependency '{}' ({}) is in lockfile but removed from manifest",
name, resource_type
)
}
StalenessReason::VersionChanged {
name,
resource_type,
old_version,
new_version,
} => {
write!(
f,
"Dependency '{}' ({}) version changed from '{}' to '{}'",
name, resource_type, old_version, new_version
)
}
StalenessReason::PathChanged {
name,
resource_type,
old_path,
new_path,
} => {
write!(
f,
"Dependency '{}' ({}) path changed from '{}' to '{}'",
name, resource_type, old_path, new_path
)
}
StalenessReason::SourceChanged {
name,
resource_type,
old_source,
new_source,
} => {
write!(
f,
"Dependency '{}' ({}) source changed from '{}' to '{}'",
name, resource_type, old_source, new_source
)
}
StalenessReason::SourceUrlChanged {
name,
old_url,
new_url,
} => {
write!(
f,
"Source '{}' URL changed from '{}' to '{}'",
name, old_url, new_url
)
}
StalenessReason::DuplicateEntries {
name,
resource_type,
count,
} => {
write!(
f,
"Found {} duplicate entries for dependency '{}' ({})",
count, 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 commit: 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,
}
impl LockFile {
const CURRENT_VERSION: u32 = 1;
#[must_use]
pub 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 'ccpm 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 lockfile: Self = toml::from_str(&content)
.map_err(|e| crate::core::CcpmError::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 ccpm.lock and run 'ccpm install' to regenerate it\n\
- Check for syntax errors if you manually edited the file\n\
- Restore from backup if available",
path.display()
)
})?;
if lockfile.version > Self::CURRENT_VERSION {
return Err(crate::core::CcpmError::Other {
message: format!(
"Lockfile version {} is newer than supported version {}.\n\n\
This lockfile was created by a newer version of ccpm.\n\
Please update ccpm 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!("commit = {:?}\n", source.commit));
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\n", resource.installed_at));
}
};
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,
commit,
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))
}
#[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 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,
) -> Result<Option<StalenessReason>> {
if let Some(reason) = self.detect_duplicate_entries()? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.agents,
&self.agents,
crate::core::ResourceType::Agent,
)? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.snippets,
&self.snippets,
crate::core::ResourceType::Snippet,
)? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.commands,
&self.commands,
crate::core::ResourceType::Command,
)? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.scripts,
&self.scripts,
crate::core::ResourceType::Script,
)? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.hooks,
&self.hooks,
crate::core::ResourceType::Hook,
)? {
return Ok(Some(reason));
}
if let Some(reason) = self.check_resource_type_staleness(
&manifest.mcp_servers,
&self.mcp_servers,
crate::core::ResourceType::McpServer,
)? {
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(),
}));
}
}
Ok(None)
}
fn check_resource_type_staleness(
&self,
manifest_resources: &std::collections::HashMap<String, ResourceDependency>,
lockfile_resources: &[LockedResource],
resource_type: crate::core::ResourceType,
) -> Result<Option<StalenessReason>> {
for (name, dep) in manifest_resources {
if self.is_pattern_dependency(dep) {
let has_pattern_matches = lockfile_resources
.iter()
.any(|locked| self.could_be_from_pattern_expansion(locked, dep));
if !has_pattern_matches {
return Ok(Some(StalenessReason::MissingDependency {
name: name.clone(),
resource_type,
}));
}
continue;
}
if !lockfile_resources.iter().any(|r| &r.name == name) {
return Ok(Some(StalenessReason::MissingDependency {
name: name.clone(),
resource_type,
}));
}
if let Some(locked) = lockfile_resources.iter().find(|r| &r.name == name)
&& let Some(reason) = self.check_dependency_changes(locked, dep, resource_type)?
{
return Ok(Some(reason));
}
}
for locked in lockfile_resources {
if !manifest_resources.contains_key(&locked.name) {
let is_pattern_match = manifest_resources.values().any(|dep| {
self.is_pattern_dependency(dep)
&& self.could_be_from_pattern_expansion(locked, dep)
});
if !is_pattern_match {
return Ok(Some(StalenessReason::RemovedDependency {
name: locked.name.clone(),
resource_type,
}));
}
}
}
Ok(None)
}
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)
}
fn is_pattern_dependency(&self, dep: &ResourceDependency) -> bool {
let path = dep.get_path();
path.contains('*') || path.contains('?') || path.contains('[') || path.contains('{')
}
fn could_be_from_pattern_expansion(
&self,
locked: &LockedResource,
pattern_dep: &ResourceDependency,
) -> bool {
let pattern_source = pattern_dep.get_source().map(|s| s.to_string());
if locked.source != pattern_source {
return false;
}
let pattern_version = pattern_dep.get_version().map(|v| v.to_string());
if locked.version != pattern_version {
return false;
}
true
}
fn check_dependency_changes(
&self,
locked: &LockedResource,
manifest_dep: &ResourceDependency,
resource_type: crate::core::ResourceType,
) -> Result<Option<StalenessReason>> {
if let (Some(locked_version), Some(manifest_version)) =
(&locked.version, manifest_dep.get_version())
{
if locked_version != manifest_version {
return Ok(Some(StalenessReason::VersionChanged {
name: locked.name.clone(),
resource_type,
old_version: locked_version.clone(),
new_version: manifest_version.to_string(),
}));
}
}
if let (Some(locked_source), Some(manifest_source)) =
(&locked.source, manifest_dep.get_source())
{
if locked.path != manifest_dep.get_path() {
return Ok(Some(StalenessReason::PathChanged {
name: locked.name.clone(),
resource_type,
old_path: locked.path.clone(),
new_path: manifest_dep.get_path().to_string(),
}));
}
if locked_source != manifest_source {
return Ok(Some(StalenessReason::SourceChanged {
name: locked.name.clone(),
resource_type,
old_source: locked_source.clone(),
new_source: manifest_source.to_string(),
}));
}
}
if locked.source.is_none()
&& manifest_dep.get_source().is_none()
&& locked.path != manifest_dep.get_path()
{
return Ok(Some(StalenessReason::PathChanged {
name: locked.name.clone(),
resource_type,
old_path: locked.path.clone(),
new_path: manifest_dep.get_path().to_string(),
}));
}
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()
}
}
pub fn check_staleness(
lockfile_path: &Path,
manifest_path: &Path,
allow_prompt: bool,
) -> Result<bool> {
let lockfile = LockFile::load(lockfile_path)?;
let manifest = crate::manifest::Manifest::load(manifest_path)?;
match lockfile.validate_against_manifest(&manifest)? {
None => Ok(true), Some(reason) => {
eprintln!("Error: Lockfile is stale and needs regeneration\n");
eprintln!("Reason: {}\n", reason);
eprintln!("This usually happens when:");
match &reason {
StalenessReason::DuplicateEntries { .. } => {
eprintln!("- Lockfile was corrupted during a previous operation");
eprintln!("- Multiple ccpm processes ran simultaneously");
eprintln!("- Pattern dependencies resolved incorrectly");
}
StalenessReason::MissingDependency { .. }
| StalenessReason::RemovedDependency { .. } => {
eprintln!("- Dependencies were added or removed from ccpm.toml");
eprintln!("- Lockfile is from an older version of the manifest");
}
StalenessReason::VersionChanged { .. }
| StalenessReason::PathChanged { .. }
| StalenessReason::SourceChanged { .. } => {
eprintln!("- Dependency constraints were modified in ccpm.toml");
eprintln!("- Resource paths or sources were changed");
}
StalenessReason::SourceUrlChanged { .. } => {
eprintln!("- Source repository URLs were updated in ccpm.toml");
eprintln!("- Repository was moved or renamed");
}
}
eprintln!("\nTo fix this issue:");
eprintln!("- Delete ccpm.lock and run 'ccpm install' to regenerate");
eprintln!("- Or run 'ccpm install --regenerate' to regenerate automatically");
eprintln!("- Or run 'ccpm install --force' to ignore this check");
if allow_prompt {
eprint!("\nContinue anyway? (y/N): ");
use std::io::{self, Write};
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
Ok(input == "y" || input == "yes")
} else {
eprintln!("\nOperation cancelled due to stale lockfile.");
eprintln!("Run with --force to bypass this check, or regenerate the lockfile.");
Ok(false)
}
}
}
}
#[must_use]
pub fn find_lockfile() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let lockfile_path = current.join("ccpm.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("ccpm.lock");
let mut lockfile = LockFile::new();
lockfile.add_source(
"official".to_string(),
"https://github.com/example-org/ccpm-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/ccpm-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(),
},
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().commit, "abc123");
assert_eq!(
loaded.get_resource("test-agent").unwrap().checksum,
"sha256:abcdef"
);
}
#[test]
fn test_staleness_reason_display() {
use crate::core::ResourceType;
let reason = StalenessReason::MissingDependency {
name: "my-agent".to_string(),
resource_type: ResourceType::Agent,
};
assert_eq!(
reason.to_string(),
"Dependency 'my-agent' (agent) is in manifest but missing from lockfile"
);
let reason = StalenessReason::RemovedDependency {
name: "old-snippet".to_string(),
resource_type: ResourceType::Snippet,
};
assert_eq!(
reason.to_string(),
"Dependency 'old-snippet' (snippet) is in lockfile but removed from manifest"
);
let reason = StalenessReason::VersionChanged {
name: "my-command".to_string(),
resource_type: ResourceType::Command,
old_version: "v1.0.0".to_string(),
new_version: "v2.0.0".to_string(),
};
assert_eq!(
reason.to_string(),
"Dependency 'my-command' (command) version changed from 'v1.0.0' to 'v2.0.0'"
);
let reason = StalenessReason::PathChanged {
name: "my-script".to_string(),
resource_type: ResourceType::Script,
old_path: "scripts/old.sh".to_string(),
new_path: "scripts/new.sh".to_string(),
};
assert_eq!(
reason.to_string(),
"Dependency 'my-script' (script) path changed from 'scripts/old.sh' to 'scripts/new.sh'"
);
let reason = StalenessReason::SourceChanged {
name: "my-hook".to_string(),
resource_type: ResourceType::Hook,
old_source: "community".to_string(),
new_source: "official".to_string(),
};
assert_eq!(
reason.to_string(),
"Dependency 'my-hook' (hook) source changed from 'community' to 'official'"
);
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 '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("ccpm.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("ccpm.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(),
},
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(),
},
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(),
},
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(),
},
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(),
},
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(),
},
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(),
},
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("ccpm.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(),
},
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(),
},
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(),
},
crate::core::ResourceType::Command,
);
let resource = lockfile.get_resource("helper").unwrap();
assert_eq!(resource.installed_at, "agents/helper.md");
}
}