Skip to main content

dot_agent_core/
profile.rs

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