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                // Get artifact configuration path
235                let tool = entry.tool.as_deref().unwrap_or("claude-code");
236                let artifact_path = manifest
237                    .get_artifact_resource_path(tool, *resource_type)
238                    .expect("Resource type should be supported by configured tools");
239                let target_dir = std::borrow::Cow::Owned(artifact_path.display().to_string());
240
241                all_entries.push((entry, target_dir));
242            }
243        }
244
245        all_entries
246    }
247
248    /// Find a resource by name across all resource types
249    ///
250    /// # Warning
251    /// This method only matches by name and may return the wrong resource
252    /// when multiple sources provide resources with the same name.
253    /// Consider using [`Self::find_resource_by_name_and_source`] instead when
254    /// source information is available.
255    pub fn find_resource_by_name<'a>(
256        lockfile: &'a LockFile,
257        name: &str,
258    ) -> Option<(ResourceType, &'a LockedResource)> {
259        for resource_type in ResourceType::all() {
260            if let Some(entry) =
261                resource_type.get_lockfile_entries(lockfile).iter().find(|e| e.name == name)
262            {
263                return Some((*resource_type, entry));
264            }
265        }
266        None
267    }
268
269    /// Find a resource by name and source across all resource types
270    ///
271    /// This method matches resources using both name and source, which correctly
272    /// handles cases where multiple sources provide resources with the same name.
273    ///
274    /// # Arguments
275    /// * `lockfile` - The lockfile to search
276    /// * `name` - The resource name to match
277    /// * `source` - The source name to match (None for local resources)
278    ///
279    /// # Returns
280    /// The resource type and locked resource entry if found
281    pub fn find_resource_by_name_and_source<'a>(
282        lockfile: &'a LockFile,
283        name: &str,
284        source: Option<&str>,
285    ) -> Option<(ResourceType, &'a LockedResource)> {
286        for resource_type in ResourceType::all() {
287            if let Some(entry) = resource_type
288                .get_lockfile_entries(lockfile)
289                .iter()
290                .find(|e| e.name == name && e.source.as_deref() == source)
291            {
292                return Some((*resource_type, entry));
293            }
294        }
295        None
296    }
297
298    /// Count total resources in a lockfile
299    pub fn count_total_resources(lockfile: &LockFile) -> usize {
300        ResourceType::all().iter().map(|rt| rt.get_lockfile_entries(lockfile).len()).sum()
301    }
302
303    /// Count total dependencies defined in a manifest
304    pub fn count_manifest_dependencies(manifest: &Manifest) -> usize {
305        ResourceType::all().iter().map(|rt| rt.get_manifest_entries(manifest).len()).sum()
306    }
307
308    /// Check if a lockfile has any resources
309    pub fn has_resources(lockfile: &LockFile) -> bool {
310        ResourceType::all().iter().any(|rt| !rt.get_lockfile_entries(lockfile).is_empty())
311    }
312
313    /// Get all resource names from a lockfile
314    pub fn get_all_resource_names(lockfile: &LockFile) -> Vec<String> {
315        let mut names = Vec::new();
316        for resource_type in ResourceType::all() {
317            for entry in resource_type.get_lockfile_entries(lockfile) {
318                names.push(entry.name.clone());
319            }
320        }
321        names
322    }
323
324    /// Get resources of a specific type by source
325    pub fn get_resources_by_source<'a>(
326        lockfile: &'a LockFile,
327        resource_type: ResourceType,
328        source: &str,
329    ) -> Vec<&'a LockedResource> {
330        resource_type
331            .get_lockfile_entries(lockfile)
332            .iter()
333            .filter(|e| e.source.as_deref() == Some(source))
334            .collect()
335    }
336
337    /// Apply a function to all resources of all types
338    pub fn for_each_resource<F>(lockfile: &LockFile, mut f: F)
339    where
340        F: FnMut(ResourceType, &LockedResource),
341    {
342        for resource_type in ResourceType::all() {
343            for entry in resource_type.get_lockfile_entries(lockfile) {
344                f(*resource_type, entry);
345            }
346        }
347    }
348
349    /// Map over all resources and collect results
350    pub fn map_resources<T, F>(lockfile: &LockFile, mut f: F) -> Vec<T>
351    where
352        F: FnMut(ResourceType, &LockedResource) -> T,
353    {
354        let mut results = Vec::new();
355        Self::for_each_resource(lockfile, |rt, entry| {
356            results.push(f(rt, entry));
357        });
358        results
359    }
360
361    /// Filter resources based on a predicate
362    pub fn filter_resources<F>(
363        lockfile: &LockFile,
364        mut predicate: F,
365    ) -> Vec<(ResourceType, LockedResource)>
366    where
367        F: FnMut(ResourceType, &LockedResource) -> bool,
368    {
369        let mut results = Vec::new();
370        Self::for_each_resource(lockfile, |rt, entry| {
371            if predicate(rt, entry) {
372                results.push((rt, entry.clone()));
373            }
374        });
375        results
376    }
377
378    /// Group resources by source
379    pub fn group_by_source(
380        lockfile: &LockFile,
381    ) -> std::collections::HashMap<String, Vec<(ResourceType, LockedResource)>> {
382        let mut groups = std::collections::HashMap::new();
383
384        Self::for_each_resource(lockfile, |rt, entry| {
385            if let Some(ref source) = entry.source {
386                groups.entry(source.clone()).or_insert_with(Vec::new).push((rt, entry.clone()));
387            }
388        });
389
390        groups
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::lockfile::{LockFile, LockedResource};
398    use crate::manifest::Manifest;
399    use crate::utils::normalize_path_for_storage;
400
401    fn create_test_lockfile() -> LockFile {
402        let mut lockfile = LockFile::new();
403
404        lockfile.agents.push(LockedResource {
405            name: "test-agent".to_string(),
406            source: Some("community".to_string()),
407            url: Some("https://github.com/test/repo.git".to_string()),
408            path: "agents/test.md".to_string(),
409            version: Some("v1.0.0".to_string()),
410            resolved_commit: Some("abc123".to_string()),
411            checksum: "sha256:abc".to_string(),
412            installed_at: ".claude/agents/test-agent.md".to_string(),
413            dependencies: vec![],
414            resource_type: crate::core::ResourceType::Agent,
415
416            tool: Some("claude-code".to_string()),
417            manifest_alias: None,
418            applied_patches: std::collections::HashMap::new(),
419            install: None,
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: Some("claude-code".to_string()),
435            manifest_alias: None,
436            applied_patches: std::collections::HashMap::new(),
437            install: None,
438        });
439
440        lockfile
441    }
442
443    fn create_test_manifest() -> Manifest {
444        Manifest::default()
445    }
446
447    fn create_multi_resource_lockfile() -> LockFile {
448        let mut lockfile = LockFile::new();
449
450        // Add agents from different sources
451        lockfile.agents.push(LockedResource {
452            name: "agent1".to_string(),
453            source: Some("source1".to_string()),
454            url: Some("https://github.com/source1/repo.git".to_string()),
455            path: "agents/agent1.md".to_string(),
456            version: Some("v1.0.0".to_string()),
457            resolved_commit: Some("abc123".to_string()),
458            checksum: "sha256:abc1".to_string(),
459            installed_at: ".claude/agents/agent1.md".to_string(),
460            dependencies: vec![],
461            resource_type: crate::core::ResourceType::Agent,
462
463            tool: Some("claude-code".to_string()),
464            manifest_alias: None,
465            applied_patches: std::collections::HashMap::new(),
466            install: None,
467        });
468
469        lockfile.agents.push(LockedResource {
470            name: "agent2".to_string(),
471            source: Some("source2".to_string()),
472            url: Some("https://github.com/source2/repo.git".to_string()),
473            path: "agents/agent2.md".to_string(),
474            version: Some("v2.0.0".to_string()),
475            resolved_commit: Some("def456".to_string()),
476            checksum: "sha256:def2".to_string(),
477            installed_at: ".claude/agents/agent2.md".to_string(),
478            dependencies: vec![],
479            resource_type: crate::core::ResourceType::Agent,
480
481            tool: Some("claude-code".to_string()),
482            manifest_alias: None,
483            applied_patches: std::collections::HashMap::new(),
484            install: None,
485        });
486
487        // Add commands from source1
488        lockfile.commands.push(LockedResource {
489            name: "command1".to_string(),
490            source: Some("source1".to_string()),
491            url: Some("https://github.com/source1/repo.git".to_string()),
492            path: "commands/command1.md".to_string(),
493            version: Some("v1.1.0".to_string()),
494            resolved_commit: Some("ghi789".to_string()),
495            checksum: "sha256:ghi3".to_string(),
496            installed_at: ".claude/commands/command1.md".to_string(),
497            dependencies: vec![],
498            resource_type: crate::core::ResourceType::Command,
499
500            tool: Some("claude-code".to_string()),
501            manifest_alias: None,
502            applied_patches: std::collections::HashMap::new(),
503            install: None,
504        });
505
506        // Add scripts
507        lockfile.scripts.push(LockedResource {
508            name: "script1".to_string(),
509            source: Some("source1".to_string()),
510            url: Some("https://github.com/source1/repo.git".to_string()),
511            path: "scripts/build.sh".to_string(),
512            version: Some("v1.0.0".to_string()),
513            resolved_commit: Some("jkl012".to_string()),
514            checksum: "sha256:jkl4".to_string(),
515            installed_at: ".claude/scripts/script1.sh".to_string(),
516            dependencies: vec![],
517            resource_type: crate::core::ResourceType::Script,
518
519            tool: Some("claude-code".to_string()),
520            manifest_alias: None,
521            applied_patches: std::collections::HashMap::new(),
522            install: None,
523        });
524
525        // Add hooks
526        lockfile.hooks.push(LockedResource {
527            name: "hook1".to_string(),
528            source: Some("source2".to_string()),
529            url: Some("https://github.com/source2/repo.git".to_string()),
530            path: "hooks/pre-commit.json".to_string(),
531            version: Some("v1.0.0".to_string()),
532            resolved_commit: Some("mno345".to_string()),
533            checksum: "sha256:mno5".to_string(),
534            installed_at: ".claude/hooks/hook1.json".to_string(),
535            dependencies: vec![],
536            resource_type: crate::core::ResourceType::Hook,
537
538            tool: Some("claude-code".to_string()),
539            manifest_alias: None,
540            applied_patches: std::collections::HashMap::new(),
541            install: None,
542        });
543
544        // Add MCP servers
545        lockfile.mcp_servers.push(LockedResource {
546            name: "mcp1".to_string(),
547            source: Some("source1".to_string()),
548            url: Some("https://github.com/source1/repo.git".to_string()),
549            path: "mcp-servers/filesystem.json".to_string(),
550            version: Some("v1.0.0".to_string()),
551            resolved_commit: Some("pqr678".to_string()),
552            checksum: "sha256:pqr6".to_string(),
553            installed_at: ".mcp-servers/mcp1.json".to_string(),
554            dependencies: vec![],
555            resource_type: crate::core::ResourceType::McpServer,
556
557            tool: Some("claude-code".to_string()),
558            manifest_alias: None,
559            applied_patches: std::collections::HashMap::new(),
560            install: None,
561        });
562
563        // Add resource without source
564        lockfile.snippets.push(LockedResource {
565            name: "local-snippet".to_string(),
566            source: None,
567            url: None,
568            path: "local/snippet.md".to_string(),
569            version: None,
570            resolved_commit: None,
571            checksum: "sha256:local".to_string(),
572            installed_at: ".agpm/snippets/local-snippet.md".to_string(),
573            dependencies: vec![],
574            resource_type: crate::core::ResourceType::Snippet,
575
576            tool: Some("claude-code".to_string()),
577            manifest_alias: None,
578            applied_patches: std::collections::HashMap::new(),
579            install: None,
580        });
581
582        lockfile
583    }
584
585    #[test]
586    fn test_resource_type_all() {
587        let all_types = ResourceType::all();
588        assert_eq!(all_types.len(), 6);
589        // Order from ResourceTypeExt::all() implementation (consistent with resource.rs)
590        assert_eq!(all_types[0], ResourceType::Agent);
591        assert_eq!(all_types[1], ResourceType::Snippet);
592        assert_eq!(all_types[2], ResourceType::Command);
593        assert_eq!(all_types[3], ResourceType::McpServer);
594        assert_eq!(all_types[4], ResourceType::Script);
595        assert_eq!(all_types[5], ResourceType::Hook);
596    }
597
598    #[test]
599    fn test_get_lockfile_entries_mut() {
600        let mut lockfile = create_test_lockfile();
601
602        // Test with agent type
603        let mut agent_type = ResourceType::Agent;
604        let entries = agent_type.get_lockfile_entries_mut(&mut lockfile);
605        assert_eq!(entries.len(), 1);
606        assert_eq!(entries[0].name, "test-agent");
607
608        // Add a new agent
609        entries.push(LockedResource {
610            name: "new-agent".to_string(),
611            source: Some("test".to_string()),
612            url: Some("https://example.com/repo.git".to_string()),
613            path: "agents/new.md".to_string(),
614            version: Some("v1.0.0".to_string()),
615            resolved_commit: Some("xyz789".to_string()),
616            checksum: "sha256:xyz".to_string(),
617            installed_at: ".claude/agents/new-agent.md".to_string(),
618            dependencies: vec![],
619            resource_type: crate::core::ResourceType::Agent,
620
621            tool: Some("claude-code".to_string()),
622            manifest_alias: None,
623            applied_patches: std::collections::HashMap::new(),
624            install: None,
625        });
626
627        // Verify the agent was added
628        assert_eq!(lockfile.agents.len(), 2);
629        assert_eq!(lockfile.agents[1].name, "new-agent");
630
631        // Test with all resource types
632        let mut snippet_type = ResourceType::Snippet;
633        let snippet_entries = snippet_type.get_lockfile_entries_mut(&mut lockfile);
634        assert_eq!(snippet_entries.len(), 1);
635
636        let mut command_type = ResourceType::Command;
637        let command_entries = command_type.get_lockfile_entries_mut(&mut lockfile);
638        assert_eq!(command_entries.len(), 0);
639
640        let mut script_type = ResourceType::Script;
641        let script_entries = script_type.get_lockfile_entries_mut(&mut lockfile);
642        assert_eq!(script_entries.len(), 0);
643
644        let mut hook_type = ResourceType::Hook;
645        let hook_entries = hook_type.get_lockfile_entries_mut(&mut lockfile);
646        assert_eq!(hook_entries.len(), 0);
647
648        let mut mcp_type = ResourceType::McpServer;
649        let mcp_entries = mcp_type.get_lockfile_entries_mut(&mut lockfile);
650        assert_eq!(mcp_entries.len(), 0);
651    }
652
653    #[test]
654    fn test_collect_all_entries() {
655        let lockfile = create_test_lockfile();
656        let manifest = create_test_manifest();
657
658        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
659        assert_eq!(entries.len(), 2);
660
661        assert_eq!(entries[0].0.name, "test-agent");
662        // Normalize path separators for cross-platform testing
663        assert_eq!(normalize_path_for_storage(entries[0].1.as_ref()), ".claude/agents");
664
665        assert_eq!(entries[1].0.name, "test-snippet");
666        // Normalize path separators for cross-platform testing
667        // Snippet uses claude-code tool, so it installs to .claude/snippets
668        assert_eq!(normalize_path_for_storage(entries[1].1.as_ref()), ".claude/snippets");
669    }
670
671    #[test]
672    fn test_collect_all_entries_empty_lockfile() {
673        let empty_lockfile = LockFile::new();
674        let manifest = create_test_manifest();
675
676        let entries = ResourceIterator::collect_all_entries(&empty_lockfile, &manifest);
677        assert_eq!(entries.len(), 0);
678    }
679
680    #[test]
681    fn test_collect_all_entries_multiple_resources() {
682        let lockfile = create_multi_resource_lockfile();
683        let manifest = create_test_manifest();
684
685        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
686
687        // Should have 5 resources total (2 agents, 1 command, 1 script, 1 snippet)
688        // Hooks and MCP servers are excluded (configured only, not installed as files)
689        assert_eq!(entries.len(), 5);
690
691        // Check that we have entries from installable resource types (not hooks/MCP)
692        let mut found_types = std::collections::HashSet::new();
693        for (resource, _) in &entries {
694            match resource.name.as_str() {
695                "agent1" | "agent2" => {
696                    found_types.insert("agent");
697                }
698                "local-snippet" => {
699                    found_types.insert("snippet");
700                }
701                "command1" => {
702                    found_types.insert("command");
703                }
704                "script1" => {
705                    found_types.insert("script");
706                }
707                // Hooks and MCP servers should not appear (configured only)
708                "hook1" | "mcp1" => {
709                    panic!("Hooks and MCP servers should not be in collected entries");
710                }
711                _ => {}
712            }
713        }
714
715        assert_eq!(found_types.len(), 4);
716    }
717
718    #[test]
719    fn test_find_resource_by_name() {
720        let lockfile = create_test_lockfile();
721
722        let result = ResourceIterator::find_resource_by_name(&lockfile, "test-agent");
723        assert!(result.is_some());
724        let (rt, resource) = result.unwrap();
725        assert_eq!(rt, ResourceType::Agent);
726        assert_eq!(resource.name, "test-agent");
727
728        let result = ResourceIterator::find_resource_by_name(&lockfile, "nonexistent");
729        assert!(result.is_none());
730    }
731
732    #[test]
733    fn test_find_resource_by_name_multiple_types() {
734        let lockfile = create_multi_resource_lockfile();
735
736        // Find agent
737        let result = ResourceIterator::find_resource_by_name(&lockfile, "agent1");
738        assert!(result.is_some());
739        let (rt, resource) = result.unwrap();
740        assert_eq!(rt, ResourceType::Agent);
741        assert_eq!(resource.name, "agent1");
742
743        // Find command
744        let result = ResourceIterator::find_resource_by_name(&lockfile, "command1");
745        assert!(result.is_some());
746        let (rt, resource) = result.unwrap();
747        assert_eq!(rt, ResourceType::Command);
748        assert_eq!(resource.name, "command1");
749
750        // Find script
751        let result = ResourceIterator::find_resource_by_name(&lockfile, "script1");
752        assert!(result.is_some());
753        let (rt, resource) = result.unwrap();
754        assert_eq!(rt, ResourceType::Script);
755        assert_eq!(resource.name, "script1");
756
757        // Find hook
758        let result = ResourceIterator::find_resource_by_name(&lockfile, "hook1");
759        assert!(result.is_some());
760        let (rt, resource) = result.unwrap();
761        assert_eq!(rt, ResourceType::Hook);
762        assert_eq!(resource.name, "hook1");
763
764        // Find MCP server
765        let result = ResourceIterator::find_resource_by_name(&lockfile, "mcp1");
766        assert!(result.is_some());
767        let (rt, resource) = result.unwrap();
768        assert_eq!(rt, ResourceType::McpServer);
769        assert_eq!(resource.name, "mcp1");
770
771        // Find local snippet (no source)
772        let result = ResourceIterator::find_resource_by_name(&lockfile, "local-snippet");
773        assert!(result.is_some());
774        let (rt, resource) = result.unwrap();
775        assert_eq!(rt, ResourceType::Snippet);
776        assert_eq!(resource.name, "local-snippet");
777        assert!(resource.source.is_none());
778    }
779
780    #[test]
781    fn test_count_and_has_resources() {
782        let lockfile = create_test_lockfile();
783        assert_eq!(ResourceIterator::count_total_resources(&lockfile), 2);
784        assert!(ResourceIterator::has_resources(&lockfile));
785
786        let empty_lockfile = LockFile::new();
787        assert_eq!(ResourceIterator::count_total_resources(&empty_lockfile), 0);
788        assert!(!ResourceIterator::has_resources(&empty_lockfile));
789
790        let multi_lockfile = create_multi_resource_lockfile();
791        assert_eq!(ResourceIterator::count_total_resources(&multi_lockfile), 7);
792        assert!(ResourceIterator::has_resources(&multi_lockfile));
793    }
794
795    #[test]
796    fn test_get_all_resource_names() {
797        let lockfile = create_test_lockfile();
798        let names = ResourceIterator::get_all_resource_names(&lockfile);
799
800        assert_eq!(names.len(), 2);
801        assert!(names.contains(&"test-agent".to_string()));
802        assert!(names.contains(&"test-snippet".to_string()));
803    }
804
805    #[test]
806    fn test_get_all_resource_names_empty() {
807        let empty_lockfile = LockFile::new();
808        let names = ResourceIterator::get_all_resource_names(&empty_lockfile);
809        assert_eq!(names.len(), 0);
810    }
811
812    #[test]
813    fn test_get_all_resource_names_multiple() {
814        let lockfile = create_multi_resource_lockfile();
815        let names = ResourceIterator::get_all_resource_names(&lockfile);
816
817        assert_eq!(names.len(), 7);
818        assert!(names.contains(&"agent1".to_string()));
819        assert!(names.contains(&"agent2".to_string()));
820        assert!(names.contains(&"local-snippet".to_string()));
821        assert!(names.contains(&"command1".to_string()));
822        assert!(names.contains(&"script1".to_string()));
823        assert!(names.contains(&"hook1".to_string()));
824        assert!(names.contains(&"mcp1".to_string()));
825    }
826
827    #[test]
828    fn test_get_resources_by_source() {
829        let lockfile = create_multi_resource_lockfile();
830
831        // Test source1 - should have agent1, command1, script1, and mcp1
832        let source1_resources =
833            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source1");
834        assert_eq!(source1_resources.len(), 1);
835        assert_eq!(source1_resources[0].name, "agent1");
836
837        let source1_commands =
838            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Command, "source1");
839        assert_eq!(source1_commands.len(), 1);
840        assert_eq!(source1_commands[0].name, "command1");
841
842        let source1_scripts =
843            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Script, "source1");
844        assert_eq!(source1_scripts.len(), 1);
845        assert_eq!(source1_scripts[0].name, "script1");
846
847        let source1_mcps = ResourceIterator::get_resources_by_source(
848            &lockfile,
849            ResourceType::McpServer,
850            "source1",
851        );
852        assert_eq!(source1_mcps.len(), 1);
853        assert_eq!(source1_mcps[0].name, "mcp1");
854
855        // Test source2 - should have agent2 and hook1
856        let source2_agents =
857            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source2");
858        assert_eq!(source2_agents.len(), 1);
859        assert_eq!(source2_agents[0].name, "agent2");
860
861        let source2_hooks =
862            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Hook, "source2");
863        assert_eq!(source2_hooks.len(), 1);
864        assert_eq!(source2_hooks[0].name, "hook1");
865
866        // Test nonexistent source
867        let nonexistent = ResourceIterator::get_resources_by_source(
868            &lockfile,
869            ResourceType::Agent,
870            "nonexistent",
871        );
872        assert_eq!(nonexistent.len(), 0);
873
874        // Test empty resource type
875        let source1_snippets =
876            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Snippet, "source1");
877        assert_eq!(source1_snippets.len(), 0);
878    }
879
880    #[test]
881    fn test_for_each_resource() {
882        let lockfile = create_multi_resource_lockfile();
883        let mut visited_resources = Vec::new();
884
885        ResourceIterator::for_each_resource(&lockfile, |resource_type, resource| {
886            visited_resources.push((resource_type, resource.name.clone()));
887        });
888
889        assert_eq!(visited_resources.len(), 7);
890
891        // Check that we visited all expected resources
892        let expected_resources = vec![
893            (ResourceType::Agent, "agent1".to_string()),
894            (ResourceType::Agent, "agent2".to_string()),
895            (ResourceType::Snippet, "local-snippet".to_string()),
896            (ResourceType::Command, "command1".to_string()),
897            (ResourceType::Script, "script1".to_string()),
898            (ResourceType::Hook, "hook1".to_string()),
899            (ResourceType::McpServer, "mcp1".to_string()),
900        ];
901
902        for expected in expected_resources {
903            assert!(visited_resources.contains(&expected));
904        }
905    }
906
907    #[test]
908    fn test_for_each_resource_empty() {
909        let empty_lockfile = LockFile::new();
910        let mut count = 0;
911
912        ResourceIterator::for_each_resource(&empty_lockfile, |_, _| {
913            count += 1;
914        });
915
916        assert_eq!(count, 0);
917    }
918
919    #[test]
920    fn test_map_resources() {
921        let lockfile = create_multi_resource_lockfile();
922
923        // Map to resource names
924        let names = ResourceIterator::map_resources(&lockfile, |_, resource| resource.name.clone());
925
926        assert_eq!(names.len(), 7);
927        assert!(names.contains(&"agent1".to_string()));
928        assert!(names.contains(&"agent2".to_string()));
929        assert!(names.contains(&"local-snippet".to_string()));
930        assert!(names.contains(&"command1".to_string()));
931        assert!(names.contains(&"script1".to_string()));
932        assert!(names.contains(&"hook1".to_string()));
933        assert!(names.contains(&"mcp1".to_string()));
934
935        // Map to resource type and name pairs
936        let type_name_pairs =
937            ResourceIterator::map_resources(&lockfile, |resource_type, resource| {
938                format!("{}:{}", resource_type, resource.name)
939            });
940
941        assert_eq!(type_name_pairs.len(), 7);
942        assert!(type_name_pairs.contains(&"agent:agent1".to_string()));
943        assert!(type_name_pairs.contains(&"agent:agent2".to_string()));
944        assert!(type_name_pairs.contains(&"snippet:local-snippet".to_string()));
945        assert!(type_name_pairs.contains(&"command:command1".to_string()));
946        assert!(type_name_pairs.contains(&"script:script1".to_string()));
947        assert!(type_name_pairs.contains(&"hook:hook1".to_string()));
948        assert!(type_name_pairs.contains(&"mcp-server:mcp1".to_string()));
949    }
950
951    #[test]
952    fn test_map_resources_empty() {
953        let empty_lockfile = LockFile::new();
954
955        let results =
956            ResourceIterator::map_resources(&empty_lockfile, |_, resource| resource.name.clone());
957
958        assert_eq!(results.len(), 0);
959    }
960
961    #[test]
962    fn test_filter_resources() {
963        let lockfile = create_multi_resource_lockfile();
964
965        // Filter by source1
966        let source1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
967            resource.source.as_deref() == Some("source1")
968        });
969
970        assert_eq!(source1_resources.len(), 4); // agent1, command1, script1, mcp1
971        let source1_names: Vec<String> =
972            source1_resources.iter().map(|(_, r)| r.name.clone()).collect();
973        assert!(source1_names.contains(&"agent1".to_string()));
974        assert!(source1_names.contains(&"command1".to_string()));
975        assert!(source1_names.contains(&"script1".to_string()));
976        assert!(source1_names.contains(&"mcp1".to_string()));
977
978        // Filter by resource type
979        let agents = ResourceIterator::filter_resources(&lockfile, |resource_type, _| {
980            resource_type == ResourceType::Agent
981        });
982
983        assert_eq!(agents.len(), 2); // agent1, agent2
984        let agent_names: Vec<String> = agents.iter().map(|(_, r)| r.name.clone()).collect();
985        assert!(agent_names.contains(&"agent1".to_string()));
986        assert!(agent_names.contains(&"agent2".to_string()));
987
988        // Filter resources without source
989        let no_source_resources =
990            ResourceIterator::filter_resources(&lockfile, |_, resource| resource.source.is_none());
991
992        assert_eq!(no_source_resources.len(), 1); // local-snippet
993        assert_eq!(no_source_resources[0].1.name, "local-snippet");
994
995        // Filter by version pattern
996        let v1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
997            resource.version.as_deref().unwrap_or("").starts_with("v1.")
998        });
999
1000        assert_eq!(v1_resources.len(), 5); // agent1, command1, script1, hook1, mcp1 all have v1.x.x
1001
1002        // Filter that matches nothing
1003        let no_matches = ResourceIterator::filter_resources(&lockfile, |_, resource| {
1004            resource.name == "nonexistent"
1005        });
1006
1007        assert_eq!(no_matches.len(), 0);
1008    }
1009
1010    #[test]
1011    fn test_filter_resources_empty() {
1012        let empty_lockfile = LockFile::new();
1013
1014        let results = ResourceIterator::filter_resources(&empty_lockfile, |_, _| true);
1015        assert_eq!(results.len(), 0);
1016    }
1017
1018    #[test]
1019    fn test_group_by_source() {
1020        let lockfile = create_multi_resource_lockfile();
1021
1022        let groups = ResourceIterator::group_by_source(&lockfile);
1023
1024        assert_eq!(groups.len(), 2); // source1 and source2
1025
1026        // Check source1 group
1027        let source1_group = groups.get("source1").unwrap();
1028        assert_eq!(source1_group.len(), 4); // agent1, command1, script1, mcp1
1029
1030        let source1_names: Vec<String> =
1031            source1_group.iter().map(|(_, r)| r.name.clone()).collect();
1032        assert!(source1_names.contains(&"agent1".to_string()));
1033        assert!(source1_names.contains(&"command1".to_string()));
1034        assert!(source1_names.contains(&"script1".to_string()));
1035        assert!(source1_names.contains(&"mcp1".to_string()));
1036
1037        // Check source2 group
1038        let source2_group = groups.get("source2").unwrap();
1039        assert_eq!(source2_group.len(), 2); // agent2, hook1
1040
1041        let source2_names: Vec<String> =
1042            source2_group.iter().map(|(_, r)| r.name.clone()).collect();
1043        assert!(source2_names.contains(&"agent2".to_string()));
1044        assert!(source2_names.contains(&"hook1".to_string()));
1045
1046        // Resources without source should not be included
1047        assert!(!groups.contains_key(""));
1048    }
1049
1050    #[test]
1051    fn test_group_by_source_empty() {
1052        let empty_lockfile = LockFile::new();
1053
1054        let groups = ResourceIterator::group_by_source(&empty_lockfile);
1055        assert_eq!(groups.len(), 0);
1056    }
1057
1058    #[test]
1059    fn test_group_by_source_no_sources() {
1060        let mut lockfile = LockFile::new();
1061
1062        // Add resource without source
1063        lockfile.agents.push(LockedResource {
1064            name: "local-agent".to_string(),
1065            source: None,
1066            url: None,
1067            path: "local/agent.md".to_string(),
1068            version: None,
1069            resolved_commit: None,
1070            checksum: "sha256:local".to_string(),
1071            installed_at: ".claude/agents/local-agent.md".to_string(),
1072            dependencies: vec![],
1073            resource_type: crate::core::ResourceType::Agent,
1074
1075            tool: Some("claude-code".to_string()),
1076            manifest_alias: None,
1077            applied_patches: std::collections::HashMap::new(),
1078            install: None,
1079        });
1080
1081        let groups = ResourceIterator::group_by_source(&lockfile);
1082        assert_eq!(groups.len(), 0); // No groups because resource has no source
1083    }
1084
1085    #[test]
1086    fn test_resource_type_ext() {
1087        let lockfile = create_test_lockfile();
1088
1089        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 1);
1090        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1091        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 0);
1092    }
1093
1094    #[test]
1095    fn test_resource_type_ext_all_types() {
1096        let lockfile = create_multi_resource_lockfile();
1097
1098        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 2);
1099        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1100        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 1);
1101        assert_eq!(ResourceType::Script.get_lockfile_entries(&lockfile).len(), 1);
1102        assert_eq!(ResourceType::Hook.get_lockfile_entries(&lockfile).len(), 1);
1103        assert_eq!(ResourceType::McpServer.get_lockfile_entries(&lockfile).len(), 1);
1104    }
1105}