agpm_cli/lockfile/
mod.rs

1//! Lockfile management for reproducible installations across environments.
2//!
3//! This module provides comprehensive lockfile functionality for AGPM, similar to Cargo's
4//! `Cargo.lock` but designed specifically for managing Claude Code resources (agents,
5//! snippets, and commands) from Git repositories. The lockfile ensures that all team members and CI/CD
6//! systems install identical versions of dependencies.
7//!
8//! # Overview
9//!
10//! The lockfile (`agpm.lock`) is automatically generated from the manifest (`agpm.toml`)
11//! during installation and contains exact resolved versions of all dependencies. Unlike
12//! the manifest which specifies version constraints, the lockfile pins exact commit hashes
13//! and file checksums for reproducibility.
14//!
15//! ## Key Concepts
16//!
17//! - **Version Resolution**: Converts version constraints to exact commits
18//! - **Dependency Pinning**: Locks all transitive dependencies at specific versions
19//! - **Reproducibility**: Guarantees identical installations across environments
20//! - **Integrity Verification**: Uses SHA-256 checksums to detect file corruption
21//! - **Atomic Operations**: All lockfile updates are atomic to prevent corruption
22//!
23//! # Lockfile Format Specification
24//!
25//! The lockfile uses TOML format with the following structure:
26//!
27//! ```toml
28//! # Auto-generated lockfile - DO NOT EDIT
29//! version = 1
30//!
31//! # Source repositories with resolved commits
32//! [[sources]]
33//! name = "community"                              # Source name from manifest
34//! url = "https://github.com/example/repo.git"     # Repository URL
35//! commit = "a1b2c3d4e5f6..."                      # Resolved commit hash (40 chars)
36//! fetched_at = "2024-01-01T00:00:00Z"             # Last fetch timestamp (RFC 3339)
37//!
38//! # Agent resources
39//! [[agents]]
40//! name = "example-agent"                          # Resource name
41//! source = "community"                            # Source name (optional for local)
42//! url = "https://github.com/example/repo.git"     # Source URL (optional for local)
43//! path = "agents/example.md"                      # Path in source repository
44//! version = "v1.0.0"                              # Requested version constraint
45//! resolved_commit = "a1b2c3d4e5f6..."             # Resolved commit for this resource
46//! checksum = "sha256:abcdef123456..."             # SHA-256 checksum of installed file
47//! installed_at = "agents/example-agent.md"        # Installation path (relative to project)
48//!
49//! # Snippet resources (same structure as agents)
50//! [[snippets]]
51//! name = "example-snippet"
52//! source = "community"
53//! path = "snippets/example.md"
54//! version = "^1.0"
55//! resolved_commit = "a1b2c3d4e5f6..."
56//! checksum = "sha256:fedcba654321..."
57//! installed_at = "snippets/example-snippet.md"
58//!
59//! # Command resources (same structure as agents)
60//! [[commands]]
61//! name = "build-command"
62//! source = "community"
63//! path = "commands/build.md"
64//! version = "v1.0.0"
65//! resolved_commit = "a1b2c3d4e5f6..."
66//! checksum = "sha256:123456abcdef..."
67//! installed_at = ".claude/commands/build-command.md"
68//! ```
69//!
70//! ## Field Details
71//!
72//! ### Version Field
73//! - **Type**: Integer
74//! - **Purpose**: Lockfile format version for future compatibility
75//! - **Current**: 1
76//!
77//! ### Sources Array
78//! - **name**: Unique identifier for the source repository
79//! - **url**: Full Git repository URL (HTTP/HTTPS/SSH)
80//! - **commit**: 40-character SHA-1 commit hash at time of resolution
81//! - **`fetched_at`**: ISO 8601 timestamp of last successful fetch
82//!
83//! ### Resources Arrays (agents/snippets/commands)
84//! - **name**: Unique resource identifier within its type
85//! - **source**: Source name (omitted for local resources)
86//! - **url**: Repository URL (omitted for local resources)  
87//! - **path**: Relative path within source repository or filesystem
88//! - **version**: Original version constraint from manifest (omitted for local)
89//! - **`resolved_commit`**: Exact commit containing this resource (omitted for local)
90//! - **checksum**: SHA-256 hash prefixed with "sha256:" for integrity verification
91//! - **`installed_at`**: Relative path where resource is installed in project
92//!
93//! # Relationship to Manifest
94//!
95//! The lockfile is generated from the manifest (`agpm.toml`) through dependency resolution:
96//!
97//! ```toml
98//! # agpm.toml (manifest)
99//! [sources]
100//! community = "https://github.com/example/repo.git"
101//!
102//! [agents]
103//! example-agent = { source = "community", path = "agents/example.md", version = "^1.0" }
104//! local-agent = { path = "../local/helper.md" }
105//! ```
106//!
107//! During `agpm install`, this becomes:
108//!
109//! ```toml
110//! # agpm.lock (lockfile)
111//! version = 1
112//!
113//! [[sources]]
114//! name = "community"
115//! url = "https://github.com/example/repo.git"
116//! commit = "a1b2c3d4e5f6..."
117//! fetched_at = "2024-01-01T00:00:00Z"
118//!
119//! [[agents]]
120//! name = "example-agent"
121//! source = "community"
122//! url = "https://github.com/example/repo.git"
123//! path = "agents/example.md"
124//! version = "^1.0"
125//! resolved_commit = "a1b2c3d4e5f6..."
126//! checksum = "sha256:abcdef..."
127//! installed_at = "agents/example-agent.md"
128//!
129//! [[agents]]
130//! name = "local-agent"
131//! path = "../local/helper.md"
132//! checksum = "sha256:123abc..."
133//! installed_at = "agents/local-agent.md"
134//! ```
135//!
136//! # Version Resolution and Pinning
137//!
138//! AGPM resolves version constraints to exact commits using Git tags and branches:
139//!
140//! ## Version Constraint Resolution
141//!
142//! 1. **Exact versions** (`"v1.2.3"`): Match exact Git tag
143//! 2. **Semantic ranges** (`"^1.0"`, `"~1.2"`): Find latest compatible tag
144//! 3. **Branch names** (`"main"`, `"develop"`): Use latest commit on branch
145//! 4. **Commit hashes** (`"a1b2c3d"`): Use exact commit (must be full 40-char hash)
146//!
147//! ## Resolution Process
148//!
149//! 1. **Fetch Repository**: Clone or update source repository cache
150//! 2. **Enumerate Tags**: List all Git tags matching semantic version pattern
151//! 3. **Apply Constraints**: Filter tags that satisfy version constraint
152//! 4. **Select Latest**: Choose highest version within constraint
153//! 5. **Resolve Commit**: Map tag to commit hash
154//! 6. **Verify Resource**: Ensure resource exists at that commit
155//! 7. **Calculate Checksum**: Generate SHA-256 hash of resource content
156//! 8. **Record Entry**: Add resolved information to lockfile
157//!
158//! # Install vs Update Semantics
159//!
160//! ## Install Behavior
161//! - Uses existing lockfile if present (respects pinned versions)
162//! - Only resolves dependencies not in lockfile
163//! - Preserves existing pins even if newer versions available
164//! - Ensures reproducible installations
165//!
166//! ## Update Behavior  
167//! - Ignores existing lockfile constraints
168//! - Re-resolves all dependencies against current manifest constraints
169//! - Updates to latest compatible versions within constraints
170//! - Regenerates entire lockfile
171//!
172//! ```bash
173//! # Install exact versions from lockfile (if available)
174//! agpm install
175//!
176//! # Update to latest within manifest constraints
177//! agpm update
178//!
179//! # Update specific resource
180//! agpm update example-agent
181//! ```
182//!
183//! # Checksum Verification
184//!
185//! AGPM uses SHA-256 checksums to ensure file integrity:
186//!
187//! ## Checksum Format
188//! - **Algorithm**: SHA-256
189//! - **Encoding**: Hexadecimal
190//! - **Prefix**: "sha256:"
191//! - **Example**: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
192//!
193//! ## Verification Process
194//! 1. **During Installation**: Calculate checksum of installed file
195//! 2. **During Validation**: Compare stored checksum with file content
196//! 3. **On Mismatch**: Report corruption and suggest re-installation
197//!
198//! # Best Practices
199//!
200//! ## Commit Lockfile to Version Control
201//! The lockfile should always be committed to version control:
202//!
203//! ```bash
204//! # Commit both manifest and lockfile together
205//! git add agpm.toml agpm.lock
206//! git commit -m "Add new agent dependency"
207//! ```
208//!
209//! This ensures all team members get identical dependency versions.
210//!
211//! ## Don't Edit Lockfile Manually
212//! The lockfile is auto-generated and should not be edited manually:
213//! - Use `agpm install` to update lockfile from manifest changes
214//! - Use `agpm update` to update dependency versions
215//! - Delete lockfile and run `agpm install` to regenerate from scratch
216//!
217//! ## Lockfile Conflicts
218//! During Git merges, lockfile conflicts may occur:
219//!
220//! ```bash
221//! # Resolve by regenerating lockfile
222//! rm agpm.lock
223//! agpm install
224//! git add agpm.lock
225//! git commit -m "Resolve lockfile conflict"
226//! ```
227//!
228//! # Migration and Upgrades
229//!
230//! ## Format Version Compatibility
231//! AGPM checks lockfile format version and provides clear error messages:
232//!
233//! ```text
234//! Error: Lockfile version 2 is newer than supported version 1.
235//! This lockfile was created by a newer version of agpm.
236//! Please update agpm to the latest version to use this lockfile.
237//! ```
238//!
239//! ## Upgrading Lockfiles
240//! Future format versions will include automatic migration:
241//!
242//! ```bash
243//! # Future: Migrate lockfile to newer format
244//! agpm install --migrate-lockfile
245//! ```
246//!
247//! # Comparison with Cargo.lock
248//!
249//! AGPM's lockfile design is inspired by Cargo but adapted for Git-based resources:
250//!
251//! | Feature | Cargo.lock | agpm.lock |
252//! |---------|------------|-----------|
253//! | Format | TOML | TOML |
254//! | Versioning | Semantic | Git tags/branches/commits |
255//! | Integrity | Checksums | SHA-256 checksums |
256//! | Sources | crates.io + git | Git repositories only |
257//! | Resources | Crates | Agents + Snippets |
258//! | Resolution | Dependency graph | Flat dependency list |
259//!
260//! # Error Handling
261//!
262//! The lockfile module provides detailed error messages with actionable suggestions:
263//!
264//! - **Parse Errors**: TOML syntax issues with fix suggestions
265//! - **Version Errors**: Incompatible format versions with upgrade instructions  
266//! - **IO Errors**: File system issues with permission/space guidance
267//! - **Corruption**: Checksum mismatches with re-installation steps
268//!
269//! # Cross-Platform Considerations
270//!
271//! Lockfiles are fully cross-platform compatible:
272//! - **Path Separators**: Always use forward slashes in lockfile paths
273//! - **Line Endings**: Normalize to LF for consistent checksums
274//! - **File Permissions**: Not stored in lockfile (Git handles this)
275//! - **Case Sensitivity**: Preserve case from source repositories
276//!
277//! # Performance Characteristics
278//!
279//! - **Parsing**: O(n) where n is number of locked resources
280//! - **Checksum Calculation**: O(m) where m is total file size
281//! - **Lookups**: O(n) linear search (suitable for typical dependency counts)
282//! - **Atomic Writes**: Single fsync per lockfile update
283//!
284//! # Thread Safety
285//!
286//! The [`LockFile`] struct is not thread-safe by itself, but the module provides
287//! atomic operations for concurrent access:
288//! - **File Locking**: Uses OS file locking during atomic writes
289//! - **Process Safety**: Multiple agpm instances coordinate via lockfile
290//! - **Concurrent Reads**: Safe to read lockfile from multiple threads
291
292use anyhow::{Context, Result};
293use serde::{Deserialize, Serialize};
294use std::fs;
295use std::path::{Path, PathBuf};
296
297use crate::utils::fs::atomic_write;
298
299/// Reasons why a lockfile might be considered stale.
300///
301/// This enum describes various conditions that indicate a lockfile is
302/// out-of-sync with the manifest and needs to be regenerated to prevent
303/// installation errors or inconsistencies.
304///
305/// # Display Format
306///
307/// Each variant implements `Display` to provide user-friendly error messages
308/// that explain the problem and suggest solutions.
309///
310/// # Examples
311///
312/// ```rust,no_run
313/// use agpm_cli::lockfile::StalenessReason;
314/// use agpm_cli::core::ResourceType;
315///
316/// let reason = StalenessReason::MissingDependency {
317///     name: "my-agent".to_string(),
318///     resource_type: ResourceType::Agent,
319/// };
320///
321/// println!("{}", reason);
322/// // Output: "Dependency 'my-agent' (agent) is in manifest but missing from lockfile"
323/// ```
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub enum StalenessReason {
326    /// A dependency is in the manifest but not in the lockfile.
327    /// This indicates the lockfile is incomplete and needs regeneration.
328    MissingDependency {
329        /// Name of the missing dependency
330        name: String,
331        /// Type of resource (agent, snippet, etc.)
332        resource_type: crate::core::ResourceType,
333    },
334
335    /// A dependency's version constraint has changed in the manifest.
336    VersionChanged {
337        /// Name of the dependency
338        name: String,
339        /// Type of resource (agent, snippet, etc.)
340        resource_type: crate::core::ResourceType,
341        /// Previous version from lockfile
342        old_version: String,
343        /// New version from manifest
344        new_version: String,
345    },
346
347    /// A dependency's path has changed in the manifest.
348    PathChanged {
349        /// Name of the dependency
350        name: String,
351        /// Type of resource (agent, snippet, etc.)
352        resource_type: crate::core::ResourceType,
353        /// Previous path from lockfile
354        old_path: String,
355        /// New path from manifest
356        new_path: String,
357    },
358
359    /// A source repository has a different URL in the manifest.
360    /// This is a security concern as it could point to a different repository.
361    SourceUrlChanged {
362        /// Name of the source repository
363        name: String,
364        /// Previous URL from lockfile
365        old_url: String,
366        /// New URL from manifest
367        new_url: String,
368    },
369
370    /// Multiple entries exist for the same dependency (lockfile corruption).
371    DuplicateEntries {
372        /// Name of the duplicated dependency
373        name: String,
374        /// Type of resource (agent, snippet, etc.)
375        resource_type: crate::core::ResourceType,
376        /// Number of duplicate entries found
377        count: usize,
378    },
379}
380
381impl std::fmt::Display for StalenessReason {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            Self::MissingDependency {
385                name,
386                resource_type,
387            } => {
388                write!(
389                    f,
390                    "Dependency '{name}' ({resource_type}) is in manifest but missing from lockfile"
391                )
392            }
393            Self::VersionChanged {
394                name,
395                resource_type,
396                old_version,
397                new_version,
398            } => {
399                write!(
400                    f,
401                    "Dependency '{name}' ({resource_type}) version changed from '{old_version}' to '{new_version}'"
402                )
403            }
404            Self::PathChanged {
405                name,
406                resource_type,
407                old_path,
408                new_path,
409            } => {
410                write!(
411                    f,
412                    "Dependency '{name}' ({resource_type}) path changed from '{old_path}' to '{new_path}'"
413                )
414            }
415            Self::SourceUrlChanged {
416                name,
417                old_url,
418                new_url,
419            } => {
420                write!(f, "Source repository '{name}' URL changed from '{old_url}' to '{new_url}'")
421            }
422            Self::DuplicateEntries {
423                name,
424                resource_type,
425                count,
426            } => {
427                write!(
428                    f,
429                    "Found {count} duplicate entries for dependency '{name}' ({resource_type})"
430                )
431            }
432        }
433    }
434}
435
436impl std::error::Error for StalenessReason {}
437
438/// The main lockfile structure representing a complete `agpm.lock` file.
439///
440/// This structure contains all resolved dependencies, source repositories, and their
441/// exact versions/commits for reproducible installations. The lockfile is automatically
442/// generated from the [`crate::manifest::Manifest`] during installation and should not
443/// be edited manually.
444///
445/// # Format Version
446///
447/// The lockfile includes a format version to enable future migrations and compatibility
448/// checking. The current version is 1.
449///
450/// # Serialization
451///
452/// The lockfile serializes to TOML format with arrays of sources, agents, and snippets.
453/// Empty arrays are omitted from serialization to keep the lockfile clean.
454///
455/// # Examples
456///
457/// Creating a new lockfile:
458///
459/// ```rust,no_run
460/// use agpm_cli::lockfile::LockFile;
461///
462/// let lockfile = LockFile::new();
463/// assert_eq!(lockfile.version, 1);
464/// assert!(lockfile.sources.is_empty());
465/// ```
466///
467/// Loading an existing lockfile:
468///
469/// ```rust,no_run
470/// # use std::path::Path;
471/// # use agpm_cli::lockfile::LockFile;
472/// # fn example() -> anyhow::Result<()> {
473/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
474/// println!("Loaded {} sources, {} agents",
475///          lockfile.sources.len(), lockfile.agents.len());
476/// # Ok(())
477/// # }
478/// ```
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct LockFile {
481    /// Version of the lockfile format.
482    ///
483    /// This field enables forward and backward compatibility checking. AGPM will
484    /// refuse to load lockfiles with versions newer than it supports, and may
485    /// provide migration paths for older versions in the future.
486    pub version: u32,
487
488    /// Locked source repositories with their resolved commit hashes.
489    ///
490    /// Each entry represents a Git repository that has been fetched and resolved
491    /// to an exact commit. The commit hash ensures all team members get identical
492    /// source content even as the upstream repository evolves.
493    ///
494    /// This field is omitted from TOML serialization if empty to keep the lockfile clean.
495    #[serde(default, skip_serializing_if = "Vec::is_empty")]
496    pub sources: Vec<LockedSource>,
497
498    /// Locked agent resources with their exact versions and checksums.
499    ///
500    /// Contains all resolved agent dependencies from the manifest, with exact
501    /// commit hashes, installation paths, and SHA-256 checksums for integrity
502    /// verification.
503    ///
504    /// This field is omitted from TOML serialization if empty.
505    #[serde(default, skip_serializing_if = "Vec::is_empty")]
506    pub agents: Vec<LockedResource>,
507
508    /// Locked snippet resources with their exact versions and checksums.
509    ///
510    /// Contains all resolved snippet dependencies from the manifest, with exact
511    /// commit hashes, installation paths, and SHA-256 checksums for integrity
512    /// verification.
513    ///
514    /// This field is omitted from TOML serialization if empty.
515    #[serde(default, skip_serializing_if = "Vec::is_empty")]
516    pub snippets: Vec<LockedResource>,
517
518    /// Locked command resources with their exact versions and checksums.
519    ///
520    /// Contains all resolved command dependencies from the manifest, with exact
521    /// commit hashes, installation paths, and SHA-256 checksums for integrity
522    /// verification.
523    ///
524    /// This field is omitted from TOML serialization if empty.
525    #[serde(default, skip_serializing_if = "Vec::is_empty")]
526    pub commands: Vec<LockedResource>,
527
528    /// Locked MCP server resources with their exact versions and checksums.
529    ///
530    /// Contains all resolved MCP server dependencies from the manifest, with exact
531    /// commit hashes, installation paths, and SHA-256 checksums for integrity
532    /// verification. MCP servers are installed as JSON files and also configured
533    /// in `.claude/settings.local.json`.
534    ///
535    /// This field is omitted from TOML serialization if empty.
536    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
537    pub mcp_servers: Vec<LockedResource>,
538
539    /// Locked script resources with their exact versions and checksums.
540    ///
541    /// Contains all resolved script dependencies from the manifest, with exact
542    /// commit hashes, installation paths, and SHA-256 checksums for integrity
543    /// verification. Scripts are executable files that can be referenced by hooks.
544    ///
545    /// This field is omitted from TOML serialization if empty.
546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
547    pub scripts: Vec<LockedResource>,
548
549    /// Locked hook configurations with their exact versions and checksums.
550    ///
551    /// Contains all resolved hook dependencies from the manifest. Hooks are
552    /// JSON configuration files that define event-based automation in Claude Code.
553    ///
554    /// This field is omitted from TOML serialization if empty.
555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
556    pub hooks: Vec<LockedResource>,
557}
558
559/// A locked source repository with resolved commit information.
560///
561/// Represents a Git repository that has been fetched and resolved to an exact
562/// commit hash. This ensures reproducible access to source repositories across
563/// different environments and times.
564///
565/// # Fields
566///
567/// - **name**: Unique identifier used in the manifest to reference this source
568/// - **url**: Full Git repository URL (HTTP/HTTPS/SSH)
569/// - **commit**: 40-character SHA-1 commit hash resolved at time of lock
570/// - **`fetched_at`**: RFC 3339 timestamp of when the repository was last fetched
571///
572/// # Examples
573///
574/// A typical locked source in TOML format:
575///
576/// ```toml
577/// [[sources]]
578/// name = "community"
579/// url = "https://github.com/example/agpm-community.git"
580/// commit = "a1b2c3d4e5f6789abcdef0123456789abcdef012"
581/// fetched_at = "2024-01-15T10:30:00Z"
582/// ```
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct LockedSource {
585    /// Unique source name from the manifest.
586    ///
587    /// This corresponds to keys in the `[sources]` section of `agpm.toml`
588    /// and is used to reference the source in resource definitions.
589    pub name: String,
590
591    /// Full Git repository URL.
592    ///
593    /// Supports HTTP, HTTPS, and SSH URLs. This is the exact URL used
594    /// for cloning and fetching the repository.
595    pub url: String,
596
597    /// Timestamp of last successful fetch in RFC 3339 format.
598    ///
599    /// Records when the repository was last fetched from the remote.
600    /// This helps track staleness and debugging fetch issues.
601    pub fetched_at: String,
602}
603
604/// A locked resource (agent or snippet) with resolved version and integrity information.
605///
606/// Represents a specific resource file that has been resolved from either a source
607/// repository or local filesystem. Contains all information needed to verify the
608/// exact version and integrity of the installed resource.
609///
610/// # Local vs Remote Resources
611///
612/// Remote resources (from Git repositories) include:
613/// - `source`: Source repository name
614/// - `url`: Repository URL  
615/// - `version`: Original version constraint
616/// - `resolved_commit`: Exact commit containing the resource
617///
618/// Local resources (from filesystem) omit these fields since they don't
619/// involve Git repositories.
620///
621/// # Integrity Verification
622///
623/// All resources include a SHA-256 checksum for integrity verification.
624/// The checksum is calculated from the file content after installation
625/// and can be used to detect corruption or tampering.
626///
627/// # Examples
628///
629/// Remote resource in TOML format:
630///
631/// ```toml
632/// [[agents]]
633/// name = "example-agent"
634/// source = "community"
635/// url = "https://github.com/example/repo.git"
636/// path = "agents/example.md"
637/// version = "^1.0"
638/// resolved_commit = "a1b2c3d4e5f6..."
639/// checksum = "sha256:abcdef123456..."
640/// installed_at = "agents/example-agent.md"
641/// ```
642///
643/// Local resource in TOML format:
644///
645/// ```toml
646/// [[agents]]
647/// name = "local-helper"
648/// path = "../local/helper.md"
649/// checksum = "sha256:fedcba654321..."
650/// installed_at = "agents/local-helper.md"
651/// ```
652#[derive(Debug, Clone, Serialize, Deserialize)]
653pub struct LockedResource {
654    /// Resource name from the manifest.
655    ///
656    /// This corresponds to keys in the `[agents]` or `[snippets]` sections
657    /// of the manifest. Resources are uniquely identified by the combination
658    /// of (name, source), allowing multiple sources to provide resources with
659    /// the same name.
660    pub name: String,
661
662    /// Source repository name for remote resources.
663    ///
664    /// References a source defined in the `[sources]` section of the manifest.
665    /// This field is `None` for local resources that don't come from Git repositories.
666    ///
667    /// Omitted from TOML serialization when `None`.
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub source: Option<String>,
670
671    /// Source repository URL for remote resources.
672    ///
673    /// The full Git repository URL where this resource originates.
674    /// This field is `None` for local resources.
675    ///
676    /// Omitted from TOML serialization when `None`.
677    #[serde(skip_serializing_if = "Option::is_none")]
678    pub url: Option<String>,
679
680    /// Path to the resource file.
681    ///
682    /// For remote resources, this is the relative path within the source repository.
683    /// For local resources, this is the filesystem path (may be relative or absolute).
684    pub path: String,
685
686    /// Original version constraint from the manifest.
687    ///
688    /// This preserves the version constraint specified in `agpm.toml` (e.g., "^1.0", "v2.1.0").
689    /// For local resources or resources without version constraints, this field is `None`.
690    ///
691    /// Omitted from TOML serialization when `None`.
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub version: Option<String>,
694
695    /// Resolved Git commit hash for remote resources.
696    ///
697    /// The exact 40-character SHA-1 commit hash where this resource was found.
698    /// This ensures reproducible installations even if the version constraint
699    /// could match multiple commits. For local resources, this field is `None`.
700    ///
701    /// Omitted from TOML serialization when `None`.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub resolved_commit: Option<String>,
704
705    /// SHA-256 checksum of the installed file content.
706    ///
707    /// Used for integrity verification to detect file corruption or tampering.
708    /// The format is "sha256:" followed by the hexadecimal hash.
709    ///
710    /// Example: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
711    pub checksum: String,
712
713    /// Installation path relative to the project root.
714    ///
715    /// Where the resource file is installed within the project directory.
716    /// This path is always relative to the project root and uses forward
717    /// slashes as separators for cross-platform compatibility.
718    ///
719    /// Examples: "agents/example-agent.md", "snippets/util-snippet.md"
720    pub installed_at: String,
721
722    /// Dependencies of this resource.
723    ///
724    /// Lists the direct dependencies that this resource requires, including
725    /// both manifest dependencies and transitive dependencies discovered from
726    /// the resource file itself. Each dependency is identified by its resource
727    /// type and name (e.g., "agents/helper-agent", "snippets/utils").
728    ///
729    /// This field enables dependency graph analysis and ensures all required
730    /// resources are installed. It follows the same model as Cargo.lock where
731    /// each package lists its dependencies.
732    ///
733    /// Always included in TOML serialization, even when empty, to match Cargo.lock format.
734    #[serde(default)]
735    pub dependencies: Vec<String>,
736
737    /// Resource type (agent, snippet, command, etc.)
738    ///
739    /// This field is populated during deserialization based on which TOML section
740    /// the resource came from (`[[agents]]`, `[[snippets]]`, etc.) and is used internally
741    /// for determining the correct lockfile section when adding/updating entries.
742    ///
743    /// It is never serialized to the lockfile - the section header provides this information.
744    #[serde(skip)]
745    pub resource_type: crate::core::ResourceType,
746
747    /// Tool type for multi-tool support (claude-code, opencode, agpm, custom).
748    ///
749    /// Specifies which target AI coding assistant tool this resource is for. This determines
750    /// where the resource is installed and how it's configured.
751    ///
752    /// **Defaults to "claude-code"** for backward compatibility with existing lockfiles.
753    ///
754    /// Omitted from TOML serialization when the value is "claude-code" (default).
755    #[serde(default = "default_tool", skip_serializing_if = "is_default_tool", rename = "tool")]
756    pub tool: String,
757}
758
759fn default_tool() -> String {
760    "claude-code".to_string()
761}
762
763fn is_default_tool(tool: &str) -> bool {
764    tool == "claude-code"
765}
766
767impl LockFile {
768    /// Current lockfile format version.
769    ///
770    /// This constant defines the lockfile format version that this version of AGPM
771    /// generates. It's used for compatibility checking when loading lockfiles that
772    /// may have been created by different versions of AGPM.
773    const CURRENT_VERSION: u32 = 1;
774
775    /// Create a new empty lockfile with the current format version.
776    ///
777    /// Returns a fresh lockfile with no sources or resources. This is typically
778    /// used when initializing a new project or regenerating a lockfile from scratch.
779    ///
780    /// # Examples
781    ///
782    /// ```rust,no_run
783    /// use agpm_cli::lockfile::LockFile;
784    ///
785    /// let lockfile = LockFile::new();
786    /// assert_eq!(lockfile.version, 1);
787    /// assert!(lockfile.sources.is_empty());
788    /// assert!(lockfile.agents.is_empty());
789    /// assert!(lockfile.snippets.is_empty());
790    /// ```
791    #[must_use]
792    pub const fn new() -> Self {
793        Self {
794            version: Self::CURRENT_VERSION,
795            sources: Vec::new(),
796            agents: Vec::new(),
797            snippets: Vec::new(),
798            commands: Vec::new(),
799            mcp_servers: Vec::new(),
800            scripts: Vec::new(),
801            hooks: Vec::new(),
802        }
803    }
804
805    /// Load a lockfile from disk with comprehensive error handling and validation.
806    ///
807    /// Attempts to load and parse a lockfile from the specified path. If the file
808    /// doesn't exist, returns a new empty lockfile. Performs format version
809    /// compatibility checking and provides detailed error messages for common issues.
810    ///
811    /// # Arguments
812    ///
813    /// * `path` - Path to the lockfile (typically "agpm.lock")
814    ///
815    /// # Returns
816    ///
817    /// * `Ok(LockFile)` - Successfully loaded lockfile or new empty lockfile if file doesn't exist
818    /// * `Err(anyhow::Error)` - Parse error, IO error, or version incompatibility
819    ///
820    /// # Error Handling
821    ///
822    /// This method provides detailed error messages for common issues:
823    /// - **File not found**: Returns empty lockfile (not an error)
824    /// - **Permission denied**: Suggests checking file ownership/permissions
825    /// - **TOML parse errors**: Suggests regenerating lockfile or checking syntax
826    /// - **Version incompatibility**: Suggests updating AGPM
827    /// - **Empty file**: Returns empty lockfile (graceful handling)
828    ///
829    /// # Examples
830    ///
831    /// ```rust,no_run
832    /// use std::path::Path;
833    /// use agpm_cli::lockfile::LockFile;
834    ///
835    /// # fn example() -> anyhow::Result<()> {
836    /// // Load existing lockfile
837    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
838    /// println!("Loaded {} sources", lockfile.sources.len());
839    ///
840    /// // Non-existent file returns empty lockfile
841    /// let empty = LockFile::load(Path::new("missing.lock"))?;
842    /// assert!(empty.sources.is_empty());
843    /// # Ok(())
844    /// # }
845    /// ```
846    ///
847    /// # Version Compatibility
848    ///
849    /// The method checks the lockfile format version and will refuse to load
850    /// lockfiles created by newer versions of AGPM:
851    ///
852    /// ```text
853    /// Error: Lockfile version 2 is newer than supported version 1.
854    /// This lockfile was created by a newer version of agpm.
855    /// Please update agpm to the latest version to use this lockfile.
856    /// ```
857    pub fn load(path: &Path) -> Result<Self> {
858        if !path.exists() {
859            return Ok(Self::new());
860        }
861
862        let content = fs::read_to_string(path).with_context(|| {
863            format!(
864                "Cannot read lockfile: {}\n\n\
865                    Possible causes:\n\
866                    - File doesn't exist (run 'agpm install' to create it)\n\
867                    - Permission denied (check file ownership)\n\
868                    - File is corrupted or locked by another process",
869                path.display()
870            )
871        })?;
872
873        // Handle empty file
874        if content.trim().is_empty() {
875            return Ok(Self::new());
876        }
877
878        let mut lockfile: Self = toml::from_str(&content)
879            .map_err(|e| crate::core::AgpmError::LockfileParseError {
880                file: path.display().to_string(),
881                reason: e.to_string(),
882            })
883            .with_context(|| {
884                format!(
885                    "Invalid TOML syntax in lockfile: {}\n\n\
886                    The lockfile may be corrupted. You can:\n\
887                    - Delete agpm.lock and run 'agpm install' to regenerate it\n\
888                    - Check for syntax errors if you manually edited the file\n\
889                    - Restore from backup if available",
890                    path.display()
891                )
892            })?;
893
894        // Set resource_type on each resource based on which section it's in
895        for resource in &mut lockfile.agents {
896            resource.resource_type = crate::core::ResourceType::Agent;
897        }
898        for resource in &mut lockfile.snippets {
899            resource.resource_type = crate::core::ResourceType::Snippet;
900            // Apply snippet-specific default: "agpm" instead of "claude-code"
901            // This handles old lockfiles that don't have tool set for snippets
902            if resource.tool == "claude-code" {
903                resource.tool = "agpm".to_string();
904            }
905        }
906        for resource in &mut lockfile.commands {
907            resource.resource_type = crate::core::ResourceType::Command;
908        }
909        for resource in &mut lockfile.scripts {
910            resource.resource_type = crate::core::ResourceType::Script;
911        }
912        for resource in &mut lockfile.hooks {
913            resource.resource_type = crate::core::ResourceType::Hook;
914        }
915        for resource in &mut lockfile.mcp_servers {
916            resource.resource_type = crate::core::ResourceType::McpServer;
917        }
918
919        // Check version compatibility
920        if lockfile.version > Self::CURRENT_VERSION {
921            return Err(crate::core::AgpmError::Other {
922                message: format!(
923                    "Lockfile version {} is newer than supported version {}.\n\n\
924                    This lockfile was created by a newer version of agpm.\n\
925                    Please update agpm to the latest version to use this lockfile.",
926                    lockfile.version,
927                    Self::CURRENT_VERSION
928                ),
929            }
930            .into());
931        }
932
933        Ok(lockfile)
934    }
935
936    /// Save the lockfile to disk with atomic write operations and custom formatting.
937    ///
938    /// Serializes the lockfile to TOML format and writes it atomically to prevent
939    /// corruption. The output includes a header warning against manual editing and
940    /// uses custom formatting for better readability compared to standard TOML
941    /// serialization.
942    ///
943    /// # Arguments
944    ///
945    /// * `path` - Path where to save the lockfile (typically "agpm.lock")
946    ///
947    /// # Returns
948    ///
949    /// * `Ok(())` - Successfully saved lockfile
950    /// * `Err(anyhow::Error)` - IO error, permission denied, or disk full
951    ///
952    /// # Atomic Write Behavior
953    ///
954    /// The save operation is atomic - the lockfile is written to a temporary file
955    /// and then renamed to the target path. This ensures the lockfile is never
956    /// left in a partially written state even if the process is interrupted.
957    ///
958    /// # Custom Formatting
959    ///
960    /// The method uses custom TOML formatting instead of standard serde serialization
961    /// to produce more readable output:
962    /// - Adds header comment warning against manual editing
963    /// - Groups related fields together
964    /// - Uses consistent indentation and spacing
965    /// - Omits empty arrays to keep the file clean
966    ///
967    /// # Error Handling
968    ///
969    /// Provides detailed error messages for common issues:
970    /// - **Permission denied**: Suggests running with elevated permissions
971    /// - **Directory doesn't exist**: Suggests creating parent directories
972    /// - **Disk full**: Suggests freeing space or using different location
973    /// - **File locked**: Suggests closing other programs using the file
974    ///
975    /// # Examples
976    ///
977    /// ```rust,no_run
978    /// use std::path::Path;
979    /// use agpm_cli::lockfile::LockFile;
980    ///
981    /// # fn example() -> anyhow::Result<()> {
982    /// let mut lockfile = LockFile::new();
983    ///
984    /// // Add a source
985    /// lockfile.add_source(
986    ///     "community".to_string(),
987    ///     "https://github.com/example/repo.git".to_string(),
988    ///     "a1b2c3d4e5f6...".to_string()
989    /// );
990    ///
991    /// // Save to disk
992    /// lockfile.save(Path::new("agpm.lock"))?;
993    /// # Ok(())
994    /// # }
995    /// ```
996    ///
997    /// # Generated File Format
998    ///
999    /// The saved file starts with a warning header:
1000    ///
1001    /// ```toml
1002    /// # Auto-generated lockfile - DO NOT EDIT
1003    /// version = 1
1004    ///
1005    /// [[sources]]
1006    /// name = "community"
1007    /// url = "https://github.com/example/repo.git"
1008    /// commit = "a1b2c3d4e5f6..."
1009    /// fetched_at = "2024-01-15T10:30:00Z"
1010    /// ```
1011    pub fn save(&self, path: &Path) -> Result<()> {
1012        let mut content = String::from("# Auto-generated lockfile - DO NOT EDIT\n");
1013        content.push_str(&format!("version = {}\n\n", self.version));
1014
1015        // Custom formatting for better readability
1016        if !self.sources.is_empty() {
1017            for source in &self.sources {
1018                content.push_str("[[sources]]\n");
1019                content.push_str(&format!("name = {:?}\n", source.name));
1020                content.push_str(&format!("url = {:?}\n", source.url));
1021                content.push_str(&format!("fetched_at = {:?}\n\n", source.fetched_at));
1022            }
1023        }
1024
1025        // Helper to write resource arrays
1026        let write_resources =
1027            |content: &mut String, resources: &[LockedResource], section: &str| {
1028                for resource in resources {
1029                    content.push_str(&format!("[[{section}]]\n"));
1030                    content.push_str(&format!("name = {:?}\n", resource.name));
1031                    if let Some(source) = &resource.source {
1032                        content.push_str(&format!("source = {source:?}\n"));
1033                    }
1034                    if let Some(url) = &resource.url {
1035                        content.push_str(&format!("url = {url:?}\n"));
1036                    }
1037                    content.push_str(&format!("path = {:?}\n", resource.path));
1038                    if let Some(version) = &resource.version {
1039                        content.push_str(&format!("version = {version:?}\n"));
1040                    }
1041                    if let Some(commit) = &resource.resolved_commit {
1042                        content.push_str(&format!("resolved_commit = {commit:?}\n"));
1043                    }
1044                    content.push_str(&format!("checksum = {:?}\n", resource.checksum));
1045                    content.push_str(&format!("installed_at = {:?}\n", resource.installed_at));
1046                    // Only include tool if it's not the default
1047                    if !is_default_tool(&resource.tool) {
1048                        content.push_str(&format!("tool = {:?}\n", resource.tool));
1049                    }
1050                    // Always include dependencies field, even if empty (matches Cargo.lock format)
1051                    content.push_str("dependencies = [");
1052                    if resource.dependencies.is_empty() {
1053                        content.push_str("]\n\n");
1054                    } else {
1055                        content.push('\n');
1056                        for dep in &resource.dependencies {
1057                            content.push_str(&format!("    {dep:?},\n"));
1058                        }
1059                        content.push_str("]\n\n");
1060                    }
1061                }
1062            };
1063
1064        write_resources(&mut content, &self.agents, "agents");
1065        write_resources(&mut content, &self.snippets, "snippets");
1066        write_resources(&mut content, &self.commands, "commands");
1067        write_resources(&mut content, &self.scripts, "scripts");
1068        write_resources(&mut content, &self.hooks, "hooks");
1069        write_resources(&mut content, &self.mcp_servers, "mcp-servers");
1070
1071        atomic_write(path, content.as_bytes()).with_context(|| {
1072            format!(
1073                "Cannot write lockfile: {}\n\n\
1074                    Possible causes:\n\
1075                    - Permission denied (try running with elevated permissions)\n\
1076                    - Directory doesn't exist\n\
1077                    - Disk is full or read-only\n\
1078                    - File is locked by another process",
1079                path.display()
1080            )
1081        })?;
1082
1083        Ok(())
1084    }
1085
1086    /// Add or update a locked source repository with current timestamp.
1087    ///
1088    /// Adds a new source entry or updates an existing one with the same name.
1089    /// The `fetched_at` timestamp is automatically set to the current UTC time
1090    /// in RFC 3339 format.
1091    ///
1092    /// # Arguments
1093    ///
1094    /// * `name` - Unique source identifier (matches manifest `[sources]` keys)
1095    /// * `url` - Full Git repository URL
1096    /// * `commit` - Resolved 40-character commit hash
1097    ///
1098    /// # Behavior
1099    ///
1100    /// If a source with the same name already exists, it will be replaced with
1101    /// the new information. This ensures that each source name appears exactly
1102    /// once in the lockfile.
1103    ///
1104    /// # Examples
1105    ///
1106    /// ```rust,no_run
1107    /// use agpm_cli::lockfile::LockFile;
1108    ///
1109    /// let mut lockfile = LockFile::new();
1110    /// lockfile.add_source(
1111    ///     "community".to_string(),
1112    ///     "https://github.com/example/community.git".to_string(),
1113    ///     "a1b2c3d4e5f6789abcdef0123456789abcdef012".to_string()
1114    /// );
1115    ///
1116    /// assert_eq!(lockfile.sources.len(), 1);
1117    /// assert_eq!(lockfile.sources[0].name, "community");
1118    /// ```
1119    ///
1120    /// # Time Zone
1121    ///
1122    /// The `fetched_at` timestamp is always recorded in UTC to ensure consistency
1123    /// across different time zones and systems.
1124    pub fn add_source(&mut self, name: String, url: String, _commit: String) {
1125        // Remove existing entry if present
1126        self.sources.retain(|s| s.name != name);
1127
1128        self.sources.push(LockedSource {
1129            name,
1130            url,
1131            fetched_at: chrono::Utc::now().to_rfc3339(),
1132        });
1133    }
1134
1135    /// Add or update a locked resource (agent or snippet).
1136    ///
1137    /// Adds a new resource entry or updates an existing one with the same name
1138    /// within the appropriate resource type (agents or snippets).
1139    ///
1140    /// **Note**: This method is kept for backward compatibility but only supports
1141    /// agents and snippets. Use `add_typed_resource` to support all resource types
1142    /// including commands.
1143    ///
1144    /// # Arguments
1145    ///
1146    /// * `name` - Unique resource identifier within its type
1147    /// * `resource` - Complete [`LockedResource`] with all resolved information
1148    /// * `is_agent` - `true` for agents, `false` for snippets
1149    ///
1150    /// # Behavior
1151    ///
1152    /// If a resource with the same name already exists in the same type category,
1153    /// it will be replaced. Resources are categorized separately (agents vs snippets),
1154    /// so an agent named "helper" and a snippet named "helper" can coexist.
1155    ///
1156    /// # Examples
1157    ///
1158    /// Adding an agent:
1159    ///
1160    /// ```rust,no_run
1161    /// use agpm_cli::lockfile::{LockFile, LockedResource};
1162    /// use agpm_cli::core::ResourceType;
1163    ///
1164    /// let mut lockfile = LockFile::new();
1165    /// let resource = LockedResource {
1166    ///     name: "example-agent".to_string(),
1167    ///     source: Some("community".to_string()),
1168    ///     url: Some("https://github.com/example/repo.git".to_string()),
1169    ///     path: "agents/example.md".to_string(),
1170    ///     version: Some("^1.0".to_string()),
1171    ///     resolved_commit: Some("a1b2c3d...".to_string()),
1172    ///     checksum: "sha256:abcdef...".to_string(),
1173    ///     installed_at: "agents/example-agent.md".to_string(),
1174    ///     dependencies: vec![],
1175    ///     resource_type: ResourceType::Agent,
1176    ///     tool: "claude-code".to_string(),
1177    /// };
1178    ///
1179    /// lockfile.add_resource("example-agent".to_string(), resource, true);
1180    /// assert_eq!(lockfile.agents.len(), 1);
1181    /// ```
1182    ///
1183    /// Adding a snippet:
1184    ///
1185    /// ```rust,no_run
1186    /// # use agpm_cli::lockfile::{LockFile, LockedResource};
1187    /// # use agpm_cli::core::ResourceType;
1188    /// # let mut lockfile = LockFile::new();
1189    /// let snippet = LockedResource {
1190    ///     name: "util-snippet".to_string(),
1191    ///     source: None,  // Local resource
1192    ///     url: None,
1193    ///     path: "../local/utils.md".to_string(),
1194    ///     version: None,
1195    ///     resolved_commit: None,
1196    ///     checksum: "sha256:fedcba...".to_string(),
1197    ///     installed_at: "snippets/util-snippet.md".to_string(),
1198    ///     dependencies: vec![],
1199    ///     resource_type: ResourceType::Snippet,
1200    ///     tool: "claude-code".to_string(),
1201    /// };
1202    ///
1203    /// lockfile.add_resource("util-snippet".to_string(), snippet, false);
1204    /// assert_eq!(lockfile.snippets.len(), 1);
1205    /// ```
1206    pub fn add_resource(&mut self, name: String, resource: LockedResource, is_agent: bool) {
1207        let resources = if is_agent {
1208            &mut self.agents
1209        } else {
1210            &mut self.snippets
1211        };
1212
1213        // Remove existing entry if present
1214        resources.retain(|r| r.name != name);
1215        resources.push(resource);
1216    }
1217
1218    /// Add or update a locked resource with specific resource type.
1219    ///
1220    /// This is the preferred method for adding resources as it explicitly
1221    /// supports all resource types including commands.
1222    ///
1223    /// # Arguments
1224    ///
1225    /// * `name` - Unique resource identifier within its type
1226    /// * `resource` - Complete [`LockedResource`] with all resolved information
1227    /// * `resource_type` - The type of resource (Agent, Snippet, or Command)
1228    ///
1229    /// # Examples
1230    ///
1231    /// ```rust,no_run
1232    /// use agpm_cli::lockfile::{LockFile, LockedResource};
1233    /// use agpm_cli::core::ResourceType;
1234    ///
1235    /// let mut lockfile = LockFile::new();
1236    /// let command = LockedResource {
1237    ///     name: "build-command".to_string(),
1238    ///     source: Some("community".to_string()),
1239    ///     url: Some("https://github.com/example/repo.git".to_string()),
1240    ///     path: "commands/build.md".to_string(),
1241    ///     version: Some("v1.0.0".to_string()),
1242    ///     resolved_commit: Some("a1b2c3d...".to_string()),
1243    ///     checksum: "sha256:abcdef...".to_string(),
1244    ///     installed_at: ".claude/commands/build-command.md".to_string(),
1245    ///     dependencies: vec![],
1246    ///     resource_type: ResourceType::Command,
1247    ///     tool: "claude-code".to_string(),
1248    /// };
1249    ///
1250    /// lockfile.add_typed_resource("build-command".to_string(), command, ResourceType::Command);
1251    /// assert_eq!(lockfile.commands.len(), 1);
1252    /// ```
1253    pub fn add_typed_resource(
1254        &mut self,
1255        name: String,
1256        resource: LockedResource,
1257        resource_type: crate::core::ResourceType,
1258    ) {
1259        let resources = match resource_type {
1260            crate::core::ResourceType::Agent => &mut self.agents,
1261            crate::core::ResourceType::Snippet => &mut self.snippets,
1262            crate::core::ResourceType::Command => &mut self.commands,
1263            crate::core::ResourceType::McpServer => {
1264                // MCP servers are handled differently - they don't use LockedResource
1265                // This shouldn't be called for MCP servers
1266                return;
1267            }
1268            crate::core::ResourceType::Script => &mut self.scripts,
1269            crate::core::ResourceType::Hook => &mut self.hooks,
1270        };
1271
1272        // Remove existing entry if present
1273        resources.retain(|r| r.name != name);
1274        resources.push(resource);
1275    }
1276
1277    /// Get a locked resource by name, searching across all resource types.
1278    ///
1279    /// Searches for a resource with the given name in the agents, snippets, commands,
1280    /// scripts, hooks, and mcp-servers collections. This method returns the first match found,
1281    /// which is suitable when resource names are unique or when the source doesn't matter.
1282    ///
1283    /// **Note**: When multiple resources have the same name from different sources (common with
1284    /// transitive dependencies), this method returns the first match based on search order.
1285    /// For precise lookups that distinguish between sources, use [`Self::get_resource_by_source`].
1286    ///
1287    /// # Arguments
1288    ///
1289    /// * `name` - Resource name to search for
1290    ///
1291    /// # Returns
1292    ///
1293    /// * `Some(&LockedResource)` - Reference to the first matching resource
1294    /// * `None` - No resource with that name exists
1295    ///
1296    /// # Examples
1297    ///
1298    /// ```rust,no_run
1299    /// # use agpm_cli::lockfile::LockFile;
1300    /// # let lockfile = LockFile::new();
1301    /// // Simple lookup when resource names are unique
1302    /// if let Some(resource) = lockfile.get_resource("example-agent") {
1303    ///     println!("Found resource: {}", resource.installed_at);
1304    /// } else {
1305    ///     println!("Resource not found");
1306    /// }
1307    /// ```
1308    ///
1309    /// # Search Order
1310    ///
1311    /// The method searches in order: agents, snippets, commands, scripts, hooks, mcp-servers.
1312    /// If multiple resource types or sources have the same name, the first match will be returned.
1313    ///
1314    /// # See Also
1315    ///
1316    /// * [`get_resource_by_source`](Self::get_resource_by_source) - Precise lookup with source filtering for handling same-named resources from different sources
1317    #[must_use]
1318    pub fn get_resource(&self, name: &str) -> Option<&LockedResource> {
1319        // Simple name matching - may return first of multiple resources with same name
1320        // For precise matching when duplicates exist, use get_resource_by_source()
1321        self.agents
1322            .iter()
1323            .find(|r| r.name == name)
1324            .or_else(|| self.snippets.iter().find(|r| r.name == name))
1325            .or_else(|| self.commands.iter().find(|r| r.name == name))
1326            .or_else(|| self.scripts.iter().find(|r| r.name == name))
1327            .or_else(|| self.hooks.iter().find(|r| r.name == name))
1328            .or_else(|| self.mcp_servers.iter().find(|r| r.name == name))
1329    }
1330
1331    /// Get a locked resource by name and source.
1332    ///
1333    /// This method provides precise resource lookup when multiple resources share the same name
1334    /// but come from different sources. This commonly occurs with transitive dependencies where
1335    /// different dependency chains pull in the same resource name from different repositories.
1336    ///
1337    /// # Arguments
1338    ///
1339    /// * `name` - Resource name to search for
1340    /// * `source` - Optional source name to match (None matches resources without a source, e.g., local resources)
1341    ///
1342    /// # Returns
1343    ///
1344    /// First matching resource with the specified name and source, or None if not found.
1345    ///
1346    /// # Examples
1347    ///
1348    /// ```rust,no_run
1349    /// # use agpm_cli::lockfile::LockFile;
1350    /// # let lockfile = LockFile::new();
1351    /// // When multiple resources have the same name from different sources
1352    /// if let Some(resource) = lockfile.get_resource_by_source("helper", Some("community")) {
1353    ///     println!("Found helper from community source: {}", resource.installed_at);
1354    /// }
1355    ///
1356    /// if let Some(resource) = lockfile.get_resource_by_source("helper", Some("internal")) {
1357    ///     println!("Found helper from internal source: {}", resource.installed_at);
1358    /// }
1359    ///
1360    /// // Match local resources (no source)
1361    /// if let Some(resource) = lockfile.get_resource_by_source("local-helper", None) {
1362    ///     println!("Found local resource: {}", resource.installed_at);
1363    /// }
1364    /// ```
1365    ///
1366    /// # Search Order
1367    ///
1368    /// The method searches in order: agents, snippets, commands, scripts, hooks, mcp-servers.
1369    /// Only resources matching both the name AND source are returned.
1370    ///
1371    /// # See Also
1372    ///
1373    /// * [`get_resource`](Self::get_resource) - Simple name-based lookup without source filtering
1374    #[must_use]
1375    pub fn get_resource_by_source(
1376        &self,
1377        name: &str,
1378        source: Option<&str>,
1379    ) -> Option<&LockedResource> {
1380        let matches = |r: &&LockedResource| r.name == name && r.source.as_deref() == source;
1381
1382        self.agents
1383            .iter()
1384            .find(matches)
1385            .or_else(|| self.snippets.iter().find(matches))
1386            .or_else(|| self.commands.iter().find(matches))
1387            .or_else(|| self.scripts.iter().find(matches))
1388            .or_else(|| self.hooks.iter().find(matches))
1389            .or_else(|| self.mcp_servers.iter().find(matches))
1390    }
1391
1392    /// Get a locked source repository by name.
1393    ///
1394    /// Searches for a source repository with the given name in the sources collection.
1395    ///
1396    /// # Arguments
1397    ///
1398    /// * `name` - Source name to search for (matches manifest `[sources]` keys)
1399    ///
1400    /// # Returns
1401    ///
1402    /// * `Some(&LockedSource)` - Reference to the found source
1403    /// * `None` - No source with that name exists
1404    ///
1405    /// # Examples
1406    ///
1407    /// ```rust,no_run
1408    /// # use agpm_cli::lockfile::LockFile;
1409    /// # let lockfile = LockFile::new();
1410    /// if let Some(source) = lockfile.get_source("community") {
1411    ///     println!("Source URL: {}", source.url);
1412    ///     println!("Fetched at: {}", source.fetched_at);
1413    /// }
1414    /// ```
1415    #[must_use]
1416    pub fn get_source(&self, name: &str) -> Option<&LockedSource> {
1417        self.sources.iter().find(|s| s.name == name)
1418    }
1419
1420    /// Check if a resource is locked in the lockfile.
1421    ///
1422    /// Convenience method that checks whether a resource with the given name
1423    /// exists in either the agents or snippets collections.
1424    ///
1425    /// # Arguments
1426    ///
1427    /// * `name` - Resource name to check
1428    ///
1429    /// # Returns
1430    ///
1431    /// * `true` - Resource exists in the lockfile
1432    /// * `false` - Resource does not exist
1433    ///
1434    /// # Examples
1435    ///
1436    /// ```rust,no_run
1437    /// # use agpm_cli::lockfile::LockFile;
1438    /// # let lockfile = LockFile::new();
1439    /// if lockfile.has_resource("example-agent") {
1440    ///     println!("Agent is already locked");
1441    /// } else {
1442    ///     println!("Agent needs to be resolved and installed");
1443    /// }
1444    /// ```
1445    ///
1446    /// This is equivalent to calling `lockfile.get_resource(name).is_some()`.
1447    #[must_use]
1448    pub fn has_resource(&self, name: &str) -> bool {
1449        self.get_resource(name).is_some()
1450    }
1451
1452    /// Get all locked resources as a combined vector.
1453    ///
1454    /// Returns references to all resources (agents, snippets, and commands) in a single
1455    /// vector for easy iteration. The order is agents first, then snippets, then commands.
1456    ///
1457    /// # Returns
1458    ///
1459    /// Vector of references to all locked resources, preserving the order within
1460    /// each type as they appear in the lockfile.
1461    ///
1462    /// # Examples
1463    ///
1464    /// ```rust,no_run
1465    /// # use agpm_cli::lockfile::LockFile;
1466    /// # let lockfile = LockFile::new();
1467    /// let all_resources = lockfile.all_resources();
1468    /// println!("Total locked resources: {}", all_resources.len());
1469    ///
1470    /// for resource in all_resources {
1471    ///     println!("- {}: {}", resource.name, resource.installed_at);
1472    /// }
1473    /// ```
1474    ///
1475    /// # Use Cases
1476    ///
1477    /// - Generating reports of all installed resources
1478    /// - Validating checksums across all resources
1479    /// - Listing resources for user display
1480    /// - Bulk operations on all resources
1481    ///   Get locked resources for a specific resource type
1482    ///
1483    ///
1484    /// Returns a slice of locked resources for the specified type.
1485    pub fn get_resources(&self, resource_type: crate::core::ResourceType) -> &[LockedResource] {
1486        use crate::core::ResourceType;
1487        match resource_type {
1488            ResourceType::Agent => &self.agents,
1489            ResourceType::Snippet => &self.snippets,
1490            ResourceType::Command => &self.commands,
1491            ResourceType::Script => &self.scripts,
1492            ResourceType::Hook => &self.hooks,
1493            ResourceType::McpServer => &self.mcp_servers,
1494        }
1495    }
1496
1497    /// Get mutable locked resources for a specific resource type
1498    ///
1499    /// Returns a mutable slice of locked resources for the specified type.
1500    pub const fn get_resources_mut(
1501        &mut self,
1502        resource_type: crate::core::ResourceType,
1503    ) -> &mut Vec<LockedResource> {
1504        use crate::core::ResourceType;
1505        match resource_type {
1506            ResourceType::Agent => &mut self.agents,
1507            ResourceType::Snippet => &mut self.snippets,
1508            ResourceType::Command => &mut self.commands,
1509            ResourceType::Script => &mut self.scripts,
1510            ResourceType::Hook => &mut self.hooks,
1511            ResourceType::McpServer => &mut self.mcp_servers,
1512        }
1513    }
1514
1515    /// Returns all locked resources across all resource types.
1516    ///
1517    /// This method collects all resources from agents, snippets, commands,
1518    /// scripts, hooks, and MCP servers into a single vector. It's useful for
1519    /// operations that need to process all resources uniformly, such as:
1520    /// - Generating installation reports
1521    /// - Validating checksums across all resources
1522    /// - Bulk operations on resources
1523    ///
1524    /// # Returns
1525    ///
1526    /// A vector containing references to all [`LockedResource`] entries in the lockfile.
1527    /// The order matches the resource type order defined in [`crate::core::ResourceType::all()`].
1528    ///
1529    /// # Examples
1530    ///
1531    /// ```rust,no_run
1532    /// # use agpm_cli::lockfile::LockFile;
1533    /// # let lockfile = LockFile::new();
1534    /// let all_resources = lockfile.all_resources();
1535    /// println!("Total locked resources: {}", all_resources.len());
1536    ///
1537    /// for resource in all_resources {
1538    ///     println!("- {}: {}", resource.name, resource.installed_at);
1539    /// }
1540    /// ```
1541    #[must_use]
1542    pub fn all_resources(&self) -> Vec<&LockedResource> {
1543        let mut resources = Vec::new();
1544
1545        // Use ResourceType::all() to iterate through all resource types
1546        for resource_type in crate::core::ResourceType::all() {
1547            resources.extend(self.get_resources(*resource_type));
1548        }
1549
1550        resources
1551    }
1552
1553    /// Clear all locked entries from the lockfile.
1554    ///
1555    /// Removes all sources, agents, snippets, and commands from the lockfile, returning
1556    /// it to an empty state. The format version remains unchanged.
1557    ///
1558    /// # Examples
1559    ///
1560    /// ```rust,no_run
1561    /// # use agpm_cli::lockfile::LockFile;
1562    /// let mut lockfile = LockFile::new();
1563    /// // ... add sources and resources ...
1564    ///
1565    /// lockfile.clear();
1566    /// assert!(lockfile.sources.is_empty());
1567    /// assert!(lockfile.agents.is_empty());
1568    /// assert!(lockfile.snippets.is_empty());
1569    /// ```
1570    ///
1571    /// # Use Cases
1572    ///
1573    /// - Preparing for complete lockfile regeneration
1574    /// - Implementing `agpm clean` functionality
1575    /// - Resetting lockfile state during testing
1576    /// - Handling lockfile corruption recovery
1577    pub fn clear(&mut self) {
1578        self.sources.clear();
1579
1580        // Use ResourceType::all() to clear all resource types
1581        for resource_type in crate::core::ResourceType::all() {
1582            self.get_resources_mut(*resource_type).clear();
1583        }
1584    }
1585
1586    /// Compute SHA-256 checksum for a file with integrity verification.
1587    ///
1588    /// Calculates the SHA-256 hash of a file's content for integrity verification.
1589    /// The checksum is used to detect file corruption, tampering, or changes after
1590    /// installation.
1591    ///
1592    /// # Arguments
1593    ///
1594    /// * `path` - Path to the file to checksum
1595    ///
1596    /// # Returns
1597    ///
1598    /// * `Ok(String)` - Checksum in format "`sha256:hexadecimal_hash`"
1599    /// * `Err(anyhow::Error)` - File read error with detailed context
1600    ///
1601    /// # Checksum Format
1602    ///
1603    /// The returned checksum follows the format:
1604    /// - **Algorithm prefix**: "sha256:"
1605    /// - **Hash encoding**: Lowercase hexadecimal
1606    /// - **Length**: 71 characters total (7 for prefix + 64 hex digits)
1607    ///
1608    /// # Examples
1609    ///
1610    /// ```rust,no_run
1611    /// use std::path::Path;
1612    /// use agpm_cli::lockfile::LockFile;
1613    ///
1614    /// # fn example() -> anyhow::Result<()> {
1615    /// let checksum = LockFile::compute_checksum(Path::new("example.md"))?;
1616    /// println!("File checksum: {}", checksum);
1617    /// // Output: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
1618    /// # Ok(())
1619    /// # }
1620    /// ```
1621    ///
1622    /// # Error Handling
1623    ///
1624    /// Provides detailed error context for common issues:
1625    /// - **File not found**: Suggests checking the path
1626    /// - **Permission denied**: Suggests checking file permissions
1627    /// - **IO errors**: Suggests checking disk health or file locks
1628    ///
1629    /// # Security Considerations
1630    ///
1631    /// - Uses SHA-256, a cryptographically secure hash function
1632    /// - Suitable for integrity verification and tamper detection
1633    /// - Consistent across platforms (Windows, macOS, Linux)
1634    /// - Not affected by line ending differences (hashes actual bytes)
1635    ///
1636    /// # Performance
1637    ///
1638    /// The method reads the entire file into memory before hashing.
1639    /// For very large files (>100MB), consider streaming implementations
1640    /// in future versions.
1641    pub fn compute_checksum(path: &Path) -> Result<String> {
1642        use sha2::{Digest, Sha256};
1643
1644        let content = fs::read(path).with_context(|| {
1645            format!(
1646                "Cannot read file for checksum calculation: {}\n\n\
1647                    This error occurs when verifying file integrity.\n\
1648                    Check that the file exists and is readable.",
1649                path.display()
1650            )
1651        })?;
1652
1653        let mut hasher = Sha256::new();
1654        hasher.update(&content);
1655        let result = hasher.finalize();
1656
1657        Ok(format!("sha256:{}", hex::encode(result)))
1658    }
1659
1660    /// Verify that a file matches its expected checksum.
1661    ///
1662    /// Computes the current checksum of a file and compares it against the
1663    /// expected checksum. Used to verify file integrity and detect corruption
1664    /// or tampering after installation.
1665    ///
1666    /// # Arguments
1667    ///
1668    /// * `path` - Path to the file to verify
1669    /// * `expected` - Expected checksum in "sha256:hex" format
1670    ///
1671    /// # Returns
1672    ///
1673    /// * `Ok(true)` - File checksum matches expected value
1674    /// * `Ok(false)` - File checksum does not match (corruption detected)
1675    /// * `Err(anyhow::Error)` - File read error or checksum calculation failed
1676    ///
1677    /// # Examples
1678    ///
1679    /// ```rust,no_run
1680    /// use std::path::Path;
1681    /// use agpm_cli::lockfile::LockFile;
1682    ///
1683    /// # fn example() -> anyhow::Result<()> {
1684    /// let expected = "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3";
1685    /// let is_valid = LockFile::verify_checksum(Path::new("example.md"), expected)?;
1686    ///
1687    /// if is_valid {
1688    ///     println!("File integrity verified");
1689    /// } else {
1690    ///     println!("WARNING: File has been modified or corrupted!");
1691    /// }
1692    /// # Ok(())
1693    /// # }
1694    /// ```
1695    ///
1696    /// # Use Cases
1697    ///
1698    /// - **Installation verification**: Ensure copied files are intact
1699    /// - **Periodic validation**: Detect file corruption over time
1700    /// - **Security checks**: Detect unauthorized modifications
1701    /// - **Troubleshooting**: Diagnose installation issues
1702    ///
1703    /// # Performance
1704    ///
1705    /// This method internally calls [`compute_checksum`](Self::compute_checksum),
1706    /// so it has the same performance characteristics. For bulk verification
1707    /// operations, consider caching computed checksums.
1708    ///
1709    /// # Security
1710    ///
1711    /// The comparison is performed using standard string equality, which is
1712    /// not timing-attack resistant. Since checksums are not secrets, this
1713    /// is acceptable for integrity verification purposes.
1714    pub fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
1715        let actual = Self::compute_checksum(path)?;
1716        Ok(actual == expected)
1717    }
1718
1719    /// Validate the lockfile against a manifest to detect staleness.
1720    ///
1721    /// Checks if the lockfile is consistent with the current manifest and detects
1722    /// common staleness indicators that require lockfile regeneration. Performs
1723    /// comprehensive validation similar to Cargo's `--locked` mode.
1724    ///
1725    /// # Arguments
1726    ///
1727    /// * `manifest` - The current project manifest to validate against
1728    /// * `strict` - If true, check version/path changes; if false, only check corruption and security
1729    ///
1730    /// # Returns
1731    ///
1732    /// * `Ok(None)` - Lockfile is valid and up-to-date
1733    /// * `Ok(Some(StalenessReason))` - Lockfile is stale and needs regeneration
1734    /// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
1735    ///
1736    /// # Examples
1737    ///
1738    /// ```rust,no_run
1739    /// # use std::path::Path;
1740    /// # use agpm_cli::lockfile::LockFile;
1741    /// # use agpm_cli::manifest::Manifest;
1742    /// # fn example() -> anyhow::Result<()> {
1743    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
1744    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1745    ///
1746    /// // Strict mode: check everything including version/path changes
1747    /// match lockfile.validate_against_manifest(&manifest, true)? {
1748    ///     None => println!("Lockfile is valid"),
1749    ///     Some(reason) => {
1750    ///         eprintln!("Lockfile is stale: {}", reason);
1751    ///         eprintln!("Run 'agpm install' to auto-update it");
1752    ///     }
1753    /// }
1754    ///
1755    /// // Lenient mode: only check corruption and security (for --frozen)
1756    /// match lockfile.validate_against_manifest(&manifest, false)? {
1757    ///     None => println!("Lockfile has no critical issues"),
1758    ///     Some(reason) => eprintln!("Critical issue: {}", reason),
1759    /// }
1760    /// # Ok(())
1761    /// # }
1762    /// ```
1763    ///
1764    /// # Staleness Detection
1765    ///
1766    /// The method checks for several staleness indicators:
1767    /// - **Duplicate entries**: Multiple entries for the same dependency (corruption) - always checked
1768    /// - **Source URL changes**: Source URLs changed in manifest (security concern) - always checked
1769    /// - **Missing dependencies**: Manifest has deps not in lockfile - only in strict mode
1770    /// - **Version changes**: Same dependency with different version constraint - only in strict mode
1771    /// - **Path changes**: Same dependency with different source path - only in strict mode
1772    ///
1773    /// Note: Extra lockfile entries are allowed (for transitive dependencies).
1774    pub fn validate_against_manifest(
1775        &self,
1776        manifest: &crate::manifest::Manifest,
1777        strict: bool,
1778    ) -> Result<Option<StalenessReason>> {
1779        // Always check for critical issues:
1780        // 1. Corruption (duplicate entries)
1781        // 2. Security concerns (source URL changes)
1782
1783        // Check for duplicate entries within the lockfile (corruption)
1784        if let Some(reason) = self.detect_duplicate_entries()? {
1785            return Ok(Some(reason));
1786        }
1787
1788        // Check source URL changes (security concern - different repository)
1789        for (source_name, manifest_url) in &manifest.sources {
1790            if let Some(locked_source) = self.get_source(source_name)
1791                && &locked_source.url != manifest_url
1792            {
1793                return Ok(Some(StalenessReason::SourceUrlChanged {
1794                    name: source_name.clone(),
1795                    old_url: locked_source.url.clone(),
1796                    new_url: manifest_url.clone(),
1797                }));
1798            }
1799        }
1800
1801        // In strict mode, also check for missing dependencies, version changes, and path changes
1802        if strict {
1803            for resource_type in crate::core::ResourceType::all() {
1804                if let Some(manifest_deps) = manifest.get_dependencies(*resource_type) {
1805                    for (name, dep) in manifest_deps {
1806                        // Find matching resource in lockfile
1807                        let locked_resource = self.get_resource(name);
1808
1809                        if locked_resource.is_none() {
1810                            // Dependency is in manifest but not in lockfile
1811                            return Ok(Some(StalenessReason::MissingDependency {
1812                                name: name.clone(),
1813                                resource_type: *resource_type,
1814                            }));
1815                        }
1816
1817                        // Check for version changes
1818                        if let Some(locked) = locked_resource {
1819                            if let Some(manifest_version) = dep.get_version()
1820                                && let Some(locked_version) = &locked.version
1821                                && manifest_version != locked_version
1822                            {
1823                                return Ok(Some(StalenessReason::VersionChanged {
1824                                    name: name.clone(),
1825                                    resource_type: *resource_type,
1826                                    old_version: locked_version.clone(),
1827                                    new_version: manifest_version.to_string(),
1828                                }));
1829                            }
1830
1831                            // Check for path changes
1832                            if dep.get_path() != locked.path {
1833                                return Ok(Some(StalenessReason::PathChanged {
1834                                    name: name.clone(),
1835                                    resource_type: *resource_type,
1836                                    old_path: locked.path.clone(),
1837                                    new_path: dep.get_path().to_string(),
1838                                }));
1839                            }
1840                        }
1841                    }
1842                }
1843            }
1844        }
1845
1846        // Extra lockfile entries are allowed (for transitive dependencies)
1847        Ok(None)
1848    }
1849
1850    /// Check if the lockfile is stale relative to the manifest.
1851    ///
1852    /// This is a convenience method that returns a simple boolean instead of
1853    /// the detailed `StalenessReason`. Useful for quick staleness checks.
1854    ///
1855    /// # Arguments
1856    ///
1857    /// * `manifest` - The current project manifest to validate against
1858    /// * `strict` - If true, check version/path changes; if false, only check corruption and security
1859    ///
1860    /// # Returns
1861    ///
1862    /// * `Ok(true)` - Lockfile is stale and needs updating
1863    /// * `Ok(false)` - Lockfile is valid and up-to-date
1864    /// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
1865    ///
1866    /// # Examples
1867    ///
1868    /// ```rust,no_run
1869    /// # use std::path::Path;
1870    /// # use agpm_cli::lockfile::LockFile;
1871    /// # use agpm_cli::manifest::Manifest;
1872    /// # fn example() -> anyhow::Result<()> {
1873    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
1874    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1875    ///
1876    /// if lockfile.is_stale(&manifest, true)? {
1877    ///     println!("Lockfile needs updating");
1878    /// }
1879    /// # Ok(())
1880    /// # }
1881    /// ```
1882    pub fn is_stale(&self, manifest: &crate::manifest::Manifest, strict: bool) -> Result<bool> {
1883        Ok(self.validate_against_manifest(manifest, strict)?.is_some())
1884    }
1885
1886    /// Detect duplicate entries within the lockfile itself.
1887    ///
1888    /// Scans all resource arrays for duplicate entries with the same name,
1889    /// which indicates lockfile corruption or staleness from previous versions.
1890    fn detect_duplicate_entries(&self) -> Result<Option<StalenessReason>> {
1891        use std::collections::HashMap;
1892
1893        // Check each resource type for duplicates
1894        for resource_type in crate::core::ResourceType::all() {
1895            let resources = self.get_resources(*resource_type);
1896            let mut seen_names = HashMap::new();
1897
1898            for resource in resources {
1899                if let Some(_first_index) = seen_names.get(&resource.name) {
1900                    return Ok(Some(StalenessReason::DuplicateEntries {
1901                        name: resource.name.clone(),
1902                        resource_type: *resource_type,
1903                        count: resources.iter().filter(|r| r.name == resource.name).count(),
1904                    }));
1905                }
1906                seen_names.insert(&resource.name, 0);
1907            }
1908        }
1909
1910        Ok(None)
1911    }
1912
1913    /// Update the checksum for a specific resource in the lockfile.
1914    ///
1915    /// This method finds a resource by name across all resource types and updates
1916    /// its checksum value. Used after installation to record the actual file checksum.
1917    ///
1918    /// # Arguments
1919    ///
1920    /// * `name` - The name of the resource to update
1921    /// * `checksum` - The new SHA-256 checksum in "sha256:hex" format
1922    ///
1923    /// # Returns
1924    ///
1925    /// Returns `true` if the resource was found and updated, `false` otherwise.
1926    ///
1927    /// # Examples
1928    ///
1929    /// ```rust,no_run
1930    /// # use agpm_cli::lockfile::{LockFile, LockedResource};
1931    /// # use agpm_cli::core::ResourceType;
1932    /// # let mut lockfile = LockFile::default();
1933    /// # // First add a resource to update
1934    /// # lockfile.add_typed_resource("my-agent".to_string(), LockedResource {
1935    /// #     name: "my-agent".to_string(),
1936    /// #     source: None,
1937    /// #     url: None,
1938    /// #     path: "my-agent.md".to_string(),
1939    /// #     version: None,
1940    /// #     resolved_commit: None,
1941    /// #     checksum: "".to_string(),
1942    /// #     installed_at: "agents/my-agent.md".to_string(),
1943    /// #     dependencies: vec![],
1944    /// #     resource_type: ResourceType::Agent,
1945    /// #     tool: "claude-code".to_string(),
1946    /// # }, ResourceType::Agent);
1947    /// let updated = lockfile.update_resource_checksum(
1948    ///     "my-agent",
1949    ///     "sha256:abcdef123456..."
1950    /// );
1951    /// assert!(updated);
1952    /// ```
1953    pub fn update_resource_checksum(&mut self, name: &str, checksum: &str) -> bool {
1954        // Try each resource type until we find a match
1955        for resource in &mut self.agents {
1956            if resource.name == name {
1957                resource.checksum = checksum.to_string();
1958                return true;
1959            }
1960        }
1961
1962        for resource in &mut self.snippets {
1963            if resource.name == name {
1964                resource.checksum = checksum.to_string();
1965                return true;
1966            }
1967        }
1968
1969        for resource in &mut self.commands {
1970            if resource.name == name {
1971                resource.checksum = checksum.to_string();
1972                return true;
1973            }
1974        }
1975
1976        for resource in &mut self.scripts {
1977            if resource.name == name {
1978                resource.checksum = checksum.to_string();
1979                return true;
1980            }
1981        }
1982
1983        for resource in &mut self.hooks {
1984            if resource.name == name {
1985                resource.checksum = checksum.to_string();
1986                return true;
1987            }
1988        }
1989
1990        for resource in &mut self.mcp_servers {
1991            if resource.name == name {
1992                resource.checksum = checksum.to_string();
1993                return true;
1994            }
1995        }
1996
1997        false
1998    }
1999}
2000
2001impl Default for LockFile {
2002    /// Create a new empty lockfile using the current format version.
2003    ///
2004    /// This implementation of [`Default`] is equivalent to calling [`LockFile::new()`].
2005    /// It creates a fresh lockfile with no sources or resources.
2006    fn default() -> Self {
2007        Self::new()
2008    }
2009}
2010
2011/// Find the lockfile in the current or parent directories.
2012///
2013/// Searches upward from the current working directory to find a `agpm.lock` file,
2014/// similar to how Git searches for `.git` directories. This enables running AGPM
2015/// commands from subdirectories within a project.
2016///
2017/// # Search Algorithm
2018///
2019/// 1. Start from current working directory
2020/// 2. Check for `agpm.lock` in current directory
2021/// 3. If found, return the path
2022/// 4. If not found, move to parent directory
2023/// 5. Repeat until root directory is reached
2024/// 6. Return `None` if no lockfile found
2025///
2026/// # Returns
2027///
2028/// * `Some(PathBuf)` - Path to the found lockfile
2029/// * `None` - No lockfile found in current or parent directories
2030///
2031/// # Examples
2032///
2033/// ```rust,no_run
2034/// use agpm_cli::lockfile::find_lockfile;
2035///
2036/// if let Some(lockfile_path) = find_lockfile() {
2037///     println!("Found lockfile: {}", lockfile_path.display());
2038/// } else {
2039///     println!("No lockfile found (run 'agpm install' to create one)");
2040/// }
2041/// ```
2042///
2043/// # Use Cases
2044///
2045/// - **CLI commands**: Find project root when run from subdirectories
2046/// - **Editor integration**: Locate project configuration
2047/// - **Build scripts**: Find lockfile for dependency information
2048/// - **Validation tools**: Check if project has lockfile
2049///
2050/// # Directory Structure Example
2051///
2052/// ```text
2053/// project/
2054/// ├── agpm.lock          # ← This will be found
2055/// ├── agpm.toml
2056/// └── src/
2057///     └── subdir/         # ← Commands run from here will find ../agpm.lock
2058/// ```
2059///
2060/// # Errors
2061///
2062/// This function does not return errors but rather `None` if:
2063/// - Cannot get current working directory (permission issues)
2064/// - No lockfile exists in the directory tree
2065/// - IO errors while checking file existence
2066///
2067/// For more robust error handling, consider using [`LockFile::load`] directly
2068/// with a known path.
2069#[must_use]
2070pub fn find_lockfile() -> Option<PathBuf> {
2071    let mut current = std::env::current_dir().ok()?;
2072
2073    loop {
2074        let lockfile_path = current.join("agpm.lock");
2075        if lockfile_path.exists() {
2076            return Some(lockfile_path);
2077        }
2078
2079        if !current.pop() {
2080            return None;
2081        }
2082    }
2083}
2084
2085#[cfg(test)]
2086mod tests {
2087    use super::*;
2088    use tempfile::tempdir;
2089
2090    #[test]
2091    fn test_lockfile_new() {
2092        let lockfile = LockFile::new();
2093        assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
2094        assert!(lockfile.sources.is_empty());
2095        assert!(lockfile.agents.is_empty());
2096    }
2097
2098    #[test]
2099    fn test_lockfile_save_load() {
2100        let temp = tempdir().unwrap();
2101        let lockfile_path = temp.path().join("agpm.lock");
2102
2103        let mut lockfile = LockFile::new();
2104
2105        // Add a source
2106        lockfile.add_source(
2107            "official".to_string(),
2108            "https://github.com/example-org/agpm-official.git".to_string(),
2109            "abc123".to_string(),
2110        );
2111
2112        // Add a resource
2113        lockfile.add_resource(
2114            "test-agent".to_string(),
2115            LockedResource {
2116                name: "test-agent".to_string(),
2117                source: Some("official".to_string()),
2118                url: Some("https://github.com/example-org/agpm-official.git".to_string()),
2119                path: "agents/test.md".to_string(),
2120                version: Some("v1.0.0".to_string()),
2121                resolved_commit: Some("abc123".to_string()),
2122                checksum: "sha256:abcdef".to_string(),
2123                installed_at: "agents/test-agent.md".to_string(),
2124                dependencies: vec![],
2125                resource_type: crate::core::ResourceType::Agent,
2126
2127                tool: "claude-code".to_string(),
2128            },
2129            true,
2130        );
2131
2132        // Save
2133        lockfile.save(&lockfile_path).unwrap();
2134        assert!(lockfile_path.exists());
2135
2136        // Load
2137        let loaded = LockFile::load(&lockfile_path).unwrap();
2138        assert_eq!(loaded.version, LockFile::CURRENT_VERSION);
2139        assert_eq!(loaded.sources.len(), 1);
2140        assert_eq!(loaded.agents.len(), 1);
2141        assert_eq!(
2142            loaded.get_source("official").unwrap().url,
2143            "https://github.com/example-org/agpm-official.git"
2144        );
2145        assert_eq!(loaded.get_resource("test-agent").unwrap().checksum, "sha256:abcdef");
2146    }
2147
2148    #[test]
2149    fn test_staleness_reason_display() {
2150        use crate::core::ResourceType;
2151
2152        // Test SourceUrlChanged
2153        let reason = StalenessReason::SourceUrlChanged {
2154            name: "community".to_string(),
2155            old_url: "https://github.com/old/repo.git".to_string(),
2156            new_url: "https://github.com/new/repo.git".to_string(),
2157        };
2158        assert_eq!(
2159            reason.to_string(),
2160            "Source repository 'community' URL changed from 'https://github.com/old/repo.git' to 'https://github.com/new/repo.git'"
2161        );
2162
2163        // Test DuplicateEntries
2164        let reason = StalenessReason::DuplicateEntries {
2165            name: "dup-agent".to_string(),
2166            resource_type: ResourceType::Agent,
2167            count: 3,
2168        };
2169        assert_eq!(
2170            reason.to_string(),
2171            "Found 3 duplicate entries for dependency 'dup-agent' (agent)"
2172        );
2173    }
2174
2175    // Note: Complex staleness checking integration tests are in tests/integration_lockfile_staleness.rs
2176    // These unit tests focus on the display formatting of StalenessReason variants
2177
2178    #[test]
2179    fn test_lockfile_empty_file() {
2180        let temp = tempdir().unwrap();
2181        let lockfile_path = temp.path().join("agpm.lock");
2182
2183        // Create empty file
2184        std::fs::write(&lockfile_path, "").unwrap();
2185
2186        // Should return new lockfile
2187        let lockfile = LockFile::load(&lockfile_path).unwrap();
2188        assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
2189        assert!(lockfile.sources.is_empty());
2190    }
2191
2192    #[test]
2193    fn test_lockfile_version_check() {
2194        let temp = tempdir().unwrap();
2195        let lockfile_path = temp.path().join("agpm.lock");
2196
2197        // Create lockfile with future version
2198        let content = "version = 999\n";
2199        std::fs::write(&lockfile_path, content).unwrap();
2200
2201        // Should fail to load
2202        let result = LockFile::load(&lockfile_path);
2203        assert!(result.is_err());
2204        assert!(result.unwrap_err().to_string().contains("newer than supported"));
2205    }
2206
2207    #[test]
2208    fn test_resource_operations() {
2209        let mut lockfile = LockFile::new();
2210
2211        // Add resources
2212        lockfile.add_resource(
2213            "agent1".to_string(),
2214            LockedResource {
2215                name: "agent1".to_string(),
2216                source: None,
2217                url: None,
2218                path: "local/agent1.md".to_string(),
2219                version: None,
2220                resolved_commit: None,
2221                checksum: "sha256:111".to_string(),
2222                installed_at: "agents/agent1.md".to_string(),
2223                dependencies: vec![],
2224                resource_type: crate::core::ResourceType::Agent,
2225
2226                tool: "claude-code".to_string(),
2227            },
2228            true, // is_agent
2229        );
2230
2231        lockfile.add_resource(
2232            "snippet1".to_string(),
2233            LockedResource {
2234                name: "snippet1".to_string(),
2235                source: None,
2236                url: None,
2237                path: "local/snippet1.md".to_string(),
2238                version: None,
2239                resolved_commit: None,
2240                checksum: "sha256:222".to_string(),
2241                installed_at: "snippets/snippet1.md".to_string(),
2242                dependencies: vec![],
2243                resource_type: crate::core::ResourceType::Snippet,
2244
2245                tool: "claude-code".to_string(),
2246            },
2247            false, // is_agent
2248        );
2249
2250        lockfile.add_resource(
2251            "dev-agent1".to_string(),
2252            LockedResource {
2253                name: "dev-agent1".to_string(),
2254                source: None,
2255                url: None,
2256                path: "local/dev-agent1.md".to_string(),
2257                version: None,
2258                resolved_commit: None,
2259                checksum: "sha256:333".to_string(),
2260                installed_at: "agents/dev-agent1.md".to_string(),
2261                dependencies: vec![],
2262                resource_type: crate::core::ResourceType::Agent,
2263
2264                tool: "claude-code".to_string(),
2265            },
2266            true, // is_agent
2267        );
2268
2269        // Test getters
2270        assert!(lockfile.has_resource("agent1"));
2271        assert!(lockfile.has_resource("snippet1"));
2272        assert!(lockfile.has_resource("dev-agent1"));
2273        assert!(!lockfile.has_resource("nonexistent"));
2274
2275        assert_eq!(lockfile.all_resources().len(), 3);
2276        // Note: production_resources() removed as dev/production concept was eliminated
2277
2278        // Test clear
2279        lockfile.clear();
2280        assert!(lockfile.all_resources().is_empty());
2281    }
2282
2283    #[test]
2284    fn test_checksum_computation() {
2285        let temp = tempdir().unwrap();
2286        let file_path = temp.path().join("test.md");
2287
2288        std::fs::write(&file_path, "Hello, World!").unwrap();
2289
2290        let checksum = LockFile::compute_checksum(&file_path).unwrap();
2291        assert!(checksum.starts_with("sha256:"));
2292
2293        // Verify checksum
2294        assert!(LockFile::verify_checksum(&file_path, &checksum).unwrap());
2295        assert!(!LockFile::verify_checksum(&file_path, "sha256:wrong").unwrap());
2296    }
2297
2298    #[test]
2299    fn test_lockfile_with_commands() {
2300        let mut lockfile = LockFile::new();
2301
2302        // Add a command resource using add_typed_resource
2303        lockfile.add_typed_resource(
2304            "build".to_string(),
2305            LockedResource {
2306                name: "build".to_string(),
2307                source: Some("community".to_string()),
2308                url: Some("https://github.com/example/community.git".to_string()),
2309                path: "commands/build.md".to_string(),
2310                version: Some("v1.0.0".to_string()),
2311                resolved_commit: Some("abc123".to_string()),
2312                checksum: "sha256:cmd123".to_string(),
2313                installed_at: ".claude/commands/build.md".to_string(),
2314                dependencies: vec![],
2315                resource_type: crate::core::ResourceType::Command,
2316
2317                tool: "claude-code".to_string(),
2318            },
2319            crate::core::ResourceType::Command,
2320        );
2321
2322        assert_eq!(lockfile.commands.len(), 1);
2323        assert!(lockfile.has_resource("build"));
2324
2325        let resource = lockfile.get_resource("build").unwrap();
2326        assert_eq!(resource.name, "build");
2327        assert_eq!(resource.installed_at, ".claude/commands/build.md");
2328    }
2329
2330    #[test]
2331    fn test_lockfile_all_resources_with_commands() {
2332        let mut lockfile = LockFile::new();
2333
2334        // Add resources of each type
2335        lockfile.add_resource(
2336            "agent1".to_string(),
2337            LockedResource {
2338                name: "agent1".to_string(),
2339                source: None,
2340                url: None,
2341                path: "agent1.md".to_string(),
2342                version: None,
2343                resolved_commit: None,
2344                checksum: "sha256:a1".to_string(),
2345                installed_at: "agents/agent1.md".to_string(),
2346                dependencies: vec![],
2347                resource_type: crate::core::ResourceType::Agent,
2348
2349                tool: "claude-code".to_string(),
2350            },
2351            true,
2352        );
2353
2354        lockfile.add_resource(
2355            "snippet1".to_string(),
2356            LockedResource {
2357                name: "snippet1".to_string(),
2358                source: None,
2359                url: None,
2360                path: "snippet1.md".to_string(),
2361                version: None,
2362                resolved_commit: None,
2363                checksum: "sha256:s1".to_string(),
2364                installed_at: "snippets/snippet1.md".to_string(),
2365                dependencies: vec![],
2366                resource_type: crate::core::ResourceType::Snippet,
2367
2368                tool: "claude-code".to_string(),
2369            },
2370            false,
2371        );
2372
2373        lockfile.add_typed_resource(
2374            "command1".to_string(),
2375            LockedResource {
2376                name: "command1".to_string(),
2377                source: None,
2378                url: None,
2379                path: "command1.md".to_string(),
2380                version: None,
2381                resolved_commit: None,
2382                checksum: "sha256:c1".to_string(),
2383                installed_at: ".claude/commands/command1.md".to_string(),
2384                dependencies: vec![],
2385                resource_type: crate::core::ResourceType::Command,
2386
2387                tool: "claude-code".to_string(),
2388            },
2389            crate::core::ResourceType::Command,
2390        );
2391
2392        let all = lockfile.all_resources();
2393        assert_eq!(all.len(), 3);
2394
2395        // Test clear includes commands
2396        lockfile.clear();
2397        assert!(lockfile.agents.is_empty());
2398        assert!(lockfile.snippets.is_empty());
2399        assert!(lockfile.commands.is_empty());
2400    }
2401
2402    #[test]
2403    fn test_lockfile_save_load_commands() {
2404        let temp = tempdir().unwrap();
2405        let lockfile_path = temp.path().join("agpm.lock");
2406
2407        let mut lockfile = LockFile::new();
2408
2409        // Add command
2410        lockfile.add_typed_resource(
2411            "deploy".to_string(),
2412            LockedResource {
2413                name: "deploy".to_string(),
2414                source: Some("official".to_string()),
2415                url: Some("https://github.com/example/official.git".to_string()),
2416                path: "commands/deploy.md".to_string(),
2417                version: Some("v2.0.0".to_string()),
2418                resolved_commit: Some("def456".to_string()),
2419                checksum: "sha256:deploy123".to_string(),
2420                installed_at: ".claude/commands/deploy.md".to_string(),
2421                dependencies: vec![],
2422                resource_type: crate::core::ResourceType::Command,
2423
2424                tool: "claude-code".to_string(),
2425            },
2426            crate::core::ResourceType::Command,
2427        );
2428
2429        // Save
2430        lockfile.save(&lockfile_path).unwrap();
2431
2432        // Load and verify
2433        let loaded = LockFile::load(&lockfile_path).unwrap();
2434        assert_eq!(loaded.commands.len(), 1);
2435        assert!(loaded.has_resource("deploy"));
2436
2437        let cmd = &loaded.commands[0];
2438        assert_eq!(cmd.name, "deploy");
2439        assert_eq!(cmd.version, Some("v2.0.0".to_string()));
2440        assert_eq!(cmd.installed_at, ".claude/commands/deploy.md");
2441    }
2442
2443    #[test]
2444    fn test_lockfile_get_resource_precedence() {
2445        let mut lockfile = LockFile::new();
2446
2447        // Add resources with same name but different types
2448        lockfile.add_resource(
2449            "helper".to_string(),
2450            LockedResource {
2451                name: "helper".to_string(),
2452                source: None,
2453                url: None,
2454                path: "agent_helper.md".to_string(),
2455                version: None,
2456                resolved_commit: None,
2457                checksum: "sha256:agent".to_string(),
2458                installed_at: "agents/helper.md".to_string(),
2459                dependencies: vec![],
2460                resource_type: crate::core::ResourceType::Agent,
2461
2462                tool: "claude-code".to_string(),
2463            },
2464            true,
2465        );
2466
2467        lockfile.add_typed_resource(
2468            "helper".to_string(),
2469            LockedResource {
2470                name: "helper".to_string(),
2471                source: None,
2472                url: None,
2473                path: "command_helper.md".to_string(),
2474                version: None,
2475                resolved_commit: None,
2476                checksum: "sha256:command".to_string(),
2477                installed_at: ".claude/commands/helper.md".to_string(),
2478                dependencies: vec![],
2479                resource_type: crate::core::ResourceType::Command,
2480
2481                tool: "claude-code".to_string(),
2482            },
2483            crate::core::ResourceType::Command,
2484        );
2485
2486        // get_resource should return agent (higher precedence)
2487        let resource = lockfile.get_resource("helper").unwrap();
2488        assert_eq!(resource.installed_at, "agents/helper.md");
2489    }
2490}