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}