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