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
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ProfileInfo {
260 pub name: String,
262
263 #[serde(default)]
265 pub version: Option<String>,
266
267 #[serde(default)]
269 pub description: Option<String>,
270
271 #[serde(default)]
273 pub author: Option<String>,
274}
275
276impl ProfileMetadata {
277 pub fn new_local(name: &str) -> Self {
279 Self {
280 profile: ProfileInfo {
281 name: name.to_string(),
282 version: Some("0.1.0".to_string()),
283 description: None,
284 author: None,
285 },
286 source: ProfileSource::Local,
287 plugin: PluginConfig::default(),
288 }
289 }
290
291 pub fn new_git(
293 name: &str,
294 url: &str,
295 branch: Option<&str>,
296 commit: Option<&str>,
297 path: Option<&str>,
298 ) -> Self {
299 Self {
300 profile: ProfileInfo {
301 name: name.to_string(),
302 version: None,
303 description: None,
304 author: None,
305 },
306 source: ProfileSource::Git {
307 url: url.to_string(),
308 branch: branch.map(|s| s.to_string()),
309 commit: commit.map(|s| s.to_string()),
310 path: path.map(|s| s.to_string()),
311 },
312 plugin: PluginConfig::default(),
313 }
314 }
315
316 pub fn new_marketplace(name: &str, channel: &str, plugin: &str, version: &str) -> Self {
318 Self {
319 profile: ProfileInfo {
320 name: name.to_string(),
321 version: Some(version.to_string()),
322 description: None,
323 author: None,
324 },
325 source: ProfileSource::Marketplace {
326 channel: channel.to_string(),
327 plugin: plugin.to_string(),
328 version: version.to_string(),
329 },
330 plugin: PluginConfig::default(),
331 }
332 }
333
334 pub fn load(profile_dir: &Path) -> Result<Option<Self>> {
336 let path = profile_dir.join(PROFILE_METADATA_FILE);
337
338 if !path.exists() {
339 return Ok(None);
340 }
341
342 let content = fs::read_to_string(&path)?;
343 let metadata: Self = toml::from_str(&content).map_err(|e| DotAgentError::ConfigParse {
344 path: path.clone(),
345 message: e.to_string(),
346 })?;
347
348 Ok(Some(metadata))
349 }
350
351 pub fn save(&self, profile_dir: &Path) -> Result<()> {
353 let path = profile_dir.join(PROFILE_METADATA_FILE);
354
355 let content = toml::to_string_pretty(self).map_err(|e| DotAgentError::ConfigParse {
356 path: path.clone(),
357 message: e.to_string(),
358 })?;
359
360 fs::write(&path, content)?;
361 Ok(())
362 }
363
364 pub fn has_plugin_features(profile_dir: &Path) -> bool {
366 let hooks_dir = profile_dir.join("hooks");
367 let mcp_file = profile_dir.join(".mcp.json");
368 let lsp_file = profile_dir.join(".lsp.json");
369
370 (hooks_dir.exists() && hooks_dir.is_dir())
371 || mcp_file.exists()
372 || lsp_file.exists()
373 }
374}
375
376pub fn migrate_existing_profiles(base_dir: &Path) -> Result<ProfilesIndex> {
378 let profiles_dir = base_dir.join("profiles");
379 let mut index = ProfilesIndex::default();
380
381 if !profiles_dir.exists() {
382 return Ok(index);
383 }
384
385 for entry in fs::read_dir(&profiles_dir)? {
386 let entry = entry?;
387 let path = entry.path();
388
389 if !path.is_dir() {
390 continue;
391 }
392
393 let name = path
394 .file_name()
395 .and_then(|n| n.to_str())
396 .ok_or_else(|| DotAgentError::InvalidProfileName {
397 name: path.display().to_string(),
398 })?;
399
400 if let Some(metadata) = ProfileMetadata::load(&path)? {
402 let entry = match &metadata.source {
404 ProfileSource::Local => ProfileIndexEntry::new_local(name),
405 ProfileSource::Git {
406 url,
407 branch,
408 commit,
409 path,
410 } => ProfileIndexEntry::new_git(
411 name,
412 url,
413 branch.as_deref(),
414 commit.as_deref(),
415 path.as_deref(),
416 ),
417 ProfileSource::Marketplace {
418 channel,
419 plugin,
420 version,
421 } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
422 };
423 index.upsert(name, entry);
424 } else {
425 let entry = ProfileIndexEntry::new_local(name);
427 let metadata = ProfileMetadata::new_local(name);
428 metadata.save(&path)?;
429 index.upsert(name, entry);
430 }
431 }
432
433 index.save(base_dir)?;
434 Ok(index)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use tempfile::TempDir;
441
442 #[test]
443 fn profile_source_local_serialization() {
444 let source = ProfileSource::Local;
445 let toml = toml::to_string(&source).unwrap();
446 assert!(toml.contains("type = \"local\""));
447 }
448
449 #[test]
450 fn profile_source_git_serialization() {
451 let source = ProfileSource::Git {
452 url: "https://github.com/user/repo".to_string(),
453 branch: Some("main".to_string()),
454 commit: Some("abc123".to_string()),
455 path: None,
456 };
457 let toml = toml::to_string(&source).unwrap();
458 assert!(toml.contains("type = \"git\""));
459 assert!(toml.contains("url = "));
460 assert!(toml.contains("branch = "));
461 }
462
463 #[test]
464 fn profile_source_marketplace_serialization() {
465 let source = ProfileSource::Marketplace {
466 channel: "claude-official".to_string(),
467 plugin: "rust-lsp".to_string(),
468 version: "1.0.0".to_string(),
469 };
470 let toml = toml::to_string(&source).unwrap();
471 assert!(toml.contains("type = \"marketplace\""));
472 assert!(toml.contains("channel = "));
473 }
474
475 #[test]
476 fn profiles_index_save_load() {
477 let tmp = TempDir::new().unwrap();
478 let base_dir = tmp.path();
479
480 let mut index = ProfilesIndex::default();
481 index.upsert("test-profile", ProfileIndexEntry::new_local("test-profile"));
482
483 index.save(base_dir).unwrap();
484
485 let loaded = ProfilesIndex::load(base_dir).unwrap();
486 assert!(loaded.contains("test-profile"));
487 }
488
489 #[test]
490 fn profile_metadata_save_load() {
491 let tmp = TempDir::new().unwrap();
492 let profile_dir = tmp.path();
493
494 let metadata = ProfileMetadata::new_local("test");
495 metadata.save(profile_dir).unwrap();
496
497 let loaded = ProfileMetadata::load(profile_dir).unwrap();
498 assert!(loaded.is_some());
499 assert_eq!(loaded.unwrap().profile.name, "test");
500 }
501
502 #[test]
503 fn profile_metadata_git() {
504 let metadata = ProfileMetadata::new_git(
505 "dotfiles",
506 "https://github.com/user/dotfiles",
507 Some("main"),
508 Some("abc123"),
509 None,
510 );
511
512 assert!(matches!(metadata.source, ProfileSource::Git { .. }));
513 }
514
515 #[test]
516 fn profile_metadata_marketplace() {
517 let metadata = ProfileMetadata::new_marketplace(
518 "rust-lsp",
519 "claude-official",
520 "rust-lsp",
521 "1.0.0",
522 );
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}