agpm_cli/lockfile/
resource_ops.rs

1//! Resource management operations for lockfiles.
2//!
3//! This module provides methods for adding, retrieving, and managing locked
4//! resources (agents, snippets, commands, scripts, hooks, MCP servers) within
5//! the lockfile.
6
7use super::{LockFile, LockedResource, LockedSource, ResourceId};
8
9impl LockFile {
10    /// Add or update source repository, setting fetched_at to current UTC time.
11    ///
12    /// Replaces existing source with same name.
13    ///
14    /// # Arguments
15    ///
16    /// * `name` - Unique source identifier (matches manifest `[sources]` keys)
17    /// * `url` - Full Git repository URL
18    /// * `commit` - Resolved 40-character commit hash
19    ///
20    /// # Behavior
21    ///
22    /// If a source with the same name already exists, it will be replaced with
23    /// the new information. This ensures that each source name appears exactly
24    /// once in the lockfile.
25    ///
26    /// # Examples
27    ///
28    /// ```rust,no_run
29    /// use agpm_cli::lockfile::LockFile;
30    ///
31    /// let mut lockfile = LockFile::new();
32    /// lockfile.add_source(
33    ///     "community".to_string(),
34    ///     "https://github.com/example/community.git".to_string(),
35    ///     "a1b2c3d4e5f6789abcdef0123456789abcdef012".to_string()
36    /// );
37    ///
38    /// assert_eq!(lockfile.sources.len(), 1);
39    /// assert_eq!(lockfile.sources[0].name, "community");
40    /// ```
41    ///
42    /// # Time Zone
43    ///
44    /// The `fetched_at` timestamp is always recorded in UTC to ensure consistency
45    /// across different time zones and systems.
46    pub fn add_source(&mut self, name: String, url: String, _commit: String) {
47        // Remove existing entry if present
48        self.sources.retain(|s| s.name != name);
49
50        self.sources.push(LockedSource {
51            name,
52            url,
53            fetched_at: chrono::Utc::now().to_rfc3339(),
54        });
55    }
56
57    /// Find source repository by name.
58    ///
59    /// # Arguments
60    ///
61    /// * `name` - Source name to search for (matches manifest `[sources]` keys)
62    ///
63    /// # Returns
64    ///
65    /// * `Some(&LockedSource)` - Reference to the found source
66    /// * `None` - No source with that name exists
67    ///
68    /// # Examples
69    ///
70    /// ```rust,no_run
71    /// # use agpm_cli::lockfile::LockFile;
72    /// # let lockfile = LockFile::new();
73    /// if let Some(source) = lockfile.get_source("community") {
74    ///     println!("Source URL: {}", source.url);
75    ///     println!("Fetched at: {}", source.fetched_at);
76    /// }
77    /// ```
78    #[must_use]
79    pub fn get_source(&self, name: &str) -> Option<&LockedSource> {
80        self.sources.iter().find(|s| s.name == name)
81    }
82
83    /// Add or update resource (agents or snippets only).
84    ///
85    /// Replaces existing resource with same name.
86    ///
87    /// **Note**: Backward compatibility only. Use `add_typed_resource` for all types.
88    ///
89    /// # Arguments
90    ///
91    /// * `name` - Unique resource identifier within its type
92    /// * `resource` - Complete [`LockedResource`] with all resolved information
93    /// * `is_agent` - `true` for agents, `false` for snippets
94    ///
95    /// # Behavior
96    ///
97    /// If a resource with the same name already exists in the same type category,
98    /// it will be replaced. Resources are categorized separately (agents vs snippets),
99    /// so an agent named "helper" and a snippet named "helper" can coexist.
100    ///
101    /// # Examples
102    ///
103    /// Adding an agent:
104    ///
105    /// ```rust,no_run
106    /// use agpm_cli::lockfile::{LockFile, LockedResourceBuilder};
107    /// use agpm_cli::core::ResourceType;
108    ///
109    /// let mut lockfile = LockFile::new();
110    /// let resource = LockedResourceBuilder::new(
111    ///     "example-agent".to_string(),
112    ///     "agents/example.md".to_string(),
113    ///     "sha256:abcdef...".to_string(),
114    ///     "agents/example-agent.md".to_string(),
115    ///     ResourceType::Agent,
116    /// )
117    /// .source(Some("community".to_string()))
118    /// .url(Some("https://github.com/example/repo.git".to_string()))
119    /// .version(Some("^1.0".to_string()))
120    /// .resolved_commit(Some("a1b2c3d...".to_string()))
121    /// .tool(Some("claude-code".to_string()))
122    /// .dependencies(Vec::new())
123    /// .applied_patches(std::collections::BTreeMap::new())
124    /// .build();
125    ///
126    /// lockfile.add_resource("example-agent".to_string(), resource, true);
127    /// assert_eq!(lockfile.agents.len(), 1);
128    /// ```
129    ///
130    /// Adding a snippet:
131    ///
132    /// ```rust,no_run
133    /// # use agpm_cli::lockfile::{LockFile, LockedResourceBuilder};
134    /// # use agpm_cli::core::ResourceType;
135    /// # let mut lockfile = LockFile::new();
136    /// let snippet = LockedResourceBuilder::new(
137    ///     "util-snippet".to_string(),
138    ///     "../local/utils.md".to_string(),
139    ///     "sha256:fedcba...".to_string(),
140    ///     "snippets/util-snippet.md".to_string(),
141    ///     ResourceType::Snippet,
142    /// )
143    /// .tool(Some("claude-code".to_string()))
144    /// .dependencies(Vec::new())
145    /// .applied_patches(std::collections::BTreeMap::new())
146    /// .build();
147    ///
148    /// lockfile.add_resource("util-snippet".to_string(), snippet, false);
149    /// assert_eq!(lockfile.snippets.len(), 1);
150    /// ```
151    pub fn add_resource(&mut self, name: String, resource: LockedResource, is_agent: bool) {
152        let resources = if is_agent {
153            &mut self.agents
154        } else {
155            &mut self.snippets
156        };
157
158        // Remove existing entry if present
159        resources.retain(|r| r.name != name);
160        resources.push(resource);
161    }
162
163    /// Add or update resource with explicit type support.
164    ///
165    /// Preferred method - supports all resource types.
166    ///
167    /// # Arguments
168    ///
169    /// * `name` - Unique resource identifier within its type
170    /// * `resource` - Complete [`LockedResource`] with all resolved information
171    /// * `resource_type` - The type of resource (Agent, Snippet, or Command)
172    ///
173    /// # Examples
174    ///
175    /// ```rust,no_run
176    /// use agpm_cli::lockfile::{LockFile, LockedResourceBuilder};
177    /// use agpm_cli::core::ResourceType;
178    ///
179    /// let mut lockfile = LockFile::new();
180    /// let command = LockedResourceBuilder::new(
181    ///     "build-command".to_string(),
182    ///     "commands/build.md".to_string(),
183    ///     "sha256:abcdef...".to_string(),
184    ///     ".claude/commands/build-command.md".to_string(),
185    ///     ResourceType::Command,
186    /// )
187    /// .source(Some("community".to_string()))
188    /// .url(Some("https://github.com/example/repo.git".to_string()))
189    /// .version(Some("v1.0.0".to_string()))
190    /// .resolved_commit(Some("a1b2c3d...".to_string()))
191    /// .tool(Some("claude-code".to_string()))
192    /// .dependencies(Vec::new())
193    /// .applied_patches(std::collections::BTreeMap::new())
194    /// .build();
195    ///
196    /// lockfile.add_typed_resource("build-command".to_string(), command, ResourceType::Command);
197    /// assert_eq!(lockfile.commands.len(), 1);
198    /// ```
199    pub fn add_typed_resource(
200        &mut self,
201        name: String,
202        resource: LockedResource,
203        resource_type: crate::core::ResourceType,
204    ) {
205        let resources = match resource_type {
206            crate::core::ResourceType::Agent => &mut self.agents,
207            crate::core::ResourceType::Snippet => &mut self.snippets,
208            crate::core::ResourceType::Command => &mut self.commands,
209            crate::core::ResourceType::McpServer => {
210                // MCP servers are handled differently - they don't use LockedResource
211                // This shouldn't be called for MCP servers
212                return;
213            }
214            crate::core::ResourceType::Script => &mut self.scripts,
215            crate::core::ResourceType::Hook => &mut self.hooks,
216        };
217
218        // Remove existing entry if present
219        resources.retain(|r| r.name != name);
220        resources.push(resource);
221    }
222
223    /// Check if resource exists by name.
224    ///
225    /// # Arguments
226    ///
227    /// * `name` - Resource name to check
228    ///
229    /// # Returns
230    ///
231    /// * `true` - Resource exists in the lockfile
232    /// * `false` - Resource does not exist
233    ///
234    /// # Examples
235    ///
236    /// ```rust,no_run
237    /// # use agpm_cli::lockfile::LockFile;
238    /// # let lockfile = LockFile::new();
239    /// if lockfile.has_resource("example-agent") {
240    ///     println!("Agent is already locked");
241    /// } else {
242    ///     println!("Agent needs to be resolved and installed");
243    /// }
244    /// ```
245    ///
246    /// This is equivalent to calling `lockfile.get_resource(name).is_some()`.
247    #[must_use]
248    pub fn has_resource(&self, name: &str) -> bool {
249        self.get_resource(name).is_some()
250    }
251
252    /// Internal name-based lookup across all types.
253    ///
254    /// Returns first match. External callers should use `find_resource_by_id` for proper lookup.
255    #[must_use]
256    pub(crate) fn get_resource(&self, name: &str) -> Option<&LockedResource> {
257        // Simple name matching - may return first of multiple resources with same name
258        // For precise matching when duplicates exist, use find_resource_by_id()
259        self.agents
260            .iter()
261            .find(|r| r.name == name)
262            .or_else(|| self.snippets.iter().find(|r| r.name == name))
263            .or_else(|| self.commands.iter().find(|r| r.name == name))
264            .or_else(|| self.scripts.iter().find(|r| r.name == name))
265            .or_else(|| self.hooks.iter().find(|r| r.name == name))
266            .or_else(|| self.mcp_servers.iter().find(|r| r.name == name))
267    }
268
269    /// Get resources by type as slice.
270    pub fn get_resources(&self, resource_type: &crate::core::ResourceType) -> &[LockedResource] {
271        use crate::core::ResourceType;
272        match resource_type {
273            ResourceType::Agent => &self.agents,
274            ResourceType::Snippet => &self.snippets,
275            ResourceType::Command => &self.commands,
276            ResourceType::Script => &self.scripts,
277            ResourceType::Hook => &self.hooks,
278            ResourceType::McpServer => &self.mcp_servers,
279        }
280    }
281
282    /// Get mutable resources by type.
283    pub const fn get_resources_mut(
284        &mut self,
285        resource_type: &crate::core::ResourceType,
286    ) -> &mut Vec<LockedResource> {
287        use crate::core::ResourceType;
288        match resource_type {
289            ResourceType::Agent => &mut self.agents,
290            ResourceType::Snippet => &mut self.snippets,
291            ResourceType::Command => &mut self.commands,
292            ResourceType::Script => &mut self.scripts,
293            ResourceType::Hook => &mut self.hooks,
294            ResourceType::McpServer => &mut self.mcp_servers,
295        }
296    }
297
298    /// Collect all resources across all types.
299    ///
300    /// Useful for operations processing resources uniformly:
301    /// - Installation reports
302    /// - Checksum validation
303    /// - Bulk operations
304    ///
305    /// # Returns
306    ///
307    /// A vector containing references to all [`LockedResource`] entries in the lockfile.
308    /// The order matches the resource type order defined in [`crate::core::ResourceType::all()`].
309    ///
310    /// # Examples
311    ///
312    /// ```rust,no_run
313    /// # use agpm_cli::lockfile::LockFile;
314    /// # let lockfile = LockFile::new();
315    /// let all_resources = lockfile.all_resources();
316    /// println!("Total locked resources: {}", all_resources.len());
317    ///
318    /// for resource in all_resources {
319    ///     println!("- {}: {}", resource.name, resource.installed_at);
320    /// }
321    /// ```
322    #[must_use]
323    pub fn all_resources(&self) -> Vec<&LockedResource> {
324        let mut resources = Vec::new();
325
326        // Use ResourceType::all() to iterate through all resource types
327        for resource_type in crate::core::ResourceType::all() {
328            resources.extend(self.get_resources(resource_type));
329        }
330
331        resources
332    }
333
334    /// Clear all entries, returning lockfile to empty state.
335    ///
336    /// Format version unchanged.
337    ///
338    /// # Examples
339    ///
340    /// ```rust,no_run
341    /// # use agpm_cli::lockfile::LockFile;
342    /// let mut lockfile = LockFile::new();
343    /// // ... add sources and resources ...
344    ///
345    /// lockfile.clear();
346    /// assert!(lockfile.sources.is_empty());
347    /// assert!(lockfile.agents.is_empty());
348    /// assert!(lockfile.snippets.is_empty());
349    /// ```
350    ///
351    /// # Use Cases
352    ///
353    /// - Preparing for complete lockfile regeneration
354    /// - Implementing `agpm clean` functionality
355    /// - Resetting lockfile state during testing
356    /// - Handling lockfile corruption recovery
357    pub fn clear(&mut self) {
358        self.sources.clear();
359
360        // Use ResourceType::all() to clear all resource types
361        for resource_type in crate::core::ResourceType::all() {
362            self.get_resources_mut(resource_type).clear();
363        }
364    }
365
366    /// Find resource by name within specific type.
367    ///
368    /// More precise than `get_resource` when type is known.
369    ///
370    /// # Arguments
371    ///
372    /// * `name` - Resource name to search for
373    /// * `resource_type` - The type of resource to search within
374    ///
375    /// # Returns
376    ///
377    /// * `Some(&LockedResource)` - Reference to the found resource
378    /// * `None` - No resource with that name exists in the specified type
379    ///
380    /// # Examples
381    ///
382    /// ```rust,no_run
383    /// # use agpm_cli::lockfile::LockFile;
384    /// # use agpm_cli::core::ResourceType;
385    /// # let lockfile = LockFile::new();
386    /// // Find a specific agent
387    /// if let Some(agent) = lockfile.find_resource("helper", &ResourceType::Agent) {
388    ///     println!("Found agent: {}", agent.installed_at);
389    /// }
390    ///
391    /// // Find a specific snippet
392    /// if let Some(snippet) = lockfile.find_resource("utils", &ResourceType::Snippet) {
393    ///     println!("Found snippet: {}", snippet.installed_at);
394    /// }
395    /// ```
396    ///
397    /// **Note**: External callers should prefer `find_resource_by_id(&ResourceId)` for ResourceId-based lookup.
398    #[must_use]
399    pub fn find_resource(
400        &self,
401        name: &str,
402        resource_type: &crate::core::ResourceType,
403    ) -> Option<&LockedResource> {
404        self.get_resources(resource_type).iter().find(|r| r.name == name)
405    }
406
407    /// Find resource by complete ResourceId (canonical lookup method).
408    ///
409    /// Checks all identity fields: name, source, tool, template_vars.
410    ///
411    /// # Arguments
412    ///
413    /// * `id` - The complete ResourceId to search for
414    ///
415    /// # Returns
416    ///
417    /// * `Some(&LockedResource)` - Reference to the matching resource
418    /// * `None` - No resource with that exact ID exists
419    ///
420    /// # Examples
421    ///
422    /// ```rust,no_run
423    /// # use agpm_cli::lockfile::{LockFile, ResourceId};
424    /// # use agpm_cli::core::ResourceType;
425    /// # use agpm_cli::utils::compute_variant_inputs_hash;
426    /// # use serde_json::json;
427    /// # let lockfile = LockFile::new();
428    /// // Find resource with specific template_vars
429    /// let template_vars = json!({"project": {"language": "python"}});
430    /// let variant_hash = compute_variant_inputs_hash(&template_vars).unwrap_or_default();
431    /// let id = ResourceId::new(
432    ///     "backend-engineer",
433    ///     Some("community"),
434    ///     Some("claude-code"),
435    ///     ResourceType::Agent,
436    ///     variant_hash
437    /// );
438    ///
439    /// if let Some(resource) = lockfile.find_resource_by_id(&id) {
440    ///     println!("Found: {}", resource.installed_at);
441    /// }
442    /// ```
443    #[must_use]
444    pub fn find_resource_by_id(&self, id: &ResourceId) -> Option<&LockedResource> {
445        // Search all resource types for exact ResourceId match
446        self.agents
447            .iter()
448            .find(|r| r.matches_id(id))
449            .or_else(|| self.snippets.iter().find(|r| r.matches_id(id)))
450            .or_else(|| self.commands.iter().find(|r| r.matches_id(id)))
451            .or_else(|| self.scripts.iter().find(|r| r.matches_id(id)))
452            .or_else(|| self.hooks.iter().find(|r| r.matches_id(id)))
453            .or_else(|| self.mcp_servers.iter().find(|r| r.matches_id(id)))
454    }
455
456    /// Find mutable resource by ResourceId.
457    ///
458    /// Use for modifications (checksums, patches, etc.).
459    ///
460    /// # Arguments
461    ///
462    /// * `id` - The complete ResourceId to search for
463    ///
464    /// # Returns
465    ///
466    /// * `Some(&mut LockedResource)` - Mutable reference to the matching resource
467    /// * `None` - No resource with that exact ID exists
468    #[must_use]
469    pub fn find_resource_by_id_mut(&mut self, id: &ResourceId) -> Option<&mut LockedResource> {
470        // Search all resource types for exact ResourceId match (mutable)
471        if let Some(r) = self.agents.iter_mut().find(|r| r.matches_id(id)) {
472            return Some(r);
473        }
474        if let Some(r) = self.snippets.iter_mut().find(|r| r.matches_id(id)) {
475            return Some(r);
476        }
477        if let Some(r) = self.commands.iter_mut().find(|r| r.matches_id(id)) {
478            return Some(r);
479        }
480        if let Some(r) = self.scripts.iter_mut().find(|r| r.matches_id(id)) {
481            return Some(r);
482        }
483        if let Some(r) = self.hooks.iter_mut().find(|r| r.matches_id(id)) {
484            return Some(r);
485        }
486        self.mcp_servers.iter_mut().find(|r| r.matches_id(id))
487    }
488
489    /// Get all resources by type for templating.
490    ///
491    /// # Arguments
492    ///
493    /// * `resource_type` - The type of resources to retrieve
494    ///
495    /// # Returns
496    ///
497    /// A slice of all resources of the specified type.
498    ///
499    /// # Examples
500    ///
501    /// ```rust,no_run
502    /// # use agpm_cli::lockfile::LockFile;
503    /// # use agpm_cli::core::ResourceType;
504    /// # let lockfile = LockFile::new();
505    /// // Get all agents for templating
506    /// let agents = lockfile.get_resources_by_type(&ResourceType::Agent);
507    /// for agent in agents {
508    ///     println!("Agent: {} -> {}", agent.name, agent.installed_at);
509    /// }
510    ///
511    /// // Get all snippets for templating
512    /// let snippets = lockfile.get_resources_by_type(&ResourceType::Snippet);
513    /// println!("Found {} snippets", snippets.len());
514    /// ```
515    ///
516    /// # See Also
517    ///
518    /// * [`get_resources`](Self::get_resources) - Get resources by type (same method)
519    /// * [`all_resources`](Self::all_resources) - Get all resources across all types
520    #[must_use]
521    pub fn get_resources_by_type(
522        &self,
523        resource_type: &crate::core::ResourceType,
524    ) -> &[LockedResource] {
525        self.get_resources(resource_type)
526    }
527}