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 serde::{Deserialize, Serialize};
293use std::collections::BTreeMap;
294use std::path::PathBuf;
295
296/// Reasons why a lockfile might be considered stale.
297///
298/// This enum describes various conditions that indicate a lockfile is
299/// out-of-sync with the manifest and needs to be regenerated to prevent
300/// installation errors or inconsistencies.
301///
302/// # Display Format
303///
304/// Each variant implements `Display` to provide user-friendly error messages
305/// that explain the problem and suggest solutions.
306///
307/// # Examples
308///
309/// ```rust,no_run
310/// use agpm_cli::lockfile::StalenessReason;
311/// use agpm_cli::core::ResourceType;
312///
313/// let reason = StalenessReason::MissingDependency {
314///     name: "my-agent".to_string(),
315///     resource_type: ResourceType::Agent,
316/// };
317///
318/// println!("{}", reason);
319/// // Output: "Dependency 'my-agent' (agent) is in manifest but missing from lockfile"
320/// ```
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum StalenessReason {
323    /// A dependency is in the manifest but not in the lockfile.
324    /// This indicates the lockfile is incomplete and needs regeneration.
325    MissingDependency {
326        /// Name of the missing dependency
327        name: String,
328        /// Type of resource (agent, snippet, etc.)
329        resource_type: crate::core::ResourceType,
330    },
331
332    /// A dependency's version constraint has changed in the manifest.
333    VersionChanged {
334        /// Name of the dependency
335        name: String,
336        /// Type of resource (agent, snippet, etc.)
337        resource_type: crate::core::ResourceType,
338        /// Previous version from lockfile
339        old_version: String,
340        /// New version from manifest
341        new_version: String,
342    },
343
344    /// A dependency's path has changed in the manifest.
345    PathChanged {
346        /// Name of the dependency
347        name: String,
348        /// Type of resource (agent, snippet, etc.)
349        resource_type: crate::core::ResourceType,
350        /// Previous path from lockfile
351        old_path: String,
352        /// New path from manifest
353        new_path: String,
354    },
355
356    /// A source repository has a different URL in the manifest.
357    /// This is a security concern as it could point to a different repository.
358    SourceUrlChanged {
359        /// Name of the source repository
360        name: String,
361        /// Previous URL from lockfile
362        old_url: String,
363        /// New URL from manifest
364        new_url: String,
365    },
366
367    /// Multiple entries exist for the same dependency (lockfile corruption).
368    DuplicateEntries {
369        /// Name of the duplicated dependency
370        name: String,
371        /// Type of resource (agent, snippet, etc.)
372        resource_type: crate::core::ResourceType,
373        /// Number of duplicate entries found
374        count: usize,
375    },
376
377    /// A dependency's tool field has changed in the manifest.
378    ToolChanged {
379        /// Name of the dependency
380        name: String,
381        /// Type of resource (agent, snippet, etc.)
382        resource_type: crate::core::ResourceType,
383        /// Previous tool from lockfile
384        old_tool: String,
385        /// New tool from manifest (with defaults applied)
386        new_tool: String,
387    },
388}
389
390impl std::fmt::Display for StalenessReason {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        match self {
393            Self::MissingDependency {
394                name,
395                resource_type,
396            } => {
397                write!(
398                    f,
399                    "Dependency '{name}' ({resource_type}) is in manifest but missing from lockfile"
400                )
401            }
402            Self::VersionChanged {
403                name,
404                resource_type,
405                old_version,
406                new_version,
407            } => {
408                write!(
409                    f,
410                    "Dependency '{name}' ({resource_type}) version changed from '{old_version}' to '{new_version}'"
411                )
412            }
413            Self::PathChanged {
414                name,
415                resource_type,
416                old_path,
417                new_path,
418            } => {
419                write!(
420                    f,
421                    "Dependency '{name}' ({resource_type}) path changed from '{old_path}' to '{new_path}'"
422                )
423            }
424            Self::SourceUrlChanged {
425                name,
426                old_url,
427                new_url,
428            } => {
429                write!(f, "Source repository '{name}' URL changed from '{old_url}' to '{new_url}'")
430            }
431            Self::DuplicateEntries {
432                name,
433                resource_type,
434                count,
435            } => {
436                write!(
437                    f,
438                    "Found {count} duplicate entries for dependency '{name}' ({resource_type})"
439                )
440            }
441            Self::ToolChanged {
442                name,
443                resource_type,
444                old_tool,
445                new_tool,
446            } => {
447                write!(
448                    f,
449                    "Dependency '{name}' ({resource_type}) tool changed from '{old_tool}' to '{new_tool}'"
450                )
451            }
452        }
453    }
454}
455
456impl std::error::Error for StalenessReason {}
457
458/// Unique identifier for a resource in the lockfile.
459///
460/// This struct ensures type-safe identification of lockfile entries by combining
461/// the resource name, source, and tool. Resources are considered unique when they
462/// have distinct combinations of these fields:
463///
464/// - Same name, different sources: Different repositories providing same-named resources
465/// - Same name, different tools: Resources used by different tools (e.g., Claude Code vs OpenCode)
466/// - Same name and source, different tools: Transitive dependencies inherited from different parent tools
467///
468/// # Examples
469///
470/// ```rust
471/// use agpm_cli::lockfile::ResourceId;
472/// use agpm_cli::core::ResourceType;
473///
474/// // Local resource (no source)
475/// let local = ResourceId::new("my-agent", None::<String>, Some("claude-code"), ResourceType::Agent, "default".to_string());
476///
477/// // Git resource from a source
478/// let git = ResourceId::new("shared-agent", Some("community"), Some("claude-code"), ResourceType::Agent, "default".to_string());
479/// ```
480#[derive(Debug, Clone, PartialEq, Eq, Hash)]
481pub struct ResourceId {
482    /// The name of the resource
483    name: String,
484    /// The source repository name (None for local resources)
485    source: Option<String>,
486    /// The tool identifier (e.g., "claude-code", "opencode", "agpm")
487    tool: Option<String>,
488    /// The resource type (Agent, Snippet, Command, etc.)
489    resource_type: crate::core::ResourceType,
490    /// SHA-256 hash of the complete merged template variable context
491    ///
492    /// This hash uniquely identifies the template inputs used during dependency resolution.
493    /// Two resources with different variant_inputs_hash are considered distinct, even if
494    /// they have the same name, source, and tool. Only the hash is needed for identity
495    /// comparison; the full JSON value is stored in LockedResource for serialization.
496    variant_inputs_hash: String,
497}
498
499impl ResourceId {
500    /// Create a new ResourceId with pre-computed hash
501    pub fn new(
502        name: impl Into<String>,
503        source: Option<impl Into<String>>,
504        tool: Option<impl Into<String>>,
505        resource_type: crate::core::ResourceType,
506        variant_inputs_hash: String,
507    ) -> Self {
508        Self {
509            name: name.into(),
510            source: source.map(|s| s.into()),
511            tool: tool.map(|t| t.into()),
512            resource_type,
513            variant_inputs_hash,
514        }
515    }
516
517    /// Create a ResourceId from a LockedResource
518    pub fn from_resource(resource: &LockedResource) -> Self {
519        Self {
520            name: resource.name.clone(),
521            source: resource.source.clone(),
522            tool: resource.tool.clone(),
523            resource_type: resource.resource_type,
524            variant_inputs_hash: resource.variant_inputs.hash().to_string(),
525        }
526    }
527
528    /// Resource name accessor.
529    #[must_use]
530    pub fn name(&self) -> &str {
531        &self.name
532    }
533
534    /// Source repository name accessor.
535    #[must_use]
536    pub fn source(&self) -> Option<&str> {
537        self.source.as_deref()
538    }
539
540    /// Tool identifier accessor.
541    #[must_use]
542    pub fn tool(&self) -> Option<&str> {
543        self.tool.as_deref()
544    }
545
546    /// Resource type accessor.
547    #[must_use]
548    pub fn resource_type(&self) -> crate::core::ResourceType {
549        self.resource_type
550    }
551
552    /// Get the variant_inputs_hash for this resource ID.
553    #[must_use]
554    pub fn variant_inputs_hash(&self) -> &str {
555        &self.variant_inputs_hash
556    }
557}
558
559impl std::fmt::Display for ResourceId {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        write!(f, "{}", self.name)?;
562        if let Some(ref source) = self.source {
563            write!(f, " (source: {})", source)?;
564        }
565        if let Some(ref tool) = self.tool {
566            write!(f, " [{}]", tool)?;
567        }
568        // Show hash prefix for variant inputs (not default empty hash)
569        if !self.variant_inputs_hash.is_empty()
570            && self.variant_inputs_hash != crate::utils::EMPTY_VARIANT_INPUTS_HASH.as_str()
571        {
572            write!(f, " <hash: {}>", &self.variant_inputs_hash[..16])?;
573        }
574        Ok(())
575    }
576}
577
578/// The main lockfile structure representing a complete `agpm.lock` file.
579///
580/// This structure contains all resolved dependencies, source repositories, and their
581/// exact versions/commits for reproducible installations. The lockfile is automatically
582/// generated from the [`crate::manifest::Manifest`] during installation and should not
583/// be edited manually.
584///
585/// # Format Version
586///
587/// The lockfile includes a format version to enable future migrations and compatibility
588/// checking. The current version is 1.
589///
590/// # Serialization
591///
592/// The lockfile serializes to TOML format with arrays of sources, agents, and snippets.
593/// Empty arrays are omitted from serialization to keep the lockfile clean.
594///
595/// # Examples
596///
597/// Creating a new lockfile:
598///
599/// ```rust,no_run
600/// use agpm_cli::lockfile::LockFile;
601///
602/// let lockfile = LockFile::new();
603/// assert_eq!(lockfile.version, 1);
604/// assert!(lockfile.sources.is_empty());
605/// ```
606///
607/// Loading an existing lockfile:
608///
609/// ```rust,no_run
610/// # use std::path::Path;
611/// # use agpm_cli::lockfile::LockFile;
612/// # fn example() -> anyhow::Result<()> {
613/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
614/// println!("Loaded {} sources, {} agents",
615///          lockfile.sources.len(), lockfile.agents.len());
616/// # Ok(())
617/// # }
618/// ```
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct LockFile {
621    /// Version of the lockfile format.
622    ///
623    /// This field enables forward and backward compatibility checking. AGPM will
624    /// refuse to load lockfiles with versions newer than it supports, and may
625    /// provide migration paths for older versions in the future.
626    pub version: u32,
627
628    /// Locked source repositories with their resolved commit hashes.
629    ///
630    /// Each entry represents a Git repository that has been fetched and resolved
631    /// to an exact commit. The commit hash ensures all team members get identical
632    /// source content even as the upstream repository evolves.
633    ///
634    /// This field is omitted from TOML serialization if empty to keep the lockfile clean.
635    #[serde(default, skip_serializing_if = "Vec::is_empty")]
636    pub sources: Vec<LockedSource>,
637
638    /// Locked agent resources with their exact versions and checksums.
639    ///
640    /// Contains all resolved agent dependencies from the manifest, with exact
641    /// commit hashes, installation paths, and SHA-256 checksums for integrity
642    /// verification.
643    ///
644    /// This field is omitted from TOML serialization if empty.
645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
646    pub agents: Vec<LockedResource>,
647
648    /// Locked snippet resources with their exact versions and checksums.
649    ///
650    /// Contains all resolved snippet dependencies from the manifest, with exact
651    /// commit hashes, installation paths, and SHA-256 checksums for integrity
652    /// verification.
653    ///
654    /// This field is omitted from TOML serialization if empty.
655    #[serde(default, skip_serializing_if = "Vec::is_empty")]
656    pub snippets: Vec<LockedResource>,
657
658    /// Locked command resources with their exact versions and checksums.
659    ///
660    /// Contains all resolved command dependencies from the manifest, with exact
661    /// commit hashes, installation paths, and SHA-256 checksums for integrity
662    /// verification.
663    ///
664    /// This field is omitted from TOML serialization if empty.
665    #[serde(default, skip_serializing_if = "Vec::is_empty")]
666    pub commands: Vec<LockedResource>,
667
668    /// Locked MCP server resources with their exact versions and checksums.
669    ///
670    /// Contains all resolved MCP server dependencies from the manifest, with exact
671    /// commit hashes, installation paths, and SHA-256 checksums for integrity
672    /// verification. MCP servers are installed as JSON files and also configured
673    /// in `.claude/settings.local.json`.
674    ///
675    /// This field is omitted from TOML serialization if empty.
676    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "mcp-servers")]
677    pub mcp_servers: Vec<LockedResource>,
678
679    /// Locked script resources with their exact versions and checksums.
680    ///
681    /// Contains all resolved script dependencies from the manifest, with exact
682    /// commit hashes, installation paths, and SHA-256 checksums for integrity
683    /// verification. Scripts are executable files that can be referenced by hooks.
684    ///
685    /// This field is omitted from TOML serialization if empty.
686    #[serde(default, skip_serializing_if = "Vec::is_empty")]
687    pub scripts: Vec<LockedResource>,
688
689    /// Locked hook configurations with their exact versions and checksums.
690    ///
691    /// Contains all resolved hook dependencies from the manifest. Hooks are
692    /// JSON configuration files that define event-based automation in Claude Code.
693    ///
694    /// This field is omitted from TOML serialization if empty.
695    #[serde(default, skip_serializing_if = "Vec::is_empty")]
696    pub hooks: Vec<LockedResource>,
697}
698
699/// A locked source repository with resolved commit information.
700///
701/// Represents a Git repository that has been fetched and resolved to an exact
702/// commit hash. This ensures reproducible access to source repositories across
703/// different environments and times.
704///
705/// # Fields
706///
707/// - **name**: Unique identifier used in the manifest to reference this source
708/// - **url**: Full Git repository URL (HTTP/HTTPS/SSH)
709/// - **commit**: 40-character SHA-1 commit hash resolved at time of lock
710/// - **`fetched_at`**: RFC 3339 timestamp of when the repository was last fetched
711///
712/// # Examples
713///
714/// A typical locked source in TOML format:
715///
716/// ```toml
717/// [[sources]]
718/// name = "community"
719/// url = "https://github.com/example/agpm-community.git"
720/// commit = "a1b2c3d4e5f6789abcdef0123456789abcdef012"
721/// fetched_at = "2024-01-15T10:30:00Z"
722/// ```
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct LockedSource {
725    /// Unique source name from the manifest.
726    ///
727    /// This corresponds to keys in the `[sources]` section of `agpm.toml`
728    /// and is used to reference the source in resource definitions.
729    pub name: String,
730
731    /// Full Git repository URL.
732    ///
733    /// Supports HTTP, HTTPS, and SSH URLs. This is the exact URL used
734    /// for cloning and fetching the repository.
735    pub url: String,
736
737    /// Timestamp of last successful fetch in RFC 3339 format.
738    ///
739    /// Records when the repository was last fetched from the remote.
740    /// This helps track staleness and debugging fetch issues.
741    pub fetched_at: String,
742}
743
744/// A locked resource (agent or snippet) with resolved version and integrity information.
745///
746/// Represents a specific resource file that has been resolved from either a source
747/// repository or local filesystem. Contains all information needed to verify the
748/// exact version and integrity of the installed resource.
749///
750/// # Local vs Remote Resources
751///
752/// Remote resources (from Git repositories) include:
753/// - `source`: Source repository name
754/// - `url`: Repository URL  
755/// - `version`: Original version constraint
756/// - `resolved_commit`: Exact commit containing the resource
757///
758/// Local resources (from filesystem) omit these fields since they don't
759/// involve Git repositories.
760///
761/// # Integrity Verification
762///
763/// All resources include a SHA-256 checksum for integrity verification.
764/// The checksum is calculated from the file content after installation
765/// and can be used to detect corruption or tampering.
766///
767/// # Examples
768///
769/// Remote resource in TOML format:
770///
771/// ```toml
772/// [[agents]]
773/// name = "example-agent"
774/// source = "community"
775/// url = "https://github.com/example/repo.git"
776/// path = "agents/example.md"
777/// version = "^1.0"
778/// resolved_commit = "a1b2c3d4e5f6..."
779/// checksum = "sha256:abcdef123456..."
780/// installed_at = "agents/example-agent.md"
781/// ```
782///
783/// Local resource in TOML format:
784///
785/// ```toml
786/// [[agents]]
787/// name = "local-helper"
788/// path = "../local/helper.md"
789/// checksum = "sha256:fedcba654321..."
790/// installed_at = "agents/local-helper.md"
791/// ```
792#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct LockedResource {
794    /// Resource name from the manifest.
795    ///
796    /// This corresponds to keys in the `[agents]` or `[snippets]` sections
797    /// of the manifest. Resources are uniquely identified by the combination
798    /// of (name, source), allowing multiple sources to provide resources with
799    /// the same name.
800    pub name: String,
801
802    /// Source repository name for remote resources.
803    ///
804    /// References a source defined in the `[sources]` section of the manifest.
805    /// This field is `None` for local resources that don't come from Git repositories.
806    ///
807    /// Omitted from TOML serialization when `None`.
808    #[serde(skip_serializing_if = "Option::is_none")]
809    pub source: Option<String>,
810
811    /// Source repository URL for remote resources.
812    ///
813    /// The full Git repository URL where this resource originates.
814    /// This field is `None` for local resources.
815    ///
816    /// Omitted from TOML serialization when `None`.
817    #[serde(skip_serializing_if = "Option::is_none")]
818    pub url: Option<String>,
819
820    /// Path to the resource file.
821    ///
822    /// For remote resources, this is the relative path within the source repository.
823    /// For local resources, this is the filesystem path (may be relative or absolute).
824    pub path: String,
825
826    /// Resolved version for the resource.
827    ///
828    /// This stores the resolved version tag (e.g., "v1.0.0", "main") that was matched
829    /// by the version constraint in `agpm.toml`. Like Cargo.lock, this provides
830    /// human-readable context while `resolved_commit` ensures reproducibility.
831    /// For local resources or resources without versions, this field is `None`.
832    ///
833    /// Omitted from TOML serialization when `None`.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub version: Option<String>,
836
837    /// Resolved Git commit hash for remote resources.
838    ///
839    /// The exact 40-character SHA-1 commit hash where this resource was found.
840    /// This ensures reproducible installations even if the version constraint
841    /// could match multiple commits. For local resources, this field is `None`.
842    ///
843    /// Omitted from TOML serialization when `None`.
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub resolved_commit: Option<String>,
846
847    /// SHA-256 checksum of the installed file content.
848    ///
849    /// Used for integrity verification to detect file corruption or tampering.
850    /// The format is "sha256:" followed by the hexadecimal hash.
851    ///
852    /// Example: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
853    pub checksum: String,
854
855    /// SHA-256 checksum of the template rendering context (NEW FIELD).
856    ///
857    /// This is None for resources that don't use templating, and Some(checksum)
858    /// for templated resources. The checksum is computed from the canonical
859    /// serialization of the template context (dependencies, variant_inputs, etc.)
860    /// and is used to detect when template inputs change, even if the rendered
861    /// output happens to be identical.
862    #[serde(skip_serializing_if = "Option::is_none")]
863    pub context_checksum: Option<String>,
864
865    /// Installation path relative to the project root.
866    ///
867    /// Where the resource file is installed within the project directory.
868    /// This path is always relative to the project root and uses forward
869    /// slashes as separators for cross-platform compatibility.
870    ///
871    /// Examples: "agents/example-agent.md", "snippets/util-snippet.md"
872    pub installed_at: String,
873
874    /// Dependencies of this resource.
875    ///
876    /// Lists the direct dependencies that this resource requires, including
877    /// both manifest dependencies and transitive dependencies discovered from
878    /// the resource file itself. Each dependency is identified by its resource
879    /// type and name (e.g., "agents/helper-agent", "snippets/utils").
880    ///
881    /// This field enables dependency graph analysis and ensures all required
882    /// resources are installed. It follows the same model as Cargo.lock where
883    /// each package lists its dependencies.
884    ///
885    /// Always included in TOML serialization, even when empty, to match Cargo.lock format.
886    #[serde(default)]
887    pub dependencies: Vec<String>,
888
889    /// Resource type (agent, snippet, command, etc.)
890    ///
891    /// This field is populated during deserialization based on which TOML section
892    /// the resource came from (`[[agents]]`, `[[snippets]]`, etc.) and is used internally
893    /// for determining the correct lockfile section when adding/updating entries.
894    ///
895    /// It is never serialized to the lockfile - the section header provides this information.
896    #[serde(skip)]
897    pub resource_type: crate::core::ResourceType,
898
899    /// Tool type for multi-tool support (claude-code, opencode, agpm, custom).
900    ///
901    /// Specifies which target AI coding assistant tool this resource is for. This determines
902    /// where the resource is installed and how it's configured.
903    ///
904    /// When None during deserialization, will be set based on resource type's default
905    /// (e.g., snippets default to "agpm", others to "claude-code").
906    ///
907    /// Always serialized for clarity and to avoid ambiguity.
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub tool: Option<String>,
910
911    /// Original manifest alias for pattern-expanded dependencies.
912    ///
913    /// When a pattern dependency (e.g., `agents/helpers/*.md` with alias "all-helpers")
914    /// expands to multiple files, each file gets its own lockfile entry with a unique `name`
915    /// (e.g., "helper-alpha", "helper-beta"). The `manifest_alias` field preserves the
916    /// original pattern alias so patches defined under that alias can be correctly applied
917    /// to all matched files.
918    ///
919    /// For non-pattern dependencies, this field is `None` since `name` already represents
920    /// the manifest alias.
921    ///
922    /// Example lockfile entry for pattern-expanded resource:
923    /// ```toml
924    /// [[agents]]
925    /// name = "helper-alpha"                    # Individual file name
926    /// manifest_alias = "all-helpers"           # Original pattern alias
927    /// path = "agents/helpers/helper-alpha.md"
928    /// ...
929    /// ```
930    ///
931    /// This enables pattern patching: all files matched by "all-helpers" pattern can
932    /// have patches applied via `[patch.agents.all-helpers]` in the manifest.
933    ///
934    /// Omitted from TOML serialization when `None` (for non-pattern dependencies).
935    #[serde(skip_serializing_if = "Option::is_none")]
936    pub manifest_alias: Option<String>,
937
938    /// Applied patches from manifest configuration.
939    ///
940    /// Contains the key-value pairs that were applied to this resource's metadata
941    /// via `[patch.<resource-type>.<alias>]` sections in agpm.toml or agpm.private.toml.
942    ///
943    /// This enables reproducible installations and provides visibility into which
944    /// resources have been patched.
945    ///
946    /// Omitted from TOML serialization when empty.
947    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
948    pub applied_patches: BTreeMap<String, toml::Value>,
949
950    /// Whether this dependency should be installed to disk.
951    ///
952    /// When `false`, the dependency is resolved, fetched, and tracked in the lockfile,
953    /// but the file is not written to the project directory. Instead, its content is
954    /// made available in template context via `agpm.deps.<type>.<name>.content`.
955    ///
956    /// This is useful for snippet embedding use cases where you want to include
957    /// content inline rather than as a separate file.
958    ///
959    /// Defaults to `true` (install the file) for backwards compatibility.
960    ///
961    /// Omitted from TOML serialization when `None` or `true`.
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub install: Option<bool>,
964
965    /// Variant inputs for template rendering.
966    ///
967    /// Stores the template variable overrides that were specified in the manifest
968    /// for this dependency. These overrides are applied when rendering templates
969    /// to allow customization of generic templates for specific use cases.
970    ///
971    /// Encapsulates both the JSON value and its pre-computed hash for identity comparison.
972    /// The hash is not serialized and is recomputed after deserialization.
973    #[serde(
974        default = "default_variant_inputs_struct",
975        serialize_with = "serialize_variant_inputs_as_toml",
976        deserialize_with = "deserialize_variant_inputs_from_toml"
977    )]
978    pub variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
979}
980
981/// Builder for creating LockedResource instances.
982///
983/// This builder helps address clippy warnings about functions with too many arguments
984/// by providing a fluent interface for constructing LockedResource instances.
985pub struct LockedResourceBuilder {
986    name: String,
987    source: Option<String>,
988    url: Option<String>,
989    path: String,
990    version: Option<String>,
991    resolved_commit: Option<String>,
992    checksum: String,
993    installed_at: String,
994    dependencies: Vec<String>,
995    resource_type: crate::core::ResourceType,
996    tool: Option<String>,
997    manifest_alias: Option<String>,
998    applied_patches: BTreeMap<String, toml::Value>,
999    install: Option<bool>,
1000    context_checksum: Option<String>,
1001    variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
1002}
1003
1004impl LockedResourceBuilder {
1005    /// Create a new builder with the required fields.
1006    pub fn new(
1007        name: String,
1008        path: String,
1009        checksum: String,
1010        installed_at: String,
1011        resource_type: crate::core::ResourceType,
1012    ) -> Self {
1013        Self {
1014            name,
1015            source: None,
1016            url: None,
1017            path,
1018            version: None,
1019            resolved_commit: None,
1020            checksum,
1021            installed_at,
1022            dependencies: Vec::new(),
1023            resource_type,
1024            tool: None,
1025            manifest_alias: None,
1026            applied_patches: BTreeMap::new(),
1027            install: None,
1028            context_checksum: None,
1029            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1030        }
1031    }
1032
1033    /// Set the source repository name.
1034    pub fn source(mut self, source: Option<String>) -> Self {
1035        self.source = source;
1036        self
1037    }
1038
1039    /// Set the source repository URL.
1040    pub fn url(mut self, url: Option<String>) -> Self {
1041        self.url = url;
1042        self
1043    }
1044
1045    /// Set the version.
1046    pub fn version(mut self, version: Option<String>) -> Self {
1047        self.version = version;
1048        self
1049    }
1050
1051    /// Set the resolved commit.
1052    pub fn resolved_commit(mut self, resolved_commit: Option<String>) -> Self {
1053        self.resolved_commit = resolved_commit;
1054        self
1055    }
1056
1057    /// Set the dependencies.
1058    pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
1059        self.dependencies = dependencies;
1060        self
1061    }
1062
1063    /// Set the tool.
1064    pub fn tool(mut self, tool: Option<String>) -> Self {
1065        self.tool = tool;
1066        self
1067    }
1068
1069    /// Set the manifest alias.
1070    pub fn manifest_alias(mut self, manifest_alias: Option<String>) -> Self {
1071        self.manifest_alias = manifest_alias;
1072        self
1073    }
1074
1075    /// Set the applied patches.
1076    pub fn applied_patches(mut self, applied_patches: BTreeMap<String, toml::Value>) -> Self {
1077        self.applied_patches = applied_patches;
1078        self
1079    }
1080
1081    /// Set the install flag.
1082    pub fn install(mut self, install: Option<bool>) -> Self {
1083        self.install = install;
1084        self
1085    }
1086
1087    /// Set the context checksum.
1088    pub fn context_checksum(mut self, context_checksum: Option<String>) -> Self {
1089        self.context_checksum = context_checksum;
1090        self
1091    }
1092
1093    /// Set the variant inputs.
1094    pub fn variant_inputs(
1095        mut self,
1096        variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
1097    ) -> Self {
1098        self.variant_inputs = variant_inputs;
1099        self
1100    }
1101
1102    /// Build the LockedResource.
1103    pub fn build(self) -> LockedResource {
1104        LockedResource {
1105            name: self.name,
1106            source: self.source,
1107            url: self.url,
1108            path: self.path,
1109            version: self.version,
1110            resolved_commit: self.resolved_commit,
1111            checksum: self.checksum,
1112            context_checksum: self.context_checksum,
1113            installed_at: self.installed_at,
1114            dependencies: self.dependencies,
1115            resource_type: self.resource_type,
1116            tool: self.tool,
1117            manifest_alias: self.manifest_alias,
1118            applied_patches: self.applied_patches,
1119            install: self.install,
1120            variant_inputs: self.variant_inputs,
1121        }
1122    }
1123}
1124
1125impl LockedResource {
1126    /// Unique identifier combining name, source, tool, and variant_inputs hash.
1127    ///
1128    /// Canonical method for resource identification in checksum updates and lookups.
1129    #[must_use]
1130    pub fn id(&self) -> ResourceId {
1131        ResourceId::from_resource(self)
1132    }
1133
1134    /// Check if resource matches ResourceId by comparing name, source, tool, and variant_inputs hash.
1135    ///
1136    /// Variant_inputs hash is part of identity - same resource with different variant_inputs
1137    /// produces different artifacts and must be tracked separately.
1138    #[must_use]
1139    pub fn matches_id(&self, id: &ResourceId) -> bool {
1140        self.name == id.name
1141            && self.source == id.source
1142            && self.tool == id.tool
1143            && self.variant_inputs.hash() == id.variant_inputs_hash
1144    }
1145
1146    /// Parse the dependencies field into structured lockfile dependency references.
1147    ///
1148    /// Returns an iterator over successfully parsed dependency references.
1149    /// Invalid references are logged as warnings and skipped.
1150    ///
1151    /// This is the centralized way to parse lockfile dependencies, ensuring
1152    /// consistent handling of the lockfile format across the codebase.
1153    ///
1154    /// # Examples
1155    ///
1156    /// ```rust,no_run
1157    /// # use agpm_cli::lockfile::LockedResource;
1158    /// # let resource: LockedResource = unimplemented!();
1159    /// for dep in resource.parsed_dependencies() {
1160    ///     println!("Dependency: {} (type: {})", dep.path, dep.resource_type);
1161    /// }
1162    /// ```
1163    pub fn parsed_dependencies(
1164        &self,
1165    ) -> impl Iterator<Item = lockfile_dependency_ref::LockfileDependencyRef> + '_ {
1166        use std::str::FromStr;
1167
1168        self.dependencies.iter().filter_map(|dep_str| {
1169            lockfile_dependency_ref::LockfileDependencyRef::from_str(dep_str)
1170                .map_err(|e| {
1171                    tracing::warn!(
1172                        "Failed to parse dependency '{}' for resource '{}': {}",
1173                        dep_str,
1174                        self.name,
1175                        e
1176                    );
1177                })
1178                .ok()
1179        })
1180    }
1181
1182    /// Create a new LockedResource with template_vars serialization handled.
1183    ///
1184    /// This constructor handles the serialization of template_vars from serde_json::Value
1185    /// to the stored String format, ensuring consistency across lockfile entries.
1186    ///
1187    /// # Deprecated
1188    ///
1189    /// This method has too many parameters and triggers clippy warnings.
1190    /// Use `LockedResourceBuilder::new()` instead for a cleaner API.
1191    #[allow(deprecated)]
1192    #[deprecated(since = "0.5.0", note = "Use LockedResourceBuilder::new() instead")]
1193    #[allow(clippy::too_many_arguments)]
1194    pub fn new(
1195        name: String,
1196        source: Option<String>,
1197        url: Option<String>,
1198        path: String,
1199        version: Option<String>,
1200        resolved_commit: Option<String>,
1201        checksum: String,
1202        installed_at: String,
1203        dependencies: Vec<String>,
1204        resource_type: crate::core::ResourceType,
1205        tool: Option<String>,
1206        manifest_alias: Option<String>,
1207        applied_patches: BTreeMap<String, toml::Value>,
1208        install: Option<bool>,
1209        variant_inputs: serde_json::Value,
1210    ) -> Self {
1211        LockedResourceBuilder::new(name, path, checksum, installed_at, resource_type)
1212            .source(source)
1213            .url(url)
1214            .version(version)
1215            .resolved_commit(resolved_commit)
1216            .dependencies(dependencies)
1217            .tool(tool)
1218            .manifest_alias(manifest_alias)
1219            .applied_patches(applied_patches)
1220            .install(install)
1221            .variant_inputs(crate::resolver::lockfile_builder::VariantInputs::new(variant_inputs))
1222            .build()
1223    }
1224
1225    /// Get the display name for user-facing contexts.
1226    ///
1227    /// Returns the manifest_alias if present (for direct manifest dependencies or
1228    /// pattern-expanded resources), otherwise returns the canonical name.
1229    /// This provides the most user-friendly name for display purposes.
1230    ///
1231    /// # Examples
1232    ///
1233    /// ```rust,ignore
1234    /// # use agpm_cli::lockfile::LockedResource;
1235    /// // Direct dependency with custom manifest name
1236    /// let resource = LockedResource {
1237    ///     name: "ai-helper".to_string(),  // canonical name from path
1238    ///     manifest_alias: Some("my-ai-helper".to_string()),  // user's chosen name
1239    ///     // ... other fields
1240    /// };
1241    /// assert_eq!(resource.display_name(), "my-ai-helper");
1242    ///
1243    /// // Pattern-expanded dependency
1244    /// let resource = LockedResource {
1245    ///     name: "helper-alpha".to_string(),  // canonical name
1246    ///     manifest_alias: Some("all-helpers".to_string()),  // pattern alias
1247    ///     // ... other fields
1248    /// };
1249    /// assert_eq!(resource.display_name(), "all-helpers");
1250    ///
1251    /// // Transitive dependency (no manifest_alias)
1252    /// let resource = LockedResource {
1253    ///     name: "utils".to_string(),  // canonical name
1254    ///     manifest_alias: None,
1255    ///     // ... other fields
1256    /// };
1257    /// assert_eq!(resource.display_name(), "utils");
1258    /// ```
1259    #[must_use]
1260    pub fn display_name(&self) -> &str {
1261        self.manifest_alias.as_ref().unwrap_or(&self.name)
1262    }
1263
1264    /// Get the lookup name for patch resolution and manifest lookups.
1265    ///
1266    /// Returns the manifest_alias if present (for direct manifest dependencies or
1267    /// pattern-expanded resources), otherwise returns the canonical name.
1268    /// This ensures patches are looked up using the correct manifest key.
1269    ///
1270    /// # Examples
1271    ///
1272    /// ```rust,ignore
1273    /// # use agpm_cli::lockfile::LockedResource;
1274    /// // Direct dependency - patches defined under manifest key
1275    /// let resource = LockedResource {
1276    ///     name: "ai-helper".to_string(),  // canonical name from path
1277    ///     manifest_alias: Some("my-ai-helper".to_string()),  // manifest key
1278    ///     // ... other fields
1279    /// };
1280    /// assert_eq!(resource.lookup_name(), "my-ai-helper");
1281    ///
1282    /// // Transitive dependency - no manifest key
1283    /// let resource = LockedResource {
1284    ///     name: "utils".to_string(),  // canonical name
1285    ///     manifest_alias: None,
1286    ///     // ... other fields
1287    /// };
1288    /// assert_eq!(resource.lookup_name(), "utils");
1289    /// ```
1290    #[must_use]
1291    pub fn lookup_name(&self) -> &str {
1292        self.manifest_alias.as_ref().unwrap_or(&self.name)
1293    }
1294
1295    /// Check if this resource represents a direct manifest dependency.
1296    ///
1297    /// Returns true if this resource was directly specified in the manifest
1298    /// (not discovered through transitive dependencies or pattern expansion).
1299    ///
1300    /// # Examples
1301    ///
1302    /// ```rust,ignore
1303    /// # use agpm_cli::lockfile::LockedResource;
1304    /// // Direct dependency from manifest
1305    /// let resource = LockedResource {
1306    ///     name: "ai-helper".to_string(),
1307    ///     manifest_alias: Some("my-ai-helper".to_string()),
1308    ///     // ... other fields
1309    /// };
1310    /// assert!(resource.is_direct_manifest());
1311    ///
1312    /// // Transitive dependency
1313    /// let resource = LockedResource {
1314    ///     name: "utils".to_string(),
1315    ///     manifest_alias: None,
1316    ///     // ... other fields
1317    /// };
1318    /// assert!(!resource.is_direct_manifest());
1319    /// ```
1320    #[must_use]
1321    pub fn is_direct_manifest(&self) -> bool {
1322        // After the canonical naming change, direct dependencies will have manifest_alias set
1323        // to preserve the original manifest key. Transitive dependencies have no manifest_alias.
1324        self.manifest_alias.is_some() && !self.name.starts_with("generated-")
1325    }
1326
1327    /// Check if this resource came from a pattern expansion.
1328    ///
1329    /// Returns true if this resource was created by expanding a pattern dependency
1330    /// from the manifest.
1331    ///
1332    /// # Examples
1333    ///
1334    /// ```rust,ignore
1335    /// # use agpm_cli::lockfile::LockedResource;
1336    /// // Pattern-expanded resource
1337    /// let resource = LockedResource {
1338    ///     name: "helper-alpha".to_string(),
1339    ///     manifest_alias: Some("all-helpers".to_string()),
1340    ///     // ... other fields
1341    /// };
1342    /// assert!(resource.is_pattern_expanded());
1343    ///
1344    /// // Direct dependency (single file)
1345    /// let resource = LockedResource {
1346    ///     name: "ai-helper".to_string(),
1347    ///     manifest_alias: None,  // Will change after implementation
1348    ///     // ... other fields
1349    /// };
1350    /// assert!(!resource.is_pattern_expanded());
1351    /// ```
1352    #[must_use]
1353    pub fn is_pattern_expanded(&self) -> bool {
1354        self.manifest_alias.is_some()
1355    }
1356}
1357
1358// Submodules for organized implementation
1359mod checksum;
1360mod helpers;
1361mod io;
1362pub mod lockfile_dependency_ref;
1363pub mod private_lock;
1364mod resource_ops;
1365mod validation;
1366pub use private_lock::PrivateLockFile;
1367
1368// Patch display utilities (currently unused - TODO: integrate with Cache API)
1369#[allow(dead_code)]
1370pub mod patch_display;
1371
1372impl LockFile {
1373    /// Current lockfile format version.
1374    ///
1375    /// This constant defines the lockfile format version that this version of AGPM
1376    /// generates. It's used for compatibility checking when loading lockfiles that
1377    /// may have been created by different versions of AGPM.
1378    const CURRENT_VERSION: u32 = 1;
1379
1380    /// Create a new empty lockfile with the current format version.
1381    ///
1382    /// Returns a fresh lockfile with no sources or resources. This is typically
1383    /// used when initializing a new project or regenerating a lockfile from scratch.
1384    ///
1385    /// # Examples
1386    ///
1387    /// ```rust,no_run
1388    /// use agpm_cli::lockfile::LockFile;
1389    ///
1390    /// let lockfile = LockFile::new();
1391    /// assert_eq!(lockfile.version, 1);
1392    /// assert!(lockfile.sources.is_empty());
1393    /// assert!(lockfile.agents.is_empty());
1394    /// assert!(lockfile.snippets.is_empty());
1395    /// ```
1396    #[must_use]
1397    pub const fn new() -> Self {
1398        Self {
1399            version: Self::CURRENT_VERSION,
1400            sources: Vec::new(),
1401            agents: Vec::new(),
1402            snippets: Vec::new(),
1403            commands: Vec::new(),
1404            mcp_servers: Vec::new(),
1405            scripts: Vec::new(),
1406            hooks: Vec::new(),
1407        }
1408    }
1409}
1410
1411impl LockFile {
1412    /// Normalize lockfile entries for backward compatibility.
1413    ///
1414    /// Converts old lockfile entries that don't follow the canonical naming convention
1415    /// to use canonical names with manifest_alias. This ensures that lockfiles created
1416    /// before the naming change remain compatible with the new system.
1417    ///
1418    /// # Returns
1419    ///
1420    /// A new LockFile with normalized entries
1421    ///
1422    /// # Examples
1423    ///
1424    /// ```rust,no_run
1425    /// # use agpm_cli::lockfile::LockFile;
1426    /// let mut lockfile = LockFile::new();
1427    /// // Load or create entries...
1428    /// let normalized = lockfile.normalize();
1429    /// ```
1430    pub fn normalize(&self) -> Self {
1431        let mut normalized = self.clone();
1432
1433        // Normalize each resource type
1434        Self::normalize_resources(&mut normalized.agents);
1435        Self::normalize_resources(&mut normalized.snippets);
1436        Self::normalize_resources(&mut normalized.commands);
1437        Self::normalize_resources(&mut normalized.scripts);
1438        Self::normalize_resources(&mut normalized.hooks);
1439        Self::normalize_resources(&mut normalized.mcp_servers);
1440
1441        // Sort all resource vectors for deterministic lockfile output
1442        // This ensures the lockfile is identical across runs regardless of
1443        // HashMap iteration order during dependency resolution
1444        normalized.agents.sort_by(Self::compare_resources);
1445        normalized.snippets.sort_by(Self::compare_resources);
1446        normalized.commands.sort_by(Self::compare_resources);
1447        normalized.scripts.sort_by(Self::compare_resources);
1448        normalized.hooks.sort_by(Self::compare_resources);
1449        normalized.mcp_servers.sort_by(Self::compare_resources);
1450
1451        normalized
1452    }
1453
1454    /// Compare two resources for deterministic sorting.
1455    ///
1456    /// Sort order:
1457    /// 1. By name (lexicographic)
1458    /// 2. By source (None first, then lexicographic)
1459    /// 3. By tool (None first, then lexicographic)
1460    /// 4. By template_vars (lexicographic comparison of JSON strings)
1461    ///
1462    /// This ensures stable, deterministic lockfile ordering even when the same resource
1463    /// exists with different template_vars (e.g., backend-engineer with language=typescript
1464    /// vs language=javascript).
1465    fn compare_resources(a: &LockedResource, b: &LockedResource) -> std::cmp::Ordering {
1466        a.name
1467            .cmp(&b.name)
1468            .then_with(|| a.source.cmp(&b.source))
1469            .then_with(|| a.tool.cmp(&b.tool))
1470            .then_with(|| a.variant_inputs.hash().cmp(b.variant_inputs.hash()))
1471    }
1472
1473    /// Normalize a vector of LockedResource entries.
1474    ///
1475    /// For each entry that doesn't follow canonical naming:
1476    /// - Compute canonical name from path
1477    /// - Move current name to manifest_alias if not already set
1478    /// - Update name to canonical value
1479    /// - Sort dependencies array for deterministic output
1480    fn normalize_resources(resources: &mut [LockedResource]) {
1481        use crate::resolver::pattern_expander::generate_dependency_name;
1482
1483        for resource in resources.iter_mut() {
1484            // Sort dependencies array for deterministic lockfile output
1485            // This ensures dependencies appear in consistent order regardless of
1486            // HashMap iteration order during resolution
1487            resource.dependencies.sort();
1488
1489            // Skip if already has manifest_alias (indicating it's already normalized)
1490            if resource.manifest_alias.is_some() {
1491                continue;
1492            }
1493
1494            // Compute expected canonical name from path using appropriate source context
1495            let canonical_name = if let Some(source_name) = &resource.source {
1496                // Remote resource - use source name context
1497                let source_context =
1498                    crate::resolver::source_context::SourceContext::remote(source_name);
1499                generate_dependency_name(&resource.path, &source_context)
1500            } else {
1501                // Local resource - handle absolute vs relative paths correctly
1502                let path = std::path::Path::new(&resource.path);
1503                if path.is_absolute() {
1504                    // Absolute paths keep their absolute form (with file extension removed)
1505                    let without_ext = path.with_extension("");
1506                    crate::utils::normalize_path_for_storage(without_ext)
1507                } else {
1508                    // Relative paths - we don't have manifest context here, so use "local" prefix
1509                    // This ensures consistency but may not match the exact manifest-relative path
1510                    let source_context =
1511                        crate::resolver::source_context::SourceContext::remote("local");
1512                    generate_dependency_name(&resource.path, &source_context)
1513                }
1514            };
1515
1516            // Skip if already has the correct canonical name
1517            if resource.name == canonical_name {
1518                continue;
1519            }
1520
1521            // Move current name to manifest_alias and update to canonical name
1522            resource.manifest_alias = Some(resource.name.clone());
1523            resource.name = canonical_name;
1524        }
1525    }
1526}
1527
1528impl Default for LockFile {
1529    /// Equivalent to [`LockFile::new()`] - creates empty lockfile with current format version.
1530    fn default() -> Self {
1531        Self::new()
1532    }
1533}
1534
1535/// Default value for `template_vars` field.
1536///
1537/// Returns an empty JSON object which will serialize as `"{}"` in TOML.
1538fn default_variant_inputs_struct() -> crate::resolver::lockfile_builder::VariantInputs {
1539    crate::resolver::lockfile_builder::VariantInputs::new(serde_json::Value::Object(
1540        serde_json::Map::new(),
1541    ))
1542}
1543
1544/// Serialize `VariantInputs` as a TOML table.
1545///
1546/// Converts the internal JSON value to a TOML value to enable proper nested table serialization.
1547fn serialize_variant_inputs_as_toml<S>(
1548    variant_inputs: &crate::resolver::lockfile_builder::VariantInputs,
1549    serializer: S,
1550) -> Result<S::Ok, S::Error>
1551where
1552    S: serde::Serializer,
1553{
1554    use crate::lockfile::patch_display::json_to_toml_value;
1555
1556    let toml_value = json_to_toml_value(variant_inputs.json()).map_err(|e| {
1557        serde::ser::Error::custom(format!("Failed to convert variant_inputs to TOML: {}", e))
1558    })?;
1559    toml_value.serialize(serializer)
1560}
1561
1562/// Deserialize `VariantInputs` from a TOML value.
1563///
1564/// Converts the TOML value back to JSON for internal storage.
1565fn deserialize_variant_inputs_from_toml<'de, D>(
1566    deserializer: D,
1567) -> Result<crate::resolver::lockfile_builder::VariantInputs, D::Error>
1568where
1569    D: serde::Deserializer<'de>,
1570{
1571    use crate::manifest::patches::toml_value_to_json;
1572
1573    let toml_value = toml::Value::deserialize(deserializer)?;
1574    let json_value = toml_value_to_json(&toml_value).map_err(|e| {
1575        serde::de::Error::custom(format!("Failed to convert TOML to variant_inputs: {}", e))
1576    })?;
1577    Ok(crate::resolver::lockfile_builder::VariantInputs::new(json_value))
1578}
1579
1580/// Find the lockfile in the current or parent directories.
1581///
1582/// Searches upward from the current working directory to find a `agpm.lock` file,
1583/// similar to how Git searches for `.git` directories. This enables running AGPM
1584/// commands from subdirectories within a project.
1585///
1586/// # Search Algorithm
1587///
1588/// 1. Start from current working directory
1589/// 2. Check for `agpm.lock` in current directory
1590/// 3. If found, return the path
1591/// 4. If not found, move to parent directory
1592/// 5. Repeat until root directory is reached
1593/// 6. Return `None` if no lockfile found
1594///
1595/// # Returns
1596///
1597/// * `Some(PathBuf)` - Path to the found lockfile
1598/// * `None` - No lockfile found in current or parent directories
1599///
1600/// # Examples
1601///
1602/// ```rust,no_run
1603/// use agpm_cli::lockfile::find_lockfile;
1604///
1605/// if let Some(lockfile_path) = find_lockfile() {
1606///     println!("Found lockfile: {}", lockfile_path.display());
1607/// } else {
1608///     println!("No lockfile found (run 'agpm install' to create one)");
1609/// }
1610/// ```
1611///
1612/// # Use Cases
1613///
1614/// - **CLI commands**: Find project root when run from subdirectories
1615/// - **Editor integration**: Locate project configuration
1616/// - **Build scripts**: Find lockfile for dependency information
1617/// - **Validation tools**: Check if project has lockfile
1618///
1619/// # Directory Structure Example
1620///
1621/// ```text
1622/// project/
1623/// ├── agpm.lock          # ← This will be found
1624/// ├── agpm.toml
1625/// └── src/
1626///     └── subdir/         # ← Commands run from here will find ../agpm.lock
1627/// ```
1628///
1629/// # Errors
1630///
1631/// This function does not return errors but rather `None` if:
1632/// - Cannot get current working directory (permission issues)
1633/// - No lockfile exists in the directory tree
1634/// - IO errors while checking file existence
1635///
1636/// For more robust error handling, consider using [`LockFile::load`] directly
1637/// with a known path.
1638#[must_use]
1639pub fn find_lockfile() -> Option<PathBuf> {
1640    let mut current = std::env::current_dir().ok()?;
1641
1642    loop {
1643        let lockfile_path = current.join("agpm.lock");
1644        if lockfile_path.exists() {
1645            return Some(lockfile_path);
1646        }
1647
1648        if !current.pop() {
1649            return None;
1650        }
1651    }
1652}