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