1use std::fs;
2use std::path::{Path, PathBuf};
3
4use walkdir::WalkDir;
5
6use crate::error::{DotAgentError, Result};
7use crate::plugin_manifest::{FilterConfig, PluginManifest, DEFAULT_COMPONENT_DIRS};
8use crate::profile_metadata::{ProfileIndexEntry, ProfileMetadata, ProfileSource, ProfilesIndex};
9
10const PROFILES_DIR: &str = "profiles";
11const IGNORED_FILES: &[&str] = &[".DS_Store", ".gitignore", ".gitkeep"];
12const IGNORED_EXTENSIONS: &[&str] = &[];
13
14pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[
16 ".git",
17 "tests",
19 "test",
20 "__tests__",
21 "__pycache__",
23 ".pytest_cache",
24 "node_modules",
25 "target",
26 ".vscode",
28 ".idea",
29 ".github",
31 ".gitlab",
32];
33
34#[derive(Debug, Clone, Default)]
36pub struct IgnoreConfig {
37 pub excluded_dirs: Vec<String>,
39 pub included_dirs: Vec<String>,
41}
42
43impl IgnoreConfig {
44 pub fn with_defaults() -> Self {
46 Self {
47 excluded_dirs: DEFAULT_EXCLUDED_DIRS
48 .iter()
49 .map(|s| s.to_string())
50 .collect(),
51 included_dirs: Vec::new(),
52 }
53 }
54
55 pub fn exclude(mut self, dir: impl Into<String>) -> Self {
57 self.excluded_dirs.push(dir.into());
58 self
59 }
60
61 pub fn include(mut self, dir: impl Into<String>) -> Self {
63 self.included_dirs.push(dir.into());
64 self
65 }
66
67 pub fn should_ignore(&self, path: &Path) -> bool {
69 if should_ignore_file(path) {
71 return true;
72 }
73
74 for component in path.components() {
76 if let std::path::Component::Normal(name) = component {
77 let name_str = name.to_string_lossy();
78
79 if self.included_dirs.iter().any(|d| d == name_str.as_ref()) {
81 continue;
82 }
83
84 if self.excluded_dirs.iter().any(|d| d == name_str.as_ref()) {
86 return true;
87 }
88 }
89 }
90
91 false
92 }
93}
94
95fn should_ignore_file(path: &Path) -> bool {
97 if let Some(name) = path.file_name() {
98 let name = name.to_string_lossy();
99 if IGNORED_FILES.contains(&name.as_ref()) {
100 return true;
101 }
102 }
103
104 if let Some(ext) = path.extension() {
105 let ext = ext.to_string_lossy();
106 if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
107 return true;
108 }
109 }
110
111 false
112}
113
114const ALLOWED_ROOT_FILE: &str = "CLAUDE.md";
116
117struct AllowedFilter {
120 base_dirs: Vec<PathBuf>,
122 include_patterns: Vec<glob::Pattern>,
124 exclude_patterns: Vec<glob::Pattern>,
126}
127
128impl AllowedFilter {
129 fn new(manifest: &Option<PluginManifest>, filter: &Option<FilterConfig>) -> Self {
130 let base_dirs: Vec<PathBuf> = if let Some(ref m) = manifest {
132 if m.has_explicit_paths() {
133 m.get_component_paths()
134 } else {
135 DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
136 }
137 } else {
138 DEFAULT_COMPONENT_DIRS.iter().map(PathBuf::from).collect()
139 };
140
141 let (include_patterns, exclude_patterns) = if let Some(ref f) = filter {
143 let includes = f
144 .include
145 .iter()
146 .filter_map(|p| glob::Pattern::new(p).ok())
147 .collect();
148 let excludes = f
149 .exclude
150 .iter()
151 .filter_map(|p| glob::Pattern::new(p).ok())
152 .collect();
153 (includes, excludes)
154 } else {
155 (Vec::new(), Vec::new())
156 };
157
158 Self {
159 base_dirs,
160 include_patterns,
161 exclude_patterns,
162 }
163 }
164
165 fn matches(&self, path: &Path) -> bool {
167 let path_str = path.to_string_lossy();
168
169 for pattern in &self.exclude_patterns {
171 if pattern.matches(&path_str) {
172 return false;
173 }
174 }
175
176 if path.components().count() == 1 {
178 if let Some(name) = path.file_name() {
179 if name == ALLOWED_ROOT_FILE {
180 return true;
181 }
182 }
183 }
184
185 for base_dir in &self.base_dirs {
187 if path.starts_with(base_dir) {
188 return true;
189 }
190 }
191
192 for pattern in &self.include_patterns {
194 if pattern.matches(&path_str) {
195 return true;
196 }
197 }
198
199 false
200 }
201}
202
203pub struct Profile {
204 pub name: String,
205 pub path: PathBuf,
206}
207
208impl Profile {
209 pub fn new(name: String, path: PathBuf) -> Self {
210 Self { name, path }
211 }
212
213 pub fn list_files(&self) -> Result<Vec<PathBuf>> {
215 self.list_files_with_config(&IgnoreConfig::with_defaults())
216 }
217
218 pub fn list_files_with_config(&self, config: &IgnoreConfig) -> Result<Vec<PathBuf>> {
224 let manifest = PluginManifest::load(&self.path)?;
226 let filter = FilterConfig::load(&self.path)?;
227
228 let allowed = AllowedFilter::new(&manifest, &filter);
230
231 let mut files: Vec<PathBuf> = Vec::new();
233
234 for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
235 let path = entry.path();
236 if !path.is_file() {
237 continue;
238 }
239
240 let relative = match path.strip_prefix(&self.path) {
241 Ok(r) => r,
242 Err(_) => continue,
243 };
244
245 if config.should_ignore(relative) {
247 continue;
248 }
249
250 if allowed.matches(relative) {
252 files.push(relative.to_path_buf());
253 }
254 }
255
256 files.sort();
257 Ok(files)
258 }
259
260 pub fn contents_summary(&self) -> String {
262 self.contents_summary_with_config(&IgnoreConfig::with_defaults())
263 }
264
265 pub fn contents_summary_with_config(&self, config: &IgnoreConfig) -> String {
267 let mut summary = Vec::new();
268
269 if let Ok(entries) = fs::read_dir(&self.path) {
270 for entry in entries.filter_map(|e| e.ok()) {
271 let path = entry.path();
272 if path.is_dir() {
273 let name = path.file_name().unwrap().to_string_lossy().to_string();
274 let count = WalkDir::new(&path)
275 .into_iter()
276 .filter_map(|e| e.ok())
277 .filter(|e| {
278 if !e.path().is_file() {
279 return false;
280 }
281 if let Ok(relative) = e.path().strip_prefix(&self.path) {
282 !config.should_ignore(relative)
283 } else {
284 false
285 }
286 })
287 .count();
288 if count > 0 {
289 summary.push(format!("{} ({})", name, count));
290 }
291 } else if path.is_file() {
292 let name = path.file_name().unwrap().to_string_lossy().to_string();
293 if let Ok(relative) = path.strip_prefix(&self.path) {
294 if !config.should_ignore(relative) {
295 summary.push(name);
296 }
297 }
298 }
299 }
300 }
301
302 if summary.is_empty() {
303 "(empty)".to_string()
304 } else {
305 summary.join(", ")
306 }
307 }
308}
309
310pub struct ProfileManager {
311 base_dir: PathBuf,
312}
313
314impl ProfileManager {
315 pub fn new(base_dir: PathBuf) -> Self {
316 Self { base_dir }
317 }
318
319 pub fn profiles_dir(&self) -> PathBuf {
320 self.base_dir.join(PROFILES_DIR)
321 }
322
323 pub fn list_profiles(&self) -> Result<Vec<Profile>> {
325 let profiles_dir = self.profiles_dir();
326 if !profiles_dir.exists() {
327 return Ok(Vec::new());
328 }
329
330 let mut profiles = Vec::new();
331 for entry in fs::read_dir(&profiles_dir)? {
332 let entry = entry?;
333 let path = entry.path();
334 if path.is_dir() {
335 let name = path.file_name().unwrap().to_string_lossy().to_string();
336 profiles.push(Profile::new(name, path));
337 }
338 }
339
340 profiles.sort_by(|a, b| a.name.cmp(&b.name));
341 Ok(profiles)
342 }
343
344 pub fn get_profile(&self, name: &str) -> Result<Profile> {
346 let path = self.profiles_dir().join(name);
347 if !path.exists() {
348 return Err(DotAgentError::ProfileNotFound {
349 name: name.to_string(),
350 });
351 }
352 Ok(Profile::new(name.to_string(), path))
353 }
354
355 pub fn create_profile(&self, name: &str) -> Result<Profile> {
357 validate_profile_name(name)?;
358
359 let path = self.profiles_dir().join(name);
360 if path.exists() {
361 return Err(DotAgentError::ProfileAlreadyExists {
362 name: name.to_string(),
363 });
364 }
365
366 fs::create_dir_all(&path)?;
368 fs::create_dir_all(path.join("agents"))?;
369 fs::create_dir_all(path.join("commands"))?;
370 fs::create_dir_all(path.join("hooks"))?;
371 fs::create_dir_all(path.join("plugins"))?;
372 fs::create_dir_all(path.join("rules"))?;
373 fs::create_dir_all(path.join("skills"))?;
374
375 let claude_md = format!(
377 r#"# {} Profile
378
379## Overview
380
381<!-- Describe what this profile is for -->
382
383## Usage
384
385```bash
386dot-agent install {}
387```
388
389## Customization
390
391<!-- Add project-specific instructions here -->
392"#,
393 name, name
394 );
395 fs::write(path.join("CLAUDE.md"), claude_md)?;
396
397 let metadata = ProfileMetadata::new_local(name);
399 metadata.save(&path)?;
400
401 let mut index = ProfilesIndex::load(&self.base_dir)?;
403 index.upsert(name, ProfileIndexEntry::new_local(name));
404 index.save(&self.base_dir)?;
405
406 Ok(Profile::new(name.to_string(), path))
407 }
408
409 pub fn remove_profile(&self, name: &str) -> Result<()> {
411 let profile = self.get_profile(name)?;
412 fs::remove_dir_all(&profile.path)?;
413
414 let mut index = ProfilesIndex::load(&self.base_dir)?;
416 index.remove(name);
417 index.save(&self.base_dir)?;
418
419 Ok(())
420 }
421
422 pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
424 let source = self.get_profile(source_name)?;
425 validate_profile_name(dest_name)?;
426
427 let dest_path = self.profiles_dir().join(dest_name);
428
429 if dest_path.exists() {
430 if !force {
431 return Err(DotAgentError::ProfileAlreadyExists {
432 name: dest_name.to_string(),
433 });
434 }
435 fs::remove_dir_all(&dest_path)?;
436 }
437
438 copy_dir_recursive(&source.path, &dest_path)?;
439
440 if let Some(mut metadata) = ProfileMetadata::load(&dest_path)? {
442 metadata.profile.name = dest_name.to_string();
443 metadata.save(&dest_path)?;
444 } else {
445 let metadata = ProfileMetadata::new_local(dest_name);
446 metadata.save(&dest_path)?;
447 }
448
449 let mut index = ProfilesIndex::load(&self.base_dir)?;
451 index.upsert(dest_name, ProfileIndexEntry::new_local(dest_name));
452 index.save(&self.base_dir)?;
453
454 Ok(Profile::new(dest_name.to_string(), dest_path))
455 }
456
457 pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
459 self.import_profile_with_source(source, name, force, ProfileSource::Local)
460 }
461
462 #[allow(clippy::too_many_arguments)]
464 pub fn import_profile_from_git(
465 &self,
466 source: &Path,
467 name: &str,
468 force: bool,
469 url: &str,
470 branch: Option<&str>,
471 commit: Option<&str>,
472 subpath: Option<&str>,
473 ) -> Result<Profile> {
474 let source_info = ProfileSource::Git {
475 url: url.to_string(),
476 branch: branch.map(|s| s.to_string()),
477 commit: commit.map(|s| s.to_string()),
478 path: subpath.map(|s| s.to_string()),
479 };
480 self.import_profile_with_source(source, name, force, source_info)
481 }
482
483 pub fn import_profile_from_marketplace(
485 &self,
486 source: &Path,
487 name: &str,
488 force: bool,
489 channel: &str,
490 plugin: &str,
491 version: &str,
492 ) -> Result<Profile> {
493 let source_info = ProfileSource::Marketplace {
494 channel: channel.to_string(),
495 plugin: plugin.to_string(),
496 version: version.to_string(),
497 };
498 self.import_profile_with_source(source, name, force, source_info)
499 }
500
501 fn import_profile_with_source(
503 &self,
504 source: &Path,
505 name: &str,
506 force: bool,
507 source_info: ProfileSource,
508 ) -> Result<Profile> {
509 validate_profile_name(name)?;
510
511 if !source.exists() {
512 return Err(DotAgentError::TargetNotFound {
513 path: source.to_path_buf(),
514 });
515 }
516
517 let dest = self.profiles_dir().join(name);
518
519 if dest.exists() {
520 if !force {
521 return Err(DotAgentError::ProfileAlreadyExists {
522 name: name.to_string(),
523 });
524 }
525 fs::remove_dir_all(&dest)?;
526 }
527
528 fs::create_dir_all(self.profiles_dir())?;
530
531 copy_dir_recursive(source, &dest)?;
533
534 let metadata = match &source_info {
536 ProfileSource::Local => ProfileMetadata::new_local(name),
537 ProfileSource::Git {
538 url,
539 branch,
540 commit,
541 path,
542 } => ProfileMetadata::new_git(
543 name,
544 url,
545 branch.as_deref(),
546 commit.as_deref(),
547 path.as_deref(),
548 ),
549 ProfileSource::Marketplace {
550 channel,
551 plugin,
552 version,
553 } => ProfileMetadata::new_marketplace(name, channel, plugin, version),
554 };
555 metadata.save(&dest)?;
556
557 let mut index = ProfilesIndex::load(&self.base_dir)?;
559 let entry = match &source_info {
560 ProfileSource::Local => ProfileIndexEntry::new_local(name),
561 ProfileSource::Git {
562 url,
563 branch,
564 commit,
565 path,
566 } => ProfileIndexEntry::new_git(
567 name,
568 url,
569 branch.as_deref(),
570 commit.as_deref(),
571 path.as_deref(),
572 ),
573 ProfileSource::Marketplace {
574 channel,
575 plugin,
576 version,
577 } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
578 };
579 index.upsert(name, entry);
580 index.save(&self.base_dir)?;
581
582 Ok(Profile::new(name.to_string(), dest))
583 }
584
585 pub fn get_profile_metadata(&self, name: &str) -> Result<Option<ProfileMetadata>> {
587 let profile = self.get_profile(name)?;
588 ProfileMetadata::load(&profile.path)
589 }
590
591 pub fn get_profile_source(&self, name: &str) -> Result<Option<ProfileSource>> {
593 let index = ProfilesIndex::load(&self.base_dir)?;
594 Ok(index.get(name).map(|e| e.source.clone()))
595 }
596}
597
598fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
599 copy_dir_recursive_with_config(src, dst, &IgnoreConfig::with_defaults())
600}
601
602fn copy_dir_recursive_with_config(src: &Path, dst: &Path, config: &IgnoreConfig) -> Result<()> {
603 fs::create_dir_all(dst)?;
604
605 for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
606 let src_path = entry.path();
607 let relative = src_path.strip_prefix(src).unwrap();
608 let dst_path = dst.join(relative);
609
610 if config.should_ignore(relative) {
612 continue;
613 }
614
615 if src_path.is_dir() {
616 fs::create_dir_all(&dst_path)?;
617 } else if src_path.is_file() {
618 if let Some(parent) = dst_path.parent() {
619 fs::create_dir_all(parent)?;
620 }
621 fs::copy(src_path, &dst_path)?;
622 }
623 }
624
625 Ok(())
626}
627
628fn validate_profile_name(name: &str) -> Result<()> {
629 if name.is_empty() || name.len() > 64 {
630 return Err(DotAgentError::InvalidProfileName {
631 name: name.to_string(),
632 });
633 }
634
635 let first_char = name.chars().next().unwrap();
636 if !first_char.is_ascii_alphabetic() {
637 return Err(DotAgentError::InvalidProfileName {
638 name: name.to_string(),
639 });
640 }
641
642 for c in name.chars() {
643 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
644 return Err(DotAgentError::InvalidProfileName {
645 name: name.to_string(),
646 });
647 }
648 }
649
650 Ok(())
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn ignore_config_default_excludes_git() {
659 let config = IgnoreConfig::with_defaults();
660 assert!(config.excluded_dirs.contains(&".git".to_string()));
661 }
662
663 #[test]
664 fn ignore_config_default_excludes_tests() {
665 let config = IgnoreConfig::with_defaults();
666 assert!(config.excluded_dirs.contains(&"tests".to_string()));
667 assert!(config.excluded_dirs.contains(&"test".to_string()));
668 assert!(config.excluded_dirs.contains(&"__tests__".to_string()));
669 }
670
671 #[test]
672 fn ignore_config_default_excludes_build_dirs() {
673 let config = IgnoreConfig::with_defaults();
674 assert!(config.excluded_dirs.contains(&"__pycache__".to_string()));
675 assert!(config.excluded_dirs.contains(&".pytest_cache".to_string()));
676 assert!(config.excluded_dirs.contains(&"node_modules".to_string()));
677 assert!(config.excluded_dirs.contains(&"target".to_string()));
678 }
679
680 #[test]
681 fn ignore_config_should_ignore_tests_dir() {
682 let config = IgnoreConfig::with_defaults();
683
684 assert!(config.should_ignore(Path::new("tests")));
685 assert!(config.should_ignore(Path::new("tests/unit/test_parser.py")));
686 assert!(config.should_ignore(Path::new("tests/claude-code/analyze-token-usage.py")));
687 }
688
689 #[test]
690 fn ignore_config_should_ignore_git_files() {
691 let config = IgnoreConfig::with_defaults();
692
693 assert!(config.should_ignore(Path::new(".git")));
695
696 assert!(config.should_ignore(Path::new(".git/HEAD")));
698 assert!(config.should_ignore(Path::new(".git/config")));
699 assert!(config.should_ignore(Path::new(".git/objects/pack/something.pack")));
700 }
701
702 #[test]
703 fn ignore_config_should_not_ignore_regular_files() {
704 let config = IgnoreConfig::with_defaults();
705
706 assert!(!config.should_ignore(Path::new("README.md")));
707 assert!(!config.should_ignore(Path::new("src/main.rs")));
708 assert!(!config.should_ignore(Path::new("skills/my-skill/SKILL.md")));
709 }
710
711 #[test]
712 fn ignore_config_include_overrides_exclude() {
713 let config = IgnoreConfig::with_defaults().include(".git");
714
715 assert!(!config.should_ignore(Path::new(".git")));
717 assert!(!config.should_ignore(Path::new(".git/HEAD")));
718 }
719
720 #[test]
721 fn ignore_config_additional_exclusions() {
722 let config = IgnoreConfig::with_defaults().exclude("node_modules");
723
724 assert!(config.should_ignore(Path::new(".git/HEAD")));
726 assert!(config.should_ignore(Path::new("node_modules/package/index.js")));
727 }
728
729 #[test]
730 fn ignore_config_static_file_ignores() {
731 let config = IgnoreConfig::with_defaults();
732
733 assert!(config.should_ignore(Path::new(".DS_Store")));
735 assert!(config.should_ignore(Path::new(".gitignore")));
736 assert!(config.should_ignore(Path::new(".gitkeep")));
737 assert!(config.should_ignore(Path::new("some/path/.DS_Store")));
738 }
739
740 #[test]
741 fn ignore_config_empty_allows_all() {
742 let config = IgnoreConfig::default();
743
744 assert!(!config.should_ignore(Path::new(".git/HEAD")));
747 assert!(!config.should_ignore(Path::new("node_modules/index.js")));
748
749 assert!(config.should_ignore(Path::new(".DS_Store")));
751 }
752}