1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! Lockfile validation and staleness detection.
//!
//! This module provides validation logic to ensure lockfiles are consistent with
//! manifests, detect corruption, and identify when lockfiles need regeneration.
use anyhow::Result;
use std::collections::HashMap;
use std::path::Path;
use super::{LockFile, StalenessReason};
impl LockFile {
/// Validate lockfile against manifest for staleness detection.
///
/// Checks consistency and detects staleness indicators requiring regeneration.
/// Similar to Cargo's `--locked` mode.
///
/// # Arguments
///
/// * `manifest` - The current project manifest to validate against
/// * `strict` - If true, check version/path changes; if false, only check corruption and security
///
/// # Returns
///
/// * `Ok(None)` - Lockfile is valid and up-to-date
/// * `Ok(Some(StalenessReason))` - Lockfile is stale and needs regeneration
/// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
///
/// # Examples
///
/// ```rust,no_run
/// # use std::path::Path;
/// # use agpm_cli::lockfile::LockFile;
/// # use agpm_cli::manifest::Manifest;
/// # fn example() -> anyhow::Result<()> {
/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
///
/// // Strict mode: check everything including version/path changes
/// match lockfile.validate_against_manifest(&manifest, true)? {
/// None => println!("Lockfile is valid"),
/// Some(reason) => {
/// eprintln!("Lockfile is stale: {}", reason);
/// eprintln!("Run 'agpm install' to auto-update it");
/// }
/// }
///
/// // Lenient mode: only check corruption and security (for --frozen)
/// match lockfile.validate_against_manifest(&manifest, false)? {
/// None => println!("Lockfile has no critical issues"),
/// Some(reason) => eprintln!("Critical issue: {}", reason),
/// }
/// # Ok(())
/// # }
/// ```
///
/// # Staleness Detection
///
/// The method checks for several staleness indicators:
/// - **Duplicate entries**: Multiple entries for the same dependency (corruption) - always checked
/// - **Source URL changes**: Source URLs changed in manifest (security concern) - always checked
/// - **Missing dependencies**: Manifest has deps not in lockfile - only in strict mode
/// - **Version changes**: Same dependency with different version constraint - only in strict mode
/// - **Path changes**: Same dependency with different source path - only in strict mode
///
/// Note: Extra lockfile entries are allowed (for transitive dependencies).
pub fn validate_against_manifest(
&self,
manifest: &crate::manifest::Manifest,
strict: bool,
) -> Result<Option<StalenessReason>> {
// Always check for critical issues:
// 1. Corruption (duplicate entries)
// 2. Security concerns (source URL changes)
// Check for duplicate entries within the lockfile (corruption)
if let Some(reason) = self.detect_duplicate_entries()? {
return Ok(Some(reason));
}
// Check source URL changes (security concern - different repository)
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(),
}));
}
}
// In strict mode, also check for missing dependencies, version changes, and path changes
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 {
// Find matching resource in lockfile
let locked_resource = self.get_resource(name);
if locked_resource.is_none() {
// Dependency is in manifest but not in lockfile
return Ok(Some(StalenessReason::MissingDependency {
name: name.clone(),
resource_type: *resource_type,
}));
}
// Check for version changes
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(),
}));
}
// Check for path changes
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(),
}));
}
// Check for tool changes (apply defaults if not specified)
let manifest_tool_string = dep
.get_tool()
.map(|s| s.to_string())
.unwrap_or_else(|| manifest.get_default_tool(*resource_type));
let manifest_tool = manifest_tool_string.as_str();
let locked_tool = locked.tool.as_deref().unwrap_or("claude-code");
if manifest_tool != locked_tool {
return Ok(Some(StalenessReason::ToolChanged {
name: name.clone(),
resource_type: *resource_type,
old_tool: locked_tool.to_string(),
new_tool: manifest_tool.to_string(),
}));
}
}
}
}
}
}
// Extra lockfile entries are allowed (for transitive dependencies)
Ok(None)
}
/// Check if lockfile is stale (boolean convenience method).
///
/// Returns simple bool instead of detailed `StalenessReason`.
///
/// # Arguments
///
/// * `manifest` - The current project manifest to validate against
/// * `strict` - If true, check version/path changes; if false, only check corruption and security
///
/// # Returns
///
/// * `Ok(true)` - Lockfile is stale and needs updating
/// * `Ok(false)` - Lockfile is valid and up-to-date
/// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
///
/// # Examples
///
/// ```rust,no_run
/// # use std::path::Path;
/// # use agpm_cli::lockfile::LockFile;
/// # use agpm_cli::manifest::Manifest;
/// # fn example() -> anyhow::Result<()> {
/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
///
/// if lockfile.is_stale(&manifest, true)? {
/// println!("Lockfile needs updating");
/// }
/// # Ok(())
/// # }
/// ```
pub fn is_stale(&self, manifest: &crate::manifest::Manifest, strict: bool) -> Result<bool> {
Ok(self.validate_against_manifest(manifest, strict)?.is_some())
}
/// Detect duplicate entries indicating corruption.
///
/// Scans all resource types for duplicate names.
pub(crate) fn detect_duplicate_entries(&self) -> Result<Option<StalenessReason>> {
// Check each resource type for duplicates
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 seen_names.contains_key(&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)
}
/// Validate no duplicate names within each resource type.
///
/// Stricter than `detect_duplicate_entries`. Used during loading to catch
/// corruption early.
///
/// # Arguments
///
/// * `path` - Path to the lockfile (used for error messages)
///
/// # Returns
///
/// * `Ok(())` - No duplicates found
/// * `Err(anyhow::Error)` - Duplicates found with detailed error message
///
/// # Errors
///
/// Returns an error if any resource type contains duplicate names, with
/// details about which resource type and names are duplicated.
pub fn validate_no_duplicates(&self, path: &Path) -> Result<()> {
let mut found_duplicates = false;
let mut error_messages = Vec::new();
// Check each resource type for duplicates
for resource_type in crate::core::ResourceType::all() {
let resources = self.get_resources(resource_type);
let mut name_counts = HashMap::new();
// Count occurrences of each name
for resource in resources {
*name_counts.entry(&resource.name).or_insert(0) += 1;
}
// Find duplicates
let duplicates: Vec<_> = name_counts.iter().filter(|(_, count)| **count > 1).collect();
if !duplicates.is_empty() {
found_duplicates = true;
let dup_names: Vec<_> = duplicates
.iter()
.map(|(name, count)| format!("{} ({} times)", name, **count))
.collect();
error_messages.push(format!(" {}: {}", resource_type, dup_names.join(", ")));
}
}
if found_duplicates {
return Err(crate::core::AgpmError::Other {
message: format!(
"Lockfile corruption detected in {}:\nDuplicate resource names found:\n{}\n\n\
This indicates lockfile corruption. To fix:\n\
- Delete agpm.lock and run 'agpm install' to regenerate it\n\
- Or restore from a backup if available",
path.display(),
error_messages.join("\n")
),
}
.into());
}
Ok(())
}
}