dot_agent_core/
profile.rs

1use 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] = &[".git", ".DS_Store", ".gitignore", ".gitkeep"];
10const IGNORED_EXTENSIONS: &[&str] = &[];
11
12pub struct Profile {
13    pub name: String,
14    pub path: PathBuf,
15}
16
17impl Profile {
18    pub fn new(name: String, path: PathBuf) -> Self {
19        Self { name, path }
20    }
21
22    /// List all files in the profile directory (relative paths)
23    pub fn list_files(&self) -> Result<Vec<PathBuf>> {
24        let mut files = Vec::new();
25
26        for entry in WalkDir::new(&self.path).into_iter().filter_map(|e| e.ok()) {
27            let path = entry.path();
28            if path.is_file() && !should_ignore(path) {
29                if let Ok(relative) = path.strip_prefix(&self.path) {
30                    files.push(relative.to_path_buf());
31                }
32            }
33        }
34
35        files.sort();
36        Ok(files)
37    }
38
39    /// Get contents summary (e.g., "skills (5), commands (3)")
40    pub fn contents_summary(&self) -> String {
41        let mut summary = Vec::new();
42
43        if let Ok(entries) = fs::read_dir(&self.path) {
44            for entry in entries.filter_map(|e| e.ok()) {
45                let path = entry.path();
46                if path.is_dir() {
47                    let name = path.file_name().unwrap().to_string_lossy().to_string();
48                    let count = WalkDir::new(&path)
49                        .into_iter()
50                        .filter_map(|e| e.ok())
51                        .filter(|e| e.path().is_file() && !should_ignore(e.path()))
52                        .count();
53                    if count > 0 {
54                        summary.push(format!("{} ({})", name, count));
55                    }
56                } else if path.is_file() {
57                    let name = path.file_name().unwrap().to_string_lossy().to_string();
58                    if !should_ignore(&path) {
59                        summary.push(name);
60                    }
61                }
62            }
63        }
64
65        if summary.is_empty() {
66            "(empty)".to_string()
67        } else {
68            summary.join(", ")
69        }
70    }
71}
72
73pub struct ProfileManager {
74    base_dir: PathBuf,
75}
76
77impl ProfileManager {
78    pub fn new(base_dir: PathBuf) -> Self {
79        Self { base_dir }
80    }
81
82    pub fn profiles_dir(&self) -> PathBuf {
83        self.base_dir.join(PROFILES_DIR)
84    }
85
86    /// Discover all profiles
87    pub fn list_profiles(&self) -> Result<Vec<Profile>> {
88        let profiles_dir = self.profiles_dir();
89        if !profiles_dir.exists() {
90            return Ok(Vec::new());
91        }
92
93        let mut profiles = Vec::new();
94        for entry in fs::read_dir(&profiles_dir)? {
95            let entry = entry?;
96            let path = entry.path();
97            if path.is_dir() {
98                let name = path.file_name().unwrap().to_string_lossy().to_string();
99                profiles.push(Profile::new(name, path));
100            }
101        }
102
103        profiles.sort_by(|a, b| a.name.cmp(&b.name));
104        Ok(profiles)
105    }
106
107    /// Get a specific profile
108    pub fn get_profile(&self, name: &str) -> Result<Profile> {
109        let path = self.profiles_dir().join(name);
110        if !path.exists() {
111            return Err(DotAgentError::ProfileNotFound {
112                name: name.to_string(),
113            });
114        }
115        Ok(Profile::new(name.to_string(), path))
116    }
117
118    /// Create a new profile
119    pub fn create_profile(&self, name: &str) -> Result<Profile> {
120        validate_profile_name(name)?;
121
122        let path = self.profiles_dir().join(name);
123        if path.exists() {
124            return Err(DotAgentError::ProfileAlreadyExists {
125                name: name.to_string(),
126            });
127        }
128
129        fs::create_dir_all(&path)?;
130        Ok(Profile::new(name.to_string(), path))
131    }
132
133    /// Remove a profile
134    pub fn remove_profile(&self, name: &str) -> Result<()> {
135        let profile = self.get_profile(name)?;
136        fs::remove_dir_all(&profile.path)?;
137        Ok(())
138    }
139
140    /// Import a directory as a profile
141    pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
142        validate_profile_name(name)?;
143
144        if !source.exists() {
145            return Err(DotAgentError::TargetNotFound {
146                path: source.to_path_buf(),
147            });
148        }
149
150        let dest = self.profiles_dir().join(name);
151
152        if dest.exists() {
153            if !force {
154                return Err(DotAgentError::ProfileAlreadyExists {
155                    name: name.to_string(),
156                });
157            }
158            fs::remove_dir_all(&dest)?;
159        }
160
161        // Ensure profiles directory exists
162        fs::create_dir_all(self.profiles_dir())?;
163
164        // Copy directory recursively
165        copy_dir_recursive(source, &dest)?;
166
167        Ok(Profile::new(name.to_string(), dest))
168    }
169}
170
171fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
172    fs::create_dir_all(dst)?;
173
174    for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
175        let src_path = entry.path();
176        let relative = src_path.strip_prefix(src).unwrap();
177        let dst_path = dst.join(relative);
178
179        if src_path.is_dir() {
180            fs::create_dir_all(&dst_path)?;
181        } else if src_path.is_file() && !should_ignore(src_path) {
182            if let Some(parent) = dst_path.parent() {
183                fs::create_dir_all(parent)?;
184            }
185            fs::copy(src_path, &dst_path)?;
186        }
187    }
188
189    Ok(())
190}
191
192fn validate_profile_name(name: &str) -> Result<()> {
193    if name.is_empty() || name.len() > 64 {
194        return Err(DotAgentError::InvalidProfileName {
195            name: name.to_string(),
196        });
197    }
198
199    let first_char = name.chars().next().unwrap();
200    if !first_char.is_ascii_alphabetic() {
201        return Err(DotAgentError::InvalidProfileName {
202            name: name.to_string(),
203        });
204    }
205
206    for c in name.chars() {
207        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
208            return Err(DotAgentError::InvalidProfileName {
209                name: name.to_string(),
210            });
211        }
212    }
213
214    Ok(())
215}
216
217fn should_ignore(path: &Path) -> bool {
218    if let Some(name) = path.file_name() {
219        let name = name.to_string_lossy();
220        if IGNORED_FILES.contains(&name.as_ref()) {
221            return true;
222        }
223    }
224
225    if let Some(ext) = path.extension() {
226        let ext = ext.to_string_lossy();
227        if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
228            return true;
229        }
230    }
231
232    false
233}