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}