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