agpm_cli/core/
resource_iterator.rs

1//! Resource iteration and collection utilities for parallel installation
2//!
3//! This module provides unified abstractions for working with multiple resource types,
4//! enabling efficient parallel processing and reducing code duplication. It supports
5//! the new installer architecture by providing iteration utilities that work seamlessly
6//! with the worktree-based parallel installation system.
7//!
8//! # Features
9//!
10//! - **Type-safe iteration**: Work with all resource types through unified interfaces
11//! - **Parallel processing support**: Optimized for concurrent resource handling
12//! - **Target directory resolution**: Maps resource types to their installation directories
13//! - **Resource lookup**: Fast lookup of resources by name across all types
14//!
15//! # Use Cases
16//!
17//! - **Installation**: Collecting all resources for parallel installation
18//! - **Updates**: Finding specific resources for selective updates
19//! - **Validation**: Iterating over all resources for consistency checks
20//! - **Reporting**: Gathering statistics across all resource types
21
22use crate::core::ResourceType;
23use crate::lockfile::{LockFile, LockedResource};
24use crate::manifest::{Manifest, ResourceDependency, TargetConfig};
25use std::collections::HashMap;
26
27/// Extension trait for `ResourceType` that adds lockfile and manifest operations
28///
29/// This trait extends the base [`ResourceType`] enum with methods for working with
30/// lockfiles and manifests in a type-safe way. It provides the foundation for
31/// unified resource processing across all resource types.
32pub trait ResourceTypeExt {
33    /// Get all resource types in their processing order
34    ///
35    /// Returns the complete list of resource types in the order they should be
36    /// processed during installation. This order is optimized for dependency
37    /// resolution and parallel processing efficiency.
38    ///
39    /// # Returns
40    ///
41    /// A vector containing all resource types in processing order
42    fn all() -> Vec<ResourceType>;
43
44    /// Get lockfile entries for this resource type
45    ///
46    /// Retrieves the slice of locked resources for this specific resource type
47    /// from the provided lockfile. This enables type-safe access to resources
48    /// without having to match on the resource type manually.
49    ///
50    /// # Arguments
51    ///
52    /// * `lockfile` - The lockfile to extract entries from
53    ///
54    /// # Returns
55    ///
56    /// A slice of [`LockedResource`] entries for this resource type
57    fn get_lockfile_entries<'a>(&self, lockfile: &'a LockFile) -> &'a [LockedResource];
58
59    /// Get mutable lockfile entries for this resource type
60    ///
61    /// Retrieves a mutable reference to the vector of locked resources for this
62    /// specific resource type. This is used when modifying lockfile contents
63    /// during resolution or updates.
64    ///
65    /// # Arguments
66    ///
67    /// * `lockfile` - The lockfile to extract entries from
68    ///
69    /// # Returns
70    ///
71    /// A mutable reference to the vector of [`LockedResource`] entries
72    fn get_lockfile_entries_mut<'a>(
73        &mut self,
74        lockfile: &'a mut LockFile,
75    ) -> &'a mut Vec<LockedResource>;
76
77    /// Get target directory for this resource type
78    ///
79    /// Retrieves the target installation directory for this resource type from
80    /// the manifest's target configuration. This determines where resources of
81    /// this type will be installed in the project.
82    ///
83    /// # Arguments
84    ///
85    /// * `targets` - The target configuration from the manifest
86    ///
87    /// # Returns
88    ///
89    /// The target directory path as a string reference
90    fn get_target_dir<'a>(&self, targets: &'a TargetConfig) -> &'a str;
91
92    /// Get manifest entries for this resource type
93    fn get_manifest_entries<'a>(
94        &self,
95        manifest: &'a Manifest,
96    ) -> &'a HashMap<String, ResourceDependency>;
97}
98
99impl ResourceTypeExt for ResourceType {
100    fn all() -> Vec<ResourceType> {
101        vec![Self::Agent, Self::Snippet, Self::Command, Self::McpServer, Self::Script, Self::Hook]
102    }
103
104    fn get_lockfile_entries<'a>(&self, lockfile: &'a LockFile) -> &'a [LockedResource] {
105        match self {
106            Self::Agent => &lockfile.agents,
107            Self::Snippet => &lockfile.snippets,
108            Self::Command => &lockfile.commands,
109            Self::Script => &lockfile.scripts,
110            Self::Hook => &lockfile.hooks,
111            Self::McpServer => &lockfile.mcp_servers,
112        }
113    }
114
115    fn get_lockfile_entries_mut<'a>(
116        &mut self,
117        lockfile: &'a mut LockFile,
118    ) -> &'a mut Vec<LockedResource> {
119        match self {
120            Self::Agent => &mut lockfile.agents,
121            Self::Snippet => &mut lockfile.snippets,
122            Self::Command => &mut lockfile.commands,
123            Self::Script => &mut lockfile.scripts,
124            Self::Hook => &mut lockfile.hooks,
125            Self::McpServer => &mut lockfile.mcp_servers,
126        }
127    }
128
129    fn get_target_dir<'a>(&self, targets: &'a TargetConfig) -> &'a str {
130        match self {
131            Self::Agent => targets.agents.as_str(),
132            Self::Snippet => targets.snippets.as_str(),
133            Self::Command => targets.commands.as_str(),
134            Self::Script => targets.scripts.as_str(),
135            Self::Hook => targets.hooks.as_str(),
136            Self::McpServer => targets.mcp_servers.as_str(),
137        }
138    }
139
140    fn get_manifest_entries<'a>(
141        &self,
142        manifest: &'a Manifest,
143    ) -> &'a HashMap<String, ResourceDependency> {
144        match self {
145            Self::Agent => &manifest.agents,
146            Self::Snippet => &manifest.snippets,
147            Self::Command => &manifest.commands,
148            Self::Script => &manifest.scripts,
149            Self::Hook => &manifest.hooks,
150            Self::McpServer => &manifest.mcp_servers,
151        }
152    }
153}
154
155/// Iterator utilities for working with resources across all types
156///
157/// The [`ResourceIterator`] provides static methods for collecting and processing
158/// resources from lockfiles in a unified way. It's designed to support the parallel
159/// installation architecture by providing efficient collection methods that work
160/// with the worktree-based installer.
161///
162/// # Use Cases
163///
164/// - **Parallel Installation**: Collecting all resources for concurrent processing
165/// - **Resource Discovery**: Finding specific resources across all types
166/// - **Statistics**: Gathering counts and information across resource types
167/// - **Validation**: Iterating over all resources for consistency checks
168///
169/// # Examples
170///
171/// ```rust,no_run
172/// use agpm_cli::core::resource_iterator::ResourceIterator;
173/// use agpm_cli::lockfile::LockFile;
174/// use agpm_cli::manifest::Manifest;
175/// use std::path::Path;
176///
177/// # fn example() -> anyhow::Result<()> {
178/// let lockfile = LockFile::load(Path::new("agpm.lock"))?;
179/// let manifest = Manifest::load(Path::new("agpm.toml"))?;
180///
181/// // Collect all resources for parallel installation
182/// let all_entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
183/// println!("Found {} total resources", all_entries.len());
184///
185/// // Find a specific resource by name
186/// if let Some((resource_type, entry)) =
187///     ResourceIterator::find_resource_by_name(&lockfile, "my-agent") {
188///     println!("Found {} in {}", entry.name, resource_type);
189/// }
190/// # Ok(())
191/// # }
192/// ```
193pub struct ResourceIterator;
194
195impl ResourceIterator {
196    /// Collect all lockfile entries with their target directories for parallel processing
197    ///
198    /// This method is optimized for the parallel installation architecture, collecting
199    /// all resources from the lockfile along with their target installation directories.
200    /// The returned collection can be directly used by the parallel installer.
201    ///
202    /// # Arguments
203    ///
204    /// * `lockfile` - The lockfile containing all resolved resources
205    /// * `manifest` - The manifest containing target directory configuration
206    ///
207    /// # Returns
208    ///
209    /// A vector of tuples containing each resource and its target directory.
210    /// The order matches the processing order defined by [`ResourceTypeExt::all()`].
211    ///
212    /// # Performance
213    ///
214    /// This method is optimized for parallel processing:
215    /// - Single allocation for the result vector
216    /// - Minimal copying of data (references are returned)
217    /// - Predictable iteration order for consistent parallel processing
218    pub fn collect_all_entries<'a>(
219        lockfile: &'a LockFile,
220        manifest: &'a Manifest,
221    ) -> Vec<(&'a LockedResource, std::borrow::Cow<'a, str>)> {
222        let mut all_entries = Vec::new();
223
224        for resource_type in ResourceType::all() {
225            // Skip hooks and MCP servers - they are configured directly from source
226            // without file copying
227            if matches!(resource_type, ResourceType::Hook | ResourceType::McpServer) {
228                continue;
229            }
230
231            let entries = resource_type.get_lockfile_entries(lockfile);
232
233            for entry in entries {
234                // Try artifact config first, fall back to legacy target config
235                let target_dir = if let Some(artifact_path) =
236                    manifest.get_artifact_resource_path(&entry.tool, *resource_type)
237                {
238                    std::borrow::Cow::Owned(artifact_path.display().to_string())
239                } else {
240                    // Fall back to legacy target config with deprecation
241                    #[allow(deprecated)]
242                    std::borrow::Cow::Borrowed(resource_type.get_target_dir(&manifest.target))
243                };
244
245                all_entries.push((entry, target_dir));
246            }
247        }
248
249        all_entries
250    }
251
252    /// Find a resource by name across all resource types
253    ///
254    /// # Warning
255    /// This method only matches by name and may return the wrong resource
256    /// when multiple sources provide resources with the same name.
257    /// Consider using [`Self::find_resource_by_name_and_source`] instead when
258    /// source information is available.
259    pub fn find_resource_by_name<'a>(
260        lockfile: &'a LockFile,
261        name: &str,
262    ) -> Option<(ResourceType, &'a LockedResource)> {
263        for resource_type in ResourceType::all() {
264            if let Some(entry) =
265                resource_type.get_lockfile_entries(lockfile).iter().find(|e| e.name == name)
266            {
267                return Some((*resource_type, entry));
268            }
269        }
270        None
271    }
272
273    /// Find a resource by name and source across all resource types
274    ///
275    /// This method matches resources using both name and source, which correctly
276    /// handles cases where multiple sources provide resources with the same name.
277    ///
278    /// # Arguments
279    /// * `lockfile` - The lockfile to search
280    /// * `name` - The resource name to match
281    /// * `source` - The source name to match (None for local resources)
282    ///
283    /// # Returns
284    /// The resource type and locked resource entry if found
285    pub fn find_resource_by_name_and_source<'a>(
286        lockfile: &'a LockFile,
287        name: &str,
288        source: Option<&str>,
289    ) -> Option<(ResourceType, &'a LockedResource)> {
290        for resource_type in ResourceType::all() {
291            if let Some(entry) = resource_type
292                .get_lockfile_entries(lockfile)
293                .iter()
294                .find(|e| e.name == name && e.source.as_deref() == source)
295            {
296                return Some((*resource_type, entry));
297            }
298        }
299        None
300    }
301
302    /// Count total resources in a lockfile
303    pub fn count_total_resources(lockfile: &LockFile) -> usize {
304        ResourceType::all().iter().map(|rt| rt.get_lockfile_entries(lockfile).len()).sum()
305    }
306
307    /// Count total dependencies defined in a manifest
308    pub fn count_manifest_dependencies(manifest: &Manifest) -> usize {
309        ResourceType::all().iter().map(|rt| rt.get_manifest_entries(manifest).len()).sum()
310    }
311
312    /// Check if a lockfile has any resources
313    pub fn has_resources(lockfile: &LockFile) -> bool {
314        ResourceType::all().iter().any(|rt| !rt.get_lockfile_entries(lockfile).is_empty())
315    }
316
317    /// Get all resource names from a lockfile
318    pub fn get_all_resource_names(lockfile: &LockFile) -> Vec<String> {
319        let mut names = Vec::new();
320        for resource_type in ResourceType::all() {
321            for entry in resource_type.get_lockfile_entries(lockfile) {
322                names.push(entry.name.clone());
323            }
324        }
325        names
326    }
327
328    /// Get resources of a specific type by source
329    pub fn get_resources_by_source<'a>(
330        lockfile: &'a LockFile,
331        resource_type: ResourceType,
332        source: &str,
333    ) -> Vec<&'a LockedResource> {
334        resource_type
335            .get_lockfile_entries(lockfile)
336            .iter()
337            .filter(|e| e.source.as_deref() == Some(source))
338            .collect()
339    }
340
341    /// Apply a function to all resources of all types
342    pub fn for_each_resource<F>(lockfile: &LockFile, mut f: F)
343    where
344        F: FnMut(ResourceType, &LockedResource),
345    {
346        for resource_type in ResourceType::all() {
347            for entry in resource_type.get_lockfile_entries(lockfile) {
348                f(*resource_type, entry);
349            }
350        }
351    }
352
353    /// Map over all resources and collect results
354    pub fn map_resources<T, F>(lockfile: &LockFile, mut f: F) -> Vec<T>
355    where
356        F: FnMut(ResourceType, &LockedResource) -> T,
357    {
358        let mut results = Vec::new();
359        Self::for_each_resource(lockfile, |rt, entry| {
360            results.push(f(rt, entry));
361        });
362        results
363    }
364
365    /// Filter resources based on a predicate
366    pub fn filter_resources<F>(
367        lockfile: &LockFile,
368        mut predicate: F,
369    ) -> Vec<(ResourceType, LockedResource)>
370    where
371        F: FnMut(ResourceType, &LockedResource) -> bool,
372    {
373        let mut results = Vec::new();
374        Self::for_each_resource(lockfile, |rt, entry| {
375            if predicate(rt, entry) {
376                results.push((rt, entry.clone()));
377            }
378        });
379        results
380    }
381
382    /// Group resources by source
383    pub fn group_by_source(
384        lockfile: &LockFile,
385    ) -> std::collections::HashMap<String, Vec<(ResourceType, LockedResource)>> {
386        let mut groups = std::collections::HashMap::new();
387
388        Self::for_each_resource(lockfile, |rt, entry| {
389            if let Some(ref source) = entry.source {
390                groups.entry(source.clone()).or_insert_with(Vec::new).push((rt, entry.clone()));
391            }
392        });
393
394        groups
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::lockfile::{LockFile, LockedResource};
402    use crate::manifest::{Manifest, TargetConfig};
403
404    fn create_test_lockfile() -> LockFile {
405        let mut lockfile = LockFile::new();
406
407        lockfile.agents.push(LockedResource {
408            name: "test-agent".to_string(),
409            source: Some("community".to_string()),
410            url: Some("https://github.com/test/repo.git".to_string()),
411            path: "agents/test.md".to_string(),
412            version: Some("v1.0.0".to_string()),
413            resolved_commit: Some("abc123".to_string()),
414            checksum: "sha256:abc".to_string(),
415            installed_at: ".claude/agents/test-agent.md".to_string(),
416            dependencies: vec![],
417            resource_type: crate::core::ResourceType::Agent,
418
419            tool: "claude-code".to_string(),
420        });
421
422        lockfile.snippets.push(LockedResource {
423            name: "test-snippet".to_string(),
424            source: Some("community".to_string()),
425            url: Some("https://github.com/test/repo.git".to_string()),
426            path: "snippets/test.md".to_string(),
427            version: Some("v1.0.0".to_string()),
428            resolved_commit: Some("def456".to_string()),
429            checksum: "sha256:def".to_string(),
430            installed_at: ".claude/snippets/test-snippet.md".to_string(),
431            dependencies: vec![],
432            resource_type: crate::core::ResourceType::Snippet,
433
434            tool: "claude-code".to_string(),
435        });
436
437        lockfile
438    }
439
440    #[allow(deprecated)]
441    fn create_test_manifest() -> Manifest {
442        Manifest {
443            target: TargetConfig::default(),
444            ..Default::default()
445        }
446    }
447
448    fn create_multi_resource_lockfile() -> LockFile {
449        let mut lockfile = LockFile::new();
450
451        // Add agents from different sources
452        lockfile.agents.push(LockedResource {
453            name: "agent1".to_string(),
454            source: Some("source1".to_string()),
455            url: Some("https://github.com/source1/repo.git".to_string()),
456            path: "agents/agent1.md".to_string(),
457            version: Some("v1.0.0".to_string()),
458            resolved_commit: Some("abc123".to_string()),
459            checksum: "sha256:abc1".to_string(),
460            installed_at: ".claude/agents/agent1.md".to_string(),
461            dependencies: vec![],
462            resource_type: crate::core::ResourceType::Agent,
463
464            tool: "claude-code".to_string(),
465        });
466
467        lockfile.agents.push(LockedResource {
468            name: "agent2".to_string(),
469            source: Some("source2".to_string()),
470            url: Some("https://github.com/source2/repo.git".to_string()),
471            path: "agents/agent2.md".to_string(),
472            version: Some("v2.0.0".to_string()),
473            resolved_commit: Some("def456".to_string()),
474            checksum: "sha256:def2".to_string(),
475            installed_at: ".claude/agents/agent2.md".to_string(),
476            dependencies: vec![],
477            resource_type: crate::core::ResourceType::Agent,
478
479            tool: "claude-code".to_string(),
480        });
481
482        // Add commands from source1
483        lockfile.commands.push(LockedResource {
484            name: "command1".to_string(),
485            source: Some("source1".to_string()),
486            url: Some("https://github.com/source1/repo.git".to_string()),
487            path: "commands/command1.md".to_string(),
488            version: Some("v1.1.0".to_string()),
489            resolved_commit: Some("ghi789".to_string()),
490            checksum: "sha256:ghi3".to_string(),
491            installed_at: ".claude/commands/command1.md".to_string(),
492            dependencies: vec![],
493            resource_type: crate::core::ResourceType::Command,
494
495            tool: "claude-code".to_string(),
496        });
497
498        // Add scripts
499        lockfile.scripts.push(LockedResource {
500            name: "script1".to_string(),
501            source: Some("source1".to_string()),
502            url: Some("https://github.com/source1/repo.git".to_string()),
503            path: "scripts/build.sh".to_string(),
504            version: Some("v1.0.0".to_string()),
505            resolved_commit: Some("jkl012".to_string()),
506            checksum: "sha256:jkl4".to_string(),
507            installed_at: ".claude/agpm/scripts/script1.sh".to_string(),
508            dependencies: vec![],
509            resource_type: crate::core::ResourceType::Script,
510
511            tool: "claude-code".to_string(),
512        });
513
514        // Add hooks
515        lockfile.hooks.push(LockedResource {
516            name: "hook1".to_string(),
517            source: Some("source2".to_string()),
518            url: Some("https://github.com/source2/repo.git".to_string()),
519            path: "hooks/pre-commit.json".to_string(),
520            version: Some("v1.0.0".to_string()),
521            resolved_commit: Some("mno345".to_string()),
522            checksum: "sha256:mno5".to_string(),
523            installed_at: ".claude/agpm/hooks/hook1.json".to_string(),
524            dependencies: vec![],
525            resource_type: crate::core::ResourceType::Hook,
526
527            tool: "claude-code".to_string(),
528        });
529
530        // Add MCP servers
531        lockfile.mcp_servers.push(LockedResource {
532            name: "mcp1".to_string(),
533            source: Some("source1".to_string()),
534            url: Some("https://github.com/source1/repo.git".to_string()),
535            path: "mcp-servers/filesystem.json".to_string(),
536            version: Some("v1.0.0".to_string()),
537            resolved_commit: Some("pqr678".to_string()),
538            checksum: "sha256:pqr6".to_string(),
539            installed_at: ".claude/agpm/mcp-servers/mcp1.json".to_string(),
540            dependencies: vec![],
541            resource_type: crate::core::ResourceType::McpServer,
542
543            tool: "claude-code".to_string(),
544        });
545
546        // Add resource without source
547        lockfile.snippets.push(LockedResource {
548            name: "local-snippet".to_string(),
549            source: None,
550            url: None,
551            path: "local/snippet.md".to_string(),
552            version: None,
553            resolved_commit: None,
554            checksum: "sha256:local".to_string(),
555            installed_at: ".claude/agpm/snippets/local-snippet.md".to_string(),
556            dependencies: vec![],
557            resource_type: crate::core::ResourceType::Snippet,
558
559            tool: "claude-code".to_string(),
560        });
561
562        lockfile
563    }
564
565    #[test]
566    fn test_resource_type_all() {
567        let all_types = ResourceType::all();
568        assert_eq!(all_types.len(), 6);
569        // Order from ResourceTypeExt::all() implementation (consistent with resource.rs)
570        assert_eq!(all_types[0], ResourceType::Agent);
571        assert_eq!(all_types[1], ResourceType::Snippet);
572        assert_eq!(all_types[2], ResourceType::Command);
573        assert_eq!(all_types[3], ResourceType::McpServer);
574        assert_eq!(all_types[4], ResourceType::Script);
575        assert_eq!(all_types[5], ResourceType::Hook);
576    }
577
578    #[test]
579    fn test_get_lockfile_entries_mut() {
580        let mut lockfile = create_test_lockfile();
581
582        // Test with agent type
583        let mut agent_type = ResourceType::Agent;
584        let entries = agent_type.get_lockfile_entries_mut(&mut lockfile);
585        assert_eq!(entries.len(), 1);
586        assert_eq!(entries[0].name, "test-agent");
587
588        // Add a new agent
589        entries.push(LockedResource {
590            name: "new-agent".to_string(),
591            source: Some("test".to_string()),
592            url: Some("https://example.com/repo.git".to_string()),
593            path: "agents/new.md".to_string(),
594            version: Some("v1.0.0".to_string()),
595            resolved_commit: Some("xyz789".to_string()),
596            checksum: "sha256:xyz".to_string(),
597            installed_at: ".claude/agents/new-agent.md".to_string(),
598            dependencies: vec![],
599            resource_type: crate::core::ResourceType::Agent,
600
601            tool: "claude-code".to_string(),
602        });
603
604        // Verify the agent was added
605        assert_eq!(lockfile.agents.len(), 2);
606        assert_eq!(lockfile.agents[1].name, "new-agent");
607
608        // Test with all resource types
609        let mut snippet_type = ResourceType::Snippet;
610        let snippet_entries = snippet_type.get_lockfile_entries_mut(&mut lockfile);
611        assert_eq!(snippet_entries.len(), 1);
612
613        let mut command_type = ResourceType::Command;
614        let command_entries = command_type.get_lockfile_entries_mut(&mut lockfile);
615        assert_eq!(command_entries.len(), 0);
616
617        let mut script_type = ResourceType::Script;
618        let script_entries = script_type.get_lockfile_entries_mut(&mut lockfile);
619        assert_eq!(script_entries.len(), 0);
620
621        let mut hook_type = ResourceType::Hook;
622        let hook_entries = hook_type.get_lockfile_entries_mut(&mut lockfile);
623        assert_eq!(hook_entries.len(), 0);
624
625        let mut mcp_type = ResourceType::McpServer;
626        let mcp_entries = mcp_type.get_lockfile_entries_mut(&mut lockfile);
627        assert_eq!(mcp_entries.len(), 0);
628    }
629
630    #[test]
631    fn test_collect_all_entries() {
632        let lockfile = create_test_lockfile();
633        let manifest = create_test_manifest();
634
635        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
636        assert_eq!(entries.len(), 2);
637
638        assert_eq!(entries[0].0.name, "test-agent");
639        // Normalize path separators for cross-platform testing
640        assert_eq!(entries[0].1.replace('\\', "/"), ".claude/agents");
641
642        assert_eq!(entries[1].0.name, "test-snippet");
643        // Normalize path separators for cross-platform testing
644        assert_eq!(entries[1].1.replace('\\', "/"), ".claude/agpm/snippets");
645    }
646
647    #[test]
648    fn test_collect_all_entries_empty_lockfile() {
649        let empty_lockfile = LockFile::new();
650        let manifest = create_test_manifest();
651
652        let entries = ResourceIterator::collect_all_entries(&empty_lockfile, &manifest);
653        assert_eq!(entries.len(), 0);
654    }
655
656    #[test]
657    fn test_collect_all_entries_multiple_resources() {
658        let lockfile = create_multi_resource_lockfile();
659        let manifest = create_test_manifest();
660
661        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
662
663        // Should have 5 resources total (2 agents, 1 command, 1 script, 1 snippet)
664        // Hooks and MCP servers are excluded (configured only, not installed as files)
665        assert_eq!(entries.len(), 5);
666
667        // Check that we have entries from installable resource types (not hooks/MCP)
668        let mut found_types = std::collections::HashSet::new();
669        for (resource, _) in &entries {
670            match resource.name.as_str() {
671                "agent1" | "agent2" => {
672                    found_types.insert("agent");
673                }
674                "local-snippet" => {
675                    found_types.insert("snippet");
676                }
677                "command1" => {
678                    found_types.insert("command");
679                }
680                "script1" => {
681                    found_types.insert("script");
682                }
683                // Hooks and MCP servers should not appear (configured only)
684                "hook1" | "mcp1" => {
685                    panic!("Hooks and MCP servers should not be in collected entries");
686                }
687                _ => {}
688            }
689        }
690
691        assert_eq!(found_types.len(), 4);
692    }
693
694    #[test]
695    fn test_find_resource_by_name() {
696        let lockfile = create_test_lockfile();
697
698        let result = ResourceIterator::find_resource_by_name(&lockfile, "test-agent");
699        assert!(result.is_some());
700        let (rt, resource) = result.unwrap();
701        assert_eq!(rt, ResourceType::Agent);
702        assert_eq!(resource.name, "test-agent");
703
704        let result = ResourceIterator::find_resource_by_name(&lockfile, "nonexistent");
705        assert!(result.is_none());
706    }
707
708    #[test]
709    fn test_find_resource_by_name_multiple_types() {
710        let lockfile = create_multi_resource_lockfile();
711
712        // Find agent
713        let result = ResourceIterator::find_resource_by_name(&lockfile, "agent1");
714        assert!(result.is_some());
715        let (rt, resource) = result.unwrap();
716        assert_eq!(rt, ResourceType::Agent);
717        assert_eq!(resource.name, "agent1");
718
719        // Find command
720        let result = ResourceIterator::find_resource_by_name(&lockfile, "command1");
721        assert!(result.is_some());
722        let (rt, resource) = result.unwrap();
723        assert_eq!(rt, ResourceType::Command);
724        assert_eq!(resource.name, "command1");
725
726        // Find script
727        let result = ResourceIterator::find_resource_by_name(&lockfile, "script1");
728        assert!(result.is_some());
729        let (rt, resource) = result.unwrap();
730        assert_eq!(rt, ResourceType::Script);
731        assert_eq!(resource.name, "script1");
732
733        // Find hook
734        let result = ResourceIterator::find_resource_by_name(&lockfile, "hook1");
735        assert!(result.is_some());
736        let (rt, resource) = result.unwrap();
737        assert_eq!(rt, ResourceType::Hook);
738        assert_eq!(resource.name, "hook1");
739
740        // Find MCP server
741        let result = ResourceIterator::find_resource_by_name(&lockfile, "mcp1");
742        assert!(result.is_some());
743        let (rt, resource) = result.unwrap();
744        assert_eq!(rt, ResourceType::McpServer);
745        assert_eq!(resource.name, "mcp1");
746
747        // Find local snippet (no source)
748        let result = ResourceIterator::find_resource_by_name(&lockfile, "local-snippet");
749        assert!(result.is_some());
750        let (rt, resource) = result.unwrap();
751        assert_eq!(rt, ResourceType::Snippet);
752        assert_eq!(resource.name, "local-snippet");
753        assert!(resource.source.is_none());
754    }
755
756    #[test]
757    fn test_count_and_has_resources() {
758        let lockfile = create_test_lockfile();
759        assert_eq!(ResourceIterator::count_total_resources(&lockfile), 2);
760        assert!(ResourceIterator::has_resources(&lockfile));
761
762        let empty_lockfile = LockFile::new();
763        assert_eq!(ResourceIterator::count_total_resources(&empty_lockfile), 0);
764        assert!(!ResourceIterator::has_resources(&empty_lockfile));
765
766        let multi_lockfile = create_multi_resource_lockfile();
767        assert_eq!(ResourceIterator::count_total_resources(&multi_lockfile), 7);
768        assert!(ResourceIterator::has_resources(&multi_lockfile));
769    }
770
771    #[test]
772    fn test_get_all_resource_names() {
773        let lockfile = create_test_lockfile();
774        let names = ResourceIterator::get_all_resource_names(&lockfile);
775
776        assert_eq!(names.len(), 2);
777        assert!(names.contains(&"test-agent".to_string()));
778        assert!(names.contains(&"test-snippet".to_string()));
779    }
780
781    #[test]
782    fn test_get_all_resource_names_empty() {
783        let empty_lockfile = LockFile::new();
784        let names = ResourceIterator::get_all_resource_names(&empty_lockfile);
785        assert_eq!(names.len(), 0);
786    }
787
788    #[test]
789    fn test_get_all_resource_names_multiple() {
790        let lockfile = create_multi_resource_lockfile();
791        let names = ResourceIterator::get_all_resource_names(&lockfile);
792
793        assert_eq!(names.len(), 7);
794        assert!(names.contains(&"agent1".to_string()));
795        assert!(names.contains(&"agent2".to_string()));
796        assert!(names.contains(&"local-snippet".to_string()));
797        assert!(names.contains(&"command1".to_string()));
798        assert!(names.contains(&"script1".to_string()));
799        assert!(names.contains(&"hook1".to_string()));
800        assert!(names.contains(&"mcp1".to_string()));
801    }
802
803    #[test]
804    fn test_get_resources_by_source() {
805        let lockfile = create_multi_resource_lockfile();
806
807        // Test source1 - should have agent1, command1, script1, and mcp1
808        let source1_resources =
809            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source1");
810        assert_eq!(source1_resources.len(), 1);
811        assert_eq!(source1_resources[0].name, "agent1");
812
813        let source1_commands =
814            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Command, "source1");
815        assert_eq!(source1_commands.len(), 1);
816        assert_eq!(source1_commands[0].name, "command1");
817
818        let source1_scripts =
819            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Script, "source1");
820        assert_eq!(source1_scripts.len(), 1);
821        assert_eq!(source1_scripts[0].name, "script1");
822
823        let source1_mcps = ResourceIterator::get_resources_by_source(
824            &lockfile,
825            ResourceType::McpServer,
826            "source1",
827        );
828        assert_eq!(source1_mcps.len(), 1);
829        assert_eq!(source1_mcps[0].name, "mcp1");
830
831        // Test source2 - should have agent2 and hook1
832        let source2_agents =
833            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source2");
834        assert_eq!(source2_agents.len(), 1);
835        assert_eq!(source2_agents[0].name, "agent2");
836
837        let source2_hooks =
838            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Hook, "source2");
839        assert_eq!(source2_hooks.len(), 1);
840        assert_eq!(source2_hooks[0].name, "hook1");
841
842        // Test nonexistent source
843        let nonexistent = ResourceIterator::get_resources_by_source(
844            &lockfile,
845            ResourceType::Agent,
846            "nonexistent",
847        );
848        assert_eq!(nonexistent.len(), 0);
849
850        // Test empty resource type
851        let source1_snippets =
852            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Snippet, "source1");
853        assert_eq!(source1_snippets.len(), 0);
854    }
855
856    #[test]
857    fn test_for_each_resource() {
858        let lockfile = create_multi_resource_lockfile();
859        let mut visited_resources = Vec::new();
860
861        ResourceIterator::for_each_resource(&lockfile, |resource_type, resource| {
862            visited_resources.push((resource_type, resource.name.clone()));
863        });
864
865        assert_eq!(visited_resources.len(), 7);
866
867        // Check that we visited all expected resources
868        let expected_resources = vec![
869            (ResourceType::Agent, "agent1".to_string()),
870            (ResourceType::Agent, "agent2".to_string()),
871            (ResourceType::Snippet, "local-snippet".to_string()),
872            (ResourceType::Command, "command1".to_string()),
873            (ResourceType::Script, "script1".to_string()),
874            (ResourceType::Hook, "hook1".to_string()),
875            (ResourceType::McpServer, "mcp1".to_string()),
876        ];
877
878        for expected in expected_resources {
879            assert!(visited_resources.contains(&expected));
880        }
881    }
882
883    #[test]
884    fn test_for_each_resource_empty() {
885        let empty_lockfile = LockFile::new();
886        let mut count = 0;
887
888        ResourceIterator::for_each_resource(&empty_lockfile, |_, _| {
889            count += 1;
890        });
891
892        assert_eq!(count, 0);
893    }
894
895    #[test]
896    fn test_map_resources() {
897        let lockfile = create_multi_resource_lockfile();
898
899        // Map to resource names
900        let names = ResourceIterator::map_resources(&lockfile, |_, resource| resource.name.clone());
901
902        assert_eq!(names.len(), 7);
903        assert!(names.contains(&"agent1".to_string()));
904        assert!(names.contains(&"agent2".to_string()));
905        assert!(names.contains(&"local-snippet".to_string()));
906        assert!(names.contains(&"command1".to_string()));
907        assert!(names.contains(&"script1".to_string()));
908        assert!(names.contains(&"hook1".to_string()));
909        assert!(names.contains(&"mcp1".to_string()));
910
911        // Map to resource type and name pairs
912        let type_name_pairs =
913            ResourceIterator::map_resources(&lockfile, |resource_type, resource| {
914                format!("{}:{}", resource_type, resource.name)
915            });
916
917        assert_eq!(type_name_pairs.len(), 7);
918        assert!(type_name_pairs.contains(&"agent:agent1".to_string()));
919        assert!(type_name_pairs.contains(&"agent:agent2".to_string()));
920        assert!(type_name_pairs.contains(&"snippet:local-snippet".to_string()));
921        assert!(type_name_pairs.contains(&"command:command1".to_string()));
922        assert!(type_name_pairs.contains(&"script:script1".to_string()));
923        assert!(type_name_pairs.contains(&"hook:hook1".to_string()));
924        assert!(type_name_pairs.contains(&"mcp-server:mcp1".to_string()));
925    }
926
927    #[test]
928    fn test_map_resources_empty() {
929        let empty_lockfile = LockFile::new();
930
931        let results =
932            ResourceIterator::map_resources(&empty_lockfile, |_, resource| resource.name.clone());
933
934        assert_eq!(results.len(), 0);
935    }
936
937    #[test]
938    fn test_filter_resources() {
939        let lockfile = create_multi_resource_lockfile();
940
941        // Filter by source1
942        let source1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
943            resource.source.as_deref() == Some("source1")
944        });
945
946        assert_eq!(source1_resources.len(), 4); // agent1, command1, script1, mcp1
947        let source1_names: Vec<String> =
948            source1_resources.iter().map(|(_, r)| r.name.clone()).collect();
949        assert!(source1_names.contains(&"agent1".to_string()));
950        assert!(source1_names.contains(&"command1".to_string()));
951        assert!(source1_names.contains(&"script1".to_string()));
952        assert!(source1_names.contains(&"mcp1".to_string()));
953
954        // Filter by resource type
955        let agents = ResourceIterator::filter_resources(&lockfile, |resource_type, _| {
956            resource_type == ResourceType::Agent
957        });
958
959        assert_eq!(agents.len(), 2); // agent1, agent2
960        let agent_names: Vec<String> = agents.iter().map(|(_, r)| r.name.clone()).collect();
961        assert!(agent_names.contains(&"agent1".to_string()));
962        assert!(agent_names.contains(&"agent2".to_string()));
963
964        // Filter resources without source
965        let no_source_resources =
966            ResourceIterator::filter_resources(&lockfile, |_, resource| resource.source.is_none());
967
968        assert_eq!(no_source_resources.len(), 1); // local-snippet
969        assert_eq!(no_source_resources[0].1.name, "local-snippet");
970
971        // Filter by version pattern
972        let v1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
973            resource.version.as_deref().unwrap_or("").starts_with("v1.")
974        });
975
976        assert_eq!(v1_resources.len(), 5); // agent1, command1, script1, hook1, mcp1 all have v1.x.x
977
978        // Filter that matches nothing
979        let no_matches = ResourceIterator::filter_resources(&lockfile, |_, resource| {
980            resource.name == "nonexistent"
981        });
982
983        assert_eq!(no_matches.len(), 0);
984    }
985
986    #[test]
987    fn test_filter_resources_empty() {
988        let empty_lockfile = LockFile::new();
989
990        let results = ResourceIterator::filter_resources(&empty_lockfile, |_, _| true);
991        assert_eq!(results.len(), 0);
992    }
993
994    #[test]
995    fn test_group_by_source() {
996        let lockfile = create_multi_resource_lockfile();
997
998        let groups = ResourceIterator::group_by_source(&lockfile);
999
1000        assert_eq!(groups.len(), 2); // source1 and source2
1001
1002        // Check source1 group
1003        let source1_group = groups.get("source1").unwrap();
1004        assert_eq!(source1_group.len(), 4); // agent1, command1, script1, mcp1
1005
1006        let source1_names: Vec<String> =
1007            source1_group.iter().map(|(_, r)| r.name.clone()).collect();
1008        assert!(source1_names.contains(&"agent1".to_string()));
1009        assert!(source1_names.contains(&"command1".to_string()));
1010        assert!(source1_names.contains(&"script1".to_string()));
1011        assert!(source1_names.contains(&"mcp1".to_string()));
1012
1013        // Check source2 group
1014        let source2_group = groups.get("source2").unwrap();
1015        assert_eq!(source2_group.len(), 2); // agent2, hook1
1016
1017        let source2_names: Vec<String> =
1018            source2_group.iter().map(|(_, r)| r.name.clone()).collect();
1019        assert!(source2_names.contains(&"agent2".to_string()));
1020        assert!(source2_names.contains(&"hook1".to_string()));
1021
1022        // Resources without source should not be included
1023        assert!(!groups.contains_key(""));
1024    }
1025
1026    #[test]
1027    fn test_group_by_source_empty() {
1028        let empty_lockfile = LockFile::new();
1029
1030        let groups = ResourceIterator::group_by_source(&empty_lockfile);
1031        assert_eq!(groups.len(), 0);
1032    }
1033
1034    #[test]
1035    fn test_group_by_source_no_sources() {
1036        let mut lockfile = LockFile::new();
1037
1038        // Add resource without source
1039        lockfile.agents.push(LockedResource {
1040            name: "local-agent".to_string(),
1041            source: None,
1042            url: None,
1043            path: "local/agent.md".to_string(),
1044            version: None,
1045            resolved_commit: None,
1046            checksum: "sha256:local".to_string(),
1047            installed_at: ".claude/agents/local-agent.md".to_string(),
1048            dependencies: vec![],
1049            resource_type: crate::core::ResourceType::Agent,
1050
1051            tool: "claude-code".to_string(),
1052        });
1053
1054        let groups = ResourceIterator::group_by_source(&lockfile);
1055        assert_eq!(groups.len(), 0); // No groups because resource has no source
1056    }
1057
1058    #[test]
1059    fn test_resource_type_ext() {
1060        let lockfile = create_test_lockfile();
1061
1062        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 1);
1063        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1064        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 0);
1065    }
1066
1067    #[test]
1068    fn test_resource_type_ext_all_types() {
1069        let lockfile = create_multi_resource_lockfile();
1070
1071        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 2);
1072        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1073        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 1);
1074        assert_eq!(ResourceType::Script.get_lockfile_entries(&lockfile).len(), 1);
1075        assert_eq!(ResourceType::Hook.get_lockfile_entries(&lockfile).len(), 1);
1076        assert_eq!(ResourceType::McpServer.get_lockfile_entries(&lockfile).len(), 1);
1077    }
1078
1079    #[test]
1080    #[allow(deprecated)]
1081    fn test_resource_type_get_target_dir() {
1082        let manifest = create_test_manifest();
1083        let targets = &manifest.target;
1084
1085        assert_eq!(ResourceType::Agent.get_target_dir(targets), ".claude/agents");
1086        assert_eq!(ResourceType::Snippet.get_target_dir(targets), ".claude/agpm/snippets");
1087        assert_eq!(ResourceType::Command.get_target_dir(targets), ".claude/commands");
1088        assert_eq!(ResourceType::Script.get_target_dir(targets), ".claude/agpm/scripts");
1089        assert_eq!(ResourceType::Hook.get_target_dir(targets), ".claude/agpm/hooks");
1090        assert_eq!(ResourceType::McpServer.get_target_dir(targets), ".claude/agpm/mcp-servers");
1091    }
1092}