1use serde::Deserialize;
2use serde::Serialize;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use crate::platform::common::MAX_MOUNT_RETRIES;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MountInfo {
12 pub target: PathBuf,
14
15 pub sources: Vec<PathBuf>,
17
18 pub status: MountStatus,
20
21 pub fs_type: String,
23
24 pub options: Vec<String>,
26
27 pub mounted_at: Option<SystemTime>,
29
30 pub pid: Option<u32>,
32
33 pub metadata: MountMetadata,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub enum MountStatus {
40 Mounted,
42
43 Unmounted,
45
46 Degraded(String),
48
49 Error(String),
51
52 Unknown,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub enum MountMetadata {
59 Linux {
60 mount_id: Option<u32>,
61 parent_id: Option<u32>,
62 major_minor: Option<String>,
63 },
64 MacOS {
65 volume_name: Option<String>,
66 volume_uuid: Option<String>,
67 disk_identifier: Option<String>,
68 },
69 Unknown,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MountOptions {
75 pub read_only: bool,
77
78 pub allow_other: bool,
80
81 pub volume_name: Option<String>,
83
84 pub extra_options: Vec<String>,
86
87 pub timeout: Option<std::time::Duration>,
89
90 pub retries: u32,
92}
93
94impl Default for MountOptions {
95 fn default() -> Self {
96 Self {
97 read_only: false,
98 allow_other: false,
99 volume_name: None,
100 extra_options: Vec::new(),
101 timeout: None,
102 retries: MAX_MOUNT_RETRIES,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MountStateCache {
110 pub version: String,
111 pub mounts: HashMap<PathBuf, CachedMountInfo>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CachedMountInfo {
117 pub target: PathBuf,
118 pub sources: Vec<PathBuf>,
119 pub mount_options: MountOptions,
120 pub created_at: SystemTime,
121 pub mount_command: String,
122 pub pid: Option<u32>,
123}
124
125use anyhow::Result;
126use std::fmt;
127
128#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub enum MountSpace {
136 Thoughts,
138
139 Context(String),
141
142 Reference {
144 org_path: String,
146 repo: String,
148 ref_key: Option<String>,
150 },
151}
152
153impl MountSpace {
154 pub fn parse(input: &str) -> Result<Self> {
156 if input == "thoughts" {
157 Ok(MountSpace::Thoughts)
158 } else if input.starts_with("references/") {
159 let rest = input.trim_start_matches("references/");
160 let (org_path, repo_segment) = rest
161 .rsplit_once('/')
162 .ok_or_else(|| anyhow::anyhow!("Invalid reference format: {}", input))?;
163 if org_path.is_empty() || repo_segment.is_empty() {
164 anyhow::bail!("Invalid reference format: {}", input);
165 }
166
167 let (repo, ref_key) = match repo_segment.rsplit_once('@') {
168 Some((repo, ref_key)) if !repo.is_empty() && !ref_key.is_empty() => {
169 (repo.to_string(), Some(ref_key.to_string()))
170 }
171 _ => (repo_segment.to_string(), None),
172 };
173
174 Ok(MountSpace::Reference {
175 org_path: org_path.to_string(),
176 repo,
177 ref_key,
178 })
179 } else if let Some(rest) = input.strip_prefix("context/") {
180 if rest.is_empty() {
181 anyhow::bail!(
182 "Invalid context mount name '{}': missing mount path after 'context/'",
183 input
184 );
185 }
186 Ok(MountSpace::Context(rest.to_string()))
187 } else {
188 Ok(MountSpace::Context(input.to_string()))
190 }
191 }
192
193 pub fn as_str(&self) -> String {
195 match self {
196 MountSpace::Thoughts => "thoughts".to_string(),
197 MountSpace::Context(path) => path.clone(),
198 MountSpace::Reference {
199 org_path,
200 repo,
201 ref_key,
202 } => match ref_key {
203 Some(ref_key) => format!("references/{}/{}@{}", org_path, repo, ref_key),
204 None => format!("references/{}/{}", org_path, repo),
205 },
206 }
207 }
208
209 pub fn relative_path(&self, mount_dirs: &crate::config::MountDirsV2) -> String {
211 match self {
212 MountSpace::Thoughts => mount_dirs.thoughts.clone(),
213 MountSpace::Context(path) => format!("{}/{}", mount_dirs.context, path),
214 MountSpace::Reference {
215 org_path,
216 repo,
217 ref_key,
218 } => match ref_key {
219 Some(ref_key) => {
220 format!(
221 "{}/{}/{}@{}",
222 mount_dirs.references, org_path, repo, ref_key
223 )
224 }
225 None => format!("{}/{}/{}", mount_dirs.references, org_path, repo),
226 },
227 }
228 }
229
230 pub fn is_read_only(&self) -> bool {
232 matches!(self, MountSpace::Reference { .. })
233 }
234}
235
236impl fmt::Display for MountSpace {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 write!(f, "{}", self.as_str())
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn test_mount_options_default() {
248 let options = MountOptions::default();
249
250 assert!(!options.read_only);
251 assert!(!options.allow_other);
252 assert_eq!(options.retries, MAX_MOUNT_RETRIES);
253 assert_eq!(options.volume_name, None);
254 }
255
256 #[test]
257 fn test_mount_status_serialization() {
258 let status = MountStatus::Mounted;
259 let json = serde_json::to_string(&status).unwrap();
260 let deserialized: MountStatus = serde_json::from_str(&json).unwrap();
261 assert_eq!(status, deserialized);
262 }
263
264 #[test]
265 fn test_mount_space_parse() {
266 let thoughts = MountSpace::parse("thoughts").unwrap();
268 assert_eq!(thoughts, MountSpace::Thoughts);
269
270 let context = MountSpace::parse("api-docs").unwrap();
272 assert_eq!(context, MountSpace::Context("api-docs".to_string()));
273
274 let reference = MountSpace::parse("references/github/example").unwrap();
276 assert_eq!(
277 reference,
278 MountSpace::Reference {
279 org_path: "github".to_string(),
280 repo: "example".to_string(),
281 ref_key: None,
282 }
283 );
284
285 assert!(MountSpace::parse("references/invalid").is_err());
287 }
288
289 #[test]
290 fn test_mount_space_parse_reference_with_multi_segment_org_and_ref() {
291 let reference =
292 MountSpace::parse("references/gitlab/group/subgroup/repo@r-refs~2ftags~2fv1.0.0")
293 .unwrap();
294 assert_eq!(
295 reference,
296 MountSpace::Reference {
297 org_path: "gitlab/group/subgroup".to_string(),
298 repo: "repo".to_string(),
299 ref_key: Some("r-refs~2ftags~2fv1.0.0".to_string()),
300 }
301 );
302 }
303
304 #[test]
305 fn test_mount_space_as_str() {
306 assert_eq!(MountSpace::Thoughts.as_str(), "thoughts");
307 assert_eq!(MountSpace::Context("docs".to_string()).as_str(), "docs");
308 assert_eq!(
309 MountSpace::Reference {
310 org_path: "org".to_string(),
311 repo: "repo".to_string(),
312 ref_key: None,
313 }
314 .as_str(),
315 "references/org/repo"
316 );
317 }
318
319 #[test]
320 fn test_mount_space_round_trip() {
321 let cases = vec![
322 ("thoughts", MountSpace::Thoughts),
323 ("api-docs", MountSpace::Context("api-docs".to_string())),
324 (
325 "references/github/example",
326 MountSpace::Reference {
327 org_path: "github".to_string(),
328 repo: "example".to_string(),
329 ref_key: None,
330 },
331 ),
332 (
333 "references/gitlab/group/repo@r-main",
334 MountSpace::Reference {
335 org_path: "gitlab/group".to_string(),
336 repo: "repo".to_string(),
337 ref_key: Some("r-main".to_string()),
338 },
339 ),
340 ];
341
342 for (input, expected) in cases {
343 let parsed = MountSpace::parse(input).unwrap();
344 assert_eq!(parsed, expected);
345 assert_eq!(parsed.as_str(), input);
346 }
347 }
348
349 #[test]
350 fn test_mount_space_is_read_only() {
351 assert!(!MountSpace::Thoughts.is_read_only());
352 assert!(!MountSpace::Context("test".to_string()).is_read_only());
353 assert!(
354 MountSpace::Reference {
355 org_path: "test".to_string(),
356 repo: "repo".to_string(),
357 ref_key: None,
358 }
359 .is_read_only()
360 );
361 }
362
363 #[test]
364 fn test_mount_space_parse_context_prefix_normalization() {
365 let ms_prefixed = MountSpace::parse("context/api-docs").unwrap();
366 assert_eq!(ms_prefixed, MountSpace::Context("api-docs".to_string()));
367
368 let ms_plain = MountSpace::parse("api-docs").unwrap();
369 assert_eq!(ms_plain, MountSpace::Context("api-docs".to_string()));
370
371 assert_eq!(ms_prefixed, ms_plain);
373
374 assert!(MountSpace::parse("context/").is_err());
376 }
377}