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::collections::HashMap;
295use std::fs;
296use std::path::{Path, PathBuf};
297
298use crate::utils::fs::atomic_write;
299
300/// Reasons why a lockfile might be considered stale.
301///
302/// This enum describes various conditions that indicate a lockfile is
303/// out-of-sync with the manifest and needs to be regenerated to prevent
304/// installation errors or inconsistencies.
305///
306/// # Display Format
307///
308/// Each variant implements `Display` to provide user-friendly error messages
309/// that explain the problem and suggest solutions.
310///
311/// # Examples
312///
313/// ```rust,no_run
314/// use agpm_cli::lockfile::StalenessReason;
315/// use agpm_cli::core::ResourceType;
316///
317/// let reason = StalenessReason::MissingDependency {
318///     name: "my-agent".to_string(),
319///     resource_type: ResourceType::Agent,
320/// };
321///
322/// println!("{}", reason);
323/// // Output: "Dependency 'my-agent' (agent) is in manifest but missing from lockfile"
324/// ```
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum StalenessReason {
327    /// A dependency is in the manifest but not in the lockfile.
328    /// This indicates the lockfile is incomplete and needs regeneration.
329    MissingDependency {
330        /// Name of the missing dependency
331        name: String,
332        /// Type of resource (agent, snippet, etc.)
333        resource_type: crate::core::ResourceType,
334    },
335
336    /// A dependency's version constraint has changed in the manifest.
337    VersionChanged {
338        /// Name of the dependency
339        name: String,
340        /// Type of resource (agent, snippet, etc.)
341        resource_type: crate::core::ResourceType,
342        /// Previous version from lockfile
343        old_version: String,
344        /// New version from manifest
345        new_version: String,
346    },
347
348    /// A dependency's path has changed in the manifest.
349    PathChanged {
350        /// Name of the dependency
351        name: String,
352        /// Type of resource (agent, snippet, etc.)
353        resource_type: crate::core::ResourceType,
354        /// Previous path from lockfile
355        old_path: String,
356        /// New path from manifest
357        new_path: String,
358    },
359
360    /// A source repository has a different URL in the manifest.
361    /// This is a security concern as it could point to a different repository.
362    SourceUrlChanged {
363        /// Name of the source repository
364        name: String,
365        /// Previous URL from lockfile
366        old_url: String,
367        /// New URL from manifest
368        new_url: String,
369    },
370
371    /// Multiple entries exist for the same dependency (lockfile corruption).
372    DuplicateEntries {
373        /// Name of the duplicated dependency
374        name: String,
375        /// Type of resource (agent, snippet, etc.)
376        resource_type: crate::core::ResourceType,
377        /// Number of duplicate entries found
378        count: usize,
379    },
380
381    /// A dependency's tool field has changed in the manifest.
382    ToolChanged {
383        /// Name of the dependency
384        name: String,
385        /// Type of resource (agent, snippet, etc.)
386        resource_type: crate::core::ResourceType,
387        /// Previous tool from lockfile
388        old_tool: String,
389        /// New tool from manifest (with defaults applied)
390        new_tool: String,
391    },
392}
393
394impl std::fmt::Display for StalenessReason {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Self::MissingDependency {
398                name,
399                resource_type,
400            } => {
401                write!(
402                    f,
403                    "Dependency '{name}' ({resource_type}) is in manifest but missing from lockfile"
404                )
405            }
406            Self::VersionChanged {
407                name,
408                resource_type,
409                old_version,
410                new_version,
411            } => {
412                write!(
413                    f,
414                    "Dependency '{name}' ({resource_type}) version changed from '{old_version}' to '{new_version}'"
415                )
416            }
417            Self::PathChanged {
418                name,
419                resource_type,
420                old_path,
421                new_path,
422            } => {
423                write!(
424                    f,
425                    "Dependency '{name}' ({resource_type}) path changed from '{old_path}' to '{new_path}'"
426                )
427            }
428            Self::SourceUrlChanged {
429                name,
430                old_url,
431                new_url,
432            } => {
433                write!(f, "Source repository '{name}' URL changed from '{old_url}' to '{new_url}'")
434            }
435            Self::DuplicateEntries {
436                name,
437                resource_type,
438                count,
439            } => {
440                write!(
441                    f,
442                    "Found {count} duplicate entries for dependency '{name}' ({resource_type})"
443                )
444            }
445            Self::ToolChanged {
446                name,
447                resource_type,
448                old_tool,
449                new_tool,
450            } => {
451                write!(
452                    f,
453                    "Dependency '{name}' ({resource_type}) tool changed from '{old_tool}' to '{new_tool}'"
454                )
455            }
456        }
457    }
458}
459
460impl std::error::Error for StalenessReason {}
461
462/// The main lockfile structure representing a complete `agpm.lock` file.
463///
464/// This structure contains all resolved dependencies, source repositories, and their
465/// exact versions/commits for reproducible installations. The lockfile is automatically
466/// generated from the [`crate::manifest::Manifest`] during installation and should not
467/// be edited manually.
468///
469/// # Format Version
470///
471/// The lockfile includes a format version to enable future migrations and compatibility
472/// checking. The current version is 1.
473///
474/// # Serialization
475///
476/// The lockfile serializes to TOML format with arrays of sources, agents, and snippets.
477/// Empty arrays are omitted from serialization to keep the lockfile clean.
478///
479/// # Examples
480///
481/// Creating a new lockfile:
482///
483/// ```rust,no_run
484/// use agpm_cli::lockfile::LockFile;
485///
486/// let lockfile = LockFile::new();
487/// assert_eq!(lockfile.version, 1);
488/// assert!(lockfile.sources.is_empty());
489/// ```
490///
491/// Loading an existing lockfile:
492///
493/// ```rust,no_run
494/// # use std::path::Path;
495/// # use agpm_cli::lockfile::LockFile;
496/// # fn example() -> anyhow::Result<()> {
497/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
498/// println!("Loaded {} sources, {} agents",
499///          lockfile.sources.len(), lockfile.agents.len());
500/// # Ok(())
501/// # }
502/// ```
503#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct LockFile {
505    /// Version of the lockfile format.
506    ///
507    /// This field enables forward and backward compatibility checking. AGPM will
508    /// refuse to load lockfiles with versions newer than it supports, and may
509    /// provide migration paths for older versions in the future.
510    pub version: u32,
511
512    /// Locked source repositories with their resolved commit hashes.
513    ///
514    /// Each entry represents a Git repository that has been fetched and resolved
515    /// to an exact commit. The commit hash ensures all team members get identical
516    /// source content even as the upstream repository evolves.
517    ///
518    /// This field is omitted from TOML serialization if empty to keep the lockfile clean.
519    #[serde(default, skip_serializing_if = "Vec::is_empty")]
520    pub sources: Vec<LockedSource>,
521
522    /// Locked agent resources with their exact versions and checksums.
523    ///
524    /// Contains all resolved agent dependencies from the manifest, with exact
525    /// commit hashes, installation paths, and SHA-256 checksums for integrity
526    /// verification.
527    ///
528    /// This field is omitted from TOML serialization if empty.
529    #[serde(default, skip_serializing_if = "Vec::is_empty")]
530    pub agents: Vec<LockedResource>,
531
532    /// Locked snippet resources with their exact versions and checksums.
533    ///
534    /// Contains all resolved snippet dependencies from the manifest, with exact
535    /// commit hashes, installation paths, and SHA-256 checksums for integrity
536    /// verification.
537    ///
538    /// This field is omitted from TOML serialization if empty.
539    #[serde(default, skip_serializing_if = "Vec::is_empty")]
540    pub snippets: Vec<LockedResource>,
541
542    /// Locked command resources with their exact versions and checksums.
543    ///
544    /// Contains all resolved command dependencies from the manifest, with exact
545    /// commit hashes, installation paths, and SHA-256 checksums for integrity
546    /// verification.
547    ///
548    /// This field is omitted from TOML serialization if empty.
549    #[serde(default, skip_serializing_if = "Vec::is_empty")]
550    pub commands: Vec<LockedResource>,
551
552    /// Locked MCP server resources with their exact versions and checksums.
553    ///
554    /// Contains all resolved MCP server dependencies from the manifest, with exact
555    /// commit hashes, installation paths, and SHA-256 checksums for integrity
556    /// verification. MCP servers are installed as JSON files and also configured
557    /// in `.claude/settings.local.json`.
558    ///
559    /// This field is omitted from TOML serialization if empty.
560    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
561    pub mcp_servers: Vec<LockedResource>,
562
563    /// Locked script resources with their exact versions and checksums.
564    ///
565    /// Contains all resolved script dependencies from the manifest, with exact
566    /// commit hashes, installation paths, and SHA-256 checksums for integrity
567    /// verification. Scripts are executable files that can be referenced by hooks.
568    ///
569    /// This field is omitted from TOML serialization if empty.
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub scripts: Vec<LockedResource>,
572
573    /// Locked hook configurations with their exact versions and checksums.
574    ///
575    /// Contains all resolved hook dependencies from the manifest. Hooks are
576    /// JSON configuration files that define event-based automation in Claude Code.
577    ///
578    /// This field is omitted from TOML serialization if empty.
579    #[serde(default, skip_serializing_if = "Vec::is_empty")]
580    pub hooks: Vec<LockedResource>,
581}
582
583/// A locked source repository with resolved commit information.
584///
585/// Represents a Git repository that has been fetched and resolved to an exact
586/// commit hash. This ensures reproducible access to source repositories across
587/// different environments and times.
588///
589/// # Fields
590///
591/// - **name**: Unique identifier used in the manifest to reference this source
592/// - **url**: Full Git repository URL (HTTP/HTTPS/SSH)
593/// - **commit**: 40-character SHA-1 commit hash resolved at time of lock
594/// - **`fetched_at`**: RFC 3339 timestamp of when the repository was last fetched
595///
596/// # Examples
597///
598/// A typical locked source in TOML format:
599///
600/// ```toml
601/// [[sources]]
602/// name = "community"
603/// url = "https://github.com/example/agpm-community.git"
604/// commit = "a1b2c3d4e5f6789abcdef0123456789abcdef012"
605/// fetched_at = "2024-01-15T10:30:00Z"
606/// ```
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct LockedSource {
609    /// Unique source name from the manifest.
610    ///
611    /// This corresponds to keys in the `[sources]` section of `agpm.toml`
612    /// and is used to reference the source in resource definitions.
613    pub name: String,
614
615    /// Full Git repository URL.
616    ///
617    /// Supports HTTP, HTTPS, and SSH URLs. This is the exact URL used
618    /// for cloning and fetching the repository.
619    pub url: String,
620
621    /// Timestamp of last successful fetch in RFC 3339 format.
622    ///
623    /// Records when the repository was last fetched from the remote.
624    /// This helps track staleness and debugging fetch issues.
625    pub fetched_at: String,
626}
627
628/// A locked resource (agent or snippet) with resolved version and integrity information.
629///
630/// Represents a specific resource file that has been resolved from either a source
631/// repository or local filesystem. Contains all information needed to verify the
632/// exact version and integrity of the installed resource.
633///
634/// # Local vs Remote Resources
635///
636/// Remote resources (from Git repositories) include:
637/// - `source`: Source repository name
638/// - `url`: Repository URL  
639/// - `version`: Original version constraint
640/// - `resolved_commit`: Exact commit containing the resource
641///
642/// Local resources (from filesystem) omit these fields since they don't
643/// involve Git repositories.
644///
645/// # Integrity Verification
646///
647/// All resources include a SHA-256 checksum for integrity verification.
648/// The checksum is calculated from the file content after installation
649/// and can be used to detect corruption or tampering.
650///
651/// # Examples
652///
653/// Remote resource in TOML format:
654///
655/// ```toml
656/// [[agents]]
657/// name = "example-agent"
658/// source = "community"
659/// url = "https://github.com/example/repo.git"
660/// path = "agents/example.md"
661/// version = "^1.0"
662/// resolved_commit = "a1b2c3d4e5f6..."
663/// checksum = "sha256:abcdef123456..."
664/// installed_at = "agents/example-agent.md"
665/// ```
666///
667/// Local resource in TOML format:
668///
669/// ```toml
670/// [[agents]]
671/// name = "local-helper"
672/// path = "../local/helper.md"
673/// checksum = "sha256:fedcba654321..."
674/// installed_at = "agents/local-helper.md"
675/// ```
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct LockedResource {
678    /// Resource name from the manifest.
679    ///
680    /// This corresponds to keys in the `[agents]` or `[snippets]` sections
681    /// of the manifest. Resources are uniquely identified by the combination
682    /// of (name, source), allowing multiple sources to provide resources with
683    /// the same name.
684    pub name: String,
685
686    /// Source repository name for remote resources.
687    ///
688    /// References a source defined in the `[sources]` section of the manifest.
689    /// This field is `None` for local resources that don't come from Git repositories.
690    ///
691    /// Omitted from TOML serialization when `None`.
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub source: Option<String>,
694
695    /// Source repository URL for remote resources.
696    ///
697    /// The full Git repository URL where this resource originates.
698    /// This field is `None` for local resources.
699    ///
700    /// Omitted from TOML serialization when `None`.
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub url: Option<String>,
703
704    /// Path to the resource file.
705    ///
706    /// For remote resources, this is the relative path within the source repository.
707    /// For local resources, this is the filesystem path (may be relative or absolute).
708    pub path: String,
709
710    /// Original version constraint from the manifest.
711    ///
712    /// This preserves the version constraint specified in `agpm.toml` (e.g., "^1.0", "v2.1.0").
713    /// For local resources or resources without version constraints, this field is `None`.
714    ///
715    /// Omitted from TOML serialization when `None`.
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub version: Option<String>,
718
719    /// Resolved Git commit hash for remote resources.
720    ///
721    /// The exact 40-character SHA-1 commit hash where this resource was found.
722    /// This ensures reproducible installations even if the version constraint
723    /// could match multiple commits. For local resources, this field is `None`.
724    ///
725    /// Omitted from TOML serialization when `None`.
726    #[serde(skip_serializing_if = "Option::is_none")]
727    pub resolved_commit: Option<String>,
728
729    /// SHA-256 checksum of the installed file content.
730    ///
731    /// Used for integrity verification to detect file corruption or tampering.
732    /// The format is "sha256:" followed by the hexadecimal hash.
733    ///
734    /// Example: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
735    pub checksum: String,
736
737    /// Installation path relative to the project root.
738    ///
739    /// Where the resource file is installed within the project directory.
740    /// This path is always relative to the project root and uses forward
741    /// slashes as separators for cross-platform compatibility.
742    ///
743    /// Examples: "agents/example-agent.md", "snippets/util-snippet.md"
744    pub installed_at: String,
745
746    /// Dependencies of this resource.
747    ///
748    /// Lists the direct dependencies that this resource requires, including
749    /// both manifest dependencies and transitive dependencies discovered from
750    /// the resource file itself. Each dependency is identified by its resource
751    /// type and name (e.g., "agents/helper-agent", "snippets/utils").
752    ///
753    /// This field enables dependency graph analysis and ensures all required
754    /// resources are installed. It follows the same model as Cargo.lock where
755    /// each package lists its dependencies.
756    ///
757    /// Always included in TOML serialization, even when empty, to match Cargo.lock format.
758    #[serde(default)]
759    pub dependencies: Vec<String>,
760
761    /// Resource type (agent, snippet, command, etc.)
762    ///
763    /// This field is populated during deserialization based on which TOML section
764    /// the resource came from (`[[agents]]`, `[[snippets]]`, etc.) and is used internally
765    /// for determining the correct lockfile section when adding/updating entries.
766    ///
767    /// It is never serialized to the lockfile - the section header provides this information.
768    #[serde(skip)]
769    pub resource_type: crate::core::ResourceType,
770
771    /// Tool type for multi-tool support (claude-code, opencode, agpm, custom).
772    ///
773    /// Specifies which target AI coding assistant tool this resource is for. This determines
774    /// where the resource is installed and how it's configured.
775    ///
776    /// When None during deserialization, will be set based on resource type's default
777    /// (e.g., snippets default to "agpm", others to "claude-code").
778    ///
779    /// Always serialized (even if Some) to avoid ambiguity.
780    #[serde(skip_serializing_if = "is_default_tool")]
781    pub tool: Option<String>,
782
783    /// Original manifest alias for pattern-expanded dependencies.
784    ///
785    /// When a pattern dependency (e.g., `agents/helpers/*.md` with alias "all-helpers")
786    /// expands to multiple files, each file gets its own lockfile entry with a unique `name`
787    /// (e.g., "helper-alpha", "helper-beta"). The `manifest_alias` field preserves the
788    /// original pattern alias so patches defined under that alias can be correctly applied
789    /// to all matched files.
790    ///
791    /// For non-pattern dependencies, this field is `None` since `name` already represents
792    /// the manifest alias.
793    ///
794    /// Example lockfile entry for pattern-expanded resource:
795    /// ```toml
796    /// [[agents]]
797    /// name = "helper-alpha"                    # Individual file name
798    /// manifest_alias = "all-helpers"           # Original pattern alias
799    /// path = "agents/helpers/helper-alpha.md"
800    /// ...
801    /// ```
802    ///
803    /// This enables pattern patching: all files matched by "all-helpers" pattern can
804    /// have patches applied via `[patch.agents.all-helpers]` in the manifest.
805    ///
806    /// Omitted from TOML serialization when `None` (for non-pattern dependencies).
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub manifest_alias: Option<String>,
809
810    /// Applied patches from manifest configuration.
811    ///
812    /// Contains the key-value pairs that were applied to this resource's metadata
813    /// via `[patch.<resource-type>.<alias>]` sections in agpm.toml or agpm.private.toml.
814    ///
815    /// This enables reproducible installations and provides visibility into which
816    /// resources have been patched.
817    ///
818    /// Omitted from TOML serialization when empty.
819    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
820    pub applied_patches: HashMap<String, toml::Value>,
821
822    /// Whether this dependency should be installed to disk.
823    ///
824    /// When `false`, the dependency is resolved, fetched, and tracked in the lockfile,
825    /// but the file is not written to the project directory. Instead, its content is
826    /// made available in template context via `agpm.deps.<type>.<name>.content`.
827    ///
828    /// This is useful for snippet embedding use cases where you want to include
829    /// content inline rather than as a separate file.
830    ///
831    /// Defaults to `true` (install the file) for backwards compatibility.
832    ///
833    /// Omitted from TOML serialization when `None` or `true`.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub install: Option<bool>,
836}
837
838fn is_default_tool(tool: &Option<String>) -> bool {
839    // Default tool is claude-code, so always skip serializing when it's Some("claude-code")
840    matches!(tool, Some(t) if t == "claude-code")
841}
842
843/// Convert lockfile to TOML string with inline tables for `applied_patches`.
844///
845/// Uses `toml_edit` to ensure `applied_patches` fields are serialized as inline tables:
846/// ```toml
847/// [[agents]]
848/// name = "example"
849/// applied_patches = { model = "haiku", temperature = "0.9" }
850/// ```
851///
852/// Instead of the confusing separate table format produced by standard TOML serialization:
853/// ```toml
854/// [[agents]]
855/// name = "example"
856///
857/// [agents.applied_patches]
858/// model = "haiku"
859/// ```
860fn serialize_lockfile_with_inline_patches<T: serde::Serialize>(lockfile: &T) -> Result<String> {
861    use toml_edit::{DocumentMut, Item};
862
863    // First serialize to a toml_edit document
864    let toml_str = toml::to_string_pretty(lockfile).context("Failed to serialize to TOML")?;
865    let mut doc: DocumentMut = toml_str.parse().context("Failed to parse TOML document")?;
866
867    // Convert all `applied_patches` tables to inline tables
868    let resource_types = ["agents", "snippets", "commands", "scripts", "hooks", "mcp-servers"];
869
870    for resource_type in &resource_types {
871        if let Some(Item::ArrayOfTables(array)) = doc.get_mut(resource_type) {
872            for table in array.iter_mut() {
873                if let Some(Item::Table(patches_table)) = table.get_mut("applied_patches") {
874                    // Convert to inline table
875                    let mut inline = toml_edit::InlineTable::new();
876                    for (key, val) in patches_table.iter() {
877                        if let Some(v) = val.as_value() {
878                            inline.insert(key, v.clone());
879                        }
880                    }
881                    table.insert("applied_patches", toml_edit::value(inline));
882                }
883            }
884        }
885    }
886
887    Ok(doc.to_string())
888}
889
890impl LockFile {
891    /// Current lockfile format version.
892    ///
893    /// This constant defines the lockfile format version that this version of AGPM
894    /// generates. It's used for compatibility checking when loading lockfiles that
895    /// may have been created by different versions of AGPM.
896    const CURRENT_VERSION: u32 = 1;
897
898    /// Create a new empty lockfile with the current format version.
899    ///
900    /// Returns a fresh lockfile with no sources or resources. This is typically
901    /// used when initializing a new project or regenerating a lockfile from scratch.
902    ///
903    /// # Examples
904    ///
905    /// ```rust,no_run
906    /// use agpm_cli::lockfile::LockFile;
907    ///
908    /// let lockfile = LockFile::new();
909    /// assert_eq!(lockfile.version, 1);
910    /// assert!(lockfile.sources.is_empty());
911    /// assert!(lockfile.agents.is_empty());
912    /// assert!(lockfile.snippets.is_empty());
913    /// ```
914    #[must_use]
915    pub const fn new() -> Self {
916        Self {
917            version: Self::CURRENT_VERSION,
918            sources: Vec::new(),
919            agents: Vec::new(),
920            snippets: Vec::new(),
921            commands: Vec::new(),
922            mcp_servers: Vec::new(),
923            scripts: Vec::new(),
924            hooks: Vec::new(),
925        }
926    }
927
928    /// Load a lockfile from disk with comprehensive error handling and validation.
929    ///
930    /// Attempts to load and parse a lockfile from the specified path. If the file
931    /// doesn't exist, returns a new empty lockfile. Performs format version
932    /// compatibility checking and provides detailed error messages for common issues.
933    ///
934    /// # Arguments
935    ///
936    /// * `path` - Path to the lockfile (typically "agpm.lock")
937    ///
938    /// # Returns
939    ///
940    /// * `Ok(LockFile)` - Successfully loaded lockfile or new empty lockfile if file doesn't exist
941    /// * `Err(anyhow::Error)` - Parse error, IO error, or version incompatibility
942    ///
943    /// # Error Handling
944    ///
945    /// This method provides detailed error messages for common issues:
946    /// - **File not found**: Returns empty lockfile (not an error)
947    /// - **Permission denied**: Suggests checking file ownership/permissions
948    /// - **TOML parse errors**: Suggests regenerating lockfile or checking syntax
949    /// - **Version incompatibility**: Suggests updating AGPM
950    /// - **Empty file**: Returns empty lockfile (graceful handling)
951    ///
952    /// # Examples
953    ///
954    /// ```rust,no_run
955    /// use std::path::Path;
956    /// use agpm_cli::lockfile::LockFile;
957    ///
958    /// # fn example() -> anyhow::Result<()> {
959    /// // Load existing lockfile
960    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
961    /// println!("Loaded {} sources", lockfile.sources.len());
962    ///
963    /// // Non-existent file returns empty lockfile
964    /// let empty = LockFile::load(Path::new("missing.lock"))?;
965    /// assert!(empty.sources.is_empty());
966    /// # Ok(())
967    /// # }
968    /// ```
969    ///
970    /// # Version Compatibility
971    ///
972    /// The method checks the lockfile format version and will refuse to load
973    /// lockfiles created by newer versions of AGPM:
974    ///
975    /// ```text
976    /// Error: Lockfile version 2 is newer than supported version 1.
977    /// This lockfile was created by a newer version of agpm.
978    /// Please update agpm to the latest version to use this lockfile.
979    /// ```
980    pub fn load(path: &Path) -> Result<Self> {
981        if !path.exists() {
982            return Ok(Self::new());
983        }
984
985        let content = fs::read_to_string(path).with_context(|| {
986            format!(
987                "Cannot read lockfile: {}\n\n\
988                    Possible causes:\n\
989                    - File doesn't exist (run 'agpm install' to create it)\n\
990                    - Permission denied (check file ownership)\n\
991                    - File is corrupted or locked by another process",
992                path.display()
993            )
994        })?;
995
996        // Handle empty file
997        if content.trim().is_empty() {
998            return Ok(Self::new());
999        }
1000
1001        let mut lockfile: Self = toml::from_str(&content)
1002            .map_err(|e| crate::core::AgpmError::LockfileParseError {
1003                file: path.display().to_string(),
1004                reason: e.to_string(),
1005            })
1006            .with_context(|| {
1007                format!(
1008                    "Invalid TOML syntax in lockfile: {}\n\n\
1009                    The lockfile may be corrupted. You can:\n\
1010                    - Delete agpm.lock and run 'agpm install' to regenerate it\n\
1011                    - Check for syntax errors if you manually edited the file\n\
1012                    - Restore from backup if available",
1013                    path.display()
1014                )
1015            })?;
1016
1017        // Set resource_type and apply tool defaults based on which section it's in
1018        for resource in &mut lockfile.agents {
1019            resource.resource_type = crate::core::ResourceType::Agent;
1020            if resource.tool.is_none() {
1021                resource.tool = Some(crate::core::ResourceType::Agent.default_tool().to_string());
1022            }
1023        }
1024        for resource in &mut lockfile.snippets {
1025            resource.resource_type = crate::core::ResourceType::Snippet;
1026            if resource.tool.is_none() {
1027                resource.tool = Some(crate::core::ResourceType::Snippet.default_tool().to_string());
1028            }
1029        }
1030        for resource in &mut lockfile.commands {
1031            resource.resource_type = crate::core::ResourceType::Command;
1032            if resource.tool.is_none() {
1033                resource.tool = Some(crate::core::ResourceType::Command.default_tool().to_string());
1034            }
1035        }
1036        for resource in &mut lockfile.scripts {
1037            resource.resource_type = crate::core::ResourceType::Script;
1038            if resource.tool.is_none() {
1039                resource.tool = Some(crate::core::ResourceType::Script.default_tool().to_string());
1040            }
1041        }
1042        for resource in &mut lockfile.hooks {
1043            resource.resource_type = crate::core::ResourceType::Hook;
1044            if resource.tool.is_none() {
1045                resource.tool = Some(crate::core::ResourceType::Hook.default_tool().to_string());
1046            }
1047        }
1048        for resource in &mut lockfile.mcp_servers {
1049            resource.resource_type = crate::core::ResourceType::McpServer;
1050            if resource.tool.is_none() {
1051                resource.tool =
1052                    Some(crate::core::ResourceType::McpServer.default_tool().to_string());
1053            }
1054        }
1055
1056        // Check version compatibility
1057        if lockfile.version > Self::CURRENT_VERSION {
1058            return Err(crate::core::AgpmError::Other {
1059                message: format!(
1060                    "Lockfile version {} is newer than supported version {}.\n\n\
1061                    This lockfile was created by a newer version of agpm.\n\
1062                    Please update agpm to the latest version to use this lockfile.",
1063                    lockfile.version,
1064                    Self::CURRENT_VERSION
1065                ),
1066            }
1067            .into());
1068        }
1069
1070        Ok(lockfile)
1071    }
1072
1073    /// Save the lockfile to disk with atomic write operations and custom formatting.
1074    ///
1075    /// Serializes the lockfile to TOML format and writes it atomically to prevent
1076    /// corruption. The output includes a header warning against manual editing and
1077    /// uses custom formatting for better readability compared to standard TOML
1078    /// serialization.
1079    ///
1080    /// # Arguments
1081    ///
1082    /// * `path` - Path where to save the lockfile (typically "agpm.lock")
1083    ///
1084    /// # Returns
1085    ///
1086    /// * `Ok(())` - Successfully saved lockfile
1087    /// * `Err(anyhow::Error)` - IO error, permission denied, or disk full
1088    ///
1089    /// # Atomic Write Behavior
1090    ///
1091    /// The save operation is atomic - the lockfile is written to a temporary file
1092    /// and then renamed to the target path. This ensures the lockfile is never
1093    /// left in a partially written state even if the process is interrupted.
1094    ///
1095    /// # Custom Formatting
1096    ///
1097    /// The method uses custom TOML formatting instead of standard serde serialization
1098    /// to produce more readable output:
1099    /// - Adds header comment warning against manual editing
1100    /// - Groups related fields together
1101    /// - Uses consistent indentation and spacing
1102    /// - Omits empty arrays to keep the file clean
1103    ///
1104    /// # Error Handling
1105    ///
1106    /// Provides detailed error messages for common issues:
1107    /// - **Permission denied**: Suggests running with elevated permissions
1108    /// - **Directory doesn't exist**: Suggests creating parent directories
1109    /// - **Disk full**: Suggests freeing space or using different location
1110    /// - **File locked**: Suggests closing other programs using the file
1111    ///
1112    /// # Examples
1113    ///
1114    /// ```rust,no_run
1115    /// use std::path::Path;
1116    /// use agpm_cli::lockfile::LockFile;
1117    ///
1118    /// # fn example() -> anyhow::Result<()> {
1119    /// let mut lockfile = LockFile::new();
1120    ///
1121    /// // Add a source
1122    /// lockfile.add_source(
1123    ///     "community".to_string(),
1124    ///     "https://github.com/example/repo.git".to_string(),
1125    ///     "a1b2c3d4e5f6...".to_string()
1126    /// );
1127    ///
1128    /// // Save to disk
1129    /// lockfile.save(Path::new("agpm.lock"))?;
1130    /// # Ok(())
1131    /// # }
1132    /// ```
1133    ///
1134    /// # Generated File Format
1135    ///
1136    /// The saved file starts with a warning header:
1137    ///
1138    /// ```toml
1139    /// # Auto-generated lockfile - DO NOT EDIT
1140    /// version = 1
1141    ///
1142    /// [[sources]]
1143    /// name = "community"
1144    /// url = "https://github.com/example/repo.git"
1145    /// commit = "a1b2c3d4e5f6..."
1146    /// fetched_at = "2024-01-15T10:30:00Z"
1147    /// ```
1148    pub fn save(&self, path: &Path) -> Result<()> {
1149        // Use toml_edit to ensure applied_patches are formatted as inline tables
1150        let mut content = String::from("# Auto-generated lockfile - DO NOT EDIT\n");
1151        let toml_content = serialize_lockfile_with_inline_patches(self)?;
1152        content.push_str(&toml_content);
1153
1154        atomic_write(path, content.as_bytes()).with_context(|| {
1155            format!(
1156                "Cannot write lockfile: {}\n\n\
1157                    Possible causes:\n\
1158                    - Permission denied (try running with elevated permissions)\n\
1159                    - Directory doesn't exist\n\
1160                    - Disk is full or read-only\n\
1161                    - File is locked by another process",
1162                path.display()
1163            )
1164        })?;
1165
1166        Ok(())
1167    }
1168
1169    /// Add or update a locked source repository with current timestamp.
1170    ///
1171    /// Adds a new source entry or updates an existing one with the same name.
1172    /// The `fetched_at` timestamp is automatically set to the current UTC time
1173    /// in RFC 3339 format.
1174    ///
1175    /// # Arguments
1176    ///
1177    /// * `name` - Unique source identifier (matches manifest `[sources]` keys)
1178    /// * `url` - Full Git repository URL
1179    /// * `commit` - Resolved 40-character commit hash
1180    ///
1181    /// # Behavior
1182    ///
1183    /// If a source with the same name already exists, it will be replaced with
1184    /// the new information. This ensures that each source name appears exactly
1185    /// once in the lockfile.
1186    ///
1187    /// # Examples
1188    ///
1189    /// ```rust,no_run
1190    /// use agpm_cli::lockfile::LockFile;
1191    ///
1192    /// let mut lockfile = LockFile::new();
1193    /// lockfile.add_source(
1194    ///     "community".to_string(),
1195    ///     "https://github.com/example/community.git".to_string(),
1196    ///     "a1b2c3d4e5f6789abcdef0123456789abcdef012".to_string()
1197    /// );
1198    ///
1199    /// assert_eq!(lockfile.sources.len(), 1);
1200    /// assert_eq!(lockfile.sources[0].name, "community");
1201    /// ```
1202    ///
1203    /// # Time Zone
1204    ///
1205    /// The `fetched_at` timestamp is always recorded in UTC to ensure consistency
1206    /// across different time zones and systems.
1207    pub fn add_source(&mut self, name: String, url: String, _commit: String) {
1208        // Remove existing entry if present
1209        self.sources.retain(|s| s.name != name);
1210
1211        self.sources.push(LockedSource {
1212            name,
1213            url,
1214            fetched_at: chrono::Utc::now().to_rfc3339(),
1215        });
1216    }
1217
1218    /// Add or update a locked resource (agent or snippet).
1219    ///
1220    /// Adds a new resource entry or updates an existing one with the same name
1221    /// within the appropriate resource type (agents or snippets).
1222    ///
1223    /// **Note**: This method is kept for backward compatibility but only supports
1224    /// agents and snippets. Use `add_typed_resource` to support all resource types
1225    /// including commands.
1226    ///
1227    /// # Arguments
1228    ///
1229    /// * `name` - Unique resource identifier within its type
1230    /// * `resource` - Complete [`LockedResource`] with all resolved information
1231    /// * `is_agent` - `true` for agents, `false` for snippets
1232    ///
1233    /// # Behavior
1234    ///
1235    /// If a resource with the same name already exists in the same type category,
1236    /// it will be replaced. Resources are categorized separately (agents vs snippets),
1237    /// so an agent named "helper" and a snippet named "helper" can coexist.
1238    ///
1239    /// # Examples
1240    ///
1241    /// Adding an agent:
1242    ///
1243    /// ```rust,no_run
1244    /// use agpm_cli::lockfile::{LockFile, LockedResource};
1245    /// use agpm_cli::core::ResourceType;
1246    ///
1247    /// let mut lockfile = LockFile::new();
1248    /// let resource = LockedResource {
1249    ///     name: "example-agent".to_string(),
1250    ///     source: Some("community".to_string()),
1251    ///     url: Some("https://github.com/example/repo.git".to_string()),
1252    ///     path: "agents/example.md".to_string(),
1253    ///     version: Some("^1.0".to_string()),
1254    ///     resolved_commit: Some("a1b2c3d...".to_string()),
1255    ///     checksum: "sha256:abcdef...".to_string(),
1256    ///     installed_at: "agents/example-agent.md".to_string(),
1257    ///     dependencies: vec![],
1258    ///     resource_type: ResourceType::Agent,
1259    ///     tool: Some("claude-code".to_string()),
1260    ///     manifest_alias: None,
1261    ///     applied_patches: std::collections::HashMap::new(),
1262    ///     install: None,
1263    /// };
1264    ///
1265    /// lockfile.add_resource("example-agent".to_string(), resource, true);
1266    /// assert_eq!(lockfile.agents.len(), 1);
1267    /// ```
1268    ///
1269    /// Adding a snippet:
1270    ///
1271    /// ```rust,no_run
1272    /// # use agpm_cli::lockfile::{LockFile, LockedResource};
1273    /// # use agpm_cli::core::ResourceType;
1274    /// # let mut lockfile = LockFile::new();
1275    /// let snippet = LockedResource {
1276    ///     name: "util-snippet".to_string(),
1277    ///     source: None,  // Local resource
1278    ///     url: None,
1279    ///     path: "../local/utils.md".to_string(),
1280    ///     version: None,
1281    ///     resolved_commit: None,
1282    ///     checksum: "sha256:fedcba...".to_string(),
1283    ///     installed_at: "snippets/util-snippet.md".to_string(),
1284    ///     dependencies: vec![],
1285    ///     resource_type: ResourceType::Snippet,
1286    ///     tool: Some("claude-code".to_string()),
1287    ///     manifest_alias: None,
1288    ///     applied_patches: std::collections::HashMap::new(),
1289    ///     install: None,
1290    /// };
1291    ///
1292    /// lockfile.add_resource("util-snippet".to_string(), snippet, false);
1293    /// assert_eq!(lockfile.snippets.len(), 1);
1294    /// ```
1295    pub fn add_resource(&mut self, name: String, resource: LockedResource, is_agent: bool) {
1296        let resources = if is_agent {
1297            &mut self.agents
1298        } else {
1299            &mut self.snippets
1300        };
1301
1302        // Remove existing entry if present
1303        resources.retain(|r| r.name != name);
1304        resources.push(resource);
1305    }
1306
1307    /// Add or update a locked resource with specific resource type.
1308    ///
1309    /// This is the preferred method for adding resources as it explicitly
1310    /// supports all resource types including commands.
1311    ///
1312    /// # Arguments
1313    ///
1314    /// * `name` - Unique resource identifier within its type
1315    /// * `resource` - Complete [`LockedResource`] with all resolved information
1316    /// * `resource_type` - The type of resource (Agent, Snippet, or Command)
1317    ///
1318    /// # Examples
1319    ///
1320    /// ```rust,no_run
1321    /// use agpm_cli::lockfile::{LockFile, LockedResource};
1322    /// use agpm_cli::core::ResourceType;
1323    ///
1324    /// let mut lockfile = LockFile::new();
1325    /// let command = LockedResource {
1326    ///     name: "build-command".to_string(),
1327    ///     source: Some("community".to_string()),
1328    ///     url: Some("https://github.com/example/repo.git".to_string()),
1329    ///     path: "commands/build.md".to_string(),
1330    ///     version: Some("v1.0.0".to_string()),
1331    ///     resolved_commit: Some("a1b2c3d...".to_string()),
1332    ///     checksum: "sha256:abcdef...".to_string(),
1333    ///     installed_at: ".claude/commands/build-command.md".to_string(),
1334    ///     dependencies: vec![],
1335    ///     resource_type: ResourceType::Command,
1336    ///     tool: Some("claude-code".to_string()),
1337    ///     manifest_alias: None,
1338    ///     applied_patches: std::collections::HashMap::new(),
1339    ///     install: None,
1340    /// };
1341    ///
1342    /// lockfile.add_typed_resource("build-command".to_string(), command, ResourceType::Command);
1343    /// assert_eq!(lockfile.commands.len(), 1);
1344    /// ```
1345    pub fn add_typed_resource(
1346        &mut self,
1347        name: String,
1348        resource: LockedResource,
1349        resource_type: crate::core::ResourceType,
1350    ) {
1351        let resources = match resource_type {
1352            crate::core::ResourceType::Agent => &mut self.agents,
1353            crate::core::ResourceType::Snippet => &mut self.snippets,
1354            crate::core::ResourceType::Command => &mut self.commands,
1355            crate::core::ResourceType::McpServer => {
1356                // MCP servers are handled differently - they don't use LockedResource
1357                // This shouldn't be called for MCP servers
1358                return;
1359            }
1360            crate::core::ResourceType::Script => &mut self.scripts,
1361            crate::core::ResourceType::Hook => &mut self.hooks,
1362        };
1363
1364        // Remove existing entry if present
1365        resources.retain(|r| r.name != name);
1366        resources.push(resource);
1367    }
1368
1369    /// Get a locked resource by name, searching across all resource types.
1370    ///
1371    /// Searches for a resource with the given name in the agents, snippets, commands,
1372    /// scripts, hooks, and mcp-servers collections. This method returns the first match found,
1373    /// which is suitable when resource names are unique or when the source doesn't matter.
1374    ///
1375    /// **Note**: When multiple resources have the same name from different sources (common with
1376    /// transitive dependencies), this method returns the first match based on search order.
1377    /// For precise lookups that distinguish between sources, use [`Self::get_resource_by_source`].
1378    ///
1379    /// # Arguments
1380    ///
1381    /// * `name` - Resource name to search for
1382    ///
1383    /// # Returns
1384    ///
1385    /// * `Some(&LockedResource)` - Reference to the first matching resource
1386    /// * `None` - No resource with that name exists
1387    ///
1388    /// # Examples
1389    ///
1390    /// ```rust,no_run
1391    /// # use agpm_cli::lockfile::LockFile;
1392    /// # let lockfile = LockFile::new();
1393    /// // Simple lookup when resource names are unique
1394    /// if let Some(resource) = lockfile.get_resource("example-agent") {
1395    ///     println!("Found resource: {}", resource.installed_at);
1396    /// } else {
1397    ///     println!("Resource not found");
1398    /// }
1399    /// ```
1400    ///
1401    /// # Search Order
1402    ///
1403    /// The method searches in order: agents, snippets, commands, scripts, hooks, mcp-servers.
1404    /// If multiple resource types or sources have the same name, the first match will be returned.
1405    ///
1406    /// # See Also
1407    ///
1408    /// * [`get_resource_by_source`](Self::get_resource_by_source) - Precise lookup with source filtering for handling same-named resources from different sources
1409    #[must_use]
1410    pub fn get_resource(&self, name: &str) -> Option<&LockedResource> {
1411        // Simple name matching - may return first of multiple resources with same name
1412        // For precise matching when duplicates exist, use get_resource_by_source()
1413        self.agents
1414            .iter()
1415            .find(|r| r.name == name)
1416            .or_else(|| self.snippets.iter().find(|r| r.name == name))
1417            .or_else(|| self.commands.iter().find(|r| r.name == name))
1418            .or_else(|| self.scripts.iter().find(|r| r.name == name))
1419            .or_else(|| self.hooks.iter().find(|r| r.name == name))
1420            .or_else(|| self.mcp_servers.iter().find(|r| r.name == name))
1421    }
1422
1423    /// Get a locked resource by name and source.
1424    ///
1425    /// This method provides precise resource lookup when multiple resources share the same name
1426    /// but come from different sources. This commonly occurs with transitive dependencies where
1427    /// different dependency chains pull in the same resource name from different repositories.
1428    ///
1429    /// # Arguments
1430    ///
1431    /// * `name` - Resource name to search for
1432    /// * `source` - Optional source name to match (None matches resources without a source, e.g., local resources)
1433    ///
1434    /// # Returns
1435    ///
1436    /// First matching resource with the specified name and source, or None if not found.
1437    ///
1438    /// # Examples
1439    ///
1440    /// ```rust,no_run
1441    /// # use agpm_cli::lockfile::LockFile;
1442    /// # let lockfile = LockFile::new();
1443    /// // When multiple resources have the same name from different sources
1444    /// if let Some(resource) = lockfile.get_resource_by_source("helper", Some("community")) {
1445    ///     println!("Found helper from community source: {}", resource.installed_at);
1446    /// }
1447    ///
1448    /// if let Some(resource) = lockfile.get_resource_by_source("helper", Some("internal")) {
1449    ///     println!("Found helper from internal source: {}", resource.installed_at);
1450    /// }
1451    ///
1452    /// // Match local resources (no source)
1453    /// if let Some(resource) = lockfile.get_resource_by_source("local-helper", None) {
1454    ///     println!("Found local resource: {}", resource.installed_at);
1455    /// }
1456    /// ```
1457    ///
1458    /// # Search Order
1459    ///
1460    /// The method searches in order: agents, snippets, commands, scripts, hooks, mcp-servers.
1461    /// Only resources matching both the name AND source are returned.
1462    ///
1463    /// # See Also
1464    ///
1465    /// * [`get_resource`](Self::get_resource) - Simple name-based lookup without source filtering
1466    #[must_use]
1467    pub fn get_resource_by_source(
1468        &self,
1469        name: &str,
1470        source: Option<&str>,
1471    ) -> Option<&LockedResource> {
1472        let matches = |r: &&LockedResource| r.name == name && r.source.as_deref() == source;
1473
1474        self.agents
1475            .iter()
1476            .find(matches)
1477            .or_else(|| self.snippets.iter().find(matches))
1478            .or_else(|| self.commands.iter().find(matches))
1479            .or_else(|| self.scripts.iter().find(matches))
1480            .or_else(|| self.hooks.iter().find(matches))
1481            .or_else(|| self.mcp_servers.iter().find(matches))
1482    }
1483
1484    /// Get a locked source repository by name.
1485    ///
1486    /// Searches for a source repository with the given name in the sources collection.
1487    ///
1488    /// # Arguments
1489    ///
1490    /// * `name` - Source name to search for (matches manifest `[sources]` keys)
1491    ///
1492    /// # Returns
1493    ///
1494    /// * `Some(&LockedSource)` - Reference to the found source
1495    /// * `None` - No source with that name exists
1496    ///
1497    /// # Examples
1498    ///
1499    /// ```rust,no_run
1500    /// # use agpm_cli::lockfile::LockFile;
1501    /// # let lockfile = LockFile::new();
1502    /// if let Some(source) = lockfile.get_source("community") {
1503    ///     println!("Source URL: {}", source.url);
1504    ///     println!("Fetched at: {}", source.fetched_at);
1505    /// }
1506    /// ```
1507    #[must_use]
1508    pub fn get_source(&self, name: &str) -> Option<&LockedSource> {
1509        self.sources.iter().find(|s| s.name == name)
1510    }
1511
1512    /// Check if a resource is locked in the lockfile.
1513    ///
1514    /// Convenience method that checks whether a resource with the given name
1515    /// exists in either the agents or snippets collections.
1516    ///
1517    /// # Arguments
1518    ///
1519    /// * `name` - Resource name to check
1520    ///
1521    /// # Returns
1522    ///
1523    /// * `true` - Resource exists in the lockfile
1524    /// * `false` - Resource does not exist
1525    ///
1526    /// # Examples
1527    ///
1528    /// ```rust,no_run
1529    /// # use agpm_cli::lockfile::LockFile;
1530    /// # let lockfile = LockFile::new();
1531    /// if lockfile.has_resource("example-agent") {
1532    ///     println!("Agent is already locked");
1533    /// } else {
1534    ///     println!("Agent needs to be resolved and installed");
1535    /// }
1536    /// ```
1537    ///
1538    /// This is equivalent to calling `lockfile.get_resource(name).is_some()`.
1539    #[must_use]
1540    pub fn has_resource(&self, name: &str) -> bool {
1541        self.get_resource(name).is_some()
1542    }
1543
1544    /// Get all locked resources as a combined vector.
1545    ///
1546    /// Returns references to all resources (agents, snippets, and commands) in a single
1547    /// vector for easy iteration. The order is agents first, then snippets, then commands.
1548    ///
1549    /// # Returns
1550    ///
1551    /// Vector of references to all locked resources, preserving the order within
1552    /// each type as they appear in the lockfile.
1553    ///
1554    /// # Examples
1555    ///
1556    /// ```rust,no_run
1557    /// # use agpm_cli::lockfile::LockFile;
1558    /// # let lockfile = LockFile::new();
1559    /// let all_resources = lockfile.all_resources();
1560    /// println!("Total locked resources: {}", all_resources.len());
1561    ///
1562    /// for resource in all_resources {
1563    ///     println!("- {}: {}", resource.name, resource.installed_at);
1564    /// }
1565    /// ```
1566    ///
1567    /// # Use Cases
1568    ///
1569    /// - Generating reports of all installed resources
1570    /// - Validating checksums across all resources
1571    /// - Listing resources for user display
1572    /// - Bulk operations on all resources
1573    ///   Get locked resources for a specific resource type
1574    ///
1575    ///
1576    /// Returns a slice of locked resources for the specified type.
1577    pub fn get_resources(&self, resource_type: crate::core::ResourceType) -> &[LockedResource] {
1578        use crate::core::ResourceType;
1579        match resource_type {
1580            ResourceType::Agent => &self.agents,
1581            ResourceType::Snippet => &self.snippets,
1582            ResourceType::Command => &self.commands,
1583            ResourceType::Script => &self.scripts,
1584            ResourceType::Hook => &self.hooks,
1585            ResourceType::McpServer => &self.mcp_servers,
1586        }
1587    }
1588
1589    /// Get mutable locked resources for a specific resource type
1590    ///
1591    /// Returns a mutable slice of locked resources for the specified type.
1592    pub const fn get_resources_mut(
1593        &mut self,
1594        resource_type: crate::core::ResourceType,
1595    ) -> &mut Vec<LockedResource> {
1596        use crate::core::ResourceType;
1597        match resource_type {
1598            ResourceType::Agent => &mut self.agents,
1599            ResourceType::Snippet => &mut self.snippets,
1600            ResourceType::Command => &mut self.commands,
1601            ResourceType::Script => &mut self.scripts,
1602            ResourceType::Hook => &mut self.hooks,
1603            ResourceType::McpServer => &mut self.mcp_servers,
1604        }
1605    }
1606
1607    /// Returns all locked resources across all resource types.
1608    ///
1609    /// This method collects all resources from agents, snippets, commands,
1610    /// scripts, hooks, and MCP servers into a single vector. It's useful for
1611    /// operations that need to process all resources uniformly, such as:
1612    /// - Generating installation reports
1613    /// - Validating checksums across all resources
1614    /// - Bulk operations on resources
1615    ///
1616    /// # Returns
1617    ///
1618    /// A vector containing references to all [`LockedResource`] entries in the lockfile.
1619    /// The order matches the resource type order defined in [`crate::core::ResourceType::all()`].
1620    ///
1621    /// # Examples
1622    ///
1623    /// ```rust,no_run
1624    /// # use agpm_cli::lockfile::LockFile;
1625    /// # let lockfile = LockFile::new();
1626    /// let all_resources = lockfile.all_resources();
1627    /// println!("Total locked resources: {}", all_resources.len());
1628    ///
1629    /// for resource in all_resources {
1630    ///     println!("- {}: {}", resource.name, resource.installed_at);
1631    /// }
1632    /// ```
1633    #[must_use]
1634    pub fn all_resources(&self) -> Vec<&LockedResource> {
1635        let mut resources = Vec::new();
1636
1637        // Use ResourceType::all() to iterate through all resource types
1638        for resource_type in crate::core::ResourceType::all() {
1639            resources.extend(self.get_resources(*resource_type));
1640        }
1641
1642        resources
1643    }
1644
1645    /// Clear all locked entries from the lockfile.
1646    ///
1647    /// Removes all sources, agents, snippets, and commands from the lockfile, returning
1648    /// it to an empty state. The format version remains unchanged.
1649    ///
1650    /// # Examples
1651    ///
1652    /// ```rust,no_run
1653    /// # use agpm_cli::lockfile::LockFile;
1654    /// let mut lockfile = LockFile::new();
1655    /// // ... add sources and resources ...
1656    ///
1657    /// lockfile.clear();
1658    /// assert!(lockfile.sources.is_empty());
1659    /// assert!(lockfile.agents.is_empty());
1660    /// assert!(lockfile.snippets.is_empty());
1661    /// ```
1662    ///
1663    /// # Use Cases
1664    ///
1665    /// - Preparing for complete lockfile regeneration
1666    /// - Implementing `agpm clean` functionality
1667    /// - Resetting lockfile state during testing
1668    /// - Handling lockfile corruption recovery
1669    pub fn clear(&mut self) {
1670        self.sources.clear();
1671
1672        // Use ResourceType::all() to clear all resource types
1673        for resource_type in crate::core::ResourceType::all() {
1674            self.get_resources_mut(*resource_type).clear();
1675        }
1676    }
1677
1678    /// Compute SHA-256 checksum for a file with integrity verification.
1679    ///
1680    /// Calculates the SHA-256 hash of a file's content for integrity verification.
1681    /// The checksum is used to detect file corruption, tampering, or changes after
1682    /// installation.
1683    ///
1684    /// # Arguments
1685    ///
1686    /// * `path` - Path to the file to checksum
1687    ///
1688    /// # Returns
1689    ///
1690    /// * `Ok(String)` - Checksum in format "`sha256:hexadecimal_hash`"
1691    /// * `Err(anyhow::Error)` - File read error with detailed context
1692    ///
1693    /// # Checksum Format
1694    ///
1695    /// The returned checksum follows the format:
1696    /// - **Algorithm prefix**: "sha256:"
1697    /// - **Hash encoding**: Lowercase hexadecimal
1698    /// - **Length**: 71 characters total (7 for prefix + 64 hex digits)
1699    ///
1700    /// # Examples
1701    ///
1702    /// ```rust,no_run
1703    /// use std::path::Path;
1704    /// use agpm_cli::lockfile::LockFile;
1705    ///
1706    /// # fn example() -> anyhow::Result<()> {
1707    /// let checksum = LockFile::compute_checksum(Path::new("example.md"))?;
1708    /// println!("File checksum: {}", checksum);
1709    /// // Output: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
1710    /// # Ok(())
1711    /// # }
1712    /// ```
1713    ///
1714    /// # Error Handling
1715    ///
1716    /// Provides detailed error context for common issues:
1717    /// - **File not found**: Suggests checking the path
1718    /// - **Permission denied**: Suggests checking file permissions
1719    /// - **IO errors**: Suggests checking disk health or file locks
1720    ///
1721    /// # Security Considerations
1722    ///
1723    /// - Uses SHA-256, a cryptographically secure hash function
1724    /// - Suitable for integrity verification and tamper detection
1725    /// - Consistent across platforms (Windows, macOS, Linux)
1726    /// - Not affected by line ending differences (hashes actual bytes)
1727    ///
1728    /// # Performance
1729    ///
1730    /// The method reads the entire file into memory before hashing.
1731    /// For very large files (>100MB), consider streaming implementations
1732    /// in future versions.
1733    pub fn compute_checksum(path: &Path) -> Result<String> {
1734        use sha2::{Digest, Sha256};
1735
1736        let content = fs::read(path).with_context(|| {
1737            format!(
1738                "Cannot read file for checksum calculation: {}\n\n\
1739                    This error occurs when verifying file integrity.\n\
1740                    Check that the file exists and is readable.",
1741                path.display()
1742            )
1743        })?;
1744
1745        let mut hasher = Sha256::new();
1746        hasher.update(&content);
1747        let result = hasher.finalize();
1748
1749        Ok(format!("sha256:{}", hex::encode(result)))
1750    }
1751
1752    /// Verify that a file matches its expected checksum.
1753    ///
1754    /// Computes the current checksum of a file and compares it against the
1755    /// expected checksum. Used to verify file integrity and detect corruption
1756    /// or tampering after installation.
1757    ///
1758    /// # Arguments
1759    ///
1760    /// * `path` - Path to the file to verify
1761    /// * `expected` - Expected checksum in "sha256:hex" format
1762    ///
1763    /// # Returns
1764    ///
1765    /// * `Ok(true)` - File checksum matches expected value
1766    /// * `Ok(false)` - File checksum does not match (corruption detected)
1767    /// * `Err(anyhow::Error)` - File read error or checksum calculation failed
1768    ///
1769    /// # Examples
1770    ///
1771    /// ```rust,no_run
1772    /// use std::path::Path;
1773    /// use agpm_cli::lockfile::LockFile;
1774    ///
1775    /// # fn example() -> anyhow::Result<()> {
1776    /// let expected = "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3";
1777    /// let is_valid = LockFile::verify_checksum(Path::new("example.md"), expected)?;
1778    ///
1779    /// if is_valid {
1780    ///     println!("File integrity verified");
1781    /// } else {
1782    ///     println!("WARNING: File has been modified or corrupted!");
1783    /// }
1784    /// # Ok(())
1785    /// # }
1786    /// ```
1787    ///
1788    /// # Use Cases
1789    ///
1790    /// - **Installation verification**: Ensure copied files are intact
1791    /// - **Periodic validation**: Detect file corruption over time
1792    /// - **Security checks**: Detect unauthorized modifications
1793    /// - **Troubleshooting**: Diagnose installation issues
1794    ///
1795    /// # Performance
1796    ///
1797    /// This method internally calls [`compute_checksum`](Self::compute_checksum),
1798    /// so it has the same performance characteristics. For bulk verification
1799    /// operations, consider caching computed checksums.
1800    ///
1801    /// # Security
1802    ///
1803    /// The comparison is performed using standard string equality, which is
1804    /// not timing-attack resistant. Since checksums are not secrets, this
1805    /// is acceptable for integrity verification purposes.
1806    pub fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
1807        let actual = Self::compute_checksum(path)?;
1808        Ok(actual == expected)
1809    }
1810
1811    /// Validate the lockfile against a manifest to detect staleness.
1812    ///
1813    /// Checks if the lockfile is consistent with the current manifest and detects
1814    /// common staleness indicators that require lockfile regeneration. Performs
1815    /// comprehensive validation similar to Cargo's `--locked` mode.
1816    ///
1817    /// # Arguments
1818    ///
1819    /// * `manifest` - The current project manifest to validate against
1820    /// * `strict` - If true, check version/path changes; if false, only check corruption and security
1821    ///
1822    /// # Returns
1823    ///
1824    /// * `Ok(None)` - Lockfile is valid and up-to-date
1825    /// * `Ok(Some(StalenessReason))` - Lockfile is stale and needs regeneration
1826    /// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
1827    ///
1828    /// # Examples
1829    ///
1830    /// ```rust,no_run
1831    /// # use std::path::Path;
1832    /// # use agpm_cli::lockfile::LockFile;
1833    /// # use agpm_cli::manifest::Manifest;
1834    /// # fn example() -> anyhow::Result<()> {
1835    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
1836    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1837    ///
1838    /// // Strict mode: check everything including version/path changes
1839    /// match lockfile.validate_against_manifest(&manifest, true)? {
1840    ///     None => println!("Lockfile is valid"),
1841    ///     Some(reason) => {
1842    ///         eprintln!("Lockfile is stale: {}", reason);
1843    ///         eprintln!("Run 'agpm install' to auto-update it");
1844    ///     }
1845    /// }
1846    ///
1847    /// // Lenient mode: only check corruption and security (for --frozen)
1848    /// match lockfile.validate_against_manifest(&manifest, false)? {
1849    ///     None => println!("Lockfile has no critical issues"),
1850    ///     Some(reason) => eprintln!("Critical issue: {}", reason),
1851    /// }
1852    /// # Ok(())
1853    /// # }
1854    /// ```
1855    ///
1856    /// # Staleness Detection
1857    ///
1858    /// The method checks for several staleness indicators:
1859    /// - **Duplicate entries**: Multiple entries for the same dependency (corruption) - always checked
1860    /// - **Source URL changes**: Source URLs changed in manifest (security concern) - always checked
1861    /// - **Missing dependencies**: Manifest has deps not in lockfile - only in strict mode
1862    /// - **Version changes**: Same dependency with different version constraint - only in strict mode
1863    /// - **Path changes**: Same dependency with different source path - only in strict mode
1864    ///
1865    /// Note: Extra lockfile entries are allowed (for transitive dependencies).
1866    pub fn validate_against_manifest(
1867        &self,
1868        manifest: &crate::manifest::Manifest,
1869        strict: bool,
1870    ) -> Result<Option<StalenessReason>> {
1871        // Always check for critical issues:
1872        // 1. Corruption (duplicate entries)
1873        // 2. Security concerns (source URL changes)
1874
1875        // Check for duplicate entries within the lockfile (corruption)
1876        if let Some(reason) = self.detect_duplicate_entries()? {
1877            return Ok(Some(reason));
1878        }
1879
1880        // Check source URL changes (security concern - different repository)
1881        for (source_name, manifest_url) in &manifest.sources {
1882            if let Some(locked_source) = self.get_source(source_name)
1883                && &locked_source.url != manifest_url
1884            {
1885                return Ok(Some(StalenessReason::SourceUrlChanged {
1886                    name: source_name.clone(),
1887                    old_url: locked_source.url.clone(),
1888                    new_url: manifest_url.clone(),
1889                }));
1890            }
1891        }
1892
1893        // In strict mode, also check for missing dependencies, version changes, and path changes
1894        if strict {
1895            for resource_type in crate::core::ResourceType::all() {
1896                if let Some(manifest_deps) = manifest.get_dependencies(*resource_type) {
1897                    for (name, dep) in manifest_deps {
1898                        // Find matching resource in lockfile
1899                        let locked_resource = self.get_resource(name);
1900
1901                        if locked_resource.is_none() {
1902                            // Dependency is in manifest but not in lockfile
1903                            return Ok(Some(StalenessReason::MissingDependency {
1904                                name: name.clone(),
1905                                resource_type: *resource_type,
1906                            }));
1907                        }
1908
1909                        // Check for version changes
1910                        if let Some(locked) = locked_resource {
1911                            if let Some(manifest_version) = dep.get_version()
1912                                && let Some(locked_version) = &locked.version
1913                                && manifest_version != locked_version
1914                            {
1915                                return Ok(Some(StalenessReason::VersionChanged {
1916                                    name: name.clone(),
1917                                    resource_type: *resource_type,
1918                                    old_version: locked_version.clone(),
1919                                    new_version: manifest_version.to_string(),
1920                                }));
1921                            }
1922
1923                            // Check for path changes
1924                            if dep.get_path() != locked.path {
1925                                return Ok(Some(StalenessReason::PathChanged {
1926                                    name: name.clone(),
1927                                    resource_type: *resource_type,
1928                                    old_path: locked.path.clone(),
1929                                    new_path: dep.get_path().to_string(),
1930                                }));
1931                            }
1932
1933                            // Check for tool changes (apply defaults if not specified)
1934                            let manifest_tool_string = dep
1935                                .get_tool()
1936                                .map(|s| s.to_string())
1937                                .unwrap_or_else(|| manifest.get_default_tool(*resource_type));
1938                            let manifest_tool = manifest_tool_string.as_str();
1939                            let locked_tool = locked.tool.as_deref().unwrap_or("claude-code");
1940                            if manifest_tool != locked_tool {
1941                                return Ok(Some(StalenessReason::ToolChanged {
1942                                    name: name.clone(),
1943                                    resource_type: *resource_type,
1944                                    old_tool: locked_tool.to_string(),
1945                                    new_tool: manifest_tool.to_string(),
1946                                }));
1947                            }
1948                        }
1949                    }
1950                }
1951            }
1952        }
1953
1954        // Extra lockfile entries are allowed (for transitive dependencies)
1955        Ok(None)
1956    }
1957
1958    /// Check if the lockfile is stale relative to the manifest.
1959    ///
1960    /// This is a convenience method that returns a simple boolean instead of
1961    /// the detailed `StalenessReason`. Useful for quick staleness checks.
1962    ///
1963    /// # Arguments
1964    ///
1965    /// * `manifest` - The current project manifest to validate against
1966    /// * `strict` - If true, check version/path changes; if false, only check corruption and security
1967    ///
1968    /// # Returns
1969    ///
1970    /// * `Ok(true)` - Lockfile is stale and needs updating
1971    /// * `Ok(false)` - Lockfile is valid and up-to-date
1972    /// * `Err(anyhow::Error)` - Validation failed due to IO or parse error
1973    ///
1974    /// # Examples
1975    ///
1976    /// ```rust,no_run
1977    /// # use std::path::Path;
1978    /// # use agpm_cli::lockfile::LockFile;
1979    /// # use agpm_cli::manifest::Manifest;
1980    /// # fn example() -> anyhow::Result<()> {
1981    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
1982    /// let manifest = Manifest::load(Path::new("agpm.toml"))?;
1983    ///
1984    /// if lockfile.is_stale(&manifest, true)? {
1985    ///     println!("Lockfile needs updating");
1986    /// }
1987    /// # Ok(())
1988    /// # }
1989    /// ```
1990    pub fn is_stale(&self, manifest: &crate::manifest::Manifest, strict: bool) -> Result<bool> {
1991        Ok(self.validate_against_manifest(manifest, strict)?.is_some())
1992    }
1993
1994    /// Detect duplicate entries within the lockfile itself.
1995    ///
1996    /// Scans all resource arrays for duplicate entries with the same name,
1997    /// which indicates lockfile corruption or staleness from previous versions.
1998    fn detect_duplicate_entries(&self) -> Result<Option<StalenessReason>> {
1999        use std::collections::HashMap;
2000
2001        // Check each resource type for duplicates
2002        for resource_type in crate::core::ResourceType::all() {
2003            let resources = self.get_resources(*resource_type);
2004            let mut seen_names = HashMap::new();
2005
2006            for resource in resources {
2007                if let Some(_first_index) = seen_names.get(&resource.name) {
2008                    return Ok(Some(StalenessReason::DuplicateEntries {
2009                        name: resource.name.clone(),
2010                        resource_type: *resource_type,
2011                        count: resources.iter().filter(|r| r.name == resource.name).count(),
2012                    }));
2013                }
2014                seen_names.insert(&resource.name, 0);
2015            }
2016        }
2017
2018        Ok(None)
2019    }
2020
2021    /// Validate that there are no duplicate names within each resource type.
2022    ///
2023    /// This method checks for lockfile corruption by ensuring that no resource type
2024    /// contains multiple entries with the same name. This is a stricter validation
2025    /// than `detect_duplicate_entries` and is used during lockfile loading to
2026    /// catch corruption early.
2027    ///
2028    /// # Arguments
2029    ///
2030    /// * `path` - Path to the lockfile (used for error messages)
2031    ///
2032    /// # Returns
2033    ///
2034    /// * `Ok(())` - No duplicates found
2035    /// * `Err(anyhow::Error)` - Duplicates found with detailed error message
2036    ///
2037    /// # Errors
2038    ///
2039    /// Returns an error if any resource type contains duplicate names, with
2040    /// details about which resource type and names are duplicated.
2041    pub fn validate_no_duplicates(&self, path: &Path) -> Result<()> {
2042        use std::collections::HashMap;
2043
2044        let mut found_duplicates = false;
2045        let mut error_messages = Vec::new();
2046
2047        // Check each resource type for duplicates
2048        for resource_type in crate::core::ResourceType::all() {
2049            let resources = self.get_resources(*resource_type);
2050            let mut name_counts = HashMap::new();
2051
2052            // Count occurrences of each name
2053            for resource in resources {
2054                *name_counts.entry(&resource.name).or_insert(0) += 1;
2055            }
2056
2057            // Find duplicates
2058            let duplicates: Vec<_> = name_counts.iter().filter(|(_, count)| **count > 1).collect();
2059
2060            if !duplicates.is_empty() {
2061                found_duplicates = true;
2062                let dup_names: Vec<_> = duplicates
2063                    .iter()
2064                    .map(|(name, count)| format!("{} ({} times)", name, **count))
2065                    .collect();
2066                error_messages.push(format!("  {}: {}", resource_type, dup_names.join(", ")));
2067            }
2068        }
2069
2070        if found_duplicates {
2071            return Err(crate::core::AgpmError::Other {
2072                message: format!(
2073                    "Lockfile corruption detected in {}:\nDuplicate resource names found:\n{}\n\n\
2074                    This indicates lockfile corruption. To fix:\n\
2075                    - Delete agpm.lock and run 'agpm install' to regenerate it\n\
2076                    - Or restore from a backup if available",
2077                    path.display(),
2078                    error_messages.join("\n")
2079                ),
2080            }
2081            .into());
2082        }
2083
2084        Ok(())
2085    }
2086
2087    /// Find a specific resource by name and type.
2088    ///
2089    /// This method searches for a resource with the given name within the specified
2090    /// resource type only. It's more precise than `get_resource` when you know the
2091    /// resource type and need to avoid ambiguity when multiple resource types have
2092    /// resources with the same name.
2093    ///
2094    /// # Arguments
2095    ///
2096    /// * `name` - Resource name to search for
2097    /// * `resource_type` - The type of resource to search within
2098    ///
2099    /// # Returns
2100    ///
2101    /// * `Some(&LockedResource)` - Reference to the found resource
2102    /// * `None` - No resource with that name exists in the specified type
2103    ///
2104    /// # Examples
2105    ///
2106    /// ```rust,no_run
2107    /// # use agpm_cli::lockfile::LockFile;
2108    /// # use agpm_cli::core::ResourceType;
2109    /// # let lockfile = LockFile::new();
2110    /// // Find a specific agent
2111    /// if let Some(agent) = lockfile.find_resource("helper", ResourceType::Agent) {
2112    ///     println!("Found agent: {}", agent.installed_at);
2113    /// }
2114    ///
2115    /// // Find a specific snippet
2116    /// if let Some(snippet) = lockfile.find_resource("utils", ResourceType::Snippet) {
2117    ///     println!("Found snippet: {}", snippet.installed_at);
2118    /// }
2119    /// ```
2120    ///
2121    /// # See Also
2122    ///
2123    /// * [`get_resource`](Self::get_resource) - Search across all resource types
2124    /// * [`get_resource_by_source`](Self::get_resource_by_source) - Search with source filtering
2125    #[must_use]
2126    pub fn find_resource(
2127        &self,
2128        name: &str,
2129        resource_type: crate::core::ResourceType,
2130    ) -> Option<&LockedResource> {
2131        self.get_resources(resource_type).iter().find(|r| r.name == name)
2132    }
2133
2134    /// Get all resources of a specific type for templating.
2135    ///
2136    /// This method returns all resources of the specified type, which is useful
2137    /// for templating operations that need to iterate over all resources of a
2138    /// particular type (e.g., all agents, all snippets).
2139    ///
2140    /// # Arguments
2141    ///
2142    /// * `resource_type` - The type of resources to retrieve
2143    ///
2144    /// # Returns
2145    ///
2146    /// A slice of all resources of the specified type.
2147    ///
2148    /// # Examples
2149    ///
2150    /// ```rust,no_run
2151    /// # use agpm_cli::lockfile::LockFile;
2152    /// # use agpm_cli::core::ResourceType;
2153    /// # let lockfile = LockFile::new();
2154    /// // Get all agents for templating
2155    /// let agents = lockfile.get_resources_by_type(ResourceType::Agent);
2156    /// for agent in agents {
2157    ///     println!("Agent: {} -> {}", agent.name, agent.installed_at);
2158    /// }
2159    ///
2160    /// // Get all snippets for templating
2161    /// let snippets = lockfile.get_resources_by_type(ResourceType::Snippet);
2162    /// println!("Found {} snippets", snippets.len());
2163    /// ```
2164    ///
2165    /// # See Also
2166    ///
2167    /// * [`get_resources`](Self::get_resources) - Get resources by type (same method)
2168    /// * [`all_resources`](Self::all_resources) - Get all resources across all types
2169    #[must_use]
2170    pub fn get_resources_by_type(
2171        &self,
2172        resource_type: crate::core::ResourceType,
2173    ) -> &[LockedResource] {
2174        self.get_resources(resource_type)
2175    }
2176
2177    /// Update the checksum for a specific resource in the lockfile.
2178    ///
2179    /// This method finds a resource by name across all resource types and updates
2180    /// its checksum value. Used after installation to record the actual file checksum.
2181    ///
2182    /// # Arguments
2183    ///
2184    /// * `name` - The name of the resource to update
2185    /// * `checksum` - The new SHA-256 checksum in "sha256:hex" format
2186    ///
2187    /// # Returns
2188    ///
2189    /// Returns `true` if the resource was found and updated, `false` otherwise.
2190    ///
2191    /// # Examples
2192    ///
2193    /// ```rust,no_run
2194    /// # use agpm_cli::lockfile::{LockFile, LockedResource};
2195    /// # use agpm_cli::core::ResourceType;
2196    /// # let mut lockfile = LockFile::default();
2197    /// # // First add a resource to update
2198    /// # lockfile.add_typed_resource("my-agent".to_string(), LockedResource {
2199    /// #     name: "my-agent".to_string(),
2200    /// #     source: None,
2201    /// #     url: None,
2202    /// #     path: "my-agent.md".to_string(),
2203    /// #     version: None,
2204    /// #     resolved_commit: None,
2205    /// #     checksum: "".to_string(),
2206    /// #     installed_at: "agents/my-agent.md".to_string(),
2207    /// #     dependencies: vec![],
2208    /// #     resource_type: ResourceType::Agent,
2209    /// #     tool: Some("claude-code".to_string()),
2210    /// #     manifest_alias: None,
2211    /// #     applied_patches: std::collections::HashMap::new(),
2212    /// #     install: None,
2213    /// # }, ResourceType::Agent);
2214    /// let updated = lockfile.update_resource_checksum(
2215    ///     "my-agent",
2216    ///     "sha256:abcdef123456..."
2217    /// );
2218    /// assert!(updated);
2219    /// ```
2220    pub fn update_resource_checksum(&mut self, name: &str, checksum: &str) -> bool {
2221        // Try each resource type until we find a match
2222        for resource in &mut self.agents {
2223            if resource.name == name {
2224                resource.checksum = checksum.to_string();
2225                return true;
2226            }
2227        }
2228
2229        for resource in &mut self.snippets {
2230            if resource.name == name {
2231                resource.checksum = checksum.to_string();
2232                return true;
2233            }
2234        }
2235
2236        for resource in &mut self.commands {
2237            if resource.name == name {
2238                resource.checksum = checksum.to_string();
2239                return true;
2240            }
2241        }
2242
2243        for resource in &mut self.scripts {
2244            if resource.name == name {
2245                resource.checksum = checksum.to_string();
2246                return true;
2247            }
2248        }
2249
2250        for resource in &mut self.hooks {
2251            if resource.name == name {
2252                resource.checksum = checksum.to_string();
2253                return true;
2254            }
2255        }
2256
2257        for resource in &mut self.mcp_servers {
2258            if resource.name == name {
2259                resource.checksum = checksum.to_string();
2260                return true;
2261            }
2262        }
2263
2264        false
2265    }
2266
2267    /// Updates the applied patches for a resource in the lockfile by name.
2268    ///
2269    /// This method searches through all resource types to find a resource with the
2270    /// matching name and updates its `applied_patches` field with the patches that
2271    /// were actually applied during installation.
2272    ///
2273    /// The `applied_patches` parameter should be the `AppliedPatches` struct returned
2274    /// from the installer, which contains both project and private patches that were
2275    /// successfully applied.
2276    ///
2277    /// # Arguments
2278    ///
2279    /// * `name` - The name of the resource to update
2280    /// * `applied_patches` - The patches that were applied (from `AppliedPatches` struct)
2281    ///
2282    /// # Returns
2283    ///
2284    /// Returns `true` if the resource was found and updated, `false` otherwise.
2285    ///
2286    /// # Examples
2287    ///
2288    /// ```no_run
2289    /// # use agpm_cli::lockfile::LockFile;
2290    /// # use agpm_cli::manifest::patches::AppliedPatches;
2291    /// # use std::collections::HashMap;
2292    /// # let mut lockfile = LockFile::new();
2293    /// let mut applied = AppliedPatches::new();
2294    /// applied.project.insert("model".to_string(), toml::Value::String("haiku".into()));
2295    ///
2296    /// let updated = lockfile.update_resource_applied_patches("my-agent", &applied);
2297    /// assert!(updated);
2298    /// ```
2299    pub fn update_resource_applied_patches(
2300        &mut self,
2301        name: &str,
2302        applied_patches: &crate::manifest::patches::AppliedPatches,
2303    ) -> bool {
2304        // Store ONLY project patches in the main lockfile (agpm.lock)
2305        // Private patches are stored separately in agpm.private.lock
2306        // This ensures the main lockfile is deterministic and safe to commit
2307        let project_patches = applied_patches.project.clone();
2308
2309        // Try each resource type until we find a match
2310        for resource in &mut self.agents {
2311            if resource.name == name {
2312                resource.applied_patches = project_patches;
2313                return true;
2314            }
2315        }
2316
2317        for resource in &mut self.snippets {
2318            if resource.name == name {
2319                resource.applied_patches = project_patches;
2320                return true;
2321            }
2322        }
2323
2324        for resource in &mut self.commands {
2325            if resource.name == name {
2326                resource.applied_patches = project_patches;
2327                return true;
2328            }
2329        }
2330
2331        for resource in &mut self.scripts {
2332            if resource.name == name {
2333                resource.applied_patches = project_patches;
2334                return true;
2335            }
2336        }
2337
2338        for resource in &mut self.hooks {
2339            if resource.name == name {
2340                resource.applied_patches = project_patches;
2341                return true;
2342            }
2343        }
2344
2345        for resource in &mut self.mcp_servers {
2346            if resource.name == name {
2347                resource.applied_patches = project_patches;
2348                return true;
2349            }
2350        }
2351
2352        false
2353    }
2354}
2355
2356impl Default for LockFile {
2357    /// Create a new empty lockfile using the current format version.
2358    ///
2359    /// This implementation of [`Default`] is equivalent to calling [`LockFile::new()`].
2360    /// It creates a fresh lockfile with no sources or resources.
2361    fn default() -> Self {
2362        Self::new()
2363    }
2364}
2365
2366/// Find the lockfile in the current or parent directories.
2367///
2368/// Searches upward from the current working directory to find a `agpm.lock` file,
2369/// similar to how Git searches for `.git` directories. This enables running AGPM
2370/// commands from subdirectories within a project.
2371///
2372/// # Search Algorithm
2373///
2374/// 1. Start from current working directory
2375/// 2. Check for `agpm.lock` in current directory
2376/// 3. If found, return the path
2377/// 4. If not found, move to parent directory
2378/// 5. Repeat until root directory is reached
2379/// 6. Return `None` if no lockfile found
2380///
2381/// # Returns
2382///
2383/// * `Some(PathBuf)` - Path to the found lockfile
2384/// * `None` - No lockfile found in current or parent directories
2385///
2386/// # Examples
2387///
2388/// ```rust,no_run
2389/// use agpm_cli::lockfile::find_lockfile;
2390///
2391/// if let Some(lockfile_path) = find_lockfile() {
2392///     println!("Found lockfile: {}", lockfile_path.display());
2393/// } else {
2394///     println!("No lockfile found (run 'agpm install' to create one)");
2395/// }
2396/// ```
2397///
2398/// # Use Cases
2399///
2400/// - **CLI commands**: Find project root when run from subdirectories
2401/// - **Editor integration**: Locate project configuration
2402/// - **Build scripts**: Find lockfile for dependency information
2403/// - **Validation tools**: Check if project has lockfile
2404///
2405/// # Directory Structure Example
2406///
2407/// ```text
2408/// project/
2409/// ├── agpm.lock          # ← This will be found
2410/// ├── agpm.toml
2411/// └── src/
2412///     └── subdir/         # ← Commands run from here will find ../agpm.lock
2413/// ```
2414///
2415/// # Errors
2416///
2417/// This function does not return errors but rather `None` if:
2418/// - Cannot get current working directory (permission issues)
2419/// - No lockfile exists in the directory tree
2420/// - IO errors while checking file existence
2421///
2422/// For more robust error handling, consider using [`LockFile::load`] directly
2423/// with a known path.
2424#[must_use]
2425pub fn find_lockfile() -> Option<PathBuf> {
2426    let mut current = std::env::current_dir().ok()?;
2427
2428    loop {
2429        let lockfile_path = current.join("agpm.lock");
2430        if lockfile_path.exists() {
2431            return Some(lockfile_path);
2432        }
2433
2434        if !current.pop() {
2435            return None;
2436        }
2437    }
2438}
2439
2440// Private lockfile module for user-level patches
2441pub mod private_lock;
2442pub use private_lock::PrivateLockFile;
2443
2444// Patch display utilities (currently unused - TODO: integrate with Cache API)
2445#[allow(dead_code)]
2446pub mod patch_display;
2447
2448#[cfg(test)]
2449mod tests {
2450    use super::*;
2451    use tempfile::tempdir;
2452
2453    #[test]
2454    fn test_lockfile_new() {
2455        let lockfile = LockFile::new();
2456        assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
2457        assert!(lockfile.sources.is_empty());
2458        assert!(lockfile.agents.is_empty());
2459    }
2460
2461    #[test]
2462    fn test_lockfile_save_load() {
2463        let temp = tempdir().unwrap();
2464        let lockfile_path = temp.path().join("agpm.lock");
2465
2466        let mut lockfile = LockFile::new();
2467
2468        // Add a source
2469        lockfile.add_source(
2470            "official".to_string(),
2471            "https://github.com/example-org/agpm-official.git".to_string(),
2472            "abc123".to_string(),
2473        );
2474
2475        // Add a resource
2476        lockfile.add_resource(
2477            "test-agent".to_string(),
2478            LockedResource {
2479                name: "test-agent".to_string(),
2480                source: Some("official".to_string()),
2481                url: Some("https://github.com/example-org/agpm-official.git".to_string()),
2482                path: "agents/test.md".to_string(),
2483                version: Some("v1.0.0".to_string()),
2484                resolved_commit: Some("abc123".to_string()),
2485                checksum: "sha256:abcdef".to_string(),
2486                installed_at: "agents/test-agent.md".to_string(),
2487                dependencies: vec![],
2488                resource_type: crate::core::ResourceType::Agent,
2489
2490                tool: Some("claude-code".to_string()),
2491                manifest_alias: None,
2492                applied_patches: std::collections::HashMap::new(),
2493                install: None,
2494            },
2495            true,
2496        );
2497
2498        // Save
2499        lockfile.save(&lockfile_path).unwrap();
2500        assert!(lockfile_path.exists());
2501
2502        // Load
2503        let loaded = LockFile::load(&lockfile_path).unwrap();
2504        assert_eq!(loaded.version, LockFile::CURRENT_VERSION);
2505        assert_eq!(loaded.sources.len(), 1);
2506        assert_eq!(loaded.agents.len(), 1);
2507        assert_eq!(
2508            loaded.get_source("official").unwrap().url,
2509            "https://github.com/example-org/agpm-official.git"
2510        );
2511        assert_eq!(loaded.get_resource("test-agent").unwrap().checksum, "sha256:abcdef");
2512    }
2513
2514    #[test]
2515    fn test_staleness_reason_display() {
2516        use crate::core::ResourceType;
2517
2518        // Test SourceUrlChanged
2519        let reason = StalenessReason::SourceUrlChanged {
2520            name: "community".to_string(),
2521            old_url: "https://github.com/old/repo.git".to_string(),
2522            new_url: "https://github.com/new/repo.git".to_string(),
2523        };
2524        assert_eq!(
2525            reason.to_string(),
2526            "Source repository 'community' URL changed from 'https://github.com/old/repo.git' to 'https://github.com/new/repo.git'"
2527        );
2528
2529        // Test DuplicateEntries
2530        let reason = StalenessReason::DuplicateEntries {
2531            name: "dup-agent".to_string(),
2532            resource_type: ResourceType::Agent,
2533            count: 3,
2534        };
2535        assert_eq!(
2536            reason.to_string(),
2537            "Found 3 duplicate entries for dependency 'dup-agent' (agent)"
2538        );
2539    }
2540
2541    // Note: Complex staleness checking integration tests are in tests/integration_lockfile_staleness.rs
2542    // These unit tests focus on the display formatting of StalenessReason variants
2543
2544    #[test]
2545    fn test_lockfile_empty_file() {
2546        let temp = tempdir().unwrap();
2547        let lockfile_path = temp.path().join("agpm.lock");
2548
2549        // Create empty file
2550        std::fs::write(&lockfile_path, "").unwrap();
2551
2552        // Should return new lockfile
2553        let lockfile = LockFile::load(&lockfile_path).unwrap();
2554        assert_eq!(lockfile.version, LockFile::CURRENT_VERSION);
2555        assert!(lockfile.sources.is_empty());
2556    }
2557
2558    #[test]
2559    fn test_lockfile_version_check() {
2560        let temp = tempdir().unwrap();
2561        let lockfile_path = temp.path().join("agpm.lock");
2562
2563        // Create lockfile with future version
2564        let content = "version = 999\n";
2565        std::fs::write(&lockfile_path, content).unwrap();
2566
2567        // Should fail to load
2568        let result = LockFile::load(&lockfile_path);
2569        assert!(result.is_err());
2570        assert!(result.unwrap_err().to_string().contains("newer than supported"));
2571    }
2572
2573    #[test]
2574    fn test_resource_operations() {
2575        let mut lockfile = LockFile::new();
2576
2577        // Add resources
2578        lockfile.add_resource(
2579            "agent1".to_string(),
2580            LockedResource {
2581                name: "agent1".to_string(),
2582                source: None,
2583                url: None,
2584                path: "local/agent1.md".to_string(),
2585                version: None,
2586                resolved_commit: None,
2587                checksum: "sha256:111".to_string(),
2588                installed_at: "agents/agent1.md".to_string(),
2589                dependencies: vec![],
2590                resource_type: crate::core::ResourceType::Agent,
2591
2592                tool: Some("claude-code".to_string()),
2593                manifest_alias: None,
2594                applied_patches: std::collections::HashMap::new(),
2595                install: None,
2596            },
2597            true, // is_agent
2598        );
2599
2600        lockfile.add_resource(
2601            "snippet1".to_string(),
2602            LockedResource {
2603                name: "snippet1".to_string(),
2604                source: None,
2605                url: None,
2606                path: "local/snippet1.md".to_string(),
2607                version: None,
2608                resolved_commit: None,
2609                checksum: "sha256:222".to_string(),
2610                installed_at: "snippets/snippet1.md".to_string(),
2611                dependencies: vec![],
2612                resource_type: crate::core::ResourceType::Snippet,
2613
2614                tool: Some("claude-code".to_string()),
2615                manifest_alias: None,
2616                applied_patches: std::collections::HashMap::new(),
2617                install: None,
2618            },
2619            false, // is_agent
2620        );
2621
2622        lockfile.add_resource(
2623            "dev-agent1".to_string(),
2624            LockedResource {
2625                name: "dev-agent1".to_string(),
2626                source: None,
2627                url: None,
2628                path: "local/dev-agent1.md".to_string(),
2629                version: None,
2630                resolved_commit: None,
2631                checksum: "sha256:333".to_string(),
2632                installed_at: "agents/dev-agent1.md".to_string(),
2633                dependencies: vec![],
2634                resource_type: crate::core::ResourceType::Agent,
2635
2636                tool: Some("claude-code".to_string()),
2637                manifest_alias: None,
2638                applied_patches: std::collections::HashMap::new(),
2639                install: None,
2640            },
2641            true, // is_agent
2642        );
2643
2644        // Test getters
2645        assert!(lockfile.has_resource("agent1"));
2646        assert!(lockfile.has_resource("snippet1"));
2647        assert!(lockfile.has_resource("dev-agent1"));
2648        assert!(!lockfile.has_resource("nonexistent"));
2649
2650        assert_eq!(lockfile.all_resources().len(), 3);
2651        // Note: production_resources() removed as dev/production concept was eliminated
2652
2653        // Test clear
2654        lockfile.clear();
2655        assert!(lockfile.all_resources().is_empty());
2656    }
2657
2658    #[test]
2659    fn test_checksum_computation() {
2660        let temp = tempdir().unwrap();
2661        let file_path = temp.path().join("test.md");
2662
2663        std::fs::write(&file_path, "Hello, World!").unwrap();
2664
2665        let checksum = LockFile::compute_checksum(&file_path).unwrap();
2666        assert!(checksum.starts_with("sha256:"));
2667
2668        // Verify checksum
2669        assert!(LockFile::verify_checksum(&file_path, &checksum).unwrap());
2670        assert!(!LockFile::verify_checksum(&file_path, "sha256:wrong").unwrap());
2671    }
2672
2673    #[test]
2674    fn test_lockfile_with_commands() {
2675        let mut lockfile = LockFile::new();
2676
2677        // Add a command resource using add_typed_resource
2678        lockfile.add_typed_resource(
2679            "build".to_string(),
2680            LockedResource {
2681                name: "build".to_string(),
2682                source: Some("community".to_string()),
2683                url: Some("https://github.com/example/community.git".to_string()),
2684                path: "commands/build.md".to_string(),
2685                version: Some("v1.0.0".to_string()),
2686                resolved_commit: Some("abc123".to_string()),
2687                checksum: "sha256:cmd123".to_string(),
2688                installed_at: ".claude/commands/build.md".to_string(),
2689                dependencies: vec![],
2690                resource_type: crate::core::ResourceType::Command,
2691
2692                tool: Some("claude-code".to_string()),
2693                manifest_alias: None,
2694                applied_patches: std::collections::HashMap::new(),
2695                install: None,
2696            },
2697            crate::core::ResourceType::Command,
2698        );
2699
2700        assert_eq!(lockfile.commands.len(), 1);
2701        assert!(lockfile.has_resource("build"));
2702
2703        let resource = lockfile.get_resource("build").unwrap();
2704        assert_eq!(resource.name, "build");
2705        assert_eq!(resource.installed_at, ".claude/commands/build.md");
2706    }
2707
2708    #[test]
2709    fn test_lockfile_all_resources_with_commands() {
2710        let mut lockfile = LockFile::new();
2711
2712        // Add resources of each type
2713        lockfile.add_resource(
2714            "agent1".to_string(),
2715            LockedResource {
2716                name: "agent1".to_string(),
2717                source: None,
2718                url: None,
2719                path: "agent1.md".to_string(),
2720                version: None,
2721                resolved_commit: None,
2722                checksum: "sha256:a1".to_string(),
2723                installed_at: "agents/agent1.md".to_string(),
2724                dependencies: vec![],
2725                resource_type: crate::core::ResourceType::Agent,
2726
2727                tool: Some("claude-code".to_string()),
2728                manifest_alias: None,
2729                applied_patches: std::collections::HashMap::new(),
2730                install: None,
2731            },
2732            true,
2733        );
2734
2735        lockfile.add_resource(
2736            "snippet1".to_string(),
2737            LockedResource {
2738                name: "snippet1".to_string(),
2739                source: None,
2740                url: None,
2741                path: "snippet1.md".to_string(),
2742                version: None,
2743                resolved_commit: None,
2744                checksum: "sha256:s1".to_string(),
2745                installed_at: "snippets/snippet1.md".to_string(),
2746                dependencies: vec![],
2747                resource_type: crate::core::ResourceType::Snippet,
2748
2749                tool: Some("claude-code".to_string()),
2750                manifest_alias: None,
2751                applied_patches: std::collections::HashMap::new(),
2752                install: None,
2753            },
2754            false,
2755        );
2756
2757        lockfile.add_typed_resource(
2758            "command1".to_string(),
2759            LockedResource {
2760                name: "command1".to_string(),
2761                source: None,
2762                url: None,
2763                path: "command1.md".to_string(),
2764                version: None,
2765                resolved_commit: None,
2766                checksum: "sha256:c1".to_string(),
2767                installed_at: ".claude/commands/command1.md".to_string(),
2768                dependencies: vec![],
2769                resource_type: crate::core::ResourceType::Command,
2770
2771                tool: Some("claude-code".to_string()),
2772                manifest_alias: None,
2773                applied_patches: std::collections::HashMap::new(),
2774                install: None,
2775            },
2776            crate::core::ResourceType::Command,
2777        );
2778
2779        let all = lockfile.all_resources();
2780        assert_eq!(all.len(), 3);
2781
2782        // Test clear includes commands
2783        lockfile.clear();
2784        assert!(lockfile.agents.is_empty());
2785        assert!(lockfile.snippets.is_empty());
2786        assert!(lockfile.commands.is_empty());
2787    }
2788
2789    #[test]
2790    fn test_lockfile_save_load_commands() {
2791        let temp = tempdir().unwrap();
2792        let lockfile_path = temp.path().join("agpm.lock");
2793
2794        let mut lockfile = LockFile::new();
2795
2796        // Add command
2797        lockfile.add_typed_resource(
2798            "deploy".to_string(),
2799            LockedResource {
2800                name: "deploy".to_string(),
2801                source: Some("official".to_string()),
2802                url: Some("https://github.com/example/official.git".to_string()),
2803                path: "commands/deploy.md".to_string(),
2804                version: Some("v2.0.0".to_string()),
2805                resolved_commit: Some("def456".to_string()),
2806                checksum: "sha256:deploy123".to_string(),
2807                installed_at: ".claude/commands/deploy.md".to_string(),
2808                dependencies: vec![],
2809                resource_type: crate::core::ResourceType::Command,
2810
2811                tool: Some("claude-code".to_string()),
2812                manifest_alias: None,
2813                applied_patches: std::collections::HashMap::new(),
2814                install: None,
2815            },
2816            crate::core::ResourceType::Command,
2817        );
2818
2819        // Save
2820        lockfile.save(&lockfile_path).unwrap();
2821
2822        // Load and verify
2823        let loaded = LockFile::load(&lockfile_path).unwrap();
2824        assert_eq!(loaded.commands.len(), 1);
2825        assert!(loaded.has_resource("deploy"));
2826
2827        let cmd = &loaded.commands[0];
2828        assert_eq!(cmd.name, "deploy");
2829        assert_eq!(cmd.version, Some("v2.0.0".to_string()));
2830        assert_eq!(cmd.installed_at, ".claude/commands/deploy.md");
2831    }
2832
2833    #[test]
2834    fn test_lockfile_get_resource_precedence() {
2835        let mut lockfile = LockFile::new();
2836
2837        // Add resources with same name but different types
2838        lockfile.add_resource(
2839            "helper".to_string(),
2840            LockedResource {
2841                name: "helper".to_string(),
2842                source: None,
2843                url: None,
2844                path: "agent_helper.md".to_string(),
2845                version: None,
2846                resolved_commit: None,
2847                checksum: "sha256:agent".to_string(),
2848                installed_at: "agents/helper.md".to_string(),
2849                dependencies: vec![],
2850                resource_type: crate::core::ResourceType::Agent,
2851
2852                tool: Some("claude-code".to_string()),
2853                manifest_alias: None,
2854                applied_patches: std::collections::HashMap::new(),
2855                install: None,
2856            },
2857            true,
2858        );
2859
2860        lockfile.add_typed_resource(
2861            "helper".to_string(),
2862            LockedResource {
2863                name: "helper".to_string(),
2864                source: None,
2865                url: None,
2866                path: "command_helper.md".to_string(),
2867                version: None,
2868                resolved_commit: None,
2869                checksum: "sha256:command".to_string(),
2870                installed_at: ".claude/commands/helper.md".to_string(),
2871                dependencies: vec![],
2872                resource_type: crate::core::ResourceType::Command,
2873
2874                tool: Some("claude-code".to_string()),
2875                manifest_alias: None,
2876                applied_patches: std::collections::HashMap::new(),
2877                install: None,
2878            },
2879            crate::core::ResourceType::Command,
2880        );
2881
2882        // get_resource should return agent (higher precedence)
2883        let resource = lockfile.get_resource("helper").unwrap();
2884        assert_eq!(resource.installed_at, "agents/helper.md");
2885    }
2886}