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
257/// Profile info section in .dot-agent.toml
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ProfileInfo {
260    /// Profile name
261    pub name: String,
262
263    /// Version
264    #[serde(default)]
265    pub version: Option<String>,
266
267    /// Description
268    #[serde(default)]
269    pub description: Option<String>,
270
271    /// Author
272    #[serde(default)]
273    pub author: Option<String>,
274}
275
276impl ProfileMetadata {
277    /// Create new local profile metadata
278    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    /// Create new git profile metadata
292    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    /// Create new marketplace profile metadata
317    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    /// Load from profile directory
335    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    /// Save to profile directory
352    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    /// Check if profile has plugin features (hooks, MCP, LSP)
365    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
376/// Migration helper: scan existing profiles and create index
377pub 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        // Check if .dot-agent.toml exists
401        if let Some(metadata) = ProfileMetadata::load(&path)? {
402            // Use existing metadata
403            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            // Create default local entry and metadata
426            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        // 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}