Skip to main content

dot_agent_core/
profile.rs

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