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}