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() -> Result<()> {
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 manifest = load_and_validate_manifest(&manifest_path, true, true)?;
203        assert!(!manifest.sources.is_empty());
204        assert!(!manifest.agents.is_empty());
205        Ok(())
206    }
207
208    #[test]
209    fn test_load_and_validate_manifest_no_sources() -> Result<()> {
210        let temp_dir = tempdir().unwrap();
211        let manifest_path = temp_dir.path().join("agpm.toml");
212
213        // Create manifest without sources
214        let content = r#"
215[agents]
216test-agent = { path = "../local/agent.md" }
217"#;
218        fs::write(&manifest_path, content).unwrap();
219
220        // Should fail when requiring sources
221        let result = load_and_validate_manifest(&manifest_path, true, false);
222        assert!(result.is_err());
223        assert!(result.unwrap_err().to_string().contains("No sources"));
224
225        // Should succeed when not requiring sources
226        load_and_validate_manifest(&manifest_path, false, false)?;
227        Ok(())
228    }
229
230    #[test]
231    fn test_load_and_validate_manifest_no_dependencies() -> Result<()> {
232        let temp_dir = tempdir().unwrap();
233        let manifest_path = temp_dir.path().join("agpm.toml");
234
235        // Create manifest with only sources
236        let content = r#"
237[sources]
238test = "https://github.com/test/repo.git"
239"#;
240        fs::write(&manifest_path, content).unwrap();
241
242        // Should fail when requiring dependencies
243        let result = load_and_validate_manifest(&manifest_path, false, true);
244        assert!(result.is_err());
245        assert!(result.unwrap_err().to_string().contains("No dependencies"));
246
247        // Should succeed when not requiring dependencies
248        load_and_validate_manifest(&manifest_path, false, false)?;
249        Ok(())
250    }
251
252    #[test]
253    fn test_load_and_validate_manifest_nonexistent() {
254        let temp_dir = tempdir().unwrap();
255        let manifest_path = temp_dir.path().join("nonexistent.toml");
256
257        let result = load_and_validate_manifest(&manifest_path, false, false);
258        assert!(result.is_err());
259        assert!(result.unwrap_err().to_string().contains("not found"));
260    }
261
262    #[test]
263    fn test_load_and_validate_manifest_with_snippets() -> Result<()> {
264        let temp_dir = tempdir().unwrap();
265        let manifest_path = temp_dir.path().join("agpm.toml");
266
267        // Create manifest with snippets dependency
268        let content = r#"
269[sources]
270test = "https://github.com/test/repo.git"
271
272[snippets]
273test-snippet = { source = "test", path = "snippet.md", version = "v1.0.0" }
274"#;
275        fs::write(&manifest_path, content).unwrap();
276
277        // Should succeed when requiring dependencies (has snippets)
278        load_and_validate_manifest(&manifest_path, true, true)?;
279        Ok(())
280    }
281
282    #[test]
283    fn test_load_and_validate_manifest_with_commands() -> Result<()> {
284        let temp_dir = tempdir().unwrap();
285        let manifest_path = temp_dir.path().join("agpm.toml");
286
287        // Create manifest with commands dependency
288        let content = r#"
289[sources]
290test = "https://github.com/test/repo.git"
291
292[commands]
293test-command = { source = "test", path = "command.md", version = "v1.0.0" }
294"#;
295        fs::write(&manifest_path, content).unwrap();
296
297        // Should succeed when requiring dependencies (has commands)
298        load_and_validate_manifest(&manifest_path, true, true)?;
299        Ok(())
300    }
301
302    #[test]
303    fn test_load_and_validate_manifest_with_mcp_servers() -> Result<()> {
304        let temp_dir = tempdir().unwrap();
305        let manifest_path = temp_dir.path().join("agpm.toml");
306
307        // Create manifest with MCP servers dependency
308        let content = r#"
309[sources]
310test = "https://github.com/test/repo.git"
311
312[mcp-servers]
313test-server = "../local/mcp-servers/test-server.json"
314"#;
315        fs::write(&manifest_path, content).unwrap();
316
317        // Should succeed when requiring dependencies (has MCP servers)
318        load_and_validate_manifest(&manifest_path, true, true)?;
319        Ok(())
320    }
321
322    #[test]
323    fn test_load_project_manifest_valid() -> Result<()> {
324        let temp_dir = tempdir().unwrap();
325        let manifest_path = temp_dir.path().join("agpm.toml");
326
327        // Create a valid manifest
328        let content = r#"
329[sources]
330test = "https://github.com/test/repo.git"
331
332[agents]
333test-agent = { source = "test", path = "agent.md", version = "v1.0.0" }
334"#;
335        fs::write(&manifest_path, content).unwrap();
336
337        let manifest = load_project_manifest(temp_dir.path())?;
338        assert_eq!(manifest.sources.len(), 1);
339        assert_eq!(manifest.agents.len(), 1);
340        Ok(())
341    }
342
343    #[test]
344    fn test_load_and_validate_empty_manifest() -> Result<()> {
345        let temp_dir = tempdir().unwrap();
346        let manifest_path = temp_dir.path().join("agpm.toml");
347
348        // Create an empty but valid manifest
349        let content = "";
350        fs::write(&manifest_path, content).unwrap();
351
352        // Should succeed when not requiring anything
353        load_and_validate_manifest(&manifest_path, false, false)?;
354
355        // Should fail when requiring sources
356        let result = load_and_validate_manifest(&manifest_path, true, false);
357        assert!(result.is_err());
358
359        // Should fail when requiring dependencies
360        let result = load_and_validate_manifest(&manifest_path, false, true);
361        assert!(result.is_err());
362        Ok(())
363    }
364
365    #[test]
366    fn test_manifest_validation_mixed_dependencies() -> Result<()> {
367        let temp_dir = tempdir().unwrap();
368        let manifest_path = temp_dir.path().join("agpm.toml");
369
370        // Create manifest with multiple types of dependencies
371        let content = r#"
372[sources]
373source1 = "https://github.com/test/repo1.git"
374source2 = "https://github.com/test/repo2.git"
375
376[agents]
377agent1 = { source = "source1", path = "agent1.md", version = "v1.0.0" }
378
379[snippets]
380snippet1 = { source = "source2", path = "snippet1.md", version = "v2.0.0" }
381
382[commands]
383cmd1 = { source = "source1", path = "cmd1.md", version = "v1.0.0" }
384"#;
385        fs::write(&manifest_path, content).unwrap();
386
387        let manifest = load_and_validate_manifest(&manifest_path, true, true)?;
388        assert_eq!(manifest.sources.len(), 2);
389        assert_eq!(manifest.agents.len(), 1);
390        assert_eq!(manifest.snippets.len(), 1);
391        assert_eq!(manifest.commands.len(), 1);
392        Ok(())
393    }
394
395    #[test]
396    fn test_error_context_in_load_project_manifest() {
397        let temp_dir = tempdir().unwrap();
398
399        // Test missing manifest error
400        let result = load_project_manifest(temp_dir.path());
401        assert!(result.is_err());
402
403        let err_chain = result.unwrap_err();
404        let err_str = format!("{:?}", err_chain);
405
406        // Should contain error context with suggestion
407        assert!(err_str.contains("agpm.toml") || err_str.contains("init"));
408    }
409
410    #[test]
411    fn test_error_context_in_validation() {
412        let temp_dir = tempdir().unwrap();
413        let manifest_path = temp_dir.path().join("agpm.toml");
414
415        // Create manifest without sources
416        fs::write(&manifest_path, "").unwrap();
417
418        // Test no sources error
419        let result = load_and_validate_manifest(&manifest_path, true, false);
420        assert!(result.is_err());
421
422        let err_chain = result.unwrap_err();
423        let err_str = format!("{:?}", err_chain);
424        assert!(err_str.contains("source") || err_str.contains("No sources"));
425
426        // Test no dependencies error
427        let result = load_and_validate_manifest(&manifest_path, false, true);
428        assert!(result.is_err());
429
430        let err_chain = result.unwrap_err();
431        let err_str = format!("{:?}", err_chain);
432        assert!(err_str.contains("dependencies") || err_str.contains("No dependencies"));
433    }
434}