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