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, PartialOrd, Ord)]
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 /// Locked skill resources with their exact versions and checksums.
699 ///
700 /// Skills are directory-based resources (unlike single-file agents/snippets)
701 /// that contain a `SKILL.md` file plus supporting files. Contains all resolved
702 /// skill dependencies with exact commit hashes, installation paths, and checksums.
703 ///
704 /// This field is omitted from TOML serialization if empty.
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
706 pub skills: Vec<LockedResource>,
707
708 /// Hash of manifest dependency specifications for fast path detection.
709 ///
710 /// This field stores a SHA-256 hash of the manifest's dependency sections
711 /// (sources, agents, commands, etc.) at the time of resolution. On subsequent
712 /// installs, if this hash matches the current manifest, we can skip resolution
713 /// entirely for immutable dependencies.
714 ///
715 /// This field is omitted from TOML serialization if None (for backward compatibility).
716 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub manifest_hash: Option<String>,
718
719 /// Whether the lockfile contains any mutable dependencies.
720 ///
721 /// Mutable dependencies include:
722 /// - Local file sources (no URL)
723 /// - Branch-based versions (e.g., "main", "develop")
724 ///
725 /// When true, even if the manifest hash matches, we must re-validate
726 /// mutable dependencies as they may have changed. When false, we can
727 /// use the full fast path and skip resolution entirely.
728 ///
729 /// This field is omitted from TOML serialization if None (for backward compatibility).
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub has_mutable_deps: Option<bool>,
732
733 /// Total count of resources in the lockfile at time of resolution.
734 ///
735 /// This field is used for lockfile integrity validation during fast path checks.
736 /// If the stored count doesn't match the actual resource count in the lockfile,
737 /// it indicates the lockfile was manually edited and we should fall back to
738 /// full resolution.
739 ///
740 /// This field is omitted from TOML serialization if None (for backward compatibility).
741 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub resource_count: Option<usize>,
743}
744
745/// A locked source repository with resolved commit information.
746///
747/// Represents a Git repository that has been fetched and resolved to an exact
748/// commit hash. This ensures reproducible access to source repositories across
749/// different environments and times.
750///
751/// # Fields
752///
753/// - **name**: Unique identifier used in the manifest to reference this source
754/// - **url**: Full Git repository URL (HTTP/HTTPS/SSH)
755/// - **commit**: 40-character SHA-1 commit hash resolved at time of lock
756/// - **`fetched_at`**: RFC 3339 timestamp of when the repository was last fetched
757///
758/// # Examples
759///
760/// A typical locked source in TOML format:
761///
762/// ```toml
763/// [[sources]]
764/// name = "community"
765/// url = "https://github.com/example/agpm-community.git"
766/// commit = "a1b2c3d4e5f6789abcdef0123456789abcdef012"
767/// fetched_at = "2024-01-15T10:30:00Z"
768/// ```
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct LockedSource {
771 /// Unique source name from the manifest.
772 ///
773 /// This corresponds to keys in the `[sources]` section of `agpm.toml`
774 /// and is used to reference the source in resource definitions.
775 pub name: String,
776
777 /// Full Git repository URL.
778 ///
779 /// Supports HTTP, HTTPS, and SSH URLs. This is the exact URL used
780 /// for cloning and fetching the repository.
781 pub url: String,
782
783 /// Timestamp of last successful fetch in RFC 3339 format.
784 ///
785 /// Records when the repository was last fetched from the remote.
786 /// This helps track staleness and debugging fetch issues.
787 pub fetched_at: String,
788}
789
790/// A locked resource (agent or snippet) with resolved version and integrity information.
791///
792/// Represents a specific resource file that has been resolved from either a source
793/// repository or local filesystem. Contains all information needed to verify the
794/// exact version and integrity of the installed resource.
795///
796/// # Local vs Remote Resources
797///
798/// Remote resources (from Git repositories) include:
799/// - `source`: Source repository name
800/// - `url`: Repository URL
801/// - `version`: Original version constraint
802/// - `resolved_commit`: Exact commit containing the resource
803///
804/// Local resources (from filesystem) omit these fields since they don't
805/// involve Git repositories.
806///
807/// # Integrity Verification
808///
809/// All resources include a SHA-256 checksum for integrity verification.
810/// The checksum is calculated from the file content after installation
811/// and can be used to detect corruption or tampering.
812///
813/// # Examples
814///
815/// Remote resource in TOML format:
816///
817/// ```toml
818/// [[agents]]
819/// name = "example-agent"
820/// source = "community"
821/// url = "https://github.com/example/repo.git"
822/// path = "agents/example.md"
823/// version = "^1.0"
824/// resolved_commit = "a1b2c3d4e5f6..."
825/// checksum = "sha256:abcdef123456..."
826/// installed_at = "agents/example-agent.md"
827/// ```
828///
829/// Local resource in TOML format:
830///
831/// ```toml
832/// [[agents]]
833/// name = "local-helper"
834/// path = "../local/helper.md"
835/// checksum = "sha256:fedcba654321..."
836/// installed_at = "agents/local-helper.md"
837/// ```
838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
839pub struct LockedResource {
840 /// Resource name from the manifest.
841 ///
842 /// This corresponds to keys in the `[agents]` or `[snippets]` sections
843 /// of the manifest. Resources are uniquely identified by the combination
844 /// of (name, source), allowing multiple sources to provide resources with
845 /// the same name.
846 pub name: String,
847
848 /// Source repository name for remote resources.
849 ///
850 /// References a source defined in the `[sources]` section of the manifest.
851 /// This field is `None` for local resources that don't come from Git repositories.
852 ///
853 /// Omitted from TOML serialization when `None`.
854 #[serde(skip_serializing_if = "Option::is_none")]
855 pub source: Option<String>,
856
857 /// Source repository URL for remote resources.
858 ///
859 /// The full Git repository URL where this resource originates.
860 /// This field is `None` for local resources.
861 ///
862 /// Omitted from TOML serialization when `None`.
863 #[serde(skip_serializing_if = "Option::is_none")]
864 pub url: Option<String>,
865
866 /// Path to the resource file.
867 ///
868 /// For remote resources, this is the relative path within the source repository.
869 /// For local resources, this is the filesystem path (may be relative or absolute).
870 pub path: String,
871
872 /// Resolved version for the resource.
873 ///
874 /// This stores the resolved version tag (e.g., "v1.0.0", "main") that was matched
875 /// by the version constraint in `agpm.toml`. Like Cargo.lock, this provides
876 /// human-readable context while `resolved_commit` ensures reproducibility.
877 /// For local resources or resources without versions, this field is `None`.
878 ///
879 /// Omitted from TOML serialization when `None`.
880 #[serde(skip_serializing_if = "Option::is_none")]
881 pub version: Option<String>,
882
883 /// Resolved Git commit hash for remote resources.
884 ///
885 /// The exact 40-character SHA-1 commit hash where this resource was found.
886 /// This ensures reproducible installations even if the version constraint
887 /// could match multiple commits. For local resources, this field is `None`.
888 ///
889 /// Omitted from TOML serialization when `None`.
890 #[serde(skip_serializing_if = "Option::is_none")]
891 pub resolved_commit: Option<String>,
892
893 /// SHA-256 checksum of the installed file content.
894 ///
895 /// Used for integrity verification to detect file corruption or tampering.
896 /// The format is "sha256:" followed by the hexadecimal hash.
897 ///
898 /// Example: "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
899 pub checksum: String,
900
901 /// SHA-256 checksum of the template rendering context (NEW FIELD).
902 ///
903 /// This is None for resources that don't use templating, and Some(checksum)
904 /// for templated resources. The checksum is computed from the canonical
905 /// serialization of the template context (dependencies, variant_inputs, etc.)
906 /// and is used to detect when template inputs change, even if the rendered
907 /// output happens to be identical.
908 #[serde(skip_serializing_if = "Option::is_none")]
909 pub context_checksum: Option<String>,
910
911 /// Installation path relative to the project root.
912 ///
913 /// Where the resource file is installed within the project directory.
914 /// This path is always relative to the project root and uses forward
915 /// slashes as separators for cross-platform compatibility.
916 ///
917 /// Examples: "agents/example-agent.md", "snippets/util-snippet.md"
918 pub installed_at: String,
919
920 /// Dependencies of this resource.
921 ///
922 /// Lists the direct dependencies that this resource requires, including
923 /// both manifest dependencies and transitive dependencies discovered from
924 /// the resource file itself. Each dependency is identified by its resource
925 /// type and name (e.g., "agents/helper-agent", "snippets/utils").
926 ///
927 /// This field enables dependency graph analysis and ensures all required
928 /// resources are installed. It follows the same model as Cargo.lock where
929 /// each package lists its dependencies.
930 ///
931 /// Always included in TOML serialization, even when empty, to match Cargo.lock format.
932 #[serde(default)]
933 pub dependencies: Vec<String>,
934
935 /// Resource type (agent, snippet, command, etc.)
936 ///
937 /// This field is populated during deserialization based on which TOML section
938 /// the resource came from (`[[agents]]`, `[[snippets]]`, etc.) and is used internally
939 /// for determining the correct lockfile section when adding/updating entries.
940 ///
941 /// It is never serialized to the lockfile - the section header provides this information.
942 #[serde(skip)]
943 pub resource_type: crate::core::ResourceType,
944
945 /// Tool type for multi-tool support (claude-code, opencode, agpm, custom).
946 ///
947 /// Specifies which target AI coding assistant tool this resource is for. This determines
948 /// where the resource is installed and how it's configured.
949 ///
950 /// When None during deserialization, will be set based on resource type's default
951 /// (e.g., snippets default to "agpm", others to "claude-code").
952 ///
953 /// Always serialized for clarity and to avoid ambiguity.
954 #[serde(skip_serializing_if = "Option::is_none")]
955 pub tool: Option<String>,
956
957 /// Original manifest alias for pattern-expanded dependencies.
958 ///
959 /// When a pattern dependency (e.g., `agents/helpers/*.md` with alias "all-helpers")
960 /// expands to multiple files, each file gets its own lockfile entry with a unique `name`
961 /// (e.g., "helper-alpha", "helper-beta"). The `manifest_alias` field preserves the
962 /// original pattern alias so patches defined under that alias can be correctly applied
963 /// to all matched files.
964 ///
965 /// For non-pattern dependencies, this field is `None` since `name` already represents
966 /// the manifest alias.
967 ///
968 /// Example lockfile entry for pattern-expanded resource:
969 /// ```toml
970 /// [[agents]]
971 /// name = "helper-alpha" # Individual file name
972 /// manifest_alias = "all-helpers" # Original pattern alias
973 /// path = "agents/helpers/helper-alpha.md"
974 /// ...
975 /// ```
976 ///
977 /// This enables pattern patching: all files matched by "all-helpers" pattern can
978 /// have patches applied via `[patch.agents.all-helpers]` in the manifest.
979 ///
980 /// Omitted from TOML serialization when `None` (for non-pattern dependencies).
981 #[serde(skip_serializing_if = "Option::is_none")]
982 pub manifest_alias: Option<String>,
983
984 /// Applied patches from manifest configuration.
985 ///
986 /// Contains the key-value pairs that were applied to this resource's metadata
987 /// via `[patch.<resource-type>.<alias>]` sections in agpm.toml or agpm.private.toml.
988 ///
989 /// This enables reproducible installations and provides visibility into which
990 /// resources have been patched.
991 ///
992 /// Omitted from TOML serialization when empty.
993 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
994 pub applied_patches: BTreeMap<String, toml::Value>,
995
996 /// Whether this dependency should be installed to disk.
997 ///
998 /// When `false`, the dependency is resolved, fetched, and tracked in the lockfile,
999 /// but the file is not written to the project directory. Instead, its content is
1000 /// made available in template context via `agpm.deps.<type>.<name>.content`.
1001 ///
1002 /// This is useful for snippet embedding use cases where you want to include
1003 /// content inline rather than as a separate file.
1004 ///
1005 /// Defaults to `true` (install the file) for backwards compatibility.
1006 ///
1007 /// Omitted from TOML serialization when `None` or `true`.
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub install: Option<bool>,
1010
1011 /// Variant inputs for template rendering.
1012 ///
1013 /// Stores the template variable overrides that were specified in the manifest
1014 /// for this dependency. These overrides are applied when rendering templates
1015 /// to allow customization of generic templates for specific use cases.
1016 ///
1017 /// Encapsulates both the JSON value and its pre-computed hash for identity comparison.
1018 /// The hash is not serialized and is recomputed after deserialization.
1019 #[serde(
1020 default = "default_variant_inputs_struct",
1021 serialize_with = "serialize_variant_inputs_as_toml",
1022 deserialize_with = "deserialize_variant_inputs_from_toml"
1023 )]
1024 pub variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
1025
1026 /// Whether this resource came from agpm.private.toml.
1027 ///
1028 /// Private resources:
1029 /// - Install to `{resource_path}/private/` subdirectory
1030 /// - Are tracked in `agpm.private.lock` instead of `agpm.lock`
1031 /// - Don't affect team lockfile consistency
1032 ///
1033 /// Omitted from TOML serialization when false (the default).
1034 #[serde(default, skip_serializing_if = "is_false")]
1035 pub is_private: bool,
1036
1037 /// Approximate token count of the installed file content.
1038 ///
1039 /// Computed using cl100k BPE encoding (compatible with Claude/GPT-4).
1040 /// Used for tracking context usage and warning on large resources.
1041 ///
1042 /// Omitted from TOML serialization when `None`.
1043 #[serde(skip_serializing_if = "Option::is_none")]
1044 pub approximate_token_count: Option<u64>,
1045}
1046
1047/// Helper function for serde skip_serializing_if on bool fields.
1048fn is_false(b: &bool) -> bool {
1049 !*b
1050}
1051
1052/// Builder for creating LockedResource instances.
1053///
1054/// This builder helps address clippy warnings about functions with too many arguments
1055/// by providing a fluent interface for constructing LockedResource instances.
1056pub struct LockedResourceBuilder {
1057 name: String,
1058 source: Option<String>,
1059 url: Option<String>,
1060 path: String,
1061 version: Option<String>,
1062 resolved_commit: Option<String>,
1063 checksum: String,
1064 installed_at: String,
1065 dependencies: Vec<String>,
1066 resource_type: crate::core::ResourceType,
1067 tool: Option<String>,
1068 manifest_alias: Option<String>,
1069 applied_patches: BTreeMap<String, toml::Value>,
1070 install: Option<bool>,
1071 context_checksum: Option<String>,
1072 variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
1073 is_private: bool,
1074 approximate_token_count: Option<u64>,
1075}
1076
1077impl LockedResourceBuilder {
1078 /// Create a new builder with the required fields.
1079 pub fn new(
1080 name: String,
1081 path: String,
1082 checksum: String,
1083 installed_at: String,
1084 resource_type: crate::core::ResourceType,
1085 ) -> Self {
1086 Self {
1087 name,
1088 source: None,
1089 url: None,
1090 path,
1091 version: None,
1092 resolved_commit: None,
1093 checksum,
1094 installed_at,
1095 dependencies: Vec::new(),
1096 resource_type,
1097 tool: None,
1098 manifest_alias: None,
1099 applied_patches: BTreeMap::new(),
1100 install: None,
1101 context_checksum: None,
1102 variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1103 is_private: false,
1104 approximate_token_count: None,
1105 }
1106 }
1107
1108 /// Set the source repository name.
1109 pub fn source(mut self, source: Option<String>) -> Self {
1110 self.source = source;
1111 self
1112 }
1113
1114 /// Set the source repository URL.
1115 pub fn url(mut self, url: Option<String>) -> Self {
1116 self.url = url;
1117 self
1118 }
1119
1120 /// Set the version.
1121 pub fn version(mut self, version: Option<String>) -> Self {
1122 self.version = version;
1123 self
1124 }
1125
1126 /// Set the resolved commit.
1127 pub fn resolved_commit(mut self, resolved_commit: Option<String>) -> Self {
1128 self.resolved_commit = resolved_commit;
1129 self
1130 }
1131
1132 /// Set the dependencies.
1133 pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
1134 self.dependencies = dependencies;
1135 self
1136 }
1137
1138 /// Set the tool.
1139 pub fn tool(mut self, tool: Option<String>) -> Self {
1140 self.tool = tool;
1141 self
1142 }
1143
1144 /// Set the manifest alias.
1145 pub fn manifest_alias(mut self, manifest_alias: Option<String>) -> Self {
1146 self.manifest_alias = manifest_alias;
1147 self
1148 }
1149
1150 /// Set the applied patches.
1151 pub fn applied_patches(mut self, applied_patches: BTreeMap<String, toml::Value>) -> Self {
1152 self.applied_patches = applied_patches;
1153 self
1154 }
1155
1156 /// Set the install flag.
1157 pub fn install(mut self, install: Option<bool>) -> Self {
1158 self.install = install;
1159 self
1160 }
1161
1162 /// Set the context checksum.
1163 pub fn context_checksum(mut self, context_checksum: Option<String>) -> Self {
1164 self.context_checksum = context_checksum;
1165 self
1166 }
1167
1168 /// Set the variant inputs.
1169 pub fn variant_inputs(
1170 mut self,
1171 variant_inputs: crate::resolver::lockfile_builder::VariantInputs,
1172 ) -> Self {
1173 self.variant_inputs = variant_inputs;
1174 self
1175 }
1176
1177 /// Set the is_private flag.
1178 pub fn is_private(mut self, is_private: bool) -> Self {
1179 self.is_private = is_private;
1180 self
1181 }
1182
1183 /// Set the approximate token count.
1184 pub fn approximate_token_count(mut self, count: Option<u64>) -> Self {
1185 self.approximate_token_count = count;
1186 self
1187 }
1188
1189 /// Build the LockedResource.
1190 pub fn build(self) -> LockedResource {
1191 LockedResource {
1192 name: self.name,
1193 source: self.source,
1194 url: self.url,
1195 path: self.path,
1196 version: self.version,
1197 resolved_commit: self.resolved_commit,
1198 checksum: self.checksum,
1199 context_checksum: self.context_checksum,
1200 installed_at: self.installed_at,
1201 dependencies: self.dependencies,
1202 resource_type: self.resource_type,
1203 tool: self.tool,
1204 manifest_alias: self.manifest_alias,
1205 applied_patches: self.applied_patches,
1206 install: self.install,
1207 variant_inputs: self.variant_inputs,
1208 is_private: self.is_private,
1209 approximate_token_count: self.approximate_token_count,
1210 }
1211 }
1212}
1213
1214impl LockedResource {
1215 /// Unique identifier combining name, source, tool, and variant_inputs hash.
1216 ///
1217 /// Canonical method for resource identification in checksum updates and lookups.
1218 #[must_use]
1219 pub fn id(&self) -> ResourceId {
1220 ResourceId::from_resource(self)
1221 }
1222
1223 /// Check if resource matches ResourceId by comparing name, source, tool, and variant_inputs hash.
1224 ///
1225 /// Variant_inputs hash is part of identity - same resource with different variant_inputs
1226 /// produces different artifacts and must be tracked separately.
1227 #[must_use]
1228 pub fn matches_id(&self, id: &ResourceId) -> bool {
1229 self.name == id.name
1230 && self.source == id.source
1231 && self.tool == id.tool
1232 && self.variant_inputs.hash() == id.variant_inputs_hash
1233 }
1234
1235 /// Parse the dependencies field into structured lockfile dependency references.
1236 ///
1237 /// Returns an iterator over successfully parsed dependency references.
1238 /// Invalid references are logged as warnings and skipped.
1239 ///
1240 /// This is the centralized way to parse lockfile dependencies, ensuring
1241 /// consistent handling of the lockfile format across the codebase.
1242 ///
1243 /// # Examples
1244 ///
1245 /// ```rust,no_run
1246 /// # use agpm_cli::lockfile::LockedResource;
1247 /// # let resource: LockedResource = unimplemented!();
1248 /// for dep in resource.parsed_dependencies() {
1249 /// println!("Dependency: {} (type: {})", dep.path, dep.resource_type);
1250 /// }
1251 /// ```
1252 pub fn parsed_dependencies(
1253 &self,
1254 ) -> impl Iterator<Item = lockfile_dependency_ref::LockfileDependencyRef> + '_ {
1255 use std::str::FromStr;
1256
1257 self.dependencies.iter().filter_map(|dep_str| {
1258 lockfile_dependency_ref::LockfileDependencyRef::from_str(dep_str)
1259 .map_err(|e| {
1260 tracing::warn!(
1261 "Failed to parse dependency '{}' for resource '{}': {}",
1262 dep_str,
1263 self.name,
1264 e
1265 );
1266 })
1267 .ok()
1268 })
1269 }
1270
1271 /// Get the display name for user-facing contexts.
1272 ///
1273 /// Returns the manifest_alias if present (for direct manifest dependencies or
1274 /// pattern-expanded resources), otherwise returns the canonical name.
1275 /// This provides the most user-friendly name for display purposes.
1276 ///
1277 /// # Examples
1278 ///
1279 /// ```rust,ignore
1280 /// # use agpm_cli::lockfile::LockedResource;
1281 /// // Direct dependency with custom manifest name
1282 /// let resource = LockedResource {
1283 /// name: "ai-helper".to_string(), // canonical name from path
1284 /// manifest_alias: Some("my-ai-helper".to_string()), // user's chosen name
1285 /// // ... other fields
1286 /// };
1287 /// assert_eq!(resource.display_name(), "my-ai-helper");
1288 ///
1289 /// // Pattern-expanded dependency
1290 /// let resource = LockedResource {
1291 /// name: "helper-alpha".to_string(), // canonical name
1292 /// manifest_alias: Some("all-helpers".to_string()), // pattern alias
1293 /// // ... other fields
1294 /// };
1295 /// assert_eq!(resource.display_name(), "all-helpers");
1296 ///
1297 /// // Transitive dependency (no manifest_alias)
1298 /// let resource = LockedResource {
1299 /// name: "utils".to_string(), // canonical name
1300 /// manifest_alias: None,
1301 /// // ... other fields
1302 /// };
1303 /// assert_eq!(resource.display_name(), "utils");
1304 /// ```
1305 #[must_use]
1306 pub fn display_name(&self) -> &str {
1307 self.manifest_alias.as_ref().unwrap_or(&self.name)
1308 }
1309
1310 /// Get the lookup name for patch resolution and manifest lookups.
1311 ///
1312 /// Returns the manifest_alias if present (for direct manifest dependencies or
1313 /// pattern-expanded resources), otherwise returns the canonical name.
1314 /// This ensures patches are looked up using the correct manifest key.
1315 ///
1316 /// # Examples
1317 ///
1318 /// ```rust,ignore
1319 /// # use agpm_cli::lockfile::LockedResource;
1320 /// // Direct dependency - patches defined under manifest key
1321 /// let resource = LockedResource {
1322 /// name: "ai-helper".to_string(), // canonical name from path
1323 /// manifest_alias: Some("my-ai-helper".to_string()), // manifest key
1324 /// // ... other fields
1325 /// };
1326 /// assert_eq!(resource.lookup_name(), "my-ai-helper");
1327 ///
1328 /// // Transitive dependency - no manifest key
1329 /// let resource = LockedResource {
1330 /// name: "utils".to_string(), // canonical name
1331 /// manifest_alias: None,
1332 /// // ... other fields
1333 /// };
1334 /// assert_eq!(resource.lookup_name(), "utils");
1335 /// ```
1336 #[must_use]
1337 pub fn lookup_name(&self) -> &str {
1338 self.manifest_alias.as_ref().unwrap_or(&self.name)
1339 }
1340
1341 /// Check if this resource represents a direct manifest dependency.
1342 ///
1343 /// Returns true if this resource was directly specified in the manifest
1344 /// (not discovered through transitive dependencies or pattern expansion).
1345 ///
1346 /// # Examples
1347 ///
1348 /// ```rust,ignore
1349 /// # use agpm_cli::lockfile::LockedResource;
1350 /// // Direct dependency from manifest
1351 /// let resource = LockedResource {
1352 /// name: "ai-helper".to_string(),
1353 /// manifest_alias: Some("my-ai-helper".to_string()),
1354 /// // ... other fields
1355 /// };
1356 /// assert!(resource.is_direct_manifest());
1357 ///
1358 /// // Transitive dependency
1359 /// let resource = LockedResource {
1360 /// name: "utils".to_string(),
1361 /// manifest_alias: None,
1362 /// // ... other fields
1363 /// };
1364 /// assert!(!resource.is_direct_manifest());
1365 /// ```
1366 #[must_use]
1367 pub fn is_direct_manifest(&self) -> bool {
1368 // After the canonical naming change, direct dependencies will have manifest_alias set
1369 // to preserve the original manifest key. Transitive dependencies have no manifest_alias.
1370 self.manifest_alias.is_some() && !self.name.starts_with("generated-")
1371 }
1372
1373 /// Check if this resource came from a pattern expansion.
1374 ///
1375 /// Returns true if this resource was created by expanding a pattern dependency
1376 /// from the manifest.
1377 ///
1378 /// # Examples
1379 ///
1380 /// ```rust,ignore
1381 /// # use agpm_cli::lockfile::LockedResource;
1382 /// // Pattern-expanded resource
1383 /// let resource = LockedResource {
1384 /// name: "helper-alpha".to_string(),
1385 /// manifest_alias: Some("all-helpers".to_string()),
1386 /// // ... other fields
1387 /// };
1388 /// assert!(resource.is_pattern_expanded());
1389 ///
1390 /// // Direct dependency (single file)
1391 /// let resource = LockedResource {
1392 /// name: "ai-helper".to_string(),
1393 /// manifest_alias: None, // Will change after implementation
1394 /// // ... other fields
1395 /// };
1396 /// assert!(!resource.is_pattern_expanded());
1397 /// ```
1398 #[must_use]
1399 pub fn is_pattern_expanded(&self) -> bool {
1400 self.manifest_alias.is_some()
1401 }
1402
1403 /// Check if this is a local resource (not from a Git repository).
1404 ///
1405 /// Local resources are filesystem-based dependencies that don't come from
1406 /// Git repositories. They are identified by having no `resolved_commit` field
1407 /// (or an empty one), indicating they weren't resolved from a Git ref.
1408 ///
1409 /// Local resources require different handling:
1410 /// - Source paths are resolved relative to manifest directory
1411 /// - No worktree checkouts are needed
1412 /// - Files are read directly from the filesystem
1413 ///
1414 /// # Examples
1415 ///
1416 /// ```rust,ignore
1417 /// # use agpm_cli::lockfile::LockedResource;
1418 /// // Local resource (no resolved_commit)
1419 /// let local = LockedResource {
1420 /// resolved_commit: None,
1421 /// // ... other fields
1422 /// };
1423 /// assert!(local.is_local());
1424 ///
1425 /// // Remote resource (has resolved_commit)
1426 /// let remote = LockedResource {
1427 /// resolved_commit: Some("abc123def456789012345678901234567890abcd".to_string()),
1428 /// // ... other fields
1429 /// };
1430 /// assert!(!remote.is_local());
1431 /// ```
1432 #[must_use]
1433 pub fn is_local(&self) -> bool {
1434 self.resolved_commit.as_deref().is_none_or(str::is_empty)
1435 }
1436}
1437
1438// Submodules for organized implementation
1439mod checksum;
1440mod helpers;
1441mod io;
1442pub mod lockfile_dependency_ref;
1443pub mod private_lock;
1444mod resource_ops;
1445mod validation;
1446pub use private_lock::PrivateLockFile;
1447
1448// Patch display utilities
1449pub mod patch_display;
1450
1451impl LockFile {
1452 /// Current lockfile format version.
1453 ///
1454 /// This constant defines the lockfile format version that this version of AGPM
1455 /// generates. It's used for compatibility checking when loading lockfiles that
1456 /// may have been created by different versions of AGPM.
1457 const CURRENT_VERSION: u32 = 1;
1458
1459 /// Create a new empty lockfile with the current format version.
1460 ///
1461 /// Returns a fresh lockfile with no sources or resources. This is typically
1462 /// used when initializing a new project or regenerating a lockfile from scratch.
1463 ///
1464 /// # Examples
1465 ///
1466 /// ```rust,no_run
1467 /// use agpm_cli::lockfile::LockFile;
1468 ///
1469 /// let lockfile = LockFile::new();
1470 /// assert_eq!(lockfile.version, 1);
1471 /// assert!(lockfile.sources.is_empty());
1472 /// assert!(lockfile.agents.is_empty());
1473 /// assert!(lockfile.snippets.is_empty());
1474 /// ```
1475 #[must_use]
1476 pub const fn new() -> Self {
1477 Self {
1478 version: Self::CURRENT_VERSION,
1479 sources: Vec::new(),
1480 agents: Vec::new(),
1481 snippets: Vec::new(),
1482 commands: Vec::new(),
1483 mcp_servers: Vec::new(),
1484 scripts: Vec::new(),
1485 hooks: Vec::new(),
1486 skills: Vec::new(),
1487 manifest_hash: None,
1488 has_mutable_deps: None,
1489 resource_count: None,
1490 }
1491 }
1492}
1493
1494impl LockFile {
1495 /// Normalize lockfile entries for backward compatibility.
1496 ///
1497 /// Converts old lockfile entries that don't follow the canonical naming convention
1498 /// to use canonical names with manifest_alias. This ensures that lockfiles created
1499 /// before the naming change remain compatible with the new system.
1500 ///
1501 /// # Returns
1502 ///
1503 /// A new LockFile with normalized entries
1504 ///
1505 /// # Examples
1506 ///
1507 /// ```rust,no_run
1508 /// # use agpm_cli::lockfile::LockFile;
1509 /// let mut lockfile = LockFile::new();
1510 /// // Load or create entries...
1511 /// let normalized = lockfile.normalize();
1512 /// ```
1513 pub fn normalize(&self) -> Self {
1514 let mut normalized = self.clone();
1515
1516 // Normalize each resource type
1517 Self::normalize_resources(&mut normalized.agents);
1518 Self::normalize_resources(&mut normalized.snippets);
1519 Self::normalize_resources(&mut normalized.commands);
1520 Self::normalize_resources(&mut normalized.scripts);
1521 Self::normalize_resources(&mut normalized.hooks);
1522 Self::normalize_resources(&mut normalized.mcp_servers);
1523
1524 // Sort all resource vectors for deterministic lockfile output
1525 // This ensures the lockfile is identical across runs regardless of
1526 // HashMap iteration order during dependency resolution
1527 normalized.agents.sort_by(Self::compare_resources);
1528 normalized.snippets.sort_by(Self::compare_resources);
1529 normalized.commands.sort_by(Self::compare_resources);
1530 normalized.scripts.sort_by(Self::compare_resources);
1531 normalized.hooks.sort_by(Self::compare_resources);
1532 normalized.mcp_servers.sort_by(Self::compare_resources);
1533
1534 normalized
1535 }
1536
1537 /// Compare two resources for deterministic sorting.
1538 ///
1539 /// Sort order:
1540 /// 1. By name (lexicographic)
1541 /// 2. By source (None first, then lexicographic)
1542 /// 3. By tool (None first, then lexicographic)
1543 /// 4. By template_vars (lexicographic comparison of JSON strings)
1544 ///
1545 /// This ensures stable, deterministic lockfile ordering even when the same resource
1546 /// exists with different template_vars (e.g., backend-engineer with language=typescript
1547 /// vs language=javascript).
1548 fn compare_resources(a: &LockedResource, b: &LockedResource) -> std::cmp::Ordering {
1549 a.name
1550 .cmp(&b.name)
1551 .then_with(|| a.source.cmp(&b.source))
1552 .then_with(|| a.tool.cmp(&b.tool))
1553 .then_with(|| a.variant_inputs.hash().cmp(b.variant_inputs.hash()))
1554 }
1555
1556 /// Normalize a vector of LockedResource entries.
1557 ///
1558 /// For each entry that doesn't follow canonical naming:
1559 /// - Compute canonical name from path
1560 /// - Move current name to manifest_alias if not already set
1561 /// - Update name to canonical value
1562 /// - Sort dependencies array for deterministic output
1563 fn normalize_resources(resources: &mut [LockedResource]) {
1564 use crate::resolver::pattern_expander::generate_dependency_name;
1565
1566 for resource in resources.iter_mut() {
1567 // Sort dependencies array for deterministic lockfile output
1568 // This ensures dependencies appear in consistent order regardless of
1569 // HashMap iteration order during resolution
1570 resource.dependencies.sort();
1571
1572 // Skip if already has manifest_alias (indicating it's already normalized)
1573 if resource.manifest_alias.is_some() {
1574 continue;
1575 }
1576
1577 // Compute expected canonical name from path using appropriate source context
1578 let canonical_name = if let Some(source_name) = &resource.source {
1579 // Remote resource - use source name context
1580 let source_context =
1581 crate::resolver::source_context::SourceContext::remote(source_name);
1582 generate_dependency_name(&resource.path, &source_context)
1583 } else {
1584 // Local resource - handle absolute vs relative paths correctly
1585 let path = std::path::Path::new(&resource.path);
1586 if path.is_absolute() {
1587 // Absolute paths keep their absolute form (with file extension removed)
1588 let without_ext = path.with_extension("");
1589 crate::utils::normalize_path_for_storage(without_ext)
1590 } else {
1591 // Relative paths - we don't have manifest context here, so use "local" prefix
1592 // This ensures consistency but may not match the exact manifest-relative path
1593 let source_context =
1594 crate::resolver::source_context::SourceContext::remote("local");
1595 generate_dependency_name(&resource.path, &source_context)
1596 }
1597 };
1598
1599 // Skip if already has the correct canonical name
1600 if resource.name == canonical_name {
1601 continue;
1602 }
1603
1604 // Move current name to manifest_alias and update to canonical name
1605 resource.manifest_alias = Some(resource.name.clone());
1606 resource.name = canonical_name;
1607 }
1608 }
1609
1610 /// Validate the lockfile's fast-path metadata fields.
1611 ///
1612 /// Checks consistency of `manifest_hash` and `has_mutable_deps` fields:
1613 /// - Returns `true` if both fields are present (valid for fast path)
1614 /// - Returns `false` if either field is missing (cannot use fast path)
1615 ///
1616 /// This validation is used during installation to determine if the fast path
1617 /// can be safely used. Lockfiles from older AGPM versions may not have these
1618 /// fields, requiring full resolution.
1619 ///
1620 /// # Returns
1621 ///
1622 /// - `true` if `manifest_hash`, `has_mutable_deps`, and `resource_count` fields are all present
1623 /// - `false` if any field is missing (older lockfile format requires full resolution)
1624 ///
1625 /// # Examples
1626 ///
1627 /// ```rust,no_run
1628 /// # use agpm_cli::lockfile::LockFile;
1629 /// # use std::path::Path;
1630 /// let lockfile = LockFile::load(Path::new("agpm.lock")).unwrap();
1631 /// if lockfile.has_valid_fast_path_metadata() {
1632 /// // Can potentially use fast path (still need to check manifest hash match)
1633 /// } else {
1634 /// // Must do full resolution
1635 /// }
1636 /// ```
1637 #[must_use]
1638 pub fn has_valid_fast_path_metadata(&self) -> bool {
1639 // All fast-path metadata fields must be present for fast path to be valid
1640 // Old lockfiles won't have these fields and require full resolution
1641 self.manifest_hash.is_some()
1642 && self.has_mutable_deps.is_some()
1643 && self.resource_count.is_some()
1644 }
1645
1646 /// Validate that the stored resource count matches the actual number of resources.
1647 ///
1648 /// This detects manual edits to the lockfile that removed resource entries.
1649 /// If the stored count doesn't match the actual count, the lockfile may have
1650 /// been tampered with and we should fall back to full resolution.
1651 ///
1652 /// # Note on Variants
1653 ///
1654 /// When the same resource has multiple `variant_inputs` (e.g., the same template
1655 /// with different `template_vars`), each variant is counted as a separate entry.
1656 /// This is intentional since each variant produces a distinct installed file.
1657 /// Adding new variants will legitimately increase the count and trigger re-resolution.
1658 ///
1659 /// # Returns
1660 ///
1661 /// - `true` if resource count is None (old lockfile) or matches actual count
1662 /// - `false` if stored count differs from actual resource count (tampered lockfile)
1663 #[must_use]
1664 pub fn has_valid_resource_count(&self) -> bool {
1665 match self.resource_count {
1666 None => true, // Old lockfile, can't validate
1667 Some(stored_count) => {
1668 let actual_count = self.all_resources().len();
1669 stored_count == actual_count
1670 }
1671 }
1672 }
1673
1674 /// Validate that manifest_hash format is correct.
1675 ///
1676 /// The manifest hash should be in the format "sha256:<hex>" where <hex> is
1677 /// a 64-character lowercase hexadecimal string.
1678 ///
1679 /// # Returns
1680 ///
1681 /// `true` if the manifest_hash is None or has valid format, `false` if present but invalid.
1682 #[must_use]
1683 pub fn has_valid_manifest_hash_format(&self) -> bool {
1684 match &self.manifest_hash {
1685 None => true,
1686 Some(hash) => {
1687 if let Some(hex_part) = hash.strip_prefix("sha256:") {
1688 hex_part.len() == 64 && hex_part.chars().all(|c| c.is_ascii_hexdigit())
1689 } else {
1690 false
1691 }
1692 }
1693 }
1694 }
1695
1696 /// Split the lockfile into public and private parts based on `is_private` flag.
1697 ///
1698 /// After dependency resolution, resources are partitioned:
1699 /// - Public resources (`is_private = false`) → main `agpm.lock`
1700 /// - Private resources (`is_private = true`) → `agpm.private.lock`
1701 ///
1702 /// This ensures team lockfiles remain consistent while allowing personal
1703 /// private dependencies without conflicts.
1704 ///
1705 /// # Returns
1706 ///
1707 /// A tuple of `(public_lockfile, private_lockfile)`.
1708 ///
1709 /// # Examples
1710 ///
1711 /// ```rust,no_run
1712 /// # use agpm_cli::lockfile::LockFile;
1713 /// # use std::path::Path;
1714 /// let combined = LockFile::new();
1715 /// // ... resolve dependencies ...
1716 /// let (public_lock, private_lock) = combined.split_by_privacy();
1717 ///
1718 /// // Save to separate files
1719 /// public_lock.save(Path::new("agpm.lock"))?;
1720 /// private_lock.save(Path::new("."))?;
1721 /// # Ok::<(), anyhow::Error>(())
1722 /// ```
1723 #[must_use]
1724 pub fn split_by_privacy(&self) -> (Self, PrivateLockFile) {
1725 let mut public_lock = Self::new();
1726 let mut private_resources: Vec<LockedResource> = Vec::new();
1727
1728 // Copy metadata to public lockfile
1729 public_lock.manifest_hash = self.manifest_hash.clone();
1730 public_lock.has_mutable_deps = self.has_mutable_deps;
1731 // Note: resource_count will be recalculated after split
1732 public_lock.sources = self.sources.clone();
1733
1734 // Partition agents
1735 for resource in &self.agents {
1736 if resource.is_private {
1737 private_resources.push(resource.clone());
1738 } else {
1739 public_lock.agents.push(resource.clone());
1740 }
1741 }
1742
1743 // Partition snippets
1744 for resource in &self.snippets {
1745 if resource.is_private {
1746 private_resources.push(resource.clone());
1747 } else {
1748 public_lock.snippets.push(resource.clone());
1749 }
1750 }
1751
1752 // Partition commands
1753 for resource in &self.commands {
1754 if resource.is_private {
1755 private_resources.push(resource.clone());
1756 } else {
1757 public_lock.commands.push(resource.clone());
1758 }
1759 }
1760
1761 // Partition scripts
1762 for resource in &self.scripts {
1763 if resource.is_private {
1764 private_resources.push(resource.clone());
1765 } else {
1766 public_lock.scripts.push(resource.clone());
1767 }
1768 }
1769
1770 // Partition mcp_servers
1771 for resource in &self.mcp_servers {
1772 if resource.is_private {
1773 private_resources.push(resource.clone());
1774 } else {
1775 public_lock.mcp_servers.push(resource.clone());
1776 }
1777 }
1778
1779 // Partition hooks
1780 for resource in &self.hooks {
1781 if resource.is_private {
1782 private_resources.push(resource.clone());
1783 } else {
1784 public_lock.hooks.push(resource.clone());
1785 }
1786 }
1787
1788 // Partition skills
1789 for resource in &self.skills {
1790 if resource.is_private {
1791 private_resources.push(resource.clone());
1792 } else {
1793 public_lock.skills.push(resource.clone());
1794 }
1795 }
1796
1797 // Update resource count for public lockfile only
1798 public_lock.resource_count = Some(public_lock.all_resources().len());
1799
1800 let private_lock = PrivateLockFile::from_resources(private_resources);
1801
1802 (public_lock, private_lock)
1803 }
1804
1805 /// Merge private resources from a `PrivateLockFile` into this lockfile.
1806 ///
1807 /// When loading lockfiles, first load `agpm.lock`, then optionally merge
1808 /// `agpm.private.lock` to get the complete set of resources for this user.
1809 ///
1810 /// Private resources are added to the appropriate resource type vectors.
1811 /// Duplicates (same name, source, tool, variant_hash) are not checked;
1812 /// the caller is responsible for ensuring no conflicts.
1813 ///
1814 /// # Examples
1815 ///
1816 /// ```rust,no_run
1817 /// # use agpm_cli::lockfile::{LockFile, PrivateLockFile};
1818 /// # use std::path::Path;
1819 /// let mut lockfile = LockFile::load(Path::new("agpm.lock"))?;
1820 /// if let Some(private_lock) = PrivateLockFile::load(Path::new("."))? {
1821 /// lockfile.merge_private(&private_lock);
1822 /// }
1823 /// # Ok::<(), anyhow::Error>(())
1824 /// ```
1825 pub fn merge_private(&mut self, private_lock: &PrivateLockFile) {
1826 // Merge agents
1827 self.agents.extend(private_lock.agents.iter().cloned());
1828
1829 // Merge snippets
1830 self.snippets.extend(private_lock.snippets.iter().cloned());
1831
1832 // Merge commands
1833 self.commands.extend(private_lock.commands.iter().cloned());
1834
1835 // Merge scripts
1836 self.scripts.extend(private_lock.scripts.iter().cloned());
1837
1838 // Merge mcp_servers
1839 self.mcp_servers.extend(private_lock.mcp_servers.iter().cloned());
1840
1841 // Merge hooks
1842 self.hooks.extend(private_lock.hooks.iter().cloned());
1843
1844 // Update resource count to reflect merged total
1845 if self.resource_count.is_some() {
1846 self.resource_count = Some(self.all_resources().len());
1847 }
1848 }
1849}
1850
1851impl Default for LockFile {
1852 /// Equivalent to [`LockFile::new()`] - creates empty lockfile with current format version.
1853 fn default() -> Self {
1854 Self::new()
1855 }
1856}
1857
1858/// Default value for `template_vars` field.
1859///
1860/// Returns an empty JSON object which will serialize as `"{}"` in TOML.
1861fn default_variant_inputs_struct() -> crate::resolver::lockfile_builder::VariantInputs {
1862 crate::resolver::lockfile_builder::VariantInputs::new(serde_json::Value::Object(
1863 serde_json::Map::new(),
1864 ))
1865}
1866
1867/// Serialize `VariantInputs` as a TOML table.
1868///
1869/// Converts the internal JSON value to a TOML value to enable proper nested table serialization.
1870fn serialize_variant_inputs_as_toml<S>(
1871 variant_inputs: &crate::resolver::lockfile_builder::VariantInputs,
1872 serializer: S,
1873) -> Result<S::Ok, S::Error>
1874where
1875 S: serde::Serializer,
1876{
1877 use crate::lockfile::patch_display::json_to_toml_value;
1878
1879 let toml_value = json_to_toml_value(variant_inputs.json()).map_err(|e| {
1880 serde::ser::Error::custom(format!("Failed to convert variant_inputs to TOML: {}", e))
1881 })?;
1882 toml_value.serialize(serializer)
1883}
1884
1885/// Deserialize `VariantInputs` from a TOML value.
1886///
1887/// Converts the TOML value back to JSON for internal storage.
1888fn deserialize_variant_inputs_from_toml<'de, D>(
1889 deserializer: D,
1890) -> Result<crate::resolver::lockfile_builder::VariantInputs, D::Error>
1891where
1892 D: serde::Deserializer<'de>,
1893{
1894 use crate::manifest::patches::toml_value_to_json;
1895
1896 let toml_value = toml::Value::deserialize(deserializer)?;
1897 let json_value = toml_value_to_json(&toml_value).map_err(|e| {
1898 serde::de::Error::custom(format!("Failed to convert TOML to variant_inputs: {}", e))
1899 })?;
1900 Ok(crate::resolver::lockfile_builder::VariantInputs::new(json_value))
1901}
1902
1903/// Find the lockfile in the current or parent directories.
1904///
1905/// Searches upward from the current working directory to find a `agpm.lock` file,
1906/// similar to how Git searches for `.git` directories. This enables running AGPM
1907/// commands from subdirectories within a project.
1908///
1909/// # Search Algorithm
1910///
1911/// 1. Start from current working directory
1912/// 2. Check for `agpm.lock` in current directory
1913/// 3. If found, return the path
1914/// 4. If not found, move to parent directory
1915/// 5. Repeat until root directory is reached
1916/// 6. Return `None` if no lockfile found
1917///
1918/// # Returns
1919///
1920/// * `Some(PathBuf)` - Path to the found lockfile
1921/// * `None` - No lockfile found in current or parent directories
1922///
1923/// # Examples
1924///
1925/// ```rust,no_run
1926/// use agpm_cli::lockfile::find_lockfile;
1927///
1928/// if let Some(lockfile_path) = find_lockfile() {
1929/// println!("Found lockfile: {}", lockfile_path.display());
1930/// } else {
1931/// println!("No lockfile found (run 'agpm install' to create one)");
1932/// }
1933/// ```
1934///
1935/// # Use Cases
1936///
1937/// - **CLI commands**: Find project root when run from subdirectories
1938/// - **Editor integration**: Locate project configuration
1939/// - **Build scripts**: Find lockfile for dependency information
1940/// - **Validation tools**: Check if project has lockfile
1941///
1942/// # Directory Structure Example
1943///
1944/// ```text
1945/// project/
1946/// ├── agpm.lock # ← This will be found
1947/// ├── agpm.toml
1948/// └── src/
1949/// └── subdir/ # ← Commands run from here will find ../agpm.lock
1950/// ```
1951///
1952/// # Errors
1953///
1954/// This function does not return errors but rather `None` if:
1955/// - Cannot get current working directory (permission issues)
1956/// - No lockfile exists in the directory tree
1957/// - IO errors while checking file existence
1958///
1959/// For more robust error handling, consider using [`LockFile::load`] directly
1960/// with a known path.
1961#[must_use]
1962pub fn find_lockfile() -> Option<PathBuf> {
1963 let mut current = std::env::current_dir().ok()?;
1964
1965 loop {
1966 let lockfile_path = current.join("agpm.lock");
1967 if lockfile_path.exists() {
1968 return Some(lockfile_path);
1969 }
1970
1971 if !current.pop() {
1972 return None;
1973 }
1974 }
1975}