Skip to main content

dot_agent_core/profile/
metadata.rs

1//! Profile Metadata Management
2//!
3//! # Files
4//!
5//! - `~/.dot-agent/profiles.toml` - Profile index (all profiles)
6//! - `~/.dot-agent/profiles/<name>/.dot-agent.toml` - Per-profile metadata
7
8use 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/// Profile source specification
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(tag = "type", rename_all = "kebab-case")]
22pub enum ProfileSource {
23    /// Locally created profile
24    #[default]
25    Local,
26
27    /// Imported from Git repository
28    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    /// Imported from Claude Code Plugin Marketplace
39    Marketplace {
40        channel: String,
41        plugin: String,
42        version: String,
43    },
44}
45
46/// Profile entry in profiles.toml
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProfileIndexEntry {
49    /// Relative path from base_dir (e.g., "profiles/my-profile")
50    pub path: String,
51
52    /// Source information
53    pub source: ProfileSource,
54
55    /// Creation timestamp
56    pub created_at: String,
57
58    /// Last update timestamp
59    pub updated_at: String,
60}
61
62impl ProfileIndexEntry {
63    /// Create a new local profile entry
64    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    /// Create a new git profile entry
75    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    /// Create a new marketplace profile entry
97    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    /// Update timestamp
112    pub fn touch(&mut self) {
113        self.updated_at = chrono::Utc::now().to_rfc3339();
114    }
115}
116
117/// profiles.toml structure
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ProfilesIndex {
120    /// File format version
121    #[serde(default = "default_version")]
122    pub version: u32,
123
124    /// Profiles map: name -> entry
125    #[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    /// Load from file, creating default if not exists
144    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    /// Save to file
161    pub fn save(&self, base_dir: &Path) -> Result<()> {
162        let path = base_dir.join(PROFILES_INDEX_FILE);
163
164        // Ensure directory exists
165        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    /// Add or update a profile entry
179    pub fn upsert(&mut self, name: &str, entry: ProfileIndexEntry) {
180        self.profiles.insert(name.to_string(), entry);
181    }
182
183    /// Remove a profile entry
184    pub fn remove(&mut self, name: &str) -> Option<ProfileIndexEntry> {
185        self.profiles.remove(name)
186    }
187
188    /// Get a profile entry
189    pub fn get(&self, name: &str) -> Option<&ProfileIndexEntry> {
190        self.profiles.get(name)
191    }
192
193    /// Check if profile exists
194    pub fn contains(&self, name: &str) -> bool {
195        self.profiles.contains_key(name)
196    }
197
198    /// List all profile names
199    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/// Plugin configuration in profile metadata
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct PluginConfig {
209    /// Whether plugin features are enabled (hooks, MCP, LSP)
210    #[serde(default = "default_true")]
211    pub enabled: bool,
212
213    /// Installation scope (user, project, local)
214    #[serde(default)]
215    pub scope: PluginScope,
216}
217
218fn default_true() -> bool {
219    true
220}
221
222/// Plugin installation scope
223#[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/// .dot-agent.toml structure (per-profile metadata)
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ProfileMetadata {
245    /// Profile section
246    pub profile: ProfileInfo,
247
248    /// Source information
249    #[serde(default)]
250    pub source: ProfileSource,
251
252    /// Plugin configuration
253    #[serde(default)]
254    pub plugin: PluginConfig,
255
256    /// Category configurations (overrides builtin categories)
257    #[serde(default)]
258    pub categories: Option<crate::category::CategoriesConfig>,
259}
260
261/// Profile info section in .dot-agent.toml
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ProfileInfo {
264    /// Profile name
265    pub name: String,
266
267    /// Version
268    #[serde(default)]
269    pub version: Option<String>,
270
271    /// Description
272    #[serde(default)]
273    pub description: Option<String>,
274
275    /// Author
276    #[serde(default)]
277    pub author: Option<String>,
278}
279
280impl ProfileMetadata {
281    /// Create new local profile metadata
282    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    /// Create new git profile metadata
297    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    /// Create new marketplace profile metadata
323    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    /// Load from profile directory
342    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    /// Save to profile directory
359    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    /// Check if profile has plugin features (hooks, MCP, LSP)
372    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
381/// Migration helper: scan existing profiles and create index
382pub 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        // Check if .dot-agent.toml exists
405        if let Some(metadata) = ProfileMetadata::load(&path)? {
406            // Use existing metadata
407            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            // Create default local entry and metadata
430            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        // No hooks initially
533        assert!(!ProfileMetadata::has_plugin_features(profile_dir));
534
535        // Create hooks directory
536        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        // Create .mcp.json
546        fs::write(profile_dir.join(".mcp.json"), "{}").unwrap();
547        assert!(ProfileMetadata::has_plugin_features(profile_dir));
548    }
549}