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