agpm_cli/lockfile/
io.rs

1//! I/O operations for lockfile loading and saving.
2//!
3//! This module handles atomic file operations for lockfiles, including
4//! loading from disk, saving with atomic writes, and format validation.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9
10use crate::utils::fs::atomic_write;
11
12use super::LockFile;
13use super::helpers::serialize_lockfile_with_inline_patches;
14
15impl LockFile {
16    /// Load lockfile from disk with error handling and validation.
17    ///
18    /// Returns empty lockfile if file doesn't exist. Performs format version
19    /// compatibility checking with detailed error messages.
20    ///
21    /// # Arguments
22    ///
23    /// * `path` - Path to the lockfile (typically "agpm.lock")
24    ///
25    /// # Returns
26    ///
27    /// * `Ok(LockFile)` - Successfully loaded lockfile or new empty lockfile if file doesn't exist
28    /// * `Err(anyhow::Error)` - Parse error, IO error, or version incompatibility
29    ///
30    /// # Error Handling
31    ///
32    /// This method provides detailed error messages for common issues:
33    /// - **File not found**: Returns empty lockfile (not an error)
34    /// - **Permission denied**: Suggests checking file ownership/permissions
35    /// - **TOML parse errors**: Suggests regenerating lockfile or checking syntax
36    /// - **Version incompatibility**: Suggests updating AGPM
37    /// - **Empty file**: Returns empty lockfile (graceful handling)
38    ///
39    /// # Examples
40    ///
41    /// ```rust,no_run
42    /// use std::path::Path;
43    /// use agpm_cli::lockfile::LockFile;
44    ///
45    /// # fn example() -> anyhow::Result<()> {
46    /// // Load existing lockfile
47    /// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
48    /// println!("Loaded {} sources", lockfile.sources.len());
49    ///
50    /// // Non-existent file returns empty lockfile
51    /// let empty = LockFile::load(Path::new("missing.lock"))?;
52    /// assert!(empty.sources.is_empty());
53    /// # Ok(())
54    /// # }
55    /// ```
56    ///
57    /// # Version Compatibility
58    ///
59    /// The method checks the lockfile format version and will refuse to load
60    /// lockfiles created by newer versions of AGPM:
61    ///
62    /// ```text
63    /// Error: Lockfile version 2 is newer than supported version 1.
64    /// This lockfile was created by a newer version of agpm.
65    /// Please update agpm to the latest version to use this lockfile.
66    /// ```
67    pub fn load(path: &Path) -> Result<Self> {
68        if !path.exists() {
69            return Ok(Self::new());
70        }
71
72        let content = fs::read_to_string(path).with_context(|| {
73            format!(
74                "Cannot read lockfile: {}\n\n\
75                    Possible causes:\n\
76                    - File doesn't exist (run 'agpm install' to create it)\n\
77                    - Permission denied (check file ownership)\n\
78                    - File is corrupted or locked by another process",
79                path.display()
80            )
81        })?;
82
83        // Handle empty file
84        if content.trim().is_empty() {
85            return Ok(Self::new());
86        }
87
88        let mut lockfile: Self = toml::from_str(&content)
89            .map_err(|e| crate::core::AgpmError::LockfileParseError {
90                file: path.display().to_string(),
91                reason: e.to_string(),
92            })
93            .with_context(|| {
94                format!(
95                    "Invalid TOML syntax in lockfile: {}\n\n\
96                    The lockfile may be corrupted. You can:\n\
97                    - Delete agpm.lock and run 'agpm install' to regenerate it\n\
98                    - Check for syntax errors if you manually edited the file\n\
99                    - Restore from backup if available",
100                    path.display()
101                )
102            })?;
103
104        // Set resource_type and apply tool defaults based on which section it's in
105        for resource in &mut lockfile.agents {
106            resource.resource_type = crate::core::ResourceType::Agent;
107            if resource.tool.is_none() {
108                resource.tool = Some(crate::core::ResourceType::Agent.default_tool().to_string());
109            }
110        }
111        for resource in &mut lockfile.snippets {
112            resource.resource_type = crate::core::ResourceType::Snippet;
113            if resource.tool.is_none() {
114                resource.tool = Some(crate::core::ResourceType::Snippet.default_tool().to_string());
115            }
116        }
117        for resource in &mut lockfile.commands {
118            resource.resource_type = crate::core::ResourceType::Command;
119            if resource.tool.is_none() {
120                resource.tool = Some(crate::core::ResourceType::Command.default_tool().to_string());
121            }
122        }
123        for resource in &mut lockfile.scripts {
124            resource.resource_type = crate::core::ResourceType::Script;
125            if resource.tool.is_none() {
126                resource.tool = Some(crate::core::ResourceType::Script.default_tool().to_string());
127            }
128        }
129        for resource in &mut lockfile.hooks {
130            resource.resource_type = crate::core::ResourceType::Hook;
131            if resource.tool.is_none() {
132                resource.tool = Some(crate::core::ResourceType::Hook.default_tool().to_string());
133            }
134        }
135        for resource in &mut lockfile.mcp_servers {
136            resource.resource_type = crate::core::ResourceType::McpServer;
137            if resource.tool.is_none() {
138                resource.tool =
139                    Some(crate::core::ResourceType::McpServer.default_tool().to_string());
140            }
141        }
142
143        // Recompute hash for all VariantInputs
144        // The hash is not stored in the lockfile (serde(skip)) but needs to be computed
145        // from the variant_inputs Value for resource identity comparison
146        for resource_type in crate::core::ResourceType::all() {
147            for resource in lockfile.get_resources_mut(resource_type) {
148                resource.variant_inputs.recompute_hash();
149            }
150        }
151
152        // Check version compatibility
153        if lockfile.version > Self::CURRENT_VERSION {
154            return Err(crate::core::AgpmError::Other {
155                message: format!(
156                    "Lockfile version {} is newer than supported version {}.\n\n\
157                    This lockfile was created by a newer version of agpm.\n\
158                    Please update agpm to the latest version to use this lockfile.",
159                    lockfile.version,
160                    Self::CURRENT_VERSION
161                ),
162            }
163            .into());
164        }
165
166        Ok(lockfile)
167    }
168
169    /// Save lockfile to disk with atomic writes and custom formatting.
170    ///
171    /// Serializes to TOML with header warning and custom formatting. Uses atomic writes
172    /// (temp file + rename) to prevent corruption.
173    ///
174    /// # Arguments
175    ///
176    /// * `path` - Path where to save the lockfile (typically "agpm.lock")
177    ///
178    /// # Returns
179    ///
180    /// * `Ok(())` - Successfully saved lockfile
181    /// * `Err(anyhow::Error)` - IO error, permission denied, or disk full
182    ///
183    /// # Atomic Write Behavior
184    ///
185    /// The save operation is atomic - the lockfile is written to a temporary file
186    /// and then renamed to the target path. This ensures the lockfile is never
187    /// left in a partially written state even if the process is interrupted.
188    ///
189    /// # Custom Formatting
190    ///
191    /// The method uses custom TOML formatting instead of standard serde serialization
192    /// to produce more readable output:
193    /// - Adds header comment warning against manual editing
194    /// - Groups related fields together
195    /// - Uses consistent indentation and spacing
196    /// - Omits empty arrays to keep the file clean
197    ///
198    /// # Error Handling
199    ///
200    /// Provides detailed error messages for common issues:
201    /// - **Permission denied**: Suggests running with elevated permissions
202    /// - **Directory doesn't exist**: Suggests creating parent directories
203    /// - **Disk full**: Suggests freeing space or using different location
204    /// - **File locked**: Suggests closing other programs using the file
205    ///
206    /// # Examples
207    ///
208    /// ```rust,no_run
209    /// use std::path::Path;
210    /// use agpm_cli::lockfile::LockFile;
211    ///
212    /// # fn example() -> anyhow::Result<()> {
213    /// let mut lockfile = LockFile::new();
214    ///
215    /// // Add a source
216    /// lockfile.add_source(
217    ///     "community".to_string(),
218    ///     "https://github.com/example/repo.git".to_string(),
219    ///     "a1b2c3d4e5f6...".to_string()
220    /// );
221    ///
222    /// // Save to disk
223    /// lockfile.save(Path::new("agpm.lock"))?;
224    /// # Ok(())
225    /// # }
226    /// ```
227    ///
228    /// # Generated File Format
229    ///
230    /// The saved file starts with a warning header:
231    ///
232    /// ```toml
233    /// # Auto-generated lockfile - DO NOT EDIT
234    /// version = 1
235    ///
236    /// [[sources]]
237    /// name = "community"
238    /// url = "https://github.com/example/repo.git"
239    /// commit = "a1b2c3d4e5f6..."
240    /// fetched_at = "2024-01-15T10:30:00Z"
241    /// ```
242    pub fn save(&self, path: &Path) -> Result<()> {
243        // Normalize lockfile for backward compatibility before saving
244        let normalized = self.normalize();
245
246        // Use toml_edit to ensure applied_patches are formatted as inline tables
247        let mut content = String::from("# Auto-generated lockfile - DO NOT EDIT\n");
248        let toml_content = serialize_lockfile_with_inline_patches(&normalized)?;
249        content.push_str(&toml_content);
250
251        atomic_write(path, content.as_bytes()).with_context(|| {
252            format!(
253                "Cannot write lockfile: {}\n\n\
254                    Possible causes:\n\
255                    - Permission denied (try running with elevated permissions)\n\
256                    - Directory doesn't exist\n\
257                    - Disk is full or read-only\n\
258                    - File is locked by another process",
259                path.display()
260            )
261        })?;
262
263        Ok(())
264    }
265}