dot_agent_core/
profile.rs1use 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 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 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 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 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 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 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 pub fn copy_profile(&self, source_name: &str, dest_name: &str, force: bool) -> Result<Profile> {
142 let source = self.get_profile(source_name)?;
143 validate_profile_name(dest_name)?;
144
145 let dest_path = self.profiles_dir().join(dest_name);
146
147 if dest_path.exists() {
148 if !force {
149 return Err(DotAgentError::ProfileAlreadyExists {
150 name: dest_name.to_string(),
151 });
152 }
153 fs::remove_dir_all(&dest_path)?;
154 }
155
156 copy_dir_recursive(&source.path, &dest_path)?;
157
158 Ok(Profile::new(dest_name.to_string(), dest_path))
159 }
160
161 pub fn import_profile(&self, source: &Path, name: &str, force: bool) -> Result<Profile> {
163 validate_profile_name(name)?;
164
165 if !source.exists() {
166 return Err(DotAgentError::TargetNotFound {
167 path: source.to_path_buf(),
168 });
169 }
170
171 let dest = self.profiles_dir().join(name);
172
173 if dest.exists() {
174 if !force {
175 return Err(DotAgentError::ProfileAlreadyExists {
176 name: name.to_string(),
177 });
178 }
179 fs::remove_dir_all(&dest)?;
180 }
181
182 fs::create_dir_all(self.profiles_dir())?;
184
185 copy_dir_recursive(source, &dest)?;
187
188 Ok(Profile::new(name.to_string(), dest))
189 }
190}
191
192fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
193 fs::create_dir_all(dst)?;
194
195 for entry in WalkDir::new(src).into_iter().filter_map(|e| e.ok()) {
196 let src_path = entry.path();
197 let relative = src_path.strip_prefix(src).unwrap();
198 let dst_path = dst.join(relative);
199
200 if src_path.is_dir() {
201 fs::create_dir_all(&dst_path)?;
202 } else if src_path.is_file() && !should_ignore(src_path) {
203 if let Some(parent) = dst_path.parent() {
204 fs::create_dir_all(parent)?;
205 }
206 fs::copy(src_path, &dst_path)?;
207 }
208 }
209
210 Ok(())
211}
212
213fn validate_profile_name(name: &str) -> Result<()> {
214 if name.is_empty() || name.len() > 64 {
215 return Err(DotAgentError::InvalidProfileName {
216 name: name.to_string(),
217 });
218 }
219
220 let first_char = name.chars().next().unwrap();
221 if !first_char.is_ascii_alphabetic() {
222 return Err(DotAgentError::InvalidProfileName {
223 name: name.to_string(),
224 });
225 }
226
227 for c in name.chars() {
228 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
229 return Err(DotAgentError::InvalidProfileName {
230 name: name.to_string(),
231 });
232 }
233 }
234
235 Ok(())
236}
237
238fn should_ignore(path: &Path) -> bool {
239 if let Some(name) = path.file_name() {
240 let name = name.to_string_lossy();
241 if IGNORED_FILES.contains(&name.as_ref()) {
242 return true;
243 }
244 }
245
246 if let Some(ext) = path.extension() {
247 let ext = ext.to_string_lossy();
248 if IGNORED_EXTENSIONS.contains(&ext.as_ref()) {
249 return true;
250 }
251 }
252
253 false
254}