dot_agent_core/
profile.rs1use 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
12pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[".git"];
14
15#[derive(Debug, Clone, Default)]
17pub struct IgnoreConfig {
18 pub excluded_dirs: Vec<String>,
20 pub included_dirs: Vec<String>,
22}
23
24impl IgnoreConfig {
25 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 pub fn exclude(mut self, dir: impl Into<String>) -> Self {
38 self.excluded_dirs.push(dir.into());
39 self
40 }
41
42 pub fn include(mut self, dir: impl Into<String>) -> Self {
44 self.included_dirs.push(dir.into());
45 self
46 }
47
48 pub fn should_ignore(&self, path: &Path) -> bool {
50 if should_ignore_file(path) {
52 return true;
53 }
54
55 for component in path.components() {
57 if let std::path::Component::Normal(name) = component {
58 let name_str = name.to_string_lossy();
59
60 if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
62 continue;
63 }
64
65 if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
67 return true;
68 }
69 }
70 }
71
72 false
73 }
74}
75
76fn 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 pub fn list_files(&self) -> Result<Vec<PathBuf>> {
107 self.list_files_with_config(&IgnoreConfig::with_defaults())
108 }
109
110 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 pub fn contents_summary(&self) -> String {
131 self.contents_summary_with_config(&IgnoreConfig::with_defaults())
132 }
133
134 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 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 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 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 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 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 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 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 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 fs::create_dir_all(self.profiles_dir())?;
320
321 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 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 assert!(config.should_ignore(Path::new(".git")));
399
400 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 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 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 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 assert!(!config.should_ignore(Path::new(".git/HEAD")));
451 assert!(!config.should_ignore(Path::new("node_modules/index.js")));
452
453 assert!(config.should_ignore(Path::new(".DS_Store")));
455 }
456}