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