agpm_cli/utils/
manifest_utils.rs

1//! Utilities for working with manifest files
2//!
3//! This module provides common functionality for loading and validating
4//! manifest files across different commands.
5
6use crate::core::error::ErrorContext;
7use crate::manifest::Manifest;
8use anyhow::{Context, Result, anyhow};
9use std::path::Path;
10
11/// Load a project manifest from the standard location
12///
13/// This function looks for a `agpm.toml` file in the given project directory
14/// and returns a parsed Manifest. It provides consistent error messages
15/// across all commands.
16///
17/// # Arguments
18///
19/// * `project_dir` - The project directory to search for agpm.toml
20///
21/// # Returns
22///
23/// * `Ok(Manifest)` - The parsed manifest
24/// * `Err` - If the manifest doesn't exist or can't be parsed
25///
26/// # Example
27///
28/// ```no_run
29/// # use anyhow::Result;
30/// # fn example() -> Result<()> {
31/// use std::path::Path;
32/// use agpm_cli::utils::manifest_utils::load_project_manifest;
33///
34/// let manifest = load_project_manifest(Path::new("."))?;
35/// # Ok(())
36/// # }
37/// ```
38pub fn load_project_manifest(project_dir: &Path) -> Result<Manifest> {
39    let manifest_path = project_dir.join("agpm.toml");
40
41    if !manifest_path.exists() {
42        return Err(anyhow!("No agpm.toml found in {}", project_dir.display()).context(
43            ErrorContext {
44                error: crate::core::AgpmError::ManifestNotFound,
45                suggestion: Some("Run 'agpm init' to create a new project".to_string()),
46                details: Some(format!("Expected manifest at: {}", manifest_path.display())),
47            },
48        ));
49    }
50
51    Manifest::load(&manifest_path).with_context(|| ErrorContext {
52        error: crate::core::AgpmError::ManifestParseError {
53            file: manifest_path.display().to_string(),
54            reason: "Failed to parse manifest".to_string(),
55        },
56        suggestion: Some("Check that agpm.toml is valid TOML syntax".to_string()),
57        details: Some(format!("Manifest path: {}", manifest_path.display())),
58    })
59}
60
61/// Load a manifest from a specific path with validation
62///
63/// This function loads a manifest from any path and optionally validates
64/// that it contains required sections.
65///
66/// # Arguments
67///
68/// * `manifest_path` - Path to the manifest file
69/// * `require_sources` - Whether to require at least one source
70/// * `require_dependencies` - Whether to require at least one dependency
71///
72/// # Returns
73///
74/// * `Ok(Manifest)` - The parsed and validated manifest
75/// * `Err` - If the manifest can't be loaded or validation fails
76pub fn load_and_validate_manifest(
77    manifest_path: &Path,
78    require_sources: bool,
79    require_dependencies: bool,
80) -> Result<Manifest> {
81    if !manifest_path.exists() {
82        return Err(anyhow!("Manifest file not found: {}", manifest_path.display()));
83    }
84
85    let manifest = Manifest::load(manifest_path)?;
86
87    if require_sources && manifest.sources.is_empty() {
88        return Err(anyhow!("No sources defined in manifest").context(ErrorContext {
89            error: crate::core::AgpmError::ManifestValidationError {
90                reason: "No sources defined in manifest".to_string(),
91            },
92            suggestion: Some("Add at least one source using 'agpm add source'".to_string()),
93            details: None,
94        }));
95    }
96
97    if require_dependencies
98        && (manifest.agents.is_empty()
99            && manifest.snippets.is_empty()
100            && manifest.commands.is_empty()
101            && manifest.mcp_servers.is_empty())
102    {
103        return Err(anyhow!("No dependencies defined in manifest").context(ErrorContext {
104            error: crate::core::AgpmError::ManifestValidationError {
105                reason: "No dependencies defined in manifest".to_string(),
106            },
107            suggestion: Some("Add dependencies using 'agpm add dep'".to_string()),
108            details: None,
109        }));
110    }
111
112    Ok(manifest)
113}
114
115/// Check if a manifest exists in the project directory
116///
117/// # Arguments
118///
119/// * `project_dir` - The project directory to check
120///
121/// # Returns
122///
123/// * `true` if agpm.toml exists, `false` otherwise
124pub fn manifest_exists(project_dir: &Path) -> bool {
125    project_dir.join("agpm.toml").exists()
126}
127
128/// Get the path to the manifest file
129///
130/// # Arguments
131///
132/// * `project_dir` - The project directory
133///
134/// # Returns
135///
136/// The path to agpm.toml in the project directory
137pub fn manifest_path(project_dir: &Path) -> std::path::PathBuf {
138    project_dir.join("agpm.toml")
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::fs;
145    use tempfile::tempdir;
146
147    #[test]
148    fn test_load_project_manifest_missing() {
149        let temp_dir = tempdir().unwrap();
150        let result = load_project_manifest(temp_dir.path());
151        assert!(result.is_err());
152        // The error will contain both the initial message and the context
153        let err = result.unwrap_err();
154        let err_str = err.to_string();
155        assert!(err_str.contains("agpm.toml") || err_str.contains("Manifest"));
156    }
157
158    #[test]
159    fn test_load_project_manifest_invalid() {
160        let temp_dir = tempdir().unwrap();
161        let manifest_path = temp_dir.path().join("agpm.toml");
162        fs::write(&manifest_path, "invalid toml {").unwrap();
163
164        let result = load_project_manifest(temp_dir.path());
165        assert!(result.is_err());
166        // Just verify it's an error - the specific message format may vary
167    }
168
169    #[test]
170    fn test_manifest_exists() {
171        let temp_dir = tempdir().unwrap();
172        assert!(!manifest_exists(temp_dir.path()));
173
174        let manifest_path = temp_dir.path().join("agpm.toml");
175        fs::write(&manifest_path, "[sources]").unwrap();
176        assert!(manifest_exists(temp_dir.path()));
177    }
178
179    #[test]
180    fn test_manifest_path() {
181        let temp_dir = tempdir().unwrap();
182        let path = manifest_path(temp_dir.path());
183        assert_eq!(path, temp_dir.path().join("agpm.toml"));
184    }
185
186    #[test]
187    fn test_load_and_validate_manifest_success() {
188        let temp_dir = tempdir().unwrap();
189        let manifest_path = temp_dir.path().join("agpm.toml");
190
191        // Create valid manifest with sources and dependencies
192        let content = r#"
193[sources]
194test = "https://github.com/test/repo.git"
195
196[agents]
197test-agent = { source = "test", path = "agent.md", version = "v1.0.0" }
198"#;
199        fs::write(&manifest_path, content).unwrap();
200
201        // Should succeed with both validations
202        let result = load_and_validate_manifest(&manifest_path, true, true);
203        assert!(result.is_ok());
204
205        let manifest = result.unwrap();
206        assert!(!manifest.sources.is_empty());
207        assert!(!manifest.agents.is_empty());
208    }
209
210    #[test]
211    fn test_load_and_validate_manifest_no_sources() {
212        let temp_dir = tempdir().unwrap();
213        let manifest_path = temp_dir.path().join("agpm.toml");
214
215        // Create manifest without sources
216        let content = r#"
217[agents]
218test-agent = { path = "../local/agent.md" }
219"#;
220        fs::write(&manifest_path, content).unwrap();
221
222        // Should fail when requiring sources
223        let result = load_and_validate_manifest(&manifest_path, true, false);
224        assert!(result.is_err());
225        assert!(result.unwrap_err().to_string().contains("No sources"));
226
227        // Should succeed when not requiring sources
228        let result = load_and_validate_manifest(&manifest_path, false, false);
229        assert!(result.is_ok());
230    }
231
232    #[test]
233    fn test_load_and_validate_manifest_no_dependencies() {
234        let temp_dir = tempdir().unwrap();
235        let manifest_path = temp_dir.path().join("agpm.toml");
236
237        // Create manifest with only sources
238        let content = r#"
239[sources]
240test = "https://github.com/test/repo.git"
241"#;
242        fs::write(&manifest_path, content).unwrap();
243
244        // Should fail when requiring dependencies
245        let result = load_and_validate_manifest(&manifest_path, false, true);
246        assert!(result.is_err());
247        assert!(result.unwrap_err().to_string().contains("No dependencies"));
248
249        // Should succeed when not requiring dependencies
250        let result = load_and_validate_manifest(&manifest_path, false, false);
251        assert!(result.is_ok());
252    }
253
254    #[test]
255    fn test_load_and_validate_manifest_nonexistent() {
256        let temp_dir = tempdir().unwrap();
257        let manifest_path = temp_dir.path().join("nonexistent.toml");
258
259        let result = load_and_validate_manifest(&manifest_path, false, false);
260        assert!(result.is_err());
261        assert!(result.unwrap_err().to_string().contains("not found"));
262    }
263
264    #[test]
265    fn test_load_and_validate_manifest_with_snippets() {
266        let temp_dir = tempdir().unwrap();
267        let manifest_path = temp_dir.path().join("agpm.toml");
268
269        // Create manifest with snippets dependency
270        let content = r#"
271[sources]
272test = "https://github.com/test/repo.git"
273
274[snippets]
275test-snippet = { source = "test", path = "snippet.md", version = "v1.0.0" }
276"#;
277        fs::write(&manifest_path, content).unwrap();
278
279        // Should succeed when requiring dependencies (has snippets)
280        let result = load_and_validate_manifest(&manifest_path, true, true);
281        assert!(result.is_ok());
282    }
283
284    #[test]
285    fn test_load_and_validate_manifest_with_commands() {
286        let temp_dir = tempdir().unwrap();
287        let manifest_path = temp_dir.path().join("agpm.toml");
288
289        // Create manifest with commands dependency
290        let content = r#"
291[sources]
292test = "https://github.com/test/repo.git"
293
294[commands]
295test-command = { source = "test", path = "command.md", version = "v1.0.0" }
296"#;
297        fs::write(&manifest_path, content).unwrap();
298
299        // Should succeed when requiring dependencies (has commands)
300        let result = load_and_validate_manifest(&manifest_path, true, true);
301        assert!(result.is_ok());
302    }
303
304    #[test]
305    fn test_load_and_validate_manifest_with_mcp_servers() {
306        let temp_dir = tempdir().unwrap();
307        let manifest_path = temp_dir.path().join("agpm.toml");
308
309        // Create manifest with MCP servers dependency
310        let content = r#"
311[sources]
312test = "https://github.com/test/repo.git"
313
314[mcp-servers]
315test-server = "../local/mcp-servers/test-server.json"
316"#;
317        fs::write(&manifest_path, content).unwrap();
318
319        // Should succeed when requiring dependencies (has MCP servers)
320        let result = load_and_validate_manifest(&manifest_path, true, true);
321        assert!(result.is_ok());
322    }
323
324    #[test]
325    fn test_load_project_manifest_valid() {
326        let temp_dir = tempdir().unwrap();
327        let manifest_path = temp_dir.path().join("agpm.toml");
328
329        // Create a valid manifest
330        let content = r#"
331[sources]
332test = "https://github.com/test/repo.git"
333
334[agents]
335test-agent = { source = "test", path = "agent.md", version = "v1.0.0" }
336"#;
337        fs::write(&manifest_path, content).unwrap();
338
339        let result = load_project_manifest(temp_dir.path());
340        assert!(result.is_ok());
341
342        let manifest = result.unwrap();
343        assert_eq!(manifest.sources.len(), 1);
344        assert_eq!(manifest.agents.len(), 1);
345    }
346
347    #[test]
348    fn test_load_and_validate_empty_manifest() {
349        let temp_dir = tempdir().unwrap();
350        let manifest_path = temp_dir.path().join("agpm.toml");
351
352        // Create an empty but valid manifest
353        let content = "";
354        fs::write(&manifest_path, content).unwrap();
355
356        // Should succeed when not requiring anything
357        let result = load_and_validate_manifest(&manifest_path, false, false);
358        assert!(result.is_ok());
359
360        // Should fail when requiring sources
361        let result = load_and_validate_manifest(&manifest_path, true, false);
362        assert!(result.is_err());
363
364        // Should fail when requiring dependencies
365        let result = load_and_validate_manifest(&manifest_path, false, true);
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_manifest_validation_mixed_dependencies() {
371        let temp_dir = tempdir().unwrap();
372        let manifest_path = temp_dir.path().join("agpm.toml");
373
374        // Create manifest with multiple types of dependencies
375        let content = r#"
376[sources]
377source1 = "https://github.com/test/repo1.git"
378source2 = "https://github.com/test/repo2.git"
379
380[agents]
381agent1 = { source = "source1", path = "agent1.md", version = "v1.0.0" }
382
383[snippets]
384snippet1 = { source = "source2", path = "snippet1.md", version = "v2.0.0" }
385
386[commands]
387cmd1 = { source = "source1", path = "cmd1.md", version = "v1.0.0" }
388"#;
389        fs::write(&manifest_path, content).unwrap();
390
391        let result = load_and_validate_manifest(&manifest_path, true, true);
392        assert!(result.is_ok());
393
394        let manifest = result.unwrap();
395        assert_eq!(manifest.sources.len(), 2);
396        assert_eq!(manifest.agents.len(), 1);
397        assert_eq!(manifest.snippets.len(), 1);
398        assert_eq!(manifest.commands.len(), 1);
399    }
400
401    #[test]
402    fn test_error_context_in_load_project_manifest() {
403        let temp_dir = tempdir().unwrap();
404
405        // Test missing manifest error
406        let result = load_project_manifest(temp_dir.path());
407        assert!(result.is_err());
408
409        let err_chain = result.unwrap_err();
410        let err_str = format!("{:?}", err_chain);
411
412        // Should contain error context with suggestion
413        assert!(err_str.contains("agpm.toml") || err_str.contains("init"));
414    }
415
416    #[test]
417    fn test_error_context_in_validation() {
418        let temp_dir = tempdir().unwrap();
419        let manifest_path = temp_dir.path().join("agpm.toml");
420
421        // Create manifest without sources
422        fs::write(&manifest_path, "").unwrap();
423
424        // Test no sources error
425        let result = load_and_validate_manifest(&manifest_path, true, false);
426        assert!(result.is_err());
427
428        let err_chain = result.unwrap_err();
429        let err_str = format!("{:?}", err_chain);
430        assert!(err_str.contains("source") || err_str.contains("No sources"));
431
432        // Test no dependencies error
433        let result = load_and_validate_manifest(&manifest_path, false, true);
434        assert!(result.is_err());
435
436        let err_chain = result.unwrap_err();
437        let err_str = format!("{:?}", err_chain);
438        assert!(err_str.contains("dependencies") || err_str.contains("No dependencies"));
439    }
440}