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, Eq, 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(Self::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(Self::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 '{input}': missing mount path after 'context/'"
183 );
184 }
185 Ok(Self::Context(rest.to_string()))
186 } else {
187 Ok(Self::Context(input.to_string()))
189 }
190 }
191
192 pub fn as_str(&self) -> String {
194 match self {
195 Self::Thoughts => "thoughts".to_string(),
196 Self::Context(path) => path.clone(),
197 Self::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 Self::Thoughts => mount_dirs.thoughts.clone(),
212 Self::Context(path) => format!("{}/{}", mount_dirs.context, path),
213 Self::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, Self::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}