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
13pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[
15 ".git",
16 "tests",
18 "test",
19 "__tests__",
20 "__pycache__",
22 ".pytest_cache",
23 "node_modules",
24 "target",
25 ".vscode",
27 ".idea",
28 ".github",
30 ".gitlab",
31];
32
33#[derive(Debug, Clone, Default)]
35pub struct IgnoreConfig {
36 pub excluded_dirs: Vec<String>,
38 pub included_dirs: Vec<String>,
40}
41
42impl IgnoreConfig {
43 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 pub fn exclude(mut self, dir: impl Into<String>) -> Self {
56 self.excluded_dirs.push(dir.into());
57 self
58 }
59
60 pub fn include(mut self, dir: impl Into<String>) -> Self {
62 self.included_dirs.push(dir.into());
63 self
64 }
65
66 pub fn should_ignore(&self, path: &Path) -> bool {
68 if should_ignore_file(path) {
70 return true;
71 }
72
73 for component in path.components() {
75 if let std::path::Component::Normal(name) = component {
76 let name_str = name.to_string_lossy();
77
78 if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
80 continue;
81 }
82
83 if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
85 return true;
86 }
87 }
88 }
89
90 false
91 }
92}
93
94fn 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 pub fn list_files(&self) -> Result<Vec<PathBuf>> {
125 self.list_files_with_config(&IgnoreConfig::with_defaults())
126 }
127
128 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 pub fn contents_summary(&self) -> String {
149 self.contents_summary_with_config(&IgnoreConfig::with_defaults())
150 }
151
152 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 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 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 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 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 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 let metadata = ProfileMetadata::new_local(name);
286 metadata.save(&path)?;
287
288 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 pub fn remove_profile(&self, name: &str) -> Result<()> {
298 let profile = self.get_profile(name)?;
299 fs::remove_dir_all(&profile.path)?;
300
301 let mut index = ProfilesIndex::load(&self.base_dir)?;
303 index.remove(name);
304 index.save(&self.base_dir)?;
305
306 Ok(())
307 }
308
309 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 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 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 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 #[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 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 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 fs::create_dir_all(self.profiles_dir())?;
417
418 copy_dir_recursive(source, &dest)?;
420
421 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 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 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 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 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 assert!(config.should_ignore(Path::new(".git")));
582
583 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 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 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 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 assert!(!config.should_ignore(Path::new(".git/HEAD")));
634 assert!(!config.should_ignore(Path::new("node_modules/index.js")));
635
636 assert!(config.should_ignore(Path::new(".DS_Store")));
638 }
639}