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            crate::core::ResourceType::Skill => &mut self.skills,
217        };
218
219        // Remove existing entry if present
220        resources.retain(|r| r.name != name);
221        resources.push(resource);
222    }
223
224    /// Check if resource exists by name.
225    ///
226    /// # Arguments
227    ///
228    /// * `name` - Resource name to check
229    ///
230    /// # Returns
231    ///
232    /// * `true` - Resource exists in the lockfile
233    /// * `false` - Resource does not exist
234    ///
235    /// # Examples
236    ///
237    /// ```rust,no_run
238    /// # use agpm_cli::lockfile::LockFile;
239    /// # let lockfile = LockFile::new();
240    /// if lockfile.has_resource("example-agent") {
241    ///     println!("Agent is already locked");
242    /// } else {
243    ///     println!("Agent needs to be resolved and installed");
244    /// }
245    /// ```
246    ///
247    /// This is equivalent to calling `lockfile.get_resource(name).is_some()`.
248    #[must_use]
249    pub fn has_resource(&self, name: &str) -> bool {
250        self.get_resource(name).is_some()
251    }
252
253    /// Internal name-based lookup across all types.
254    ///
255    /// Returns first match. External callers should use `find_resource_by_id` for proper lookup.
256    #[must_use]
257    pub(crate) fn get_resource(&self, name: &str) -> Option<&LockedResource> {
258        // Simple name matching - may return first of multiple resources with same name
259        // For precise matching when duplicates exist, use find_resource_by_id()
260        self.agents
261            .iter()
262            .find(|r| r.name == name)
263            .or_else(|| self.snippets.iter().find(|r| r.name == name))
264            .or_else(|| self.commands.iter().find(|r| r.name == name))
265            .or_else(|| self.scripts.iter().find(|r| r.name == name))
266            .or_else(|| self.hooks.iter().find(|r| r.name == name))
267            .or_else(|| self.mcp_servers.iter().find(|r| r.name == name))
268    }
269
270    /// Get resources by type as slice.
271    pub fn get_resources(&self, resource_type: &crate::core::ResourceType) -> &[LockedResource] {
272        use crate::core::ResourceType;
273        match resource_type {
274            ResourceType::Agent => &self.agents,
275            ResourceType::Snippet => &self.snippets,
276            ResourceType::Command => &self.commands,
277            ResourceType::Script => &self.scripts,
278            ResourceType::Hook => &self.hooks,
279            ResourceType::McpServer => &self.mcp_servers,
280            ResourceType::Skill => &self.skills,
281        }
282    }
283
284    /// Get mutable resources by type.
285    pub fn get_resources_mut(
286        &mut self,
287        resource_type: &crate::core::ResourceType,
288    ) -> &mut Vec<LockedResource> {
289        use crate::core::ResourceType;
290        match resource_type {
291            ResourceType::Agent => &mut self.agents,
292            ResourceType::Snippet => &mut self.snippets,
293            ResourceType::Command => &mut self.commands,
294            ResourceType::Script => &mut self.scripts,
295            ResourceType::Hook => &mut self.hooks,
296            ResourceType::McpServer => &mut self.mcp_servers,
297            ResourceType::Skill => &mut self.skills,
298        }
299    }
300
301    /// Collect all resources across all types.
302    ///
303    /// Useful for operations processing resources uniformly:
304    /// - Installation reports
305    /// - Checksum validation
306    /// - Bulk operations
307    ///
308    /// # Returns
309    ///
310    /// A vector containing references to all [`LockedResource`] entries in the lockfile.
311    /// The order matches the resource type order defined in [`crate::core::ResourceType::all()`].
312    ///
313    /// # Examples
314    ///
315    /// ```rust,no_run
316    /// # use agpm_cli::lockfile::LockFile;
317    /// # let lockfile = LockFile::new();
318    /// let all_resources = lockfile.all_resources();
319    /// println!("Total locked resources: {}", all_resources.len());
320    ///
321    /// for resource in all_resources {
322    ///     println!("- {}: {}", resource.name, resource.installed_at);
323    /// }
324    /// ```
325    #[must_use]
326    pub fn all_resources(&self) -> Vec<&LockedResource> {
327        let mut resources = Vec::new();
328
329        // Use ResourceType::all() to iterate through all resource types
330        for resource_type in crate::core::ResourceType::all() {
331            resources.extend(self.get_resources(resource_type));
332        }
333
334        resources
335    }
336
337    /// Clear all entries, returning lockfile to empty state.
338    ///
339    /// Format version unchanged.
340    ///
341    /// # Examples
342    ///
343    /// ```rust,no_run
344    /// # use agpm_cli::lockfile::LockFile;
345    /// let mut lockfile = LockFile::new();
346    /// // ... add sources and resources ...
347    ///
348    /// lockfile.clear();
349    /// assert!(lockfile.sources.is_empty());
350    /// assert!(lockfile.agents.is_empty());
351    /// assert!(lockfile.snippets.is_empty());
352    /// ```
353    ///
354    /// # Use Cases
355    ///
356    /// - Preparing for complete lockfile regeneration
357    /// - Implementing `agpm clean` functionality
358    /// - Resetting lockfile state during testing
359    /// - Handling lockfile corruption recovery
360    pub fn clear(&mut self) {
361        self.sources.clear();
362
363        // Use ResourceType::all() to clear all resource types
364        for resource_type in crate::core::ResourceType::all() {
365            self.get_resources_mut(resource_type).clear();
366        }
367    }
368
369    /// Find resource by name within specific type.
370    ///
371    /// More precise than `get_resource` when type is known.
372    ///
373    /// # Arguments
374    ///
375    /// * `name` - Resource name to search for
376    /// * `resource_type` - The type of resource to search within
377    ///
378    /// # Returns
379    ///
380    /// * `Some(&LockedResource)` - Reference to the found resource
381    /// * `None` - No resource with that name exists in the specified type
382    ///
383    /// # Examples
384    ///
385    /// ```rust,no_run
386    /// # use agpm_cli::lockfile::LockFile;
387    /// # use agpm_cli::core::ResourceType;
388    /// # let lockfile = LockFile::new();
389    /// // Find a specific agent
390    /// if let Some(agent) = lockfile.find_resource("helper", &ResourceType::Agent) {
391    ///     println!("Found agent: {}", agent.installed_at);
392    /// }
393    ///
394    /// // Find a specific snippet
395    /// if let Some(snippet) = lockfile.find_resource("utils", &ResourceType::Snippet) {
396    ///     println!("Found snippet: {}", snippet.installed_at);
397    /// }
398    /// ```
399    ///
400    /// **Note**: External callers should prefer `find_resource_by_id(&ResourceId)` for ResourceId-based lookup.
401    #[must_use]
402    pub fn find_resource(
403        &self,
404        name: &str,
405        resource_type: &crate::core::ResourceType,
406    ) -> Option<&LockedResource> {
407        self.get_resources(resource_type).iter().find(|r| r.name == name)
408    }
409
410    /// Find resource by complete ResourceId (canonical lookup method).
411    ///
412    /// Checks all identity fields: name, source, tool, template_vars.
413    ///
414    /// # Arguments
415    ///
416    /// * `id` - The complete ResourceId to search for
417    ///
418    /// # Returns
419    ///
420    /// * `Some(&LockedResource)` - Reference to the matching resource
421    /// * `None` - No resource with that exact ID exists
422    ///
423    /// # Examples
424    ///
425    /// ```rust,no_run
426    /// # use agpm_cli::lockfile::{LockFile, ResourceId};
427    /// # use agpm_cli::core::ResourceType;
428    /// # use agpm_cli::utils::compute_variant_inputs_hash;
429    /// # use serde_json::json;
430    /// # let lockfile = LockFile::new();
431    /// // Find resource with specific template_vars
432    /// let template_vars = json!({"project": {"language": "python"}});
433    /// let variant_hash = compute_variant_inputs_hash(&template_vars).unwrap_or_default();
434    /// let id = ResourceId::new(
435    ///     "backend-engineer",
436    ///     Some("community"),
437    ///     Some("claude-code"),
438    ///     ResourceType::Agent,
439    ///     variant_hash
440    /// );
441    ///
442    /// if let Some(resource) = lockfile.find_resource_by_id(&id) {
443    ///     println!("Found: {}", resource.installed_at);
444    /// }
445    /// ```
446    #[must_use]
447    pub fn find_resource_by_id(&self, id: &ResourceId) -> Option<&LockedResource> {
448        // Search all resource types for exact ResourceId match
449        self.agents
450            .iter()
451            .find(|r| r.matches_id(id))
452            .or_else(|| self.snippets.iter().find(|r| r.matches_id(id)))
453            .or_else(|| self.commands.iter().find(|r| r.matches_id(id)))
454            .or_else(|| self.scripts.iter().find(|r| r.matches_id(id)))
455            .or_else(|| self.hooks.iter().find(|r| r.matches_id(id)))
456            .or_else(|| self.mcp_servers.iter().find(|r| r.matches_id(id)))
457    }
458
459    /// Find mutable resource by ResourceId.
460    ///
461    /// Use for modifications (checksums, patches, etc.).
462    ///
463    /// # Arguments
464    ///
465    /// * `id` - The complete ResourceId to search for
466    ///
467    /// # Returns
468    ///
469    /// * `Some(&mut LockedResource)` - Mutable reference to the matching resource
470    /// * `None` - No resource with that exact ID exists
471    #[must_use]
472    pub fn find_resource_by_id_mut(&mut self, id: &ResourceId) -> Option<&mut LockedResource> {
473        // Search all resource types for exact ResourceId match (mutable)
474        if let Some(r) = self.agents.iter_mut().find(|r| r.matches_id(id)) {
475            return Some(r);
476        }
477        if let Some(r) = self.snippets.iter_mut().find(|r| r.matches_id(id)) {
478            return Some(r);
479        }
480        if let Some(r) = self.commands.iter_mut().find(|r| r.matches_id(id)) {
481            return Some(r);
482        }
483        if let Some(r) = self.scripts.iter_mut().find(|r| r.matches_id(id)) {
484            return Some(r);
485        }
486        if let Some(r) = self.hooks.iter_mut().find(|r| r.matches_id(id)) {
487            return Some(r);
488        }
489        self.mcp_servers.iter_mut().find(|r| r.matches_id(id))
490    }
491
492    /// Get all resources by type for templating.
493    ///
494    /// # Arguments
495    ///
496    /// * `resource_type` - The type of resources to retrieve
497    ///
498    /// # Returns
499    ///
500    /// A slice of all resources of the specified type.
501    ///
502    /// # Examples
503    ///
504    /// ```rust,no_run
505    /// # use agpm_cli::lockfile::LockFile;
506    /// # use agpm_cli::core::ResourceType;
507    /// # let lockfile = LockFile::new();
508    /// // Get all agents for templating
509    /// let agents = lockfile.get_resources_by_type(&ResourceType::Agent);
510    /// for agent in agents {
511    ///     println!("Agent: {} -> {}", agent.name, agent.installed_at);
512    /// }
513    ///
514    /// // Get all snippets for templating
515    /// let snippets = lockfile.get_resources_by_type(&ResourceType::Snippet);
516    /// println!("Found {} snippets", snippets.len());
517    /// ```
518    ///
519    /// # See Also
520    ///
521    /// * [`get_resources`](Self::get_resources) - Get resources by type (same method)
522    /// * [`all_resources`](Self::all_resources) - Get all resources across all types
523    #[must_use]
524    pub fn get_resources_by_type(
525        &self,
526        resource_type: &crate::core::ResourceType,
527    ) -> &[LockedResource] {
528        self.get_resources(resource_type)
529    }
530}