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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
//! I/O operations for manifest files.
//!
//! This module contains all file I/O operations for the manifest, including:
//! - Loading manifests from TOML files
//! - Saving manifests to TOML files
//! - Creating new empty manifests
//! - Applying tool defaults
//! - Merging private configurations
use crate::core::file_error::{FileOperation, FileResultExt};
use crate::manifest::{Manifest, ManifestPatches, PatchConflict, ResourceDependency};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
impl Manifest {
/// Load and parse a manifest from a TOML file.
///
/// This method reads the specified file, parses it as TOML, deserializes
/// it into a [`Manifest`] struct, and validates the result. The entire
/// operation is atomic - either the manifest loads successfully or an
/// error is returned.
///
/// # Validation
///
/// After parsing, the manifest is automatically validated to ensure:
/// - All dependency sources reference valid entries in the `[sources]` section
/// - Required fields are present and non-empty
/// - Version constraints are properly specified for remote dependencies
/// - Source URLs use supported protocols
/// - No version conflicts exist between dependencies
///
/// # Error Handling
///
/// Returns detailed errors for common problems:
/// - **File I/O errors**: File not found, permission denied, etc.
/// - **TOML syntax errors**: Invalid TOML format with helpful suggestions
/// - **Validation errors**: Logical inconsistencies in the manifest
/// - **Security errors**: Unsafe URL patterns or credential leakage
///
/// All errors include contextual information and actionable suggestions.
///
/// # Examples
///
/// ```rust,no_run,ignore
/// use agpm_cli::manifest::Manifest;
/// use std::path::Path;
///
/// // Load a manifest file
/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
///
/// // Access parsed data
/// println!("Found {} sources", manifest.sources.len());
/// println!("Found {} agents", manifest.agents.len());
/// println!("Found {} snippets", manifest.snippets.len());
/// # Ok::<(), anyhow::Error>(())
/// ```
///
/// # File Format
///
/// Expects a valid TOML file following the AGPM manifest format.
/// See the module-level documentation for complete format specification.
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_file_context(
FileOperation::Read,
path,
"reading manifest file",
"manifest_module",
)?;
let mut manifest: Self = toml::from_str(&content)
.map_err(|e| crate::core::AgpmError::ManifestParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in manifest file: {}\n\n\
Common TOML syntax errors:\n\
- Missing quotes around strings\n\
- Unmatched brackets [ ] or braces {{ }}\n\
- Invalid characters in keys or values\n\
- Incorrect indentation or structure",
path.display()
)
})?;
// Apply resource-type-specific defaults for tool
// Snippets default to "agpm" (shared infrastructure) instead of "claude-code"
manifest.apply_tool_defaults();
// Store the manifest directory for resolving relative paths
manifest.manifest_dir = Some(
path.parent()
.ok_or_else(|| anyhow::anyhow!("Manifest path has no parent directory"))?
.to_path_buf(),
);
manifest.validate()?;
Ok(manifest)
}
/// Load manifest with private config merged.
///
/// Loads the project manifest from `agpm.toml` and then attempts to load
/// `agpm.private.toml` from the same directory. If a private config exists:
/// - **Sources** are merged (private sources can use same names, which shadows project sources)
/// - **Dependencies** are merged (private deps tracked via `private_dependency_names`)
/// - **Patches** are merged (private patches take precedence)
///
/// Any conflicts (same field defined in both files with different values) are
/// returned for informational purposes only. Private patches always override
/// project patches without raising an error.
///
/// # Arguments
///
/// * `path` - Path to the project manifest file (`agpm.toml`)
///
/// # Returns
///
/// A manifest with merged sources, dependencies, patches, and a list of any
/// patch conflicts detected (for informational/debugging purposes).
///
/// # Examples
///
/// ```no_run
/// use agpm_cli::manifest::Manifest;
/// use std::path::Path;
///
/// let (manifest, conflicts) = Manifest::load_with_private(Path::new("agpm.toml"))?;
/// // Conflicts are informational only - private patches already won
/// if !conflicts.is_empty() {
/// eprintln!("Note: {} private patch(es) override project settings", conflicts.len());
/// }
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn load_with_private(path: &Path) -> Result<(Self, Vec<PatchConflict>)> {
// Load the main project manifest
let mut manifest = Self::load(path)?;
// Store project patches before merging
manifest.project_patches = manifest.patches.clone();
// Try to load private config
let private_path = if let Some(parent) = path.parent() {
parent.join("agpm.private.toml")
} else {
PathBuf::from("agpm.private.toml")
};
if private_path.exists() {
let private_manifest = Self::load_private(&private_path)?;
// Merge sources (private can shadow project sources with same name)
for (name, url) in private_manifest.sources {
manifest.sources.insert(name, url);
}
// Track which dependencies are from private manifest and merge them
let mut private_names = std::collections::HashSet::new();
// Merge agents
for (name, dep) in private_manifest.agents {
private_names.insert(("agents".to_string(), name.clone()));
manifest.agents.insert(name, dep);
}
// Merge snippets
for (name, dep) in private_manifest.snippets {
private_names.insert(("snippets".to_string(), name.clone()));
manifest.snippets.insert(name, dep);
}
// Merge commands
for (name, dep) in private_manifest.commands {
private_names.insert(("commands".to_string(), name.clone()));
manifest.commands.insert(name, dep);
}
// Merge scripts
for (name, dep) in private_manifest.scripts {
private_names.insert(("scripts".to_string(), name.clone()));
manifest.scripts.insert(name, dep);
}
// Merge hooks
for (name, dep) in private_manifest.hooks {
private_names.insert(("hooks".to_string(), name.clone()));
manifest.hooks.insert(name, dep);
}
// Merge MCP servers
for (name, dep) in private_manifest.mcp_servers {
private_names.insert(("mcp-servers".to_string(), name.clone()));
manifest.mcp_servers.insert(name, dep);
}
manifest.private_dependency_names = private_names;
// Store private patches
manifest.private_patches = private_manifest.patches.clone();
// Merge patches (private takes precedence)
let (merged_patches, conflicts) =
manifest.patches.merge_with(&private_manifest.patches);
manifest.patches = merged_patches;
// Re-validate after merge to ensure private dependencies reference valid sources
// This catches cases where private deps reference sources not in either manifest
manifest.validate().with_context(|| {
format!(
"Validation failed after merging private manifest: {}",
private_path.display()
)
})?;
Ok((manifest, conflicts))
} else {
// No private config, keep private_patches empty
manifest.private_patches = ManifestPatches::new();
Ok((manifest, Vec::new()))
}
}
/// Load a private manifest file.
///
/// Private manifests can contain:
/// - **Sources**: Private Git repositories with authentication
/// - **Dependencies**: User-only resources (agents, snippets, commands, etc.)
/// - **Patches**: Customizations to project or private dependencies
///
/// Private manifests **cannot** contain:
/// - **Tools**: Tool configuration must be in the main manifest
///
/// # Arguments
///
/// * `path` - Path to the private manifest file (`agpm.private.toml`)
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be read
/// - The TOML syntax is invalid
/// - The private config contains tools configuration
fn load_private(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).with_file_context(
FileOperation::Read,
path,
"reading private manifest file",
"manifest_module",
)?;
let mut manifest: Self = toml::from_str(&content)
.map_err(|e| crate::core::AgpmError::ManifestParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
.with_context(|| {
format!(
"Invalid TOML syntax in private manifest file: {}\n\n\
Common TOML syntax errors:\n\
- Missing quotes around strings\n\
- Unmatched brackets [ ] or braces {{ }}\n\
- Invalid characters in keys or values\n\
- Incorrect indentation or structure",
path.display()
)
})?;
// Validate that private config doesn't contain tools
if manifest.tools.is_some() {
anyhow::bail!(
"Private manifest file ({}) cannot contain [tools] section. \
Tool configuration must be defined in the project manifest (agpm.toml).",
path.display()
);
}
// Apply resource-type-specific defaults for tool
manifest.apply_tool_defaults();
// Store the manifest directory for resolving relative paths
manifest.manifest_dir = Some(
path.parent()
.ok_or_else(|| anyhow::anyhow!("Private manifest path has no parent directory"))?
.to_path_buf(),
);
Ok(manifest)
}
/// Get the default tool for a resource type.
///
/// Checks the `[default-tools]` configuration first, then falls back to
/// the built-in defaults:
/// - `snippets` → `"agpm"` (shared infrastructure)
/// - All other resource types → `"claude-code"`
///
/// # Arguments
///
/// * `resource_type` - The resource type to get the default tool for
///
/// # Returns
///
/// The default tool name as a string.
///
/// # Examples
///
/// ```rust,no_run
/// use agpm_cli::manifest::Manifest;
/// use agpm_cli::core::ResourceType;
///
/// let manifest = Manifest::new();
/// assert_eq!(manifest.get_default_tool(ResourceType::Snippet), "agpm");
/// assert_eq!(manifest.get_default_tool(ResourceType::Agent), "claude-code");
/// ```
#[must_use]
pub fn get_default_tool(&self, resource_type: crate::core::ResourceType) -> String {
// Get the resource name in plural form for consistency with TOML section names
// (agents, snippets, commands, etc.)
let resource_name = match resource_type {
crate::core::ResourceType::Agent => "agents",
crate::core::ResourceType::Snippet => "snippets",
crate::core::ResourceType::Command => "commands",
crate::core::ResourceType::Script => "scripts",
crate::core::ResourceType::Hook => "hooks",
crate::core::ResourceType::McpServer => "mcp-servers",
};
// Check if there's a configured override
if let Some(tool) = self.default_tools.get(resource_name) {
return tool.clone();
}
// Fall back to built-in defaults
resource_type.default_tool().to_string()
}
fn apply_tool_defaults(&mut self) {
// Apply resource-type-specific defaults only when tool is not explicitly specified
for resource_type in [
crate::core::ResourceType::Snippet,
crate::core::ResourceType::Agent,
crate::core::ResourceType::Command,
crate::core::ResourceType::Script,
crate::core::ResourceType::Hook,
crate::core::ResourceType::McpServer,
] {
// Get the default tool before the mutable borrow to avoid borrow conflicts
let default_tool = self.get_default_tool(resource_type);
if let Some(deps) = self.get_dependencies_mut(resource_type) {
for dependency in deps.values_mut() {
if let ResourceDependency::Detailed(details) = dependency {
if details.tool.is_none() {
details.tool = Some(default_tool.clone());
}
}
}
}
}
}
/// Save the manifest to a TOML file with pretty formatting.
///
/// This method serializes the manifest to TOML format and writes it to the
/// specified file path. The output is pretty-printed for human readability
/// and follows TOML best practices.
///
/// # Formatting
///
/// The generated TOML file will:
/// - Use consistent indentation and spacing
/// - Omit empty sections for cleaner output
/// - Order sections logically (sources, target, agents, snippets)
/// - Include inline tables for detailed dependencies
///
/// # Atomic Operation
///
/// The save operation is atomic - the file is either completely written
/// or left unchanged. This prevents corruption if the operation fails
/// partway through.
///
/// # Error Handling
///
/// Returns detailed errors for common problems:
/// - **Permission denied**: Insufficient write permissions
/// - **Directory doesn't exist**: Parent directory missing
/// - **Disk full**: Insufficient storage space
/// - **File locked**: Another process has the file open
///
/// # Examples
///
/// ```rust,no_run
/// use agpm_cli::manifest::Manifest;
/// use std::path::Path;
///
/// let mut manifest = Manifest::new();
/// manifest.add_source(
/// "official".to_string(),
/// "https://github.com/claude-org/resources.git".to_string()
/// );
///
/// // Save to file
/// # use tempfile::tempdir;
/// # let temp_dir = tempdir()?;
/// # let manifest_path = temp_dir.path().join("agpm.toml");
/// manifest.save(&manifest_path)?;
/// # Ok::<(), anyhow::Error>(())
/// ```
///
/// # Output Format
///
/// The generated file will follow this structure:
///
/// ```toml
/// [sources]
/// official = "https://github.com/claude-org/resources.git"
///
/// [target]
/// agents = ".claude/agents"
/// snippets = ".agpm/snippets"
///
/// [agents]
/// helper = { source = "official", path = "agents/helper.md", version = "v1.0.0" }
///
/// [snippets]
/// utils = { source = "official", path = "snippets/utils.md", version = "v1.0.0" }
/// ```
pub fn save(&self, path: &Path) -> Result<()> {
// Serialize to a document first so we can control formatting
let mut doc = toml_edit::ser::to_document(self)
.with_context(|| "Failed to serialize manifest data to TOML format")?;
// Convert top-level inline tables to regular tables (section headers)
// This keeps [sources], [agents], etc. as sections but nested values stay inline
for (_key, value) in doc.iter_mut() {
if let Some(inline_table) = value.as_inline_table() {
// Convert inline table to regular table
let table = inline_table.clone().into_table();
*value = toml_edit::Item::Table(table);
}
}
let content = doc.to_string();
std::fs::write(path, content).with_file_context(
FileOperation::Write,
path,
"writing manifest file",
"manifest_module",
)?;
Ok(())
}
}