dot_agent_core/
profile.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use walkdir::WalkDir;
5
6use crate::error::{DotAgentError, Result};
7
8const PROFILES_DIR: &str = "profiles";
9const IGNORED_FILES: &[&str] = &[".DS_Store", ".gitignore", ".gitkeep"];
10const IGNORED_EXTENSIONS: &[&str] = &[];
11
12/// Default directories to exclude from profile operations
13pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[".git"];
14
15/// Configuration for file ignore/include behavior
16#[derive(Debug, Clone, Default)]
17pub struct IgnoreConfig {
18    /// Directories to exclude (checked against path components)
19    pub excluded_dirs: Vec<String>,
20    /// Directories to explicitly include (overrides default exclusions)
21    pub included_dirs: Vec<String>,
22}
23
24impl IgnoreConfig {
25    /// Create with default exclusions (.git)
26    pub fn with_defaults() -> Self {
27        Self {
28            excluded_dirs: DEFAULT_EXCLUDED_DIRS
29                .iter()
30                .map(|s| s.to_string())
31                .collect(),
32            included_dirs: Vec::new(),
33        }
34    }
35
36    /// Add a directory to exclude
37    pub fn exclude(mut self, dir: impl Into<String>) -> Self {
38        self.excluded_dirs.push(dir.into());
39        self
40    }
41
42    /// Add a directory to include (overrides default exclusion)
43    pub fn include(mut self, dir: impl Into<String>) -> Self {
44        self.included_dirs.push(dir.into());
45        self
46    }
47
48    /// Check if a path should be ignored based on this config
49    pub fn should_ignore(&self, path: &Path) -> bool {
50        // Check static file ignores first
51        if should_ignore_file(path) {
52            return true;
53        }
54
55        // Check path components against excluded directories
56        for component in path.components() {
57            if let std::path::Component::Normal(name) = component {
58                let name_str = name.to_string_lossy();
59
60                // Check if explicitly included (overrides exclusion)
61                if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
62                    continue;
63                }
64
65                // Check if excluded
66                if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
67                    return true;
68                }
69            }
70        }
71
72        false
73    }
74}
75
76/// Check if a file should be ignored (static rules, not directory-based)
77fn should_ignore_file(path: &Path) -> bool {
78    if let Some(name) = path.file_name() {
79        let name = name.to_string_lossy();
80        if IGNORED_FILES.contains(&name.as_ref()) {
81            return true;
82        }
83    }
84
85    if let Some(ext) = path.extension() {
86        let ext = ext.to_string_lossy();
87        if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
88            return true;
89        }
90    }
91
92    false
93}
94
95pub struct Profile {
96    pub name: String,
97    pub path: PathBuf,
98}
99
100impl Profile {
101    pub fn new(name: String, path: PathBuf) -> Self {
102        Self { name, path }
103    }
104
105    /// List all files in the profile directory (relative paths) with default ignore config
106    pub fn list_files(&self) -> Result<Vec<PathBuf>> {
107        self.list_files_with_config(&IgnoreConfig::with_defaults())
108    }
109
110    /// List all files in the profile directory (relative paths) with custom ignore config
111    pub fn list_files_with_config(&self, config: &IgnoreConfig) -> Result<Vec<PathBuf>> {
112        let mut files = Vec::new();
113
114        for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
115            let path = entry.path();
116            if path.is_file() {
117                if let Ok(relative) = path.strip_prefix(&self.path) {
118                    if !config.should_ignore(relative) {
119                        files.push(relative.to_path_buf());
120                    }
121                }
122            }
123        }
124
125        files.sort();
126        Ok(files)
127    }
128
129    /// Get contents summary (e.g., "skills (5), commands (3)")
130    pub fn contents_summary(&self) -> String {
131        self.contents_summary_with_config(&IgnoreConfig::with_defaults())
132    }
133
134    /// Get contents summary with custom ignore config
135    pub fn contents_summary_with_config(&self, config: &IgnoreConfig) -> String {
136        let mut summary = Vec::new();
137
138        if let Ok(entries) = fs::read_dir(&self.path) {
139            for entry in entries.filter_map(|e| e.ok()) {
140                let path = entry.path();
141                if path.is_dir() {
142                    let name = path.file_name().unwrap().to_string_lossy().to_string();
143                    let count = WalkDir::new(&path)
144                        .into_iter()
145                        .filter_map(|e| e.ok())
146                        .filter(|e| {
147                            if !e.path().is_file() {
148                                return false;
149                            }
150                            if let Ok(relative) = e.path().strip_prefix(&self.path) {
151                                !config.should_ignore(relative)
152                            } else {
153                                false
154                            }
155                        })
156                        .count();
157                    if count > 0 {
158                        summary.push(format!("{} ({})", name, count));
159                    }
160                } else if path.is_file() {
161                    let name = path.file_name().unwrap().to_string_lossy().to_string();
162                    if let Ok(relative) = path.strip_prefix(&self.path) {
163                        if !config.should_ignore(relative) {
164                            summary.push(name);
165                        }
166                    }
167                }
168            }
169        }
170
171        if summary.is_empty() {
172            "(empty)".to_string()
173        } else {
174            summary.join(", ")
175        }
176    }
177}
178
179pub struct ProfileManager {
180    base_dir: PathBuf,
181}
182
183impl ProfileManager {
184    pub fn new(base_dir: PathBuf) -> Self {
185        Self { base_dir }
186    }
187
188    pub fn profiles_dir(&self) -> PathBuf {
189        self.base_dir.join(PROFILES_DIR)
190    }
191
192    /// Discover all profiles
193    pub fn list_profiles(&self) -> Result<Vec<Profile>> {
194        let profiles_dir = self.profiles_dir();
195        if !profiles_dir.exists() {
196            return Ok(Vec::new());
197        }
198
199        let mut profiles = Vec::new();
200        for entry in fs::read_dir(&profiles_dir)? {
201            let entry = entry?;
202            let path = entry.path();
203            if path.is_dir() {
204                let name = path.file_name().unwrap().to_string_lossy().to_string();
205                profiles.push(Profile::new(name, path));
206            }
207        }
208
209        profiles.sort_by(|a, b| a.name.cmp(&b.name));
210        Ok(profiles)
211    }
212
213    /// Get a specific profile
214    pub fn get_profile(&self, name: &str) -> Result<Profile> {
215        let path = self.profiles_dir().join(name);
216        if !path.exists() {
217            return Err(DotAgentError::ProfileNotFound {
218                name: name.to_string(),
219            });
220        }
221        Ok(Profile::new(name.to_string(), path))
222    }
223
224    /// Create a new profile with scaffolding
225    pub fn create_profile(&self, name: &str) -> Result<Profile> {
226        validate_profile_name(name)?;
227
228        let path = self.profiles_dir().join(name);
229        if path.exists() {
230            return Err(DotAgentError::ProfileAlreadyExists {
231                name: name.to_string(),
232            });
233        }
234
235        // Create directory structure
236        fs::create_dir_all(&path)?;
237        fs::create_dir_all(path.join("agents"))?;
238        fs::create_dir_all(path.join("commands"))?;
239        fs::create_dir_all(path.join("hooks"))?;
240        fs::create_dir_all(path.join("plugins"))?;
241        fs::create_dir_all(path.join("rules"))?;
242        fs::create_dir_all(path.join("skills"))?;
243
244        // Create CLAUDE.md template
245        let claude_md = format!(
246            r#"# {} Profile
247
248## Overview
249
250<!-- Describe what this profile is for -->
251
252## Usage
253
254```bash
255dot-agent install {}
256```
257
258## Customization
259
260<!-- Add project-specific instructions here -->
261"#,
262            name, name
263        );
264        fs::write(path.join("CLAUDE.md"), claude_md)?;
265
266        Ok(Profile::new(name.to_string(), path))
267    }
268
269    /// Remove a profile
270    pub fn remove_profile(&self, name: &str) -> Result<()> {
271        let profile = self.get_profile(name)?;
272        fs::remove_dir_all(&profile.path)?;
273        Ok(())
274    }
275
276    /// Copy an existing profile to a new name
277    pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
278        let source = self.get_profile(source_name)?;
279        validate_profile_name(dest_name)?;
280
281        let dest_path = self.profiles_dir().join(dest_name);
282
283        if dest_path.exists() {
284            if !force {
285                return Err(DotAgentError::ProfileAlreadyExists {
286                    name: dest_name.to_string(),
287                });
288            }
289            fs::remove_dir_all(&dest_path)?;
290        }
291
292        copy_dir_recursive(&source.path, &dest_path)?;
293
294        Ok(Profile::new(dest_name.to_string(), dest_path))
295    }
296
297    /// Import a directory as a profile
298    pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
299        validate_profile_name(name)?;
300
301        if !source.exists() {
302            return Err(DotAgentError::TargetNotFound {
303                path: source.to_path_buf(),
304            });
305        }
306
307        let dest = self.profiles_dir().join(name);
308
309        if dest.exists() {
310            if !force {
311                return Err(DotAgentError::ProfileAlreadyExists {
312                    name: name.to_string(),
313                });
314            }
315            fs::remove_dir_all(&dest)?;
316        }
317
318        // Ensure profiles directory exists
319        fs::create_dir_all(self.profiles_dir())?;
320
321        // Copy directory recursively
322        copy_dir_recursive(source, &dest)?;
323
324        Ok(Profile::new(name.to_string(), dest))
325    }
326}
327
328fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
329    copy_dir_recursive_with_config(src, dst, &IgnoreConfig::with_defaults())
330}
331
332fn copy_dir_recursive_with_config(src: &Path, dst: &Path, config: &IgnoreConfig) -> Result<()> {
333    fs::create_dir_all(dst)?;
334
335    for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
336        let src_path = entry.path();
337        let relative = src_path.strip_prefix(src).unwrap();
338        let dst_path = dst.join(relative);
339
340        // Skip ignored directories/files
341        if config.should_ignore(relative) {
342            continue;
343        }
344
345        if src_path.is_dir() {
346            fs::create_dir_all(&dst_path)?;
347        } else if src_path.is_file() {
348            if let Some(parent) = dst_path.parent() {
349                fs::create_dir_all(parent)?;
350            }
351            fs::copy(src_path, &dst_path)?;
352        }
353    }
354
355    Ok(())
356}
357
358fn validate_profile_name(name: &str) -> Result<()> {
359    if name.is_empty() || name.len() > 64 {
360        return Err(DotAgentError::InvalidProfileName {
361            name: name.to_string(),
362        });
363    }
364
365    let first_char = name.chars().next().unwrap();
366    if !first_char.is_ascii_alphabetic() {
367        return Err(DotAgentError::InvalidProfileName {
368            name: name.to_string(),
369        });
370    }
371
372    for c in name.chars() {
373        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
374            return Err(DotAgentError::InvalidProfileName {
375                name: name.to_string(),
376            });
377        }
378    }
379
380    Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn ignore_config_default_excludes_git() {
389        let config = IgnoreConfig::with_defaults();
390        assert!(config.excluded_dirs.contains(&".git".to_string()));
391    }
392
393    #[test]
394    fn ignore_config_should_ignore_git_files() {
395        let config = IgnoreConfig::with_defaults();
396
397        // .git directory itself
398        assert!(config.should_ignore(Path::new(".git")));
399
400        // Files inside .git
401        assert!(config.should_ignore(Path::new(".git/HEAD")));
402        assert!(config.should_ignore(Path::new(".git/config")));
403        assert!(config.should_ignore(Path::new(".git/objects/pack/something.pack")));
404    }
405
406    #[test]
407    fn ignore_config_should_not_ignore_regular_files() {
408        let config = IgnoreConfig::with_defaults();
409
410        assert!(!config.should_ignore(Path::new("README.md")));
411        assert!(!config.should_ignore(Path::new("src/main.rs")));
412        assert!(!config.should_ignore(Path::new("skills/my-skill/SKILL.md")));
413    }
414
415    #[test]
416    fn ignore_config_include_overrides_exclude() {
417        let config = IgnoreConfig::with_defaults().include(".git");
418
419        // .git should no longer be ignored because it's included
420        assert!(!config.should_ignore(Path::new(".git")));
421        assert!(!config.should_ignore(Path::new(".git/HEAD")));
422    }
423
424    #[test]
425    fn ignore_config_additional_exclusions() {
426        let config = IgnoreConfig::with_defaults().exclude("node_modules");
427
428        // Both .git and node_modules should be ignored
429        assert!(config.should_ignore(Path::new(".git/HEAD")));
430        assert!(config.should_ignore(Path::new("node_modules/package/index.js")));
431    }
432
433    #[test]
434    fn ignore_config_static_file_ignores() {
435        let config = IgnoreConfig::with_defaults();
436
437        // Static file ignores should still work
438        assert!(config.should_ignore(Path::new(".DS_Store")));
439        assert!(config.should_ignore(Path::new(".gitignore")));
440        assert!(config.should_ignore(Path::new(".gitkeep")));
441        assert!(config.should_ignore(Path::new("some/path/.DS_Store")));
442    }
443
444    #[test]
445    fn ignore_config_empty_allows_all() {
446        let config = IgnoreConfig::default();
447
448        // With no exclusions, nothing directory-related is ignored
449        // (but static file ignores still apply)
450        assert!(!config.should_ignore(Path::new(".git/HEAD")));
451        assert!(!config.should_ignore(Path::new("node_modules/index.js")));
452
453        // Static ignores still work
454        assert!(config.should_ignore(Path::new(".DS_Store")));
455    }
456}