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