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] = &[".git", ".DS_Store", ".gitignore", ".gitkeep"];
10const IGNORED_EXTENSIONS: &[&str] = &[];
11
12pub struct Profile {
13    pub name: String,
14    pub path: PathBuf,
15}
16
17impl Profile {
18    pub fn new(name: String, path: PathBuf) -> Self {
19        Self { name, path }
20    }
21
22    /// List all files in the profile directory (relative paths)
23    pub fn list_files(&self) -> Result<Vec<PathBuf>> {
24        let mut files = Vec::new();
25
26        for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
27            let path = entry.path();
28            if path.is_file() && !should_ignore(path) {
29                if let Ok(relative) = path.strip_prefix(&self.path) {
30                    files.push(relative.to_path_buf());
31                }
32            }
33        }
34
35        files.sort();
36        Ok(files)
37    }
38
39    /// Get contents summary (e.g., "skills (5), commands (3)")
40    pub fn contents_summary(&self) -> String {
41        let mut summary = Vec::new();
42
43        if let Ok(entries) = fs::read_dir(&self.path) {
44            for entry in entries.filter_map(|e| e.ok()) {
45                let path = entry.path();
46                if path.is_dir() {
47                    let name = path.file_name().unwrap().to_string_lossy().to_string();
48                    let count = WalkDir::new(&path)
49                        .into_iter()
50                        .filter_map(|e| e.ok())
51                        .filter(|e| e.path().is_file() && !should_ignore(e.path()))
52                        .count();
53                    if count > 0 {
54                        summary.push(format!("{} ({})", name, count));
55                    }
56                } else if path.is_file() {
57                    let name = path.file_name().unwrap().to_string_lossy().to_string();
58                    if !should_ignore(&path) {
59                        summary.push(name);
60                    }
61                }
62            }
63        }
64
65        if summary.is_empty() {
66            "(empty)".to_string()
67        } else {
68            summary.join(", ")
69        }
70    }
71}
72
73pub struct ProfileManager {
74    base_dir: PathBuf,
75}
76
77impl ProfileManager {
78    pub fn new(base_dir: PathBuf) -> Self {
79        Self { base_dir }
80    }
81
82    pub fn profiles_dir(&self) -> PathBuf {
83        self.base_dir.join(PROFILES_DIR)
84    }
85
86    /// Discover all profiles
87    pub fn list_profiles(&self) -> Result<Vec<Profile>> {
88        let profiles_dir = self.profiles_dir();
89        if !profiles_dir.exists() {
90            return Ok(Vec::new());
91        }
92
93        let mut profiles = Vec::new();
94        for entry in fs::read_dir(&profiles_dir)? {
95            let entry = entry?;
96            let path = entry.path();
97            if path.is_dir() {
98                let name = path.file_name().unwrap().to_string_lossy().to_string();
99                profiles.push(Profile::new(name, path));
100            }
101        }
102
103        profiles.sort_by(|a, b| a.name.cmp(&b.name));
104        Ok(profiles)
105    }
106
107    /// Get a specific profile
108    pub fn get_profile(&self, name: &str) -> Result<Profile> {
109        let path = self.profiles_dir().join(name);
110        if !path.exists() {
111            return Err(DotAgentError::ProfileNotFound {
112                name: name.to_string(),
113            });
114        }
115        Ok(Profile::new(name.to_string(), path))
116    }
117
118    /// Create a new profile
119    pub fn create_profile(&self, name: &str) -> Result<Profile> {
120        validate_profile_name(name)?;
121
122        let path = self.profiles_dir().join(name);
123        if path.exists() {
124            return Err(DotAgentError::ProfileAlreadyExists {
125                name: name.to_string(),
126            });
127        }
128
129        fs::create_dir_all(&path)?;
130        Ok(Profile::new(name.to_string(), path))
131    }
132
133    /// Remove a profile
134    pub fn remove_profile(&self, name: &str) -> Result<()> {
135        let profile = self.get_profile(name)?;
136        fs::remove_dir_all(&profile.path)?;
137        Ok(())
138    }
139
140    /// Copy an existing profile to a new name
141    pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
142        let source = self.get_profile(source_name)?;
143        validate_profile_name(dest_name)?;
144
145        let dest_path = self.profiles_dir().join(dest_name);
146
147        if dest_path.exists() {
148            if !force {
149                return Err(DotAgentError::ProfileAlreadyExists {
150                    name: dest_name.to_string(),
151                });
152            }
153            fs::remove_dir_all(&dest_path)?;
154        }
155
156        copy_dir_recursive(&source.path, &dest_path)?;
157
158        Ok(Profile::new(dest_name.to_string(), dest_path))
159    }
160
161    /// Import a directory as a profile
162    pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
163        validate_profile_name(name)?;
164
165        if !source.exists() {
166            return Err(DotAgentError::TargetNotFound {
167                path: source.to_path_buf(),
168            });
169        }
170
171        let dest = self.profiles_dir().join(name);
172
173        if dest.exists() {
174            if !force {
175                return Err(DotAgentError::ProfileAlreadyExists {
176                    name: name.to_string(),
177                });
178            }
179            fs::remove_dir_all(&dest)?;
180        }
181
182        // Ensure profiles directory exists
183        fs::create_dir_all(self.profiles_dir())?;
184
185        // Copy directory recursively
186        copy_dir_recursive(source, &dest)?;
187
188        Ok(Profile::new(name.to_string(), dest))
189    }
190}
191
192fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
193    fs::create_dir_all(dst)?;
194
195    for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
196        let src_path = entry.path();
197        let relative = src_path.strip_prefix(src).unwrap();
198        let dst_path = dst.join(relative);
199
200        if src_path.is_dir() {
201            fs::create_dir_all(&dst_path)?;
202        } else if src_path.is_file() && !should_ignore(src_path) {
203            if let Some(parent) = dst_path.parent() {
204                fs::create_dir_all(parent)?;
205            }
206            fs::copy(src_path, &dst_path)?;
207        }
208    }
209
210    Ok(())
211}
212
213fn validate_profile_name(name: &str) -> Result<()> {
214    if name.is_empty() || name.len() > 64 {
215        return Err(DotAgentError::InvalidProfileName {
216            name: name.to_string(),
217        });
218    }
219
220    let first_char = name.chars().next().unwrap();
221    if !first_char.is_ascii_alphabetic() {
222        return Err(DotAgentError::InvalidProfileName {
223            name: name.to_string(),
224        });
225    }
226
227    for c in name.chars() {
228        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
229            return Err(DotAgentError::InvalidProfileName {
230                name: name.to_string(),
231            });
232        }
233    }
234
235    Ok(())
236}
237
238fn should_ignore(path: &Path) -> bool {
239    if let Some(name) = path.file_name() {
240        let name = name.to_string_lossy();
241        if IGNORED_FILES.contains(&name.as_ref()) {
242            return true;
243        }
244    }
245
246    if let Some(ext) = path.extension() {
247        let ext = ext.to_string_lossy();
248        if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
249            return true;
250        }
251    }
252
253    false
254}