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