1use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{DotAgentError, Result};
15
16const PROFILES_INDEX_FILE: &str = "profiles.toml";
17const PROFILE_METADATA_FILE: &str = ".dot-agent.toml";
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(tag = "type", rename_all = "kebab-case")]
22pub enum ProfileSource {
23 #[default]
25 Local,
26
27 Git {
29 url: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 branch: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 commit: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 path: Option<String>,
36 },
37
38 Marketplace {
40 channel: String,
41 plugin: String,
42 version: String,
43 },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProfileIndexEntry {
49 pub path: String,
51
52 pub source: ProfileSource,
54
55 pub created_at: String,
57
58 pub updated_at: String,
60}
61
62impl ProfileIndexEntry {
63 pub fn new_local(name: &str) -> Self {
65 let now = chrono::Utc::now().to_rfc3339();
66 Self {
67 path: format!("profiles/{}", name),
68 source: ProfileSource::Local,
69 created_at: now.clone(),
70 updated_at: now,
71 }
72 }
73
74 pub fn new_git(
76 name: &str,
77 url: &str,
78 branch: Option<&str>,
79 commit: Option<&str>,
80 path: Option<&str>,
81 ) -> Self {
82 let now = chrono::Utc::now().to_rfc3339();
83 Self {
84 path: format!("profiles/{}", name),
85 source: ProfileSource::Git {
86 url: url.to_string(),
87 branch: branch.map(|s| s.to_string()),
88 commit: commit.map(|s| s.to_string()),
89 path: path.map(|s| s.to_string()),
90 },
91 created_at: now.clone(),
92 updated_at: now,
93 }
94 }
95
96 pub fn new_marketplace(name: &str, channel: &str, plugin: &str, version: &str) -> Self {
98 let now = chrono::Utc::now().to_rfc3339();
99 Self {
100 path: format!("profiles/{}", name),
101 source: ProfileSource::Marketplace {
102 channel: channel.to_string(),
103 plugin: plugin.to_string(),
104 version: version.to_string(),
105 },
106 created_at: now.clone(),
107 updated_at: now,
108 }
109 }
110
111 pub fn touch(&mut self) {
113 self.updated_at = chrono::Utc::now().to_rfc3339();
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ProfilesIndex {
120 #[serde(default = "default_version")]
122 pub version: u32,
123
124 #[serde(default)]
126 pub profiles: HashMap<String, ProfileIndexEntry>,
127}
128
129fn default_version() -> u32 {
130 1
131}
132
133impl Default for ProfilesIndex {
134 fn default() -> Self {
135 Self {
136 version: 1,
137 profiles: HashMap::new(),
138 }
139 }
140}
141
142impl ProfilesIndex {
143 pub fn load(base_dir: &Path) -> Result<Self> {
145 let path = base_dir.join(PROFILES_INDEX_FILE);
146
147 if !path.exists() {
148 return Ok(Self::default());
149 }
150
151 let content = fs::read_to_string(&path)?;
152 let index: Self = toml::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
153 path: path.clone(),
154 message: e.to_string(),
155 })?;
156
157 Ok(index)
158 }
159
160 pub fn save(&self, base_dir: &Path) -> Result<()> {
162 let path = base_dir.join(PROFILES_INDEX_FILE);
163
164 if let Some(parent) = path.parent() {
166 fs::create_dir_all(parent)?;
167 }
168
169 let content = toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParse {
170 path: path.clone(),
171 message: e.to_string(),
172 })?;
173
174 fs::write(&path, content)?;
175 Ok(())
176 }
177
178 pub fn upsert(&mut self, name: &str, entry: ProfileIndexEntry) {
180 self.profiles.insert(name.to_string(), entry);
181 }
182
183 pub fn remove(&mut self, name: &str) -> Option<ProfileIndexEntry> {
185 self.profiles.remove(name)
186 }
187
188 pub fn get(&self, name: &str) -> Option<&ProfileIndexEntry> {
190 self.profiles.get(name)
191 }
192
193 pub fn contains(&self, name: &str) -> bool {
195 self.profiles.contains_key(name)
196 }
197
198 pub fn names(&self) -> Vec<&str> {
200 let mut names: Vec<_> = self.profiles.keys().map(|s| s.as_str()).collect();
201 names.sort();
202 names
203 }
204}
205
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct PluginConfig {
209 #[serde(default = "default_true")]
211 pub enabled: bool,
212
213 #[serde(default)]
215 pub scope: PluginScope,
216}
217
218fn default_true() -> bool {
219 true
220}
221
222#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum PluginScope {
226 #[default]
227 User,
228 Project,
229 Local,
230}
231
232impl std::fmt::Display for PluginScope {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 match self {
235 Self::User => write!(f, "user"),
236 Self::Project => write!(f, "project"),
237 Self::Local => write!(f, "local"),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ProfileMetadata {
245 pub profile: ProfileInfo,
247
248 #[serde(default)]
250 pub source: ProfileSource,
251
252 #[serde(default)]
254 pub plugin: PluginConfig,
255
256 #[serde(default)]
258 pub categories: Option<crate::category::CategoriesConfig>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ProfileInfo {
264 pub name: String,
266
267 #[serde(default)]
269 pub version: Option<String>,
270
271 #[serde(default)]
273 pub description: Option<String>,
274
275 #[serde(default)]
277 pub author: Option<String>,
278}
279
280impl ProfileMetadata {
281 pub fn new_local(name: &str) -> Self {
283 Self {
284 profile: ProfileInfo {
285 name: name.to_string(),
286 version: Some("0.1.0".to_string()),
287 description: None,
288 author: None,
289 },
290 source: ProfileSource::Local,
291 plugin: PluginConfig::default(),
292 categories: None,
293 }
294 }
295
296 pub fn new_git(
298 name: &str,
299 url: &str,
300 branch: Option<&str>,
301 commit: Option<&str>,
302 path: Option<&str>,
303 ) -> Self {
304 Self {
305 profile: ProfileInfo {
306 name: name.to_string(),
307 version: None,
308 description: None,
309 author: None,
310 },
311 source: ProfileSource::Git {
312 url: url.to_string(),
313 branch: branch.map(|s| s.to_string()),
314 commit: commit.map(|s| s.to_string()),
315 path: path.map(|s| s.to_string()),
316 },
317 plugin: PluginConfig::default(),
318 categories: None,
319 }
320 }
321
322 pub fn new_marketplace(name: &str, channel: &str, plugin: &str, version: &str) -> Self {
324 Self {
325 profile: ProfileInfo {
326 name: name.to_string(),
327 version: Some(version.to_string()),
328 description: None,
329 author: None,
330 },
331 source: ProfileSource::Marketplace {
332 channel: channel.to_string(),
333 plugin: plugin.to_string(),
334 version: version.to_string(),
335 },
336 plugin: PluginConfig::default(),
337 categories: None,
338 }
339 }
340
341 pub fn load(profile_dir: &Path) -> Result<Option<Self>> {
343 let path = profile_dir.join(PROFILE_METADATA_FILE);
344
345 if !path.exists() {
346 return Ok(None);
347 }
348
349 let content = fs::read_to_string(&path)?;
350 let metadata: Self = toml::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
351 path: path.clone(),
352 message: e.to_string(),
353 })?;
354
355 Ok(Some(metadata))
356 }
357
358 pub fn save(&self, profile_dir: &Path) -> Result<()> {
360 let path = profile_dir.join(PROFILE_METADATA_FILE);
361
362 let content = toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParse {
363 path: path.clone(),
364 message: e.to_string(),
365 })?;
366
367 fs::write(&path, content)?;
368 Ok(())
369 }
370
371 pub fn has_plugin_features(profile_dir: &Path) -> bool {
373 let hooks_dir = profile_dir.join("hooks");
374 let mcp_file = profile_dir.join(".mcp.json");
375 let lsp_file = profile_dir.join(".lsp.json");
376
377 (hooks_dir.exists() && hooks_dir.is_dir()) || mcp_file.exists() || lsp_file.exists()
378 }
379}
380
381pub fn migrate_existing_profiles(base_dir: &Path) -> Result<ProfilesIndex> {
383 let profiles_dir = base_dir.join("profiles");
384 let mut index = ProfilesIndex::default();
385
386 if !profiles_dir.exists() {
387 return Ok(index);
388 }
389
390 for entry in fs::read_dir(&profiles_dir)? {
391 let entry = entry?;
392 let path = entry.path();
393
394 if !path.is_dir() {
395 continue;
396 }
397
398 let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
399 DotAgentError::InvalidProfileName {
400 name: path.display().to_string(),
401 }
402 })?;
403
404 if let Some(metadata) = ProfileMetadata::load(&path)? {
406 let entry = match &metadata.source {
408 ProfileSource::Local => ProfileIndexEntry::new_local(name),
409 ProfileSource::Git {
410 url,
411 branch,
412 commit,
413 path,
414 } => ProfileIndexEntry::new_git(
415 name,
416 url,
417 branch.as_deref(),
418 commit.as_deref(),
419 path.as_deref(),
420 ),
421 ProfileSource::Marketplace {
422 channel,
423 plugin,
424 version,
425 } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
426 };
427 index.upsert(name, entry);
428 } else {
429 let entry = ProfileIndexEntry::new_local(name);
431 let metadata = ProfileMetadata::new_local(name);
432 metadata.save(&path)?;
433 index.upsert(name, entry);
434 }
435 }
436
437 index.save(base_dir)?;
438 Ok(index)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use tempfile::TempDir;
445
446 #[test]
447 fn profile_source_local_serialization() {
448 let source = ProfileSource::Local;
449 let toml = toml::to_string(&source).unwrap();
450 assert!(toml.contains("type = \"local\""));
451 }
452
453 #[test]
454 fn profile_source_git_serialization() {
455 let source = ProfileSource::Git {
456 url: "https://github.com/user/repo".to_string(),
457 branch: Some("main".to_string()),
458 commit: Some("abc123".to_string()),
459 path: None,
460 };
461 let toml = toml::to_string(&source).unwrap();
462 assert!(toml.contains("type = \"git\""));
463 assert!(toml.contains("url = "));
464 assert!(toml.contains("branch = "));
465 }
466
467 #[test]
468 fn profile_source_marketplace_serialization() {
469 let source = ProfileSource::Marketplace {
470 channel: "claude-official".to_string(),
471 plugin: "rust-lsp".to_string(),
472 version: "1.0.0".to_string(),
473 };
474 let toml = toml::to_string(&source).unwrap();
475 assert!(toml.contains("type = \"marketplace\""));
476 assert!(toml.contains("channel = "));
477 }
478
479 #[test]
480 fn profiles_index_save_load() {
481 let tmp = TempDir::new().unwrap();
482 let base_dir = tmp.path();
483
484 let mut index = ProfilesIndex::default();
485 index.upsert("test-profile", ProfileIndexEntry::new_local("test-profile"));
486
487 index.save(base_dir).unwrap();
488
489 let loaded = ProfilesIndex::load(base_dir).unwrap();
490 assert!(loaded.contains("test-profile"));
491 }
492
493 #[test]
494 fn profile_metadata_save_load() {
495 let tmp = TempDir::new().unwrap();
496 let profile_dir = tmp.path();
497
498 let metadata = ProfileMetadata::new_local("test");
499 metadata.save(profile_dir).unwrap();
500
501 let loaded = ProfileMetadata::load(profile_dir).unwrap();
502 assert!(loaded.is_some());
503 assert_eq!(loaded.unwrap().profile.name, "test");
504 }
505
506 #[test]
507 fn profile_metadata_git() {
508 let metadata = ProfileMetadata::new_git(
509 "dotfiles",
510 "https://github.com/user/dotfiles",
511 Some("main"),
512 Some("abc123"),
513 None,
514 );
515
516 assert!(matches!(metadata.source, ProfileSource::Git { .. }));
517 }
518
519 #[test]
520 fn profile_metadata_marketplace() {
521 let metadata =
522 ProfileMetadata::new_marketplace("rust-lsp", "claude-official", "rust-lsp", "1.0.0");
523
524 assert!(matches!(metadata.source, ProfileSource::Marketplace { .. }));
525 }
526
527 #[test]
528 fn has_plugin_features_detects_hooks() {
529 let tmp = TempDir::new().unwrap();
530 let profile_dir = tmp.path();
531
532 assert!(!ProfileMetadata::has_plugin_features(profile_dir));
534
535 fs::create_dir(profile_dir.join("hooks")).unwrap();
537 assert!(ProfileMetadata::has_plugin_features(profile_dir));
538 }
539
540 #[test]
541 fn has_plugin_features_detects_mcp() {
542 let tmp = TempDir::new().unwrap();
543 let profile_dir = tmp.path();
544
545 fs::write(profile_dir.join(".mcp.json"), "{}").unwrap();
547 assert!(ProfileMetadata::has_plugin_features(profile_dir));
548 }
549}