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