Skip to main content

dot_agent_core/profile/
mod.rs

1mod fusion;
2mod metadata;
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use once_cell::unsync::OnceCell;
8
9use walkdir::WalkDir;
10
11use crate::error::{DotAgentError, Result};
12use crate::plugin::manifest::{FilterConfig, PluginManifest, DEFAULT_COMPONENT_DIRS};
13
14// Re-exports
15pub use fusion::{
16    CollectedFile, FusionConfig, FusionConflict, FusionExecutor, FusionPlan, FusionResult,
17    FusionSpec,
18};
19pub use metadata::{
20    migrate_existing_profiles, PluginConfig, PluginScope, ProfileIndexEntry, ProfileInfo,
21    ProfileMetadata, ProfileSource, ProfilesIndex,
22};
23
24const PROFILES_DIR: &str = "profiles";
25const IGNORED_FILES: &[&str] = &[".DS_Store", ".gitignore", ".gitkeep"];
26const IGNORED_EXTENSIONS: &[&str] = &[];
27
28/// Default directories to exclude from profile operations
29pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[
30    ".git",
31    // Test directories
32    "tests",
33    "test",
34    "__tests__",
35    // Build/cache directories
36    "__pycache__",
37    ".pytest_cache",
38    "node_modules",
39    "target",
40    // IDE/editor directories
41    ".vscode",
42    ".idea",
43    // CI/CD directories
44    ".github",
45    ".gitlab",
46];
47
48/// Configuration for file ignore/include behavior
49#[derive(Debug, Clone, Default)]
50pub struct IgnoreConfig {
51    /// Directories to exclude (checked against path components)
52    pub excluded_dirs: Vec<String>,
53    /// Directories to explicitly include (overrides default exclusions)
54    pub included_dirs: Vec<String>,
55}
56
57impl IgnoreConfig {
58    /// Create with default exclusions (.git)
59    pub fn with_defaults() -> Self {
60        Self {
61            excluded_dirs: DEFAULT_EXCLUDED_DIRS
62                .iter()
63                .map(|s| s.to_string())
64                .collect(),
65            included_dirs: Vec::new(),
66        }
67    }
68
69    /// Add a directory to exclude
70    pub fn exclude(mut self, dir: impl Into<String>) -> Self {
71        self.excluded_dirs.push(dir.into());
72        self
73    }
74
75    /// Add a directory to include (overrides default exclusion)
76    pub fn include(mut self, dir: impl Into<String>) -> Self {
77        self.included_dirs.push(dir.into());
78        self
79    }
80
81    /// Check if a path should be ignored based on this config
82    pub fn should_ignore(&self, path: &Path) -> bool {
83        // Check static file ignores first
84        if should_ignore_file(path) {
85            return true;
86        }
87
88        // Check path components against excluded directories
89        for component in path.components() {
90            if let std::path::Component::Normal(name) = component {
91                let name_str = name.to_string_lossy();
92
93                // Check if explicitly included (overrides exclusion)
94                if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
95                    continue;
96                }
97
98                // Check if excluded
99                if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
100                    return true;
101                }
102            }
103        }
104
105        false
106    }
107}
108
109/// Check if a file should be ignored (static rules, not directory-based)
110fn should_ignore_file(path: &Path) -> bool {
111    if let Some(name) = path.file_name() {
112        let name = name.to_string_lossy();
113        if IGNORED_FILES.contains(&name.as_ref()) {
114            return true;
115        }
116    }
117
118    if let Some(ext) = path.extension() {
119        let ext = ext.to_string_lossy();
120        if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
121            return true;
122        }
123    }
124
125    false
126}
127
128/// Root file always allowed (CLAUDE.md)
129const ALLOWED_ROOT_FILE: &str = "CLAUDE.md";
130
131/// Filter that determines which files are allowed for sync
132/// Built once from: (DEFAULT_DIRS or plugin paths) + CLAUDE.md + include - exclude
133struct AllowedFilter {
134    /// Base directories (from plugin.json or DEFAULT_COMPONENT_DIRS)
135    base_dirs: Vec<PathBuf>,
136    /// Include patterns from .dot-agent.toml
137    include_patterns: Vec<glob::Pattern>,
138    /// Exclude patterns from .dot-agent.toml
139    exclude_patterns: Vec<glob::Pattern>,
140}
141
142impl AllowedFilter {
143    fn new(manifest: &Option<PluginManifest>, filter: &Option<FilterConfig>) -> Self {
144        // Determine base directories
145        let base_dirs: Vec<PathBuf> = if let Some(ref m) = manifest {
146            if m.has_explicit_paths() {
147                m.get_component_paths()
148            } else {
149                DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
150            }
151        } else {
152            DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
153        };
154
155        // Parse include/exclude patterns
156        let (include_patterns, exclude_patterns) = if let Some(ref f) = filter {
157            let includes = f
158                .include
159                .iter()
160                .filter_map(|p| glob::Pattern::new(p).ok())
161                .collect();
162            let excludes = f
163                .exclude
164                .iter()
165                .filter_map(|p| glob::Pattern::new(p).ok())
166                .collect();
167            (includes, excludes)
168        } else {
169            (Vec::new(), Vec::new())
170        };
171
172        Self {
173            base_dirs,
174            include_patterns,
175            exclude_patterns,
176        }
177    }
178
179    /// Check if a relative path is allowed
180    fn matches(&self, path: &Path) -> bool {
181        let path_str = path.to_string_lossy();
182
183        // 1. Check exclude first - if excluded, not allowed
184        for pattern in &self.exclude_patterns {
185            if pattern.matches(&path_str) {
186                return false;
187            }
188        }
189
190        // 2. Check if it's the always-allowed root file (CLAUDE.md)
191        if path.components().count() == 1 {
192            if let Some(name) = path.file_name() {
193                if name == ALLOWED_ROOT_FILE {
194                    return true;
195                }
196            }
197        }
198
199        // 3. Check if path starts with any base directory
200        for base_dir in &self.base_dirs {
201            if path.starts_with(base_dir) {
202                return true;
203            }
204        }
205
206        // 4. Check include patterns
207        for pattern in &self.include_patterns {
208            if pattern.matches(&path_str) {
209                return true;
210            }
211        }
212
213        false
214    }
215}
216
217/// A profile containing configuration files for Claude Code
218///
219/// Profile aggregates related metadata with lazy loading:
220/// - `PluginManifest` from `.claude-plugin/plugin.json`
221/// - `ProfileMetadata` from `.dot-agent.toml`
222/// - `FilterConfig` from `.dot-agent.toml` filter section
223pub struct Profile {
224    pub name: String,
225    pub path: PathBuf,
226
227    // Lazy-loaded cached data
228    manifest: OnceCell<Option<PluginManifest>>,
229    metadata: OnceCell<Option<ProfileMetadata>>,
230    filter: OnceCell<Option<FilterConfig>>,
231}
232
233impl Profile {
234    pub fn new(name: String, path: PathBuf) -> Self {
235        Self {
236            name,
237            path,
238            manifest: OnceCell::new(),
239            metadata: OnceCell::new(),
240            filter: OnceCell::new(),
241        }
242    }
243
244    // =========================================================================
245    // Lazy-loaded accessors
246    // =========================================================================
247
248    /// Get plugin manifest (lazy loaded from `.claude-plugin/plugin.json`)
249    pub fn manifest(&self) -> Result<Option<&PluginManifest>> {
250        self.manifest
251            .get_or_try_init(|| PluginManifest::load(&self.path))
252            .map(|opt| opt.as_ref())
253    }
254
255    /// Get profile metadata (lazy loaded from `.dot-agent.toml`)
256    pub fn metadata(&self) -> Result<Option<&ProfileMetadata>> {
257        self.metadata
258            .get_or_try_init(|| ProfileMetadata::load(&self.path))
259            .map(|opt| opt.as_ref())
260    }
261
262    /// Get filter config (lazy loaded from `.dot-agent.toml`)
263    pub fn filter_config(&self) -> Result<Option<&FilterConfig>> {
264        self.filter
265            .get_or_try_init(|| FilterConfig::load(&self.path))
266            .map(|opt| opt.as_ref())
267    }
268
269    // =========================================================================
270    // Convenience methods
271    // =========================================================================
272
273    /// Get profile source (Local, Git, or Marketplace)
274    pub fn source(&self) -> Result<ProfileSource> {
275        Ok(self
276            .metadata()?
277            .map(|m| m.source.clone())
278            .unwrap_or_default())
279    }
280
281    /// Get profile version
282    pub fn version(&self) -> Result<Option<String>> {
283        Ok(self.metadata()?.and_then(|m| m.profile.version.clone()))
284    }
285
286    /// Get profile description
287    pub fn description(&self) -> Result<Option<String>> {
288        Ok(self.metadata()?.and_then(|m| m.profile.description.clone()))
289    }
290
291    /// Get category store (builtin + profile overrides)
292    ///
293    /// Returns a CategoryStore with builtin categories, optionally
294    /// overridden by categories defined in `.dot-agent.toml`.
295    pub fn category_store(&self) -> Result<crate::category::CategoryStore> {
296        use crate::category::CategoryStore;
297
298        let store = CategoryStore::builtin();
299
300        // Apply profile-specific overrides if present
301        if let Some(metadata) = self.metadata()? {
302            if let Some(ref categories_config) = metadata.categories {
303                return Ok(store.with_config(categories_config));
304            }
305        }
306
307        Ok(store)
308    }
309
310    /// Check if profile has plugin features (hooks, MCP, LSP)
311    pub fn has_plugin_features(&self) -> bool {
312        ProfileMetadata::has_plugin_features(&self.path)
313    }
314
315    /// Get plugin scope (User, Project, or Local)
316    pub fn plugin_scope(&self) -> Result<PluginScope> {
317        Ok(self.metadata()?.map(|m| m.plugin.scope).unwrap_or_default())
318    }
319
320    /// Check if plugin is enabled
321    pub fn plugin_enabled(&self) -> Result<bool> {
322        Ok(self.metadata()?.map(|m| m.plugin.enabled).unwrap_or(true))
323    }
324
325    // =========================================================================
326    // File listing
327    // =========================================================================
328
329    /// List all files in the profile directory (relative paths) with default ignore config
330    pub fn list_files(&self) -> Result<Vec<PathBuf>> {
331        self.list_files_with_config(&IgnoreConfig::with_defaults())
332    }
333
334    /// List all files in the profile directory (relative paths) with custom ignore config
335    ///
336    /// File collection logic:
337    /// 1. Build allowed filter: (DEFAULT_DIRS or plugin paths) + CLAUDE.md + include - exclude
338    /// 2. Walk all files and collect only those matching the filter
339    pub fn list_files_with_config(&self, config: &IgnoreConfig) -> Result<Vec<PathBuf>> {
340        // Use cached manifest and filter
341        let manifest = self.manifest()?;
342        let filter = self.filter_config()?;
343
344        // Build allowed filter upfront
345        let allowed = AllowedFilter::new(&manifest.cloned(), &filter.cloned());
346
347        // Walk all files and collect only those matching the filter
348        let mut files: Vec<PathBuf> = Vec::new();
349
350        for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
351            let path = entry.path();
352            if !path.is_file() {
353                continue;
354            }
355
356            let relative = match path.strip_prefix(&self.path) {
357                Ok(r) => r,
358                Err(_) => continue,
359            };
360
361            // Skip system-ignored files (.DS_Store, .git, etc.)
362            if config.should_ignore(relative) {
363                continue;
364            }
365
366            // Check against allowed filter
367            if allowed.matches(relative) {
368                files.push(relative.to_path_buf());
369            }
370        }
371
372        files.sort();
373        Ok(files)
374    }
375
376    /// Get contents summary (e.g., "skills (5), commands (3)")
377    pub fn contents_summary(&self) -> String {
378        self.contents_summary_with_config(&IgnoreConfig::with_defaults())
379    }
380
381    /// Get contents summary with custom ignore config
382    pub fn contents_summary_with_config(&self, config: &IgnoreConfig) -> String {
383        let mut summary = Vec::new();
384
385        if let Ok(entries) = fs::read_dir(&self.path) {
386            for entry in entries.filter_map(|e| e.ok()) {
387                let path = entry.path();
388                if path.is_dir() {
389                    let name = path.file_name().unwrap().to_string_lossy().to_string();
390                    let count = WalkDir::new(&path)
391                        .into_iter()
392                        .filter_map(|e| e.ok())
393                        .filter(|e| {
394                            if !e.path().is_file() {
395                                return false;
396                            }
397                            if let Ok(relative) = e.path().strip_prefix(&self.path) {
398                                !config.should_ignore(relative)
399                            } else {
400                                false
401                            }
402                        })
403                        .count();
404                    if count > 0 {
405                        summary.push(format!("{} ({})", name, count));
406                    }
407                } else if path.is_file() {
408                    let name = path.file_name().unwrap().to_string_lossy().to_string();
409                    if let Ok(relative) = path.strip_prefix(&self.path) {
410                        if !config.should_ignore(relative) {
411                            summary.push(name);
412                        }
413                    }
414                }
415            }
416        }
417
418        if summary.is_empty() {
419            "(empty)".to_string()
420        } else {
421            summary.join(", ")
422        }
423    }
424}
425
426pub struct ProfileManager {
427    base_dir: PathBuf,
428}
429
430impl ProfileManager {
431    pub fn new(base_dir: PathBuf) -> Self {
432        Self { base_dir }
433    }
434
435    pub fn profiles_dir(&self) -> PathBuf {
436        self.base_dir.join(PROFILES_DIR)
437    }
438
439    /// Discover all profiles
440    pub fn list_profiles(&self) -> Result<Vec<Profile>> {
441        let profiles_dir = self.profiles_dir();
442        if !profiles_dir.exists() {
443            return Ok(Vec::new());
444        }
445
446        let mut profiles = Vec::new();
447        for entry in fs::read_dir(&profiles_dir)? {
448            let entry = entry?;
449            let path = entry.path();
450            if path.is_dir() {
451                let name = path.file_name().unwrap().to_string_lossy().to_string();
452                profiles.push(Profile::new(name, path));
453            }
454        }
455
456        profiles.sort_by(|a, b| a.name.cmp(&b.name));
457        Ok(profiles)
458    }
459
460    /// Get a specific profile
461    pub fn get_profile(&self, name: &str) -> Result<Profile> {
462        let path = self.profiles_dir().join(name);
463        if !path.exists() {
464            return Err(DotAgentError::ProfileNotFound {
465                name: name.to_string(),
466            });
467        }
468        Ok(Profile::new(name.to_string(), path))
469    }
470
471    /// Create a new profile with scaffolding
472    pub fn create_profile(&self, name: &str) -> Result<Profile> {
473        validate_profile_name(name)?;
474
475        let path = self.profiles_dir().join(name);
476        if path.exists() {
477            return Err(DotAgentError::ProfileAlreadyExists {
478                name: name.to_string(),
479            });
480        }
481
482        // Create directory structure
483        fs::create_dir_all(&path)?;
484        fs::create_dir_all(path.join("agents"))?;
485        fs::create_dir_all(path.join("commands"))?;
486        fs::create_dir_all(path.join("hooks"))?;
487        fs::create_dir_all(path.join("plugins"))?;
488        fs::create_dir_all(path.join("rules"))?;
489        fs::create_dir_all(path.join("skills"))?;
490
491        // Create CLAUDE.md template
492        let claude_md = format!(
493            r#"# {} Profile
494
495## Overview
496
497<!-- Describe what this profile is for -->
498
499## Usage
500
501```bash
502dot-agent install {}
503```
504
505## Customization
506
507<!-- Add project-specific instructions here -->
508"#,
509            name, name
510        );
511        fs::write(path.join("CLAUDE.md"), claude_md)?;
512
513        // Create profile metadata
514        let metadata = ProfileMetadata::new_local(name);
515        metadata.save(&path)?;
516
517        // Update profiles index
518        let mut index = ProfilesIndex::load(&self.base_dir)?;
519        index.upsert(name, ProfileIndexEntry::new_local(name));
520        index.save(&self.base_dir)?;
521
522        Ok(Profile::new(name.to_string(), path))
523    }
524
525    /// Remove a profile
526    pub fn remove_profile(&self, name: &str) -> Result<()> {
527        let profile = self.get_profile(name)?;
528        fs::remove_dir_all(&profile.path)?;
529
530        // Update profiles index
531        let mut index = ProfilesIndex::load(&self.base_dir)?;
532        index.remove(name);
533        index.save(&self.base_dir)?;
534
535        Ok(())
536    }
537
538    /// Copy an existing profile to a new name
539    pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
540        let source = self.get_profile(source_name)?;
541        validate_profile_name(dest_name)?;
542
543        let dest_path = self.profiles_dir().join(dest_name);
544
545        if dest_path.exists() {
546            if !force {
547                return Err(DotAgentError::ProfileAlreadyExists {
548                    name: dest_name.to_string(),
549                });
550            }
551            fs::remove_dir_all(&dest_path)?;
552        }
553
554        copy_dir_recursive(&source.path, &dest_path)?;
555
556        // Update metadata with new name
557        if let Some(mut metadata) = ProfileMetadata::load(&dest_path)? {
558            metadata.profile.name = dest_name.to_string();
559            metadata.save(&dest_path)?;
560        } else {
561            let metadata = ProfileMetadata::new_local(dest_name);
562            metadata.save(&dest_path)?;
563        }
564
565        // Update profiles index
566        let mut index = ProfilesIndex::load(&self.base_dir)?;
567        index.upsert(dest_name, ProfileIndexEntry::new_local(dest_name));
568        index.save(&self.base_dir)?;
569
570        Ok(Profile::new(dest_name.to_string(), dest_path))
571    }
572
573    /// Import a directory as a profile (local source)
574    pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
575        self.import_profile_with_source(source, name, force, ProfileSource::Local)
576    }
577
578    /// Import a directory as a profile from git
579    #[allow(clippy::too_many_arguments)]
580    pub fn import_profile_from_git(
581        &self,
582        source: &Path,
583        name: &str,
584        force: bool,
585        url: &str,
586        branch: Option<&str>,
587        commit: Option<&str>,
588        subpath: Option<&str>,
589    ) -> Result<Profile> {
590        let source_info = ProfileSource::Git {
591            url: url.to_string(),
592            branch: branch.map(|s| s.to_string()),
593            commit: commit.map(|s| s.to_string()),
594            path: subpath.map(|s| s.to_string()),
595        };
596        self.import_profile_with_source(source, name, force, source_info)
597    }
598
599    /// Import a directory as a profile from marketplace
600    pub fn import_profile_from_marketplace(
601        &self,
602        source: &Path,
603        name: &str,
604        force: bool,
605        channel: &str,
606        plugin: &str,
607        version: &str,
608    ) -> Result<Profile> {
609        let source_info = ProfileSource::Marketplace {
610            channel: channel.to_string(),
611            plugin: plugin.to_string(),
612            version: version.to_string(),
613        };
614        self.import_profile_with_source(source, name, force, source_info)
615    }
616
617    /// Import a directory as a profile with source information
618    fn import_profile_with_source(
619        &self,
620        source: &Path,
621        name: &str,
622        force: bool,
623        source_info: ProfileSource,
624    ) -> Result<Profile> {
625        validate_profile_name(name)?;
626
627        if !source.exists() {
628            return Err(DotAgentError::TargetNotFound {
629                path: source.to_path_buf(),
630            });
631        }
632
633        let dest = self.profiles_dir().join(name);
634
635        if dest.exists() {
636            if !force {
637                return Err(DotAgentError::ProfileAlreadyExists {
638                    name: name.to_string(),
639                });
640            }
641            fs::remove_dir_all(&dest)?;
642        }
643
644        // Ensure profiles directory exists
645        fs::create_dir_all(self.profiles_dir())?;
646
647        // Copy directory recursively
648        copy_dir_recursive(source, &dest)?;
649
650        // Create or update profile metadata
651        let metadata = match &source_info {
652            ProfileSource::Local => ProfileMetadata::new_local(name),
653            ProfileSource::Git {
654                url,
655                branch,
656                commit,
657                path,
658            } => ProfileMetadata::new_git(
659                name,
660                url,
661                branch.as_deref(),
662                commit.as_deref(),
663                path.as_deref(),
664            ),
665            ProfileSource::Marketplace {
666                channel,
667                plugin,
668                version,
669            } => ProfileMetadata::new_marketplace(name, channel, plugin, version),
670        };
671        metadata.save(&dest)?;
672
673        // Update profiles index
674        let mut index = ProfilesIndex::load(&self.base_dir)?;
675        let entry = match &source_info {
676            ProfileSource::Local => ProfileIndexEntry::new_local(name),
677            ProfileSource::Git {
678                url,
679                branch,
680                commit,
681                path,
682            } => ProfileIndexEntry::new_git(
683                name,
684                url,
685                branch.as_deref(),
686                commit.as_deref(),
687                path.as_deref(),
688            ),
689            ProfileSource::Marketplace {
690                channel,
691                plugin,
692                version,
693            } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
694        };
695        index.upsert(name, entry);
696        index.save(&self.base_dir)?;
697
698        Ok(Profile::new(name.to_string(), dest))
699    }
700
701    /// Get metadata for a profile
702    pub fn get_profile_metadata(&self, name: &str) -> Result<Option<ProfileMetadata>> {
703        let profile = self.get_profile(name)?;
704        ProfileMetadata::load(&profile.path)
705    }
706
707    /// Get profile source from index
708    pub fn get_profile_source(&self, name: &str) -> Result<Option<ProfileSource>> {
709        let index = ProfilesIndex::load(&self.base_dir)?;
710        Ok(index.get(name).map(|e| e.source.clone()))
711    }
712}
713
714fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
715    copy_dir_recursive_with_config(src, dst, &IgnoreConfig::with_defaults())
716}
717
718fn copy_dir_recursive_with_config(src: &Path, dst: &Path, config: &IgnoreConfig) -> Result<()> {
719    fs::create_dir_all(dst)?;
720
721    for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
722        let src_path = entry.path();
723        let relative = src_path.strip_prefix(src).unwrap();
724        let dst_path = dst.join(relative);
725
726        // Skip ignored directories/files
727        if config.should_ignore(relative) {
728            continue;
729        }
730
731        if src_path.is_dir() {
732            fs::create_dir_all(&dst_path)?;
733        } else if src_path.is_file() {
734            if let Some(parent) = dst_path.parent() {
735                fs::create_dir_all(parent)?;
736            }
737            fs::copy(src_path, &dst_path)?;
738        }
739    }
740
741    Ok(())
742}
743
744fn validate_profile_name(name: &str) -> Result<()> {
745    if name.is_empty() || name.len() > 64 {
746        return Err(DotAgentError::InvalidProfileName {
747            name: name.to_string(),
748        });
749    }
750
751    let first_char = name.chars().next().unwrap();
752    if !first_char.is_ascii_alphabetic() {
753        return Err(DotAgentError::InvalidProfileName {
754            name: name.to_string(),
755        });
756    }
757
758    for c in name.chars() {
759        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
760            return Err(DotAgentError::InvalidProfileName {
761                name: name.to_string(),
762            });
763        }
764    }
765
766    Ok(())
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn ignore_config_default_excludes_git() {
775        let config = IgnoreConfig::with_defaults();
776        assert!(config.excluded_dirs.contains(&".git".to_string()));
777    }
778
779    #[test]
780    fn ignore_config_default_excludes_tests() {
781        let config = IgnoreConfig::with_defaults();
782        assert!(config.excluded_dirs.contains(&"tests".to_string()));
783        assert!(config.excluded_dirs.contains(&"test".to_string()));
784        assert!(config.excluded_dirs.contains(&"__tests__".to_string()));
785    }
786
787    #[test]
788    fn ignore_config_default_excludes_build_dirs() {
789        let config = IgnoreConfig::with_defaults();
790        assert!(config.excluded_dirs.contains(&"__pycache__".to_string()));
791        assert!(config.excluded_dirs.contains(&".pytest_cache".to_string()));
792        assert!(config.excluded_dirs.contains(&"node_modules".to_string()));
793        assert!(config.excluded_dirs.contains(&"target".to_string()));
794    }
795
796    #[test]
797    fn ignore_config_should_ignore_tests_dir() {
798        let config = IgnoreConfig::with_defaults();
799
800        assert!(config.should_ignore(Path::new("tests")));
801        assert!(config.should_ignore(Path::new("tests/unit/test_parser.py")));
802        assert!(config.should_ignore(Path::new("tests/claude-code/analyze-token-usage.py")));
803    }
804
805    #[test]
806    fn ignore_config_should_ignore_git_files() {
807        let config = IgnoreConfig::with_defaults();
808
809        // .git directory itself
810        assert!(config.should_ignore(Path::new(".git")));
811
812        // Files inside .git
813        assert!(config.should_ignore(Path::new(".git/HEAD")));
814        assert!(config.should_ignore(Path::new(".git/config")));
815        assert!(config.should_ignore(Path::new(".git/objects/pack/something.pack")));
816    }
817
818    #[test]
819    fn ignore_config_should_not_ignore_regular_files() {
820        let config = IgnoreConfig::with_defaults();
821
822        assert!(!config.should_ignore(Path::new("README.md")));
823        assert!(!config.should_ignore(Path::new("src/main.rs")));
824        assert!(!config.should_ignore(Path::new("skills/my-skill/SKILL.md")));
825    }
826
827    #[test]
828    fn ignore_config_include_overrides_exclude() {
829        let config = IgnoreConfig::with_defaults().include(".git");
830
831        // .git should no longer be ignored because it's included
832        assert!(!config.should_ignore(Path::new(".git")));
833        assert!(!config.should_ignore(Path::new(".git/HEAD")));
834    }
835
836    #[test]
837    fn ignore_config_additional_exclusions() {
838        let config = IgnoreConfig::with_defaults().exclude("node_modules");
839
840        // Both .git and node_modules should be ignored
841        assert!(config.should_ignore(Path::new(".git/HEAD")));
842        assert!(config.should_ignore(Path::new("node_modules/package/index.js")));
843    }
844
845    #[test]
846    fn ignore_config_static_file_ignores() {
847        let config = IgnoreConfig::with_defaults();
848
849        // Static file ignores should still work
850        assert!(config.should_ignore(Path::new(".DS_Store")));
851        assert!(config.should_ignore(Path::new(".gitignore")));
852        assert!(config.should_ignore(Path::new(".gitkeep")));
853        assert!(config.should_ignore(Path::new("some/path/.DS_Store")));
854    }
855
856    #[test]
857    fn ignore_config_empty_allows_all() {
858        let config = IgnoreConfig::default();
859
860        // With no exclusions, nothing directory-related is ignored
861        // (but static file ignores still apply)
862        assert!(!config.should_ignore(Path::new(".git/HEAD")));
863        assert!(!config.should_ignore(Path::new("node_modules/index.js")));
864
865        // Static ignores still work
866        assert!(config.should_ignore(Path::new(".DS_Store")));
867    }
868
869    #[test]
870    fn profile_lazy_load_returns_none_for_missing() {
871        let tmp = tempfile::TempDir::new().unwrap();
872        let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
873
874        // No manifest file exists
875        assert!(profile.manifest().unwrap().is_none());
876
877        // No metadata file exists
878        assert!(profile.metadata().unwrap().is_none());
879
880        // No filter config exists
881        assert!(profile.filter_config().unwrap().is_none());
882    }
883
884    #[test]
885    fn profile_source_defaults_to_local() {
886        let tmp = tempfile::TempDir::new().unwrap();
887        let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
888
889        // Without metadata, source defaults to Local
890        let source = profile.source().unwrap();
891        assert!(matches!(source, ProfileSource::Local));
892    }
893
894    #[test]
895    fn profile_plugin_defaults() {
896        let tmp = tempfile::TempDir::new().unwrap();
897        let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
898
899        // Without metadata, plugin_enabled defaults to true
900        assert!(profile.plugin_enabled().unwrap());
901
902        // Without metadata, plugin_scope defaults to User
903        assert!(matches!(profile.plugin_scope().unwrap(), PluginScope::User));
904    }
905}