1use std::fs;
2use std::path::{Path, PathBuf};
3
4use once_cell::unsync::OnceCell;
5
6use walkdir::WalkDir;
7
8use crate::error::{DotAgentError, Result};
9use crate::plugin_manifest::{FilterConfig, PluginManifest, DEFAULT_COMPONENT_DIRS};
10use crate::profile_metadata::{
11 PluginScope, ProfileIndexEntry, ProfileMetadata, ProfileSource, ProfilesIndex,
12};
13
14const PROFILES_DIR: &str = "profiles";
15const IGNORED_FILES: &[&str] = &[".DS_Store", ".gitignore", ".gitkeep"];
16const IGNORED_EXTENSIONS: &[&str] = &[];
17
18pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[
20 ".git",
21 "tests",
23 "test",
24 "__tests__",
25 "__pycache__",
27 ".pytest_cache",
28 "node_modules",
29 "target",
30 ".vscode",
32 ".idea",
33 ".github",
35 ".gitlab",
36];
37
38#[derive(Debug, Clone, Default)]
40pub struct IgnoreConfig {
41 pub excluded_dirs: Vec<String>,
43 pub included_dirs: Vec<String>,
45}
46
47impl IgnoreConfig {
48 pub fn with_defaults() -> Self {
50 Self {
51 excluded_dirs: DEFAULT_EXCLUDED_DIRS
52 .iter()
53 .map(|s| s.to_string())
54 .collect(),
55 included_dirs: Vec::new(),
56 }
57 }
58
59 pub fn exclude(mut self, dir: impl Into<String>) -> Self {
61 self.excluded_dirs.push(dir.into());
62 self
63 }
64
65 pub fn include(mut self, dir: impl Into<String>) -> Self {
67 self.included_dirs.push(dir.into());
68 self
69 }
70
71 pub fn should_ignore(&self, path: &Path) -> bool {
73 if should_ignore_file(path) {
75 return true;
76 }
77
78 for component in path.components() {
80 if let std::path::Component::Normal(name) = component {
81 let name_str = name.to_string_lossy();
82
83 if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
85 continue;
86 }
87
88 if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
90 return true;
91 }
92 }
93 }
94
95 false
96 }
97}
98
99fn should_ignore_file(path: &Path) -> bool {
101 if let Some(name) = path.file_name() {
102 let name = name.to_string_lossy();
103 if IGNORED_FILES.contains(&name.as_ref()) {
104 return true;
105 }
106 }
107
108 if let Some(ext) = path.extension() {
109 let ext = ext.to_string_lossy();
110 if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
111 return true;
112 }
113 }
114
115 false
116}
117
118const ALLOWED_ROOT_FILE: &str = "CLAUDE.md";
120
121struct AllowedFilter {
124 base_dirs: Vec<PathBuf>,
126 include_patterns: Vec<glob::Pattern>,
128 exclude_patterns: Vec<glob::Pattern>,
130}
131
132impl AllowedFilter {
133 fn new(manifest: &Option<PluginManifest>, filter: &Option<FilterConfig>) -> Self {
134 let base_dirs: Vec<PathBuf> = if let Some(ref m) = manifest {
136 if m.has_explicit_paths() {
137 m.get_component_paths()
138 } else {
139 DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
140 }
141 } else {
142 DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
143 };
144
145 let (include_patterns, exclude_patterns) = if let Some(ref f) = filter {
147 let includes = f
148 .include
149 .iter()
150 .filter_map(|p| glob::Pattern::new(p).ok())
151 .collect();
152 let excludes = f
153 .exclude
154 .iter()
155 .filter_map(|p| glob::Pattern::new(p).ok())
156 .collect();
157 (includes, excludes)
158 } else {
159 (Vec::new(), Vec::new())
160 };
161
162 Self {
163 base_dirs,
164 include_patterns,
165 exclude_patterns,
166 }
167 }
168
169 fn matches(&self, path: &Path) -> bool {
171 let path_str = path.to_string_lossy();
172
173 for pattern in &self.exclude_patterns {
175 if pattern.matches(&path_str) {
176 return false;
177 }
178 }
179
180 if path.components().count() == 1 {
182 if let Some(name) = path.file_name() {
183 if name == ALLOWED_ROOT_FILE {
184 return true;
185 }
186 }
187 }
188
189 for base_dir in &self.base_dirs {
191 if path.starts_with(base_dir) {
192 return true;
193 }
194 }
195
196 for pattern in &self.include_patterns {
198 if pattern.matches(&path_str) {
199 return true;
200 }
201 }
202
203 false
204 }
205}
206
207pub struct Profile {
214 pub name: String,
215 pub path: PathBuf,
216
217 manifest: OnceCell<Option<PluginManifest>>,
219 metadata: OnceCell<Option<ProfileMetadata>>,
220 filter: OnceCell<Option<FilterConfig>>,
221}
222
223impl Profile {
224 pub fn new(name: String, path: PathBuf) -> Self {
225 Self {
226 name,
227 path,
228 manifest: OnceCell::new(),
229 metadata: OnceCell::new(),
230 filter: OnceCell::new(),
231 }
232 }
233
234 pub fn manifest(&self) -> Result<Option<&PluginManifest>> {
240 self.manifest
241 .get_or_try_init(|| PluginManifest::load(&self.path))
242 .map(|opt| opt.as_ref())
243 }
244
245 pub fn metadata(&self) -> Result<Option<&ProfileMetadata>> {
247 self.metadata
248 .get_or_try_init(|| ProfileMetadata::load(&self.path))
249 .map(|opt| opt.as_ref())
250 }
251
252 pub fn filter_config(&self) -> Result<Option<&FilterConfig>> {
254 self.filter
255 .get_or_try_init(|| FilterConfig::load(&self.path))
256 .map(|opt| opt.as_ref())
257 }
258
259 pub fn source(&self) -> Result<ProfileSource> {
265 Ok(self
266 .metadata()?
267 .map(|m| m.source.clone())
268 .unwrap_or_default())
269 }
270
271 pub fn version(&self) -> Result<Option<String>> {
273 Ok(self.metadata()?.and_then(|m| m.profile.version.clone()))
274 }
275
276 pub fn description(&self) -> Result<Option<String>> {
278 Ok(self.metadata()?.and_then(|m| m.profile.description.clone()))
279 }
280
281 pub fn has_plugin_features(&self) -> bool {
283 ProfileMetadata::has_plugin_features(&self.path)
284 }
285
286 pub fn plugin_scope(&self) -> Result<PluginScope> {
288 Ok(self.metadata()?.map(|m| m.plugin.scope).unwrap_or_default())
289 }
290
291 pub fn plugin_enabled(&self) -> Result<bool> {
293 Ok(self.metadata()?.map(|m| m.plugin.enabled).unwrap_or(true))
294 }
295
296 pub fn list_files(&self) -> Result<Vec<PathBuf>> {
302 self.list_files_with_config(&IgnoreConfig::with_defaults())
303 }
304
305 pub fn list_files_with_config(&self, config: &IgnoreConfig) -> Result<Vec<PathBuf>> {
311 let manifest = self.manifest()?;
313 let filter = self.filter_config()?;
314
315 let allowed = AllowedFilter::new(&manifest.cloned(), &filter.cloned());
317
318 let mut files: Vec<PathBuf> = Vec::new();
320
321 for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
322 let path = entry.path();
323 if !path.is_file() {
324 continue;
325 }
326
327 let relative = match path.strip_prefix(&self.path) {
328 Ok(r) => r,
329 Err(_) => continue,
330 };
331
332 if config.should_ignore(relative) {
334 continue;
335 }
336
337 if allowed.matches(relative) {
339 files.push(relative.to_path_buf());
340 }
341 }
342
343 files.sort();
344 Ok(files)
345 }
346
347 pub fn contents_summary(&self) -> String {
349 self.contents_summary_with_config(&IgnoreConfig::with_defaults())
350 }
351
352 pub fn contents_summary_with_config(&self, config: &IgnoreConfig) -> String {
354 let mut summary = Vec::new();
355
356 if let Ok(entries) = fs::read_dir(&self.path) {
357 for entry in entries.filter_map(|e| e.ok()) {
358 let path = entry.path();
359 if path.is_dir() {
360 let name = path.file_name().unwrap().to_string_lossy().to_string();
361 let count = WalkDir::new(&path)
362 .into_iter()
363 .filter_map(|e| e.ok())
364 .filter(|e| {
365 if !e.path().is_file() {
366 return false;
367 }
368 if let Ok(relative) = e.path().strip_prefix(&self.path) {
369 !config.should_ignore(relative)
370 } else {
371 false
372 }
373 })
374 .count();
375 if count > 0 {
376 summary.push(format!("{} ({})", name, count));
377 }
378 } else if path.is_file() {
379 let name = path.file_name().unwrap().to_string_lossy().to_string();
380 if let Ok(relative) = path.strip_prefix(&self.path) {
381 if !config.should_ignore(relative) {
382 summary.push(name);
383 }
384 }
385 }
386 }
387 }
388
389 if summary.is_empty() {
390 "(empty)".to_string()
391 } else {
392 summary.join(", ")
393 }
394 }
395}
396
397pub struct ProfileManager {
398 base_dir: PathBuf,
399}
400
401impl ProfileManager {
402 pub fn new(base_dir: PathBuf) -> Self {
403 Self { base_dir }
404 }
405
406 pub fn profiles_dir(&self) -> PathBuf {
407 self.base_dir.join(PROFILES_DIR)
408 }
409
410 pub fn list_profiles(&self) -> Result<Vec<Profile>> {
412 let profiles_dir = self.profiles_dir();
413 if !profiles_dir.exists() {
414 return Ok(Vec::new());
415 }
416
417 let mut profiles = Vec::new();
418 for entry in fs::read_dir(&profiles_dir)? {
419 let entry = entry?;
420 let path = entry.path();
421 if path.is_dir() {
422 let name = path.file_name().unwrap().to_string_lossy().to_string();
423 profiles.push(Profile::new(name, path));
424 }
425 }
426
427 profiles.sort_by(|a, b| a.name.cmp(&b.name));
428 Ok(profiles)
429 }
430
431 pub fn get_profile(&self, name: &str) -> Result<Profile> {
433 let path = self.profiles_dir().join(name);
434 if !path.exists() {
435 return Err(DotAgentError::ProfileNotFound {
436 name: name.to_string(),
437 });
438 }
439 Ok(Profile::new(name.to_string(), path))
440 }
441
442 pub fn create_profile(&self, name: &str) -> Result<Profile> {
444 validate_profile_name(name)?;
445
446 let path = self.profiles_dir().join(name);
447 if path.exists() {
448 return Err(DotAgentError::ProfileAlreadyExists {
449 name: name.to_string(),
450 });
451 }
452
453 fs::create_dir_all(&path)?;
455 fs::create_dir_all(path.join("agents"))?;
456 fs::create_dir_all(path.join("commands"))?;
457 fs::create_dir_all(path.join("hooks"))?;
458 fs::create_dir_all(path.join("plugins"))?;
459 fs::create_dir_all(path.join("rules"))?;
460 fs::create_dir_all(path.join("skills"))?;
461
462 let claude_md = format!(
464 r#"# {} Profile
465
466## Overview
467
468<!-- Describe what this profile is for -->
469
470## Usage
471
472```bash
473dot-agent install {}
474```
475
476## Customization
477
478<!-- Add project-specific instructions here -->
479"#,
480 name, name
481 );
482 fs::write(path.join("CLAUDE.md"), claude_md)?;
483
484 let metadata = ProfileMetadata::new_local(name);
486 metadata.save(&path)?;
487
488 let mut index = ProfilesIndex::load(&self.base_dir)?;
490 index.upsert(name, ProfileIndexEntry::new_local(name));
491 index.save(&self.base_dir)?;
492
493 Ok(Profile::new(name.to_string(), path))
494 }
495
496 pub fn remove_profile(&self, name: &str) -> Result<()> {
498 let profile = self.get_profile(name)?;
499 fs::remove_dir_all(&profile.path)?;
500
501 let mut index = ProfilesIndex::load(&self.base_dir)?;
503 index.remove(name);
504 index.save(&self.base_dir)?;
505
506 Ok(())
507 }
508
509 pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
511 let source = self.get_profile(source_name)?;
512 validate_profile_name(dest_name)?;
513
514 let dest_path = self.profiles_dir().join(dest_name);
515
516 if dest_path.exists() {
517 if !force {
518 return Err(DotAgentError::ProfileAlreadyExists {
519 name: dest_name.to_string(),
520 });
521 }
522 fs::remove_dir_all(&dest_path)?;
523 }
524
525 copy_dir_recursive(&source.path, &dest_path)?;
526
527 if let Some(mut metadata) = ProfileMetadata::load(&dest_path)? {
529 metadata.profile.name = dest_name.to_string();
530 metadata.save(&dest_path)?;
531 } else {
532 let metadata = ProfileMetadata::new_local(dest_name);
533 metadata.save(&dest_path)?;
534 }
535
536 let mut index = ProfilesIndex::load(&self.base_dir)?;
538 index.upsert(dest_name, ProfileIndexEntry::new_local(dest_name));
539 index.save(&self.base_dir)?;
540
541 Ok(Profile::new(dest_name.to_string(), dest_path))
542 }
543
544 pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
546 self.import_profile_with_source(source, name, force, ProfileSource::Local)
547 }
548
549 #[allow(clippy::too_many_arguments)]
551 pub fn import_profile_from_git(
552 &self,
553 source: &Path,
554 name: &str,
555 force: bool,
556 url: &str,
557 branch: Option<&str>,
558 commit: Option<&str>,
559 subpath: Option<&str>,
560 ) -> Result<Profile> {
561 let source_info = ProfileSource::Git {
562 url: url.to_string(),
563 branch: branch.map(|s| s.to_string()),
564 commit: commit.map(|s| s.to_string()),
565 path: subpath.map(|s| s.to_string()),
566 };
567 self.import_profile_with_source(source, name, force, source_info)
568 }
569
570 pub fn import_profile_from_marketplace(
572 &self,
573 source: &Path,
574 name: &str,
575 force: bool,
576 channel: &str,
577 plugin: &str,
578 version: &str,
579 ) -> Result<Profile> {
580 let source_info = ProfileSource::Marketplace {
581 channel: channel.to_string(),
582 plugin: plugin.to_string(),
583 version: version.to_string(),
584 };
585 self.import_profile_with_source(source, name, force, source_info)
586 }
587
588 fn import_profile_with_source(
590 &self,
591 source: &Path,
592 name: &str,
593 force: bool,
594 source_info: ProfileSource,
595 ) -> Result<Profile> {
596 validate_profile_name(name)?;
597
598 if !source.exists() {
599 return Err(DotAgentError::TargetNotFound {
600 path: source.to_path_buf(),
601 });
602 }
603
604 let dest = self.profiles_dir().join(name);
605
606 if dest.exists() {
607 if !force {
608 return Err(DotAgentError::ProfileAlreadyExists {
609 name: name.to_string(),
610 });
611 }
612 fs::remove_dir_all(&dest)?;
613 }
614
615 fs::create_dir_all(self.profiles_dir())?;
617
618 copy_dir_recursive(source, &dest)?;
620
621 let metadata = match &source_info {
623 ProfileSource::Local => ProfileMetadata::new_local(name),
624 ProfileSource::Git {
625 url,
626 branch,
627 commit,
628 path,
629 } => ProfileMetadata::new_git(
630 name,
631 url,
632 branch.as_deref(),
633 commit.as_deref(),
634 path.as_deref(),
635 ),
636 ProfileSource::Marketplace {
637 channel,
638 plugin,
639 version,
640 } => ProfileMetadata::new_marketplace(name, channel, plugin, version),
641 };
642 metadata.save(&dest)?;
643
644 let mut index = ProfilesIndex::load(&self.base_dir)?;
646 let entry = match &source_info {
647 ProfileSource::Local => ProfileIndexEntry::new_local(name),
648 ProfileSource::Git {
649 url,
650 branch,
651 commit,
652 path,
653 } => ProfileIndexEntry::new_git(
654 name,
655 url,
656 branch.as_deref(),
657 commit.as_deref(),
658 path.as_deref(),
659 ),
660 ProfileSource::Marketplace {
661 channel,
662 plugin,
663 version,
664 } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
665 };
666 index.upsert(name, entry);
667 index.save(&self.base_dir)?;
668
669 Ok(Profile::new(name.to_string(), dest))
670 }
671
672 pub fn get_profile_metadata(&self, name: &str) -> Result<Option<ProfileMetadata>> {
674 let profile = self.get_profile(name)?;
675 ProfileMetadata::load(&profile.path)
676 }
677
678 pub fn get_profile_source(&self, name: &str) -> Result<Option<ProfileSource>> {
680 let index = ProfilesIndex::load(&self.base_dir)?;
681 Ok(index.get(name).map(|e| e.source.clone()))
682 }
683}
684
685fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
686 copy_dir_recursive_with_config(src, dst, &IgnoreConfig::with_defaults())
687}
688
689fn copy_dir_recursive_with_config(src: &Path, dst: &Path, config: &IgnoreConfig) -> Result<()> {
690 fs::create_dir_all(dst)?;
691
692 for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
693 let src_path = entry.path();
694 let relative = src_path.strip_prefix(src).unwrap();
695 let dst_path = dst.join(relative);
696
697 if config.should_ignore(relative) {
699 continue;
700 }
701
702 if src_path.is_dir() {
703 fs::create_dir_all(&dst_path)?;
704 } else if src_path.is_file() {
705 if let Some(parent) = dst_path.parent() {
706 fs::create_dir_all(parent)?;
707 }
708 fs::copy(src_path, &dst_path)?;
709 }
710 }
711
712 Ok(())
713}
714
715fn validate_profile_name(name: &str) -> Result<()> {
716 if name.is_empty() || name.len() > 64 {
717 return Err(DotAgentError::InvalidProfileName {
718 name: name.to_string(),
719 });
720 }
721
722 let first_char = name.chars().next().unwrap();
723 if !first_char.is_ascii_alphabetic() {
724 return Err(DotAgentError::InvalidProfileName {
725 name: name.to_string(),
726 });
727 }
728
729 for c in name.chars() {
730 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
731 return Err(DotAgentError::InvalidProfileName {
732 name: name.to_string(),
733 });
734 }
735 }
736
737 Ok(())
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn ignore_config_default_excludes_git() {
746 let config = IgnoreConfig::with_defaults();
747 assert!(config.excluded_dirs.contains(&".git".to_string()));
748 }
749
750 #[test]
751 fn ignore_config_default_excludes_tests() {
752 let config = IgnoreConfig::with_defaults();
753 assert!(config.excluded_dirs.contains(&"tests".to_string()));
754 assert!(config.excluded_dirs.contains(&"test".to_string()));
755 assert!(config.excluded_dirs.contains(&"__tests__".to_string()));
756 }
757
758 #[test]
759 fn ignore_config_default_excludes_build_dirs() {
760 let config = IgnoreConfig::with_defaults();
761 assert!(config.excluded_dirs.contains(&"__pycache__".to_string()));
762 assert!(config.excluded_dirs.contains(&".pytest_cache".to_string()));
763 assert!(config.excluded_dirs.contains(&"node_modules".to_string()));
764 assert!(config.excluded_dirs.contains(&"target".to_string()));
765 }
766
767 #[test]
768 fn ignore_config_should_ignore_tests_dir() {
769 let config = IgnoreConfig::with_defaults();
770
771 assert!(config.should_ignore(Path::new("tests")));
772 assert!(config.should_ignore(Path::new("tests/unit/test_parser.py")));
773 assert!(config.should_ignore(Path::new("tests/claude-code/analyze-token-usage.py")));
774 }
775
776 #[test]
777 fn ignore_config_should_ignore_git_files() {
778 let config = IgnoreConfig::with_defaults();
779
780 assert!(config.should_ignore(Path::new(".git")));
782
783 assert!(config.should_ignore(Path::new(".git/HEAD")));
785 assert!(config.should_ignore(Path::new(".git/config")));
786 assert!(config.should_ignore(Path::new(".git/objects/pack/something.pack")));
787 }
788
789 #[test]
790 fn ignore_config_should_not_ignore_regular_files() {
791 let config = IgnoreConfig::with_defaults();
792
793 assert!(!config.should_ignore(Path::new("README.md")));
794 assert!(!config.should_ignore(Path::new("src/main.rs")));
795 assert!(!config.should_ignore(Path::new("skills/my-skill/SKILL.md")));
796 }
797
798 #[test]
799 fn ignore_config_include_overrides_exclude() {
800 let config = IgnoreConfig::with_defaults().include(".git");
801
802 assert!(!config.should_ignore(Path::new(".git")));
804 assert!(!config.should_ignore(Path::new(".git/HEAD")));
805 }
806
807 #[test]
808 fn ignore_config_additional_exclusions() {
809 let config = IgnoreConfig::with_defaults().exclude("node_modules");
810
811 assert!(config.should_ignore(Path::new(".git/HEAD")));
813 assert!(config.should_ignore(Path::new("node_modules/package/index.js")));
814 }
815
816 #[test]
817 fn ignore_config_static_file_ignores() {
818 let config = IgnoreConfig::with_defaults();
819
820 assert!(config.should_ignore(Path::new(".DS_Store")));
822 assert!(config.should_ignore(Path::new(".gitignore")));
823 assert!(config.should_ignore(Path::new(".gitkeep")));
824 assert!(config.should_ignore(Path::new("some/path/.DS_Store")));
825 }
826
827 #[test]
828 fn ignore_config_empty_allows_all() {
829 let config = IgnoreConfig::default();
830
831 assert!(!config.should_ignore(Path::new(".git/HEAD")));
834 assert!(!config.should_ignore(Path::new("node_modules/index.js")));
835
836 assert!(config.should_ignore(Path::new(".DS_Store")));
838 }
839
840 #[test]
841 fn profile_lazy_load_returns_none_for_missing() {
842 let tmp = tempfile::TempDir::new().unwrap();
843 let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
844
845 assert!(profile.manifest().unwrap().is_none());
847
848 assert!(profile.metadata().unwrap().is_none());
850
851 assert!(profile.filter_config().unwrap().is_none());
853 }
854
855 #[test]
856 fn profile_source_defaults_to_local() {
857 let tmp = tempfile::TempDir::new().unwrap();
858 let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
859
860 let source = profile.source().unwrap();
862 assert!(matches!(source, ProfileSource::Local));
863 }
864
865 #[test]
866 fn profile_plugin_defaults() {
867 let tmp = tempfile::TempDir::new().unwrap();
868 let profile = Profile::new("test".to_string(), tmp.path().to_path_buf());
869
870 assert!(profile.plugin_enabled().unwrap());
872
873 assert!(matches!(profile.plugin_scope().unwrap(), PluginScope::User));
875 }
876}