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}