Skip to main content

modde_core/
instance.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5/// A modde instance — a self-contained data directory.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Instance {
8    pub name: String,
9    pub data_dir: PathBuf,
10    #[serde(default)]
11    pub is_default: bool,
12}
13
14/// Instance registry stored at ~/.config/modde/instances.toml
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct InstanceRegistry {
17    #[serde(default)]
18    pub instances: Vec<Instance>,
19    #[serde(default)]
20    pub active: Option<String>,
21}
22
23impl InstanceRegistry {
24    /// Load the registry from the default config location.
25    #[must_use]
26    pub fn load() -> Self {
27        let path = registry_path();
28        if !path.exists() {
29            return Self::default();
30        }
31        std::fs::read_to_string(&path)
32            .ok()
33            .and_then(|s| toml::from_str(&s).ok())
34            .unwrap_or_default()
35    }
36
37    /// Save the registry.
38    pub fn save(&self) -> crate::error::Result<()> {
39        let path = registry_path();
40        if let Some(parent) = path.parent() {
41            std::fs::create_dir_all(parent)?;
42        }
43        let content = toml::to_string_pretty(self)?;
44        std::fs::write(&path, content)?;
45        Ok(())
46    }
47
48    /// Create a new instance.
49    pub fn create(&mut self, name: &str, data_dir: PathBuf) -> crate::error::Result<()> {
50        if self.instances.iter().any(|i| i.name == name) {
51            return Err(crate::error::CoreError::Validation(
52                format!("instance '{name}' already exists").into(),
53            ));
54        }
55        std::fs::create_dir_all(&data_dir)?;
56        self.instances.push(Instance {
57            name: name.to_string(),
58            data_dir,
59            is_default: self.instances.is_empty(),
60        });
61        if self.active.is_none() {
62            self.active = Some(name.to_string());
63        }
64        self.save()?;
65        Ok(())
66    }
67
68    /// Switch to a different instance.
69    pub fn switch(&mut self, name: &str) -> crate::error::Result<()> {
70        if !self.instances.iter().any(|i| i.name == name) {
71            return Err(crate::error::CoreError::Validation(
72                format!("instance '{name}' not found").into(),
73            ));
74        }
75        self.active = Some(name.to_string());
76        self.save()?;
77        Ok(())
78    }
79
80    /// Get the active instance's data directory.
81    #[must_use]
82    pub fn active_data_dir(&self) -> Option<&Path> {
83        let name = self.active.as_ref()?;
84        self.instances
85            .iter()
86            .find(|i| &i.name == name)
87            .map(|i| i.data_dir.as_path())
88    }
89
90    /// List all instances.
91    #[must_use]
92    pub fn list(&self) -> &[Instance] {
93        &self.instances
94    }
95
96    /// Load the registry from a specific file path (for testing).
97    #[must_use]
98    pub fn load_from(path: &Path) -> Self {
99        if !path.exists() {
100            return Self::default();
101        }
102        std::fs::read_to_string(path)
103            .ok()
104            .and_then(|s| toml::from_str(&s).ok())
105            .unwrap_or_default()
106    }
107
108    /// Save the registry to a specific file path (for testing).
109    pub fn save_to(&self, path: &Path) -> crate::error::Result<()> {
110        if let Some(parent) = path.parent() {
111            std::fs::create_dir_all(parent)?;
112        }
113        let content = toml::to_string_pretty(self)?;
114        std::fs::write(path, content)?;
115        Ok(())
116    }
117
118    /// Create a new instance, saving to a specific registry path (for testing).
119    pub fn create_with_path(
120        &mut self,
121        name: &str,
122        data_dir: PathBuf,
123        registry_path: &Path,
124    ) -> crate::error::Result<()> {
125        if self.instances.iter().any(|i| i.name == name) {
126            return Err(crate::error::CoreError::Validation(
127                format!("instance '{name}' already exists").into(),
128            ));
129        }
130        std::fs::create_dir_all(&data_dir)?;
131        self.instances.push(Instance {
132            name: name.to_string(),
133            data_dir,
134            is_default: self.instances.is_empty(),
135        });
136        if self.active.is_none() {
137            self.active = Some(name.to_string());
138        }
139        self.save_to(registry_path)?;
140        Ok(())
141    }
142
143    /// Switch to a different instance, saving to a specific registry path (for testing).
144    pub fn switch_with_path(
145        &mut self,
146        name: &str,
147        registry_path: &Path,
148    ) -> crate::error::Result<()> {
149        if !self.instances.iter().any(|i| i.name == name) {
150            return Err(crate::error::CoreError::Validation(
151                format!("instance '{name}' not found").into(),
152            ));
153        }
154        self.active = Some(name.to_string());
155        self.save_to(registry_path)?;
156        Ok(())
157    }
158}
159
160fn registry_path() -> PathBuf {
161    crate::paths::modde_config_dir().join("instances.toml")
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_create_and_list() {
170        let tmp = tempfile::tempdir().unwrap();
171        let registry_path = tmp.path().join("instances.toml");
172        let data_dir = tmp.path().join("instance-data");
173
174        let mut reg = InstanceRegistry::default();
175        reg.create_with_path("default", data_dir.clone(), &registry_path)
176            .unwrap();
177
178        assert_eq!(reg.list().len(), 1);
179        assert_eq!(reg.list()[0].name, "default");
180        assert_eq!(reg.list()[0].data_dir, data_dir);
181        assert!(reg.list()[0].is_default);
182    }
183
184    #[test]
185    fn test_switch() {
186        let tmp = tempfile::tempdir().unwrap();
187        let registry_path = tmp.path().join("instances.toml");
188
189        let mut reg = InstanceRegistry::default();
190        reg.create_with_path("first", tmp.path().join("first"), &registry_path)
191            .unwrap();
192        reg.create_with_path("second", tmp.path().join("second"), &registry_path)
193            .unwrap();
194
195        assert_eq!(reg.active.as_deref(), Some("first"));
196
197        reg.switch_with_path("second", &registry_path).unwrap();
198        assert_eq!(reg.active.as_deref(), Some("second"));
199    }
200
201    #[test]
202    fn test_duplicate_name_errors() {
203        let tmp = tempfile::tempdir().unwrap();
204        let registry_path = tmp.path().join("instances.toml");
205
206        let mut reg = InstanceRegistry::default();
207        reg.create_with_path("myinstance", tmp.path().join("data1"), &registry_path)
208            .unwrap();
209
210        let result = reg.create_with_path("myinstance", tmp.path().join("data2"), &registry_path);
211        assert!(result.is_err());
212        let err = result.unwrap_err();
213        assert!(
214            err.to_string().contains("already exists"),
215            "expected 'already exists' error, got: {err}"
216        );
217    }
218
219    #[test]
220    fn test_active_data_dir() {
221        let tmp = tempfile::tempdir().unwrap();
222        let registry_path = tmp.path().join("instances.toml");
223        let first_dir = tmp.path().join("first");
224        let second_dir = tmp.path().join("second");
225
226        let mut reg = InstanceRegistry::default();
227        reg.create_with_path("first", first_dir.clone(), &registry_path)
228            .unwrap();
229        reg.create_with_path("second", second_dir.clone(), &registry_path)
230            .unwrap();
231
232        // First instance is active by default
233        assert_eq!(reg.active_data_dir(), Some(first_dir.as_path()));
234
235        // Switch and verify
236        reg.switch_with_path("second", &registry_path).unwrap();
237        assert_eq!(reg.active_data_dir(), Some(second_dir.as_path()));
238    }
239}