1use crate::error::{ErrorCode, MitosisError, Result};
2use std::ffi::OsString;
3use std::os::unix::fs::PermissionsExt;
4use std::path::PathBuf;
5
6fn resolve(value: Option<OsString>, home_dir: Option<PathBuf>) -> Result<PathBuf> {
8 match value.filter(|v| !v.is_empty()) {
9 Some(p) => Ok(std::path::absolute(p)?),
10 None => {
11 let home = home_dir.ok_or_else(|| {
12 MitosisError::new(ErrorCode::Internal, "HOME is not set")
13 .with_hint("set MITOSIS_HOME to choose where mitosis keeps its data")
14 })?;
15 Ok(home.join(DEFAULT_DIR))
16 }
17 }
18}
19
20#[cfg(target_os = "macos")]
22const DEFAULT_DIR: &str = "Library/Application Support/mitosis";
23#[cfg(not(target_os = "macos"))]
24const DEFAULT_DIR: &str = ".local/share/mitosis";
25
26#[cfg(test)]
29pub(crate) fn test_tmp() -> PathBuf {
30 if cfg!(target_os = "macos") {
31 PathBuf::from("/tmp")
32 } else {
33 std::env::temp_dir()
34 }
35}
36
37const MARKER: &str = ".mitosis-home";
38
39fn is_own_root_entry(name: &str) -> bool {
41 matches!(
42 name,
43 MARKER | "templates" | "clones" | "sockets" | "logs" | ".DS_Store"
44 ) || name.starts_with("state.db")
45 || name.starts_with("daemon.")
46 || name.starts_with("config.toml")
47}
48
49#[derive(Clone, Debug)]
50pub struct Home {
51 pub root: PathBuf,
52}
53
54impl Home {
55 pub fn from_env() -> Result<Home> {
57 Ok(Home::at(resolve(
58 std::env::var_os("MITOSIS_HOME"),
59 std::env::var_os("HOME").map(PathBuf::from),
60 )?))
61 }
62
63 pub fn at(root: PathBuf) -> Home {
64 Home { root }
65 }
66
67 pub fn templates_dir(&self) -> PathBuf {
68 self.root.join("templates")
69 }
70
71 pub fn clones_dir(&self) -> PathBuf {
72 self.root.join("clones")
73 }
74
75 pub fn pg_dir(&self) -> PathBuf {
77 self.root.join("pg")
78 }
79
80 pub fn sockets_dir(&self) -> PathBuf {
81 self.root.join("sockets")
82 }
83
84 pub fn logs_dir(&self) -> PathBuf {
85 self.root.join("logs")
86 }
87
88 pub fn state_db(&self) -> PathBuf {
89 self.root.join("state.db")
90 }
91
92 pub fn daemon_sock(&self) -> PathBuf {
93 self.root.join("daemon.sock")
94 }
95
96 pub fn daemon_lock(&self) -> PathBuf {
97 self.root.join("daemon.lock")
98 }
99
100 pub fn config_file(&self) -> PathBuf {
101 self.root.join("config.toml")
102 }
103
104 pub fn marker(&self) -> PathBuf {
106 self.root.join(MARKER)
107 }
108
109 pub fn ensure_dirs(&self) -> Result<()> {
113 if !self.marker().exists() {
114 std::fs::create_dir_all(&self.root)?;
115 let names: Vec<String> = std::fs::read_dir(&self.root)?
116 .flatten()
117 .map(|e| e.file_name().to_string_lossy().into_owned())
118 .filter(|n| n != ".DS_Store")
119 .collect();
120 let has_db = names.iter().any(|n| n == "state.db");
123 let only_config = names.iter().all(|n| n == "config.toml");
124 let mut foreign: Vec<String> = if !only_config && !has_db {
125 names
126 } else {
127 names
128 .into_iter()
129 .filter(|n| !is_own_root_entry(n))
130 .collect()
131 };
132 if !foreign.is_empty() {
133 foreign.sort();
134 if foreign.len() > 5 {
135 foreign.truncate(5);
136 foreign.push("…".into());
137 }
138 return Err(MitosisError::new(
139 ErrorCode::ForeignHome,
140 format!(
141 "{} already holds files Mitosis did not create ({})",
142 self.root.display(),
143 foreign.join(", ")
144 ),
145 )
146 .with_hint(
147 "MITOSIS_HOME points at a folder Mitosis did not create; choose an empty or new folder",
148 ));
149 }
150 std::fs::write(self.marker(), "")?;
151 }
152 std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
154 for d in [
155 self.templates_dir(),
156 self.clones_dir(),
157 self.sockets_dir(),
158 self.logs_dir(),
159 ] {
160 std::fs::create_dir_all(d)?;
161 }
162 Ok(())
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn layout_and_ensure_dirs() {
172 let tmp = tempfile::tempdir().unwrap();
173 let h = Home::at(tmp.path().join("m"));
174 h.ensure_dirs().unwrap();
175 for d in [
176 h.templates_dir(),
177 h.clones_dir(),
178 h.sockets_dir(),
179 h.logs_dir(),
180 ] {
181 assert!(d.is_dir());
182 }
183 assert!(h.marker().is_file());
184 assert_eq!(h.state_db(), h.root.join("state.db"));
185 assert_eq!(h.daemon_sock(), h.root.join("daemon.sock"));
186 assert_eq!(h.daemon_lock(), h.root.join("daemon.lock"));
187 }
188
189 #[test]
190 fn home_root_is_private() {
191 let tmp = tempfile::tempdir().unwrap();
192 let h = Home::at(tmp.path().join("m"));
193 std::fs::create_dir(&h.root).unwrap();
194 std::fs::set_permissions(&h.root, std::fs::Permissions::from_mode(0o755)).unwrap();
195 h.ensure_dirs().unwrap();
196 assert_eq!(
197 std::fs::metadata(&h.root).unwrap().permissions().mode() & 0o777,
198 0o700
199 );
200 }
201
202 #[test]
203 fn foreign_home_is_refused_untouched_then_adopted_once_only_own_entries_remain() {
204 let tmp = tempfile::tempdir().unwrap();
205 let h = Home::at(tmp.path().join("m"));
206 std::fs::create_dir(&h.root).unwrap();
207 std::fs::set_permissions(&h.root, std::fs::Permissions::from_mode(0o755)).unwrap();
208 std::fs::create_dir(h.root.join("clones")).unwrap();
209 std::fs::write(h.root.join("notes.txt"), "mine").unwrap();
210 let e = h.ensure_dirs().unwrap_err();
211 assert_eq!(e.code, ErrorCode::ForeignHome);
212 assert!(e.message.contains("notes.txt"), "{e}");
213 assert!(!h.marker().exists());
214 assert_eq!(
215 std::fs::metadata(&h.root).unwrap().permissions().mode() & 0o777,
216 0o755,
217 "an unmarked root is never chmodded"
218 );
219
220 std::fs::remove_file(h.root.join("notes.txt")).unwrap();
222 std::fs::write(h.root.join("state.db"), "").unwrap();
223 std::fs::write(h.root.join("state.db-wal"), "").unwrap();
224 std::fs::write(h.root.join("daemon.lock"), "").unwrap();
225 h.ensure_dirs().unwrap();
226 assert!(h.marker().is_file());
227
228 std::fs::write(h.root.join("notes.txt"), "mine").unwrap();
230 h.ensure_dirs().unwrap();
231 }
232
233 #[test]
234 fn unmarked_root_without_state_db_is_foreign() {
235 let tmp = tempfile::tempdir().unwrap();
236 let h = Home::at(tmp.path().join("m"));
237 std::fs::create_dir(&h.root).unwrap();
238 std::fs::create_dir(h.root.join("clones")).unwrap();
239 let e = h.ensure_dirs().unwrap_err();
240 assert_eq!(e.code, ErrorCode::ForeignHome);
241 assert!(!h.marker().exists());
242 }
243
244 #[test]
245 fn root_with_only_config_toml_is_adopted() {
246 let tmp = tempfile::tempdir().unwrap();
247 let h = Home::at(tmp.path().join("m"));
248 std::fs::create_dir(&h.root).unwrap();
249 std::fs::write(h.config_file(), "max_clones = 4\n").unwrap();
250 h.ensure_dirs().unwrap();
251 assert!(h.marker().is_file());
252 assert_eq!(
253 std::fs::read_to_string(h.config_file()).unwrap(),
254 "max_clones = 4\n"
255 );
256 }
257
258 #[test]
259 fn resolve_empty_is_default_and_relative_is_absolute() {
260 let home = Some(PathBuf::from("/Users/x"));
261 let default = PathBuf::from("/Users/x").join(DEFAULT_DIR);
262 assert_eq!(resolve(Some("".into()), home.clone()).unwrap(), default);
263 assert_eq!(resolve(None, home.clone()).unwrap(), default);
264 assert_eq!(
265 resolve(Some("/abs/m".into()), home.clone()).unwrap(),
266 PathBuf::from("/abs/m")
267 );
268 let rel = resolve(Some("rel/m".into()), home).unwrap();
269 assert!(rel.is_absolute());
270 assert!(rel.ends_with("rel/m"));
271 assert!(resolve(None, None).is_err());
272 }
273}