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()) || mcp_file.exists() || lsp_file.exists()
371    }
372}
373
374/// Migration helper: scan existing profiles and create index
375pub fn migrate_existing_profiles(base_dir: &Path) -> Result<ProfilesIndex> {
376    let profiles_dir = base_dir.join("profiles");
377    let mut index = ProfilesIndex::default();
378
379    if !profiles_dir.exists() {
380        return Ok(index);
381    }
382
383    for entry in fs::read_dir(&profiles_dir)? {
384        let entry = entry?;
385        let path = entry.path();
386
387        if !path.is_dir() {
388            continue;
389        }
390
391        let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| {
392            DotAgentError::InvalidProfileName {
393                name: path.display().to_string(),
394            }
395        })?;
396
397        // Check if .dot-agent.toml exists
398        if let Some(metadata) = ProfileMetadata::load(&path)? {
399            // Use existing metadata
400            let entry = match &metadata.source {
401                ProfileSource::Local => ProfileIndexEntry::new_local(name),
402                ProfileSource::Git {
403                    url,
404                    branch,
405                    commit,
406                    path,
407                } => ProfileIndexEntry::new_git(
408                    name,
409                    url,
410                    branch.as_deref(),
411                    commit.as_deref(),
412                    path.as_deref(),
413                ),
414                ProfileSource::Marketplace {
415                    channel,
416                    plugin,
417                    version,
418                } => ProfileIndexEntry::new_marketplace(name, channel, plugin, version),
419            };
420            index.upsert(name, entry);
421        } else {
422            // Create default local entry and metadata
423            let entry = ProfileIndexEntry::new_local(name);
424            let metadata = ProfileMetadata::new_local(name);
425            metadata.save(&path)?;
426            index.upsert(name, entry);
427        }
428    }
429
430    index.save(base_dir)?;
431    Ok(index)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use tempfile::TempDir;
438
439    #[test]
440    fn profile_source_local_serialization() {
441        let source = ProfileSource::Local;
442        let toml = toml::to_string(&source).unwrap();
443        assert!(toml.contains("type = \"local\""));
444    }
445
446    #[test]
447    fn profile_source_git_serialization() {
448        let source = ProfileSource::Git {
449            url: "https://github.com/user/repo".to_string(),
450            branch: Some("main".to_string()),
451            commit: Some("abc123".to_string()),
452            path: None,
453        };
454        let toml = toml::to_string(&source).unwrap();
455        assert!(toml.contains("type = \"git\""));
456        assert!(toml.contains("url = "));
457        assert!(toml.contains("branch = "));
458    }
459
460    #[test]
461    fn profile_source_marketplace_serialization() {
462        let source = ProfileSource::Marketplace {
463            channel: "claude-official".to_string(),
464            plugin: "rust-lsp".to_string(),
465            version: "1.0.0".to_string(),
466        };
467        let toml = toml::to_string(&source).unwrap();
468        assert!(toml.contains("type = \"marketplace\""));
469        assert!(toml.contains("channel = "));
470    }
471
472    #[test]
473    fn profiles_index_save_load() {
474        let tmp = TempDir::new().unwrap();
475        let base_dir = tmp.path();
476
477        let mut index = ProfilesIndex::default();
478        index.upsert("test-profile", ProfileIndexEntry::new_local("test-profile"));
479
480        index.save(base_dir).unwrap();
481
482        let loaded = ProfilesIndex::load(base_dir).unwrap();
483        assert!(loaded.contains("test-profile"));
484    }
485
486    #[test]
487    fn profile_metadata_save_load() {
488        let tmp = TempDir::new().unwrap();
489        let profile_dir = tmp.path();
490
491        let metadata = ProfileMetadata::new_local("test");
492        metadata.save(profile_dir).unwrap();
493
494        let loaded = ProfileMetadata::load(profile_dir).unwrap();
495        assert!(loaded.is_some());
496        assert_eq!(loaded.unwrap().profile.name, "test");
497    }
498
499    #[test]
500    fn profile_metadata_git() {
501        let metadata = ProfileMetadata::new_git(
502            "dotfiles",
503            "https://github.com/user/dotfiles",
504            Some("main"),
505            Some("abc123"),
506            None,
507        );
508
509        assert!(matches!(metadata.source, ProfileSource::Git { .. }));
510    }
511
512    #[test]
513    fn profile_metadata_marketplace() {
514        let metadata =
515            ProfileMetadata::new_marketplace("rust-lsp", "claude-official", "rust-lsp", "1.0.0");
516
517        assert!(matches!(metadata.source, ProfileSource::Marketplace { .. }));
518    }
519
520    #[test]
521    fn has_plugin_features_detects_hooks() {
522        let tmp = TempDir::new().unwrap();
523        let profile_dir = tmp.path();
524
525        // No hooks initially
526        assert!(!ProfileMetadata::has_plugin_features(profile_dir));
527
528        // Create hooks directory
529        fs::create_dir(profile_dir.join("hooks")).unwrap();
530        assert!(ProfileMetadata::has_plugin_features(profile_dir));
531    }
532
533    #[test]
534    fn has_plugin_features_detects_mcp() {
535        let tmp = TempDir::new().unwrap();
536        let profile_dir = tmp.path();
537
538        // Create .mcp.json
539        fs::write(profile_dir.join(".mcp.json"), "{}").unwrap();
540        assert!(ProfileMetadata::has_plugin_features(profile_dir));
541    }
542}