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            approximate_token_count: None,
415        });
416
417        lockfile.snippets.push(LockedResource {
418            name: "test-snippet".to_string(),
419            source: Some("community".to_string()),
420            url: Some("https://github.com/test/repo.git".to_string()),
421            path: "snippets/test.md".to_string(),
422            version: Some("v1.0.0".to_string()),
423            resolved_commit: Some("def456".to_string()),
424            checksum: "sha256:def".to_string(),
425            installed_at: ".claude/snippets/test-snippet.md".to_string(),
426            dependencies: vec![],
427            resource_type: crate::core::ResourceType::Snippet,
428            context_checksum: None,
429            tool: Some("claude-code".to_string()),
430            manifest_alias: None,
431            applied_patches: std::collections::BTreeMap::new(),
432            install: None,
433            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
434            is_private: false,
435            approximate_token_count: None,
436        });
437
438        lockfile
439    }
440
441    fn create_test_manifest() -> Manifest {
442        Manifest::default()
443    }
444
445    fn create_multi_resource_lockfile() -> LockFile {
446        let mut lockfile = LockFile::new();
447
448        // Add agents from different sources
449        lockfile.agents.push(LockedResource {
450            name: "agent1".to_string(),
451            source: Some("source1".to_string()),
452            url: Some("https://github.com/source1/repo.git".to_string()),
453            path: "agents/agent1.md".to_string(),
454            version: Some("v1.0.0".to_string()),
455            resolved_commit: Some("abc123".to_string()),
456            checksum: "sha256:abc1".to_string(),
457            installed_at: ".claude/agents/agent1.md".to_string(),
458            dependencies: vec![],
459            resource_type: crate::core::ResourceType::Agent,
460            context_checksum: None,
461            tool: Some("claude-code".to_string()),
462            manifest_alias: None,
463            applied_patches: std::collections::BTreeMap::new(),
464            install: None,
465            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
466            is_private: false,
467            approximate_token_count: None,
468        });
469
470        lockfile.agents.push(LockedResource {
471            name: "agent2".to_string(),
472            source: Some("source2".to_string()),
473            url: Some("https://github.com/source2/repo.git".to_string()),
474            path: "agents/agent2.md".to_string(),
475            version: Some("v2.0.0".to_string()),
476            resolved_commit: Some("def456".to_string()),
477            checksum: "sha256:def2".to_string(),
478            installed_at: ".claude/agents/agent2.md".to_string(),
479            dependencies: vec![],
480            resource_type: crate::core::ResourceType::Agent,
481            context_checksum: None,
482            tool: Some("claude-code".to_string()),
483            manifest_alias: None,
484            applied_patches: std::collections::BTreeMap::new(),
485            install: None,
486            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
487            is_private: false,
488            approximate_token_count: None,
489        });
490
491        // Add commands from source1
492        lockfile.commands.push(LockedResource {
493            name: "command1".to_string(),
494            source: Some("source1".to_string()),
495            url: Some("https://github.com/source1/repo.git".to_string()),
496            path: "commands/command1.md".to_string(),
497            version: Some("v1.1.0".to_string()),
498            resolved_commit: Some("ghi789".to_string()),
499            checksum: "sha256:ghi3".to_string(),
500            installed_at: ".claude/commands/command1.md".to_string(),
501            dependencies: vec![],
502            resource_type: crate::core::ResourceType::Command,
503            context_checksum: None,
504            tool: Some("claude-code".to_string()),
505            manifest_alias: None,
506            applied_patches: std::collections::BTreeMap::new(),
507            install: None,
508            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
509            is_private: false,
510            approximate_token_count: None,
511        });
512
513        // Add scripts
514        lockfile.scripts.push(LockedResource {
515            name: "script1".to_string(),
516            source: Some("source1".to_string()),
517            url: Some("https://github.com/source1/repo.git".to_string()),
518            path: "scripts/build.sh".to_string(),
519            version: Some("v1.0.0".to_string()),
520            resolved_commit: Some("jkl012".to_string()),
521            checksum: "sha256:jkl4".to_string(),
522            installed_at: ".claude/scripts/script1.sh".to_string(),
523            dependencies: vec![],
524            resource_type: crate::core::ResourceType::Script,
525            context_checksum: None,
526            tool: Some("claude-code".to_string()),
527            manifest_alias: None,
528            applied_patches: std::collections::BTreeMap::new(),
529            install: None,
530            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
531            is_private: false,
532            approximate_token_count: None,
533        });
534
535        // Add hooks
536        lockfile.hooks.push(LockedResource {
537            name: "hook1".to_string(),
538            source: Some("source2".to_string()),
539            url: Some("https://github.com/source2/repo.git".to_string()),
540            path: "hooks/pre-commit.json".to_string(),
541            version: Some("v1.0.0".to_string()),
542            resolved_commit: Some("mno345".to_string()),
543            checksum: "sha256:mno5".to_string(),
544            installed_at: ".claude/hooks/hook1.json".to_string(),
545            dependencies: vec![],
546            resource_type: crate::core::ResourceType::Hook,
547            context_checksum: None,
548            tool: Some("claude-code".to_string()),
549            manifest_alias: None,
550            applied_patches: std::collections::BTreeMap::new(),
551            install: None,
552            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
553            is_private: false,
554            approximate_token_count: None,
555        });
556
557        // Add MCP servers
558        lockfile.mcp_servers.push(LockedResource {
559            name: "mcp1".to_string(),
560            source: Some("source1".to_string()),
561            url: Some("https://github.com/source1/repo.git".to_string()),
562            path: "mcp-servers/filesystem.json".to_string(),
563            version: Some("v1.0.0".to_string()),
564            resolved_commit: Some("pqr678".to_string()),
565            checksum: "sha256:pqr6".to_string(),
566            installed_at: ".mcp-servers/mcp1.json".to_string(),
567            dependencies: vec![],
568            resource_type: crate::core::ResourceType::McpServer,
569            context_checksum: None,
570            tool: Some("claude-code".to_string()),
571            manifest_alias: None,
572            applied_patches: std::collections::BTreeMap::new(),
573            install: None,
574            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
575            is_private: false,
576            approximate_token_count: None,
577        });
578
579        // Add resource without source
580        lockfile.snippets.push(LockedResource {
581            name: "local-snippet".to_string(),
582            source: None,
583            url: None,
584            path: "local/snippet.md".to_string(),
585            version: None,
586            resolved_commit: None,
587            checksum: "sha256:local".to_string(),
588            installed_at: ".agpm/snippets/local-snippet.md".to_string(),
589            dependencies: vec![],
590            resource_type: crate::core::ResourceType::Snippet,
591            context_checksum: None,
592            tool: Some("claude-code".to_string()),
593            manifest_alias: None,
594            applied_patches: std::collections::BTreeMap::new(),
595            install: None,
596            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
597            is_private: false,
598            approximate_token_count: None,
599        });
600
601        lockfile
602    }
603
604    #[test]
605    fn test_resource_type_all() {
606        let all_types = ResourceType::all();
607        assert_eq!(all_types.len(), 7);
608        // Order from ResourceTypeExt::all() implementation (consistent with resource.rs)
609        assert_eq!(all_types[0], ResourceType::Agent);
610        assert_eq!(all_types[1], ResourceType::Snippet);
611        assert_eq!(all_types[2], ResourceType::Command);
612        assert_eq!(all_types[3], ResourceType::McpServer);
613        assert_eq!(all_types[4], ResourceType::Script);
614        assert_eq!(all_types[5], ResourceType::Hook);
615        assert_eq!(all_types[6], ResourceType::Skill);
616    }
617
618    #[test]
619    fn test_get_lockfile_entries_mut() {
620        let mut lockfile = create_test_lockfile();
621
622        // Test with agent type
623        let mut agent_type = ResourceType::Agent;
624        let entries = agent_type.get_lockfile_entries_mut(&mut lockfile);
625        assert_eq!(entries.len(), 1);
626        assert_eq!(entries[0].name, "test-agent");
627
628        // Add a new agent
629        entries.push(LockedResource {
630            name: "new-agent".to_string(),
631            source: Some("test".to_string()),
632            url: Some("https://example.com/repo.git".to_string()),
633            path: "agents/new.md".to_string(),
634            version: Some("v1.0.0".to_string()),
635            resolved_commit: Some("xyz789".to_string()),
636            checksum: "sha256:xyz".to_string(),
637            installed_at: ".claude/agents/new-agent.md".to_string(),
638            dependencies: vec![],
639            resource_type: crate::core::ResourceType::Agent,
640            context_checksum: None,
641            tool: Some("claude-code".to_string()),
642            manifest_alias: None,
643            applied_patches: std::collections::BTreeMap::new(),
644            install: None,
645            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
646            is_private: false,
647            approximate_token_count: None,
648        });
649
650        // Verify the agent was added
651        assert_eq!(lockfile.agents.len(), 2);
652        assert_eq!(lockfile.agents[1].name, "new-agent");
653
654        // Test with all resource types
655        let mut snippet_type = ResourceType::Snippet;
656        let snippet_entries = snippet_type.get_lockfile_entries_mut(&mut lockfile);
657        assert_eq!(snippet_entries.len(), 1);
658
659        let mut command_type = ResourceType::Command;
660        let command_entries = command_type.get_lockfile_entries_mut(&mut lockfile);
661        assert_eq!(command_entries.len(), 0);
662
663        let mut script_type = ResourceType::Script;
664        let script_entries = script_type.get_lockfile_entries_mut(&mut lockfile);
665        assert_eq!(script_entries.len(), 0);
666
667        let mut hook_type = ResourceType::Hook;
668        let hook_entries = hook_type.get_lockfile_entries_mut(&mut lockfile);
669        assert_eq!(hook_entries.len(), 0);
670
671        let mut mcp_type = ResourceType::McpServer;
672        let mcp_entries = mcp_type.get_lockfile_entries_mut(&mut lockfile);
673        assert_eq!(mcp_entries.len(), 0);
674    }
675
676    #[test]
677    fn test_collect_all_entries() {
678        let lockfile = create_test_lockfile();
679        let manifest = create_test_manifest();
680
681        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
682        assert_eq!(entries.len(), 2);
683
684        assert_eq!(entries[0].0.name, "test-agent");
685        // Normalize path separators for cross-platform testing
686        assert_eq!(normalize_path_for_storage(entries[0].1.as_ref()), ".claude/agents/agpm");
687
688        assert_eq!(entries[1].0.name, "test-snippet");
689        // Normalize path separators for cross-platform testing
690        // Snippet uses claude-code tool, so it installs to .claude/snippets/agpm
691        assert_eq!(normalize_path_for_storage(entries[1].1.as_ref()), ".claude/snippets/agpm");
692    }
693
694    #[test]
695    fn test_collect_all_entries_empty_lockfile() {
696        let empty_lockfile = LockFile::new();
697        let manifest = create_test_manifest();
698
699        let entries = ResourceIterator::collect_all_entries(&empty_lockfile, &manifest);
700        assert_eq!(entries.len(), 0);
701    }
702
703    #[test]
704    fn test_collect_all_entries_multiple_resources() {
705        let lockfile = create_multi_resource_lockfile();
706        let manifest = create_test_manifest();
707
708        let entries = ResourceIterator::collect_all_entries(&lockfile, &manifest);
709
710        // Should have 5 resources total (2 agents, 1 command, 1 script, 1 snippet)
711        // Hooks and MCP servers are excluded (configured only, not installed as files)
712        assert_eq!(entries.len(), 5);
713
714        // Check that we have entries from installable resource types (not hooks/MCP)
715        let mut found_types = std::collections::HashSet::new();
716        for (resource, _) in &entries {
717            match resource.name.as_str() {
718                "agent1" | "agent2" => {
719                    found_types.insert("agent");
720                }
721                "local-snippet" => {
722                    found_types.insert("snippet");
723                }
724                "command1" => {
725                    found_types.insert("command");
726                }
727                "script1" => {
728                    found_types.insert("script");
729                }
730                // Hooks and MCP servers should not appear (configured only)
731                "hook1" | "mcp1" => {
732                    panic!("Hooks and MCP servers should not be in collected entries");
733                }
734                _ => {}
735            }
736        }
737
738        assert_eq!(found_types.len(), 4);
739    }
740
741    #[test]
742    fn test_find_resource_by_name() {
743        let lockfile = create_test_lockfile();
744
745        let result = ResourceIterator::find_resource_by_name(&lockfile, "test-agent");
746        assert!(result.is_some());
747        let (rt, resource) = result.unwrap();
748        assert_eq!(rt, ResourceType::Agent);
749        assert_eq!(resource.name, "test-agent");
750
751        let result = ResourceIterator::find_resource_by_name(&lockfile, "nonexistent");
752        assert!(result.is_none());
753    }
754
755    #[test]
756    fn test_find_resource_by_name_multiple_types() {
757        let lockfile = create_multi_resource_lockfile();
758
759        // Find agent
760        let result = ResourceIterator::find_resource_by_name(&lockfile, "agent1");
761        assert!(result.is_some());
762        let (rt, resource) = result.unwrap();
763        assert_eq!(rt, ResourceType::Agent);
764        assert_eq!(resource.name, "agent1");
765
766        // Find command
767        let result = ResourceIterator::find_resource_by_name(&lockfile, "command1");
768        assert!(result.is_some());
769        let (rt, resource) = result.unwrap();
770        assert_eq!(rt, ResourceType::Command);
771        assert_eq!(resource.name, "command1");
772
773        // Find script
774        let result = ResourceIterator::find_resource_by_name(&lockfile, "script1");
775        assert!(result.is_some());
776        let (rt, resource) = result.unwrap();
777        assert_eq!(rt, ResourceType::Script);
778        assert_eq!(resource.name, "script1");
779
780        // Find hook
781        let result = ResourceIterator::find_resource_by_name(&lockfile, "hook1");
782        assert!(result.is_some());
783        let (rt, resource) = result.unwrap();
784        assert_eq!(rt, ResourceType::Hook);
785        assert_eq!(resource.name, "hook1");
786
787        // Find MCP server
788        let result = ResourceIterator::find_resource_by_name(&lockfile, "mcp1");
789        assert!(result.is_some());
790        let (rt, resource) = result.unwrap();
791        assert_eq!(rt, ResourceType::McpServer);
792        assert_eq!(resource.name, "mcp1");
793
794        // Find local snippet (no source)
795        let result = ResourceIterator::find_resource_by_name(&lockfile, "local-snippet");
796        assert!(result.is_some());
797        let (rt, resource) = result.unwrap();
798        assert_eq!(rt, ResourceType::Snippet);
799        assert_eq!(resource.name, "local-snippet");
800        assert!(resource.source.is_none());
801    }
802
803    #[test]
804    fn test_count_and_has_resources() {
805        let lockfile = create_test_lockfile();
806        assert_eq!(ResourceIterator::count_total_resources(&lockfile), 2);
807        assert!(ResourceIterator::has_resources(&lockfile));
808
809        let empty_lockfile = LockFile::new();
810        assert_eq!(ResourceIterator::count_total_resources(&empty_lockfile), 0);
811        assert!(!ResourceIterator::has_resources(&empty_lockfile));
812
813        let multi_lockfile = create_multi_resource_lockfile();
814        assert_eq!(ResourceIterator::count_total_resources(&multi_lockfile), 7);
815        assert!(ResourceIterator::has_resources(&multi_lockfile));
816    }
817
818    #[test]
819    fn test_get_all_resource_names() {
820        let lockfile = create_test_lockfile();
821        let names = ResourceIterator::get_all_resource_names(&lockfile);
822
823        assert_eq!(names.len(), 2);
824        assert!(names.contains(&"test-agent".to_string()));
825        assert!(names.contains(&"test-snippet".to_string()));
826    }
827
828    #[test]
829    fn test_get_all_resource_names_empty() {
830        let empty_lockfile = LockFile::new();
831        let names = ResourceIterator::get_all_resource_names(&empty_lockfile);
832        assert_eq!(names.len(), 0);
833    }
834
835    #[test]
836    fn test_get_all_resource_names_multiple() {
837        let lockfile = create_multi_resource_lockfile();
838        let names = ResourceIterator::get_all_resource_names(&lockfile);
839
840        assert_eq!(names.len(), 7);
841        assert!(names.contains(&"agent1".to_string()));
842        assert!(names.contains(&"agent2".to_string()));
843        assert!(names.contains(&"local-snippet".to_string()));
844        assert!(names.contains(&"command1".to_string()));
845        assert!(names.contains(&"script1".to_string()));
846        assert!(names.contains(&"hook1".to_string()));
847        assert!(names.contains(&"mcp1".to_string()));
848    }
849
850    #[test]
851    fn test_get_resources_by_source() {
852        let lockfile = create_multi_resource_lockfile();
853
854        // Test source1 - should have agent1, command1, script1, and mcp1
855        let source1_resources =
856            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source1");
857        assert_eq!(source1_resources.len(), 1);
858        assert_eq!(source1_resources[0].name, "agent1");
859
860        let source1_commands =
861            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Command, "source1");
862        assert_eq!(source1_commands.len(), 1);
863        assert_eq!(source1_commands[0].name, "command1");
864
865        let source1_scripts =
866            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Script, "source1");
867        assert_eq!(source1_scripts.len(), 1);
868        assert_eq!(source1_scripts[0].name, "script1");
869
870        let source1_mcps = ResourceIterator::get_resources_by_source(
871            &lockfile,
872            ResourceType::McpServer,
873            "source1",
874        );
875        assert_eq!(source1_mcps.len(), 1);
876        assert_eq!(source1_mcps[0].name, "mcp1");
877
878        // Test source2 - should have agent2 and hook1
879        let source2_agents =
880            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Agent, "source2");
881        assert_eq!(source2_agents.len(), 1);
882        assert_eq!(source2_agents[0].name, "agent2");
883
884        let source2_hooks =
885            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Hook, "source2");
886        assert_eq!(source2_hooks.len(), 1);
887        assert_eq!(source2_hooks[0].name, "hook1");
888
889        // Test nonexistent source
890        let nonexistent = ResourceIterator::get_resources_by_source(
891            &lockfile,
892            ResourceType::Agent,
893            "nonexistent",
894        );
895        assert_eq!(nonexistent.len(), 0);
896
897        // Test empty resource type
898        let source1_snippets =
899            ResourceIterator::get_resources_by_source(&lockfile, ResourceType::Snippet, "source1");
900        assert_eq!(source1_snippets.len(), 0);
901    }
902
903    #[test]
904    fn test_for_each_resource() {
905        let lockfile = create_multi_resource_lockfile();
906        let mut visited_resources = Vec::new();
907
908        ResourceIterator::for_each_resource(&lockfile, |resource_type, resource| {
909            visited_resources.push((resource_type, resource.name.clone()));
910        });
911
912        assert_eq!(visited_resources.len(), 7);
913
914        // Check that we visited all expected resources
915        let expected_resources = vec![
916            (ResourceType::Agent, "agent1".to_string()),
917            (ResourceType::Agent, "agent2".to_string()),
918            (ResourceType::Snippet, "local-snippet".to_string()),
919            (ResourceType::Command, "command1".to_string()),
920            (ResourceType::Script, "script1".to_string()),
921            (ResourceType::Hook, "hook1".to_string()),
922            (ResourceType::McpServer, "mcp1".to_string()),
923        ];
924
925        for expected in expected_resources {
926            assert!(visited_resources.contains(&expected));
927        }
928    }
929
930    #[test]
931    fn test_for_each_resource_empty() {
932        let empty_lockfile = LockFile::new();
933        let mut count = 0;
934
935        ResourceIterator::for_each_resource(&empty_lockfile, |_, _| {
936            count += 1;
937        });
938
939        assert_eq!(count, 0);
940    }
941
942    #[test]
943    fn test_map_resources() {
944        let lockfile = create_multi_resource_lockfile();
945
946        // Map to resource names
947        let names = ResourceIterator::map_resources(&lockfile, |_, resource| resource.name.clone());
948
949        assert_eq!(names.len(), 7);
950        assert!(names.contains(&"agent1".to_string()));
951        assert!(names.contains(&"agent2".to_string()));
952        assert!(names.contains(&"local-snippet".to_string()));
953        assert!(names.contains(&"command1".to_string()));
954        assert!(names.contains(&"script1".to_string()));
955        assert!(names.contains(&"hook1".to_string()));
956        assert!(names.contains(&"mcp1".to_string()));
957
958        // Map to resource type and name pairs
959        let type_name_pairs =
960            ResourceIterator::map_resources(&lockfile, |resource_type, resource| {
961                format!("{}:{}", resource_type, resource.name)
962            });
963
964        assert_eq!(type_name_pairs.len(), 7);
965        assert!(type_name_pairs.contains(&"agent:agent1".to_string()));
966        assert!(type_name_pairs.contains(&"agent:agent2".to_string()));
967        assert!(type_name_pairs.contains(&"snippet:local-snippet".to_string()));
968        assert!(type_name_pairs.contains(&"command:command1".to_string()));
969        assert!(type_name_pairs.contains(&"script:script1".to_string()));
970        assert!(type_name_pairs.contains(&"hook:hook1".to_string()));
971        assert!(type_name_pairs.contains(&"mcp-server:mcp1".to_string()));
972    }
973
974    #[test]
975    fn test_map_resources_empty() {
976        let empty_lockfile = LockFile::new();
977
978        let results =
979            ResourceIterator::map_resources(&empty_lockfile, |_, resource| resource.name.clone());
980
981        assert_eq!(results.len(), 0);
982    }
983
984    #[test]
985    fn test_filter_resources() {
986        let lockfile = create_multi_resource_lockfile();
987
988        // Filter by source1
989        let source1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
990            resource.source.as_deref() == Some("source1")
991        });
992
993        assert_eq!(source1_resources.len(), 4); // agent1, command1, script1, mcp1
994        let source1_names: Vec<String> =
995            source1_resources.iter().map(|(_, r)| r.name.clone()).collect();
996        assert!(source1_names.contains(&"agent1".to_string()));
997        assert!(source1_names.contains(&"command1".to_string()));
998        assert!(source1_names.contains(&"script1".to_string()));
999        assert!(source1_names.contains(&"mcp1".to_string()));
1000
1001        // Filter by resource type
1002        let agents = ResourceIterator::filter_resources(&lockfile, |resource_type, _| {
1003            resource_type == ResourceType::Agent
1004        });
1005
1006        assert_eq!(agents.len(), 2); // agent1, agent2
1007        let agent_names: Vec<String> = agents.iter().map(|(_, r)| r.name.clone()).collect();
1008        assert!(agent_names.contains(&"agent1".to_string()));
1009        assert!(agent_names.contains(&"agent2".to_string()));
1010
1011        // Filter resources without source
1012        let no_source_resources =
1013            ResourceIterator::filter_resources(&lockfile, |_, resource| resource.source.is_none());
1014
1015        assert_eq!(no_source_resources.len(), 1); // local-snippet
1016        assert_eq!(no_source_resources[0].1.name, "local-snippet");
1017
1018        // Filter by version pattern
1019        let v1_resources = ResourceIterator::filter_resources(&lockfile, |_, resource| {
1020            resource.version.as_deref().unwrap_or("").starts_with("v1.")
1021        });
1022
1023        assert_eq!(v1_resources.len(), 5); // agent1, command1, script1, hook1, mcp1 all have v1.x.x
1024
1025        // Filter that matches nothing
1026        let no_matches = ResourceIterator::filter_resources(&lockfile, |_, resource| {
1027            resource.name == "nonexistent"
1028        });
1029
1030        assert_eq!(no_matches.len(), 0);
1031    }
1032
1033    #[test]
1034    fn test_filter_resources_empty() {
1035        let empty_lockfile = LockFile::new();
1036
1037        let results = ResourceIterator::filter_resources(&empty_lockfile, |_, _| true);
1038        assert_eq!(results.len(), 0);
1039    }
1040
1041    #[test]
1042    fn test_group_by_source() {
1043        let lockfile = create_multi_resource_lockfile();
1044
1045        let groups = ResourceIterator::group_by_source(&lockfile);
1046
1047        assert_eq!(groups.len(), 2); // source1 and source2
1048
1049        // Check source1 group
1050        let source1_group = groups.get("source1").unwrap();
1051        assert_eq!(source1_group.len(), 4); // agent1, command1, script1, mcp1
1052
1053        let source1_names: Vec<String> =
1054            source1_group.iter().map(|(_, r)| r.name.clone()).collect();
1055        assert!(source1_names.contains(&"agent1".to_string()));
1056        assert!(source1_names.contains(&"command1".to_string()));
1057        assert!(source1_names.contains(&"script1".to_string()));
1058        assert!(source1_names.contains(&"mcp1".to_string()));
1059
1060        // Check source2 group
1061        let source2_group = groups.get("source2").unwrap();
1062        assert_eq!(source2_group.len(), 2); // agent2, hook1
1063
1064        let source2_names: Vec<String> =
1065            source2_group.iter().map(|(_, r)| r.name.clone()).collect();
1066        assert!(source2_names.contains(&"agent2".to_string()));
1067        assert!(source2_names.contains(&"hook1".to_string()));
1068
1069        // Resources without source should not be included
1070        assert!(!groups.contains_key(""));
1071    }
1072
1073    #[test]
1074    fn test_group_by_source_empty() {
1075        let empty_lockfile = LockFile::new();
1076
1077        let groups = ResourceIterator::group_by_source(&empty_lockfile);
1078        assert_eq!(groups.len(), 0);
1079    }
1080
1081    #[test]
1082    fn test_group_by_source_no_sources() {
1083        let mut lockfile = LockFile::new();
1084
1085        // Add resource without source
1086        lockfile.agents.push(LockedResource {
1087            name: "local-agent".to_string(),
1088            source: None,
1089            url: None,
1090            path: "local/agent.md".to_string(),
1091            version: None,
1092            resolved_commit: None,
1093            checksum: "sha256:local".to_string(),
1094            installed_at: ".claude/agents/local-agent.md".to_string(),
1095            dependencies: vec![],
1096            resource_type: crate::core::ResourceType::Agent,
1097            context_checksum: None,
1098            tool: Some("claude-code".to_string()),
1099            manifest_alias: None,
1100            applied_patches: std::collections::BTreeMap::new(),
1101            install: None,
1102            variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1103            is_private: false,
1104            approximate_token_count: None,
1105        });
1106
1107        let groups = ResourceIterator::group_by_source(&lockfile);
1108        assert_eq!(groups.len(), 0); // No groups because resource has no source
1109    }
1110
1111    #[test]
1112    fn test_resource_type_ext() {
1113        let lockfile = create_test_lockfile();
1114
1115        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 1);
1116        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1117        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 0);
1118    }
1119
1120    #[test]
1121    fn test_resource_type_ext_all_types() {
1122        let lockfile = create_multi_resource_lockfile();
1123
1124        assert_eq!(ResourceType::Agent.get_lockfile_entries(&lockfile).len(), 2);
1125        assert_eq!(ResourceType::Snippet.get_lockfile_entries(&lockfile).len(), 1);
1126        assert_eq!(ResourceType::Command.get_lockfile_entries(&lockfile).len(), 1);
1127        assert_eq!(ResourceType::Script.get_lockfile_entries(&lockfile).len(), 1);
1128        assert_eq!(ResourceType::Hook.get_lockfile_entries(&lockfile).len(), 1);
1129        assert_eq!(ResourceType::McpServer.get_lockfile_entries(&lockfile).len(), 1);
1130    }
1131}