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