Skip to main content

thoughts_tool/mount/
types.rs

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/// Information about an active mount
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MountInfo {
12    /// Target mount point
13    pub target: PathBuf,
14
15    /// Source directories being merged
16    pub sources: Vec<PathBuf>,
17
18    /// Mount status
19    pub status: MountStatus,
20
21    /// Filesystem type (e.g., "fuse.mergerfs")
22    pub fs_type: String,
23
24    /// Mount options used
25    pub options: Vec<String>,
26
27    /// When the mount was created
28    pub mounted_at: Option<SystemTime>,
29
30    /// Process ID of the mount process (if applicable)
31    pub pid: Option<u32>,
32
33    /// Additional platform-specific metadata
34    pub metadata: MountMetadata,
35}
36
37/// Mount status
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub enum MountStatus {
40    /// Successfully mounted and accessible
41    Mounted,
42
43    /// Not currently mounted
44    Unmounted,
45
46    /// Mount exists but may have issues
47    Degraded(String),
48
49    /// Mount failed with error
50    Error(String),
51
52    /// Status cannot be determined
53    Unknown,
54}
55
56/// Platform-specific mount metadata
57#[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/// Options for mount operations
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MountOptions {
75    /// Read-only mount
76    pub read_only: bool,
77
78    /// Allow other users to access the mount
79    pub allow_other: bool,
80
81    /// Custom volume name (macOS)
82    pub volume_name: Option<String>,
83
84    /// Additional platform-specific options
85    pub extra_options: Vec<String>,
86
87    /// Timeout for mount operation
88    pub timeout: Option<std::time::Duration>,
89
90    /// Number of retries on failure
91    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/// Mount state cache for persistence (macOS FUSE-T)
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MountStateCache {
110    pub version: String,
111    pub mounts: HashMap<PathBuf, CachedMountInfo>,
112}
113
114/// Cached information about a mount
115#[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/// Represents the different types of mount spaces in thoughts_tool.
129///
130/// The three-space architecture consists of:
131/// - `Thoughts`: Single workspace for active development thoughts
132/// - `Context`: Multiple mounts for team-shared documentation
133/// - `Reference`: Read-only external repository references organized by org/repo
134#[derive(Debug, Clone, PartialEq, Eq, Hash)]
135pub enum MountSpace {
136    /// The primary thoughts workspace mount
137    Thoughts,
138
139    /// A context mount with its mount path
140    Context(String),
141
142    /// A reference mount organized by organization and repository
143    Reference {
144        /// Organization or user path
145        org_path: String,
146        /// Repository name
147        repo: String,
148        /// Filesystem-safe encoded ref identity for pinned references
149        ref_key: Option<String>,
150    },
151}
152
153impl MountSpace {
154    /// Parse a mount identifier string into a MountSpace
155    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            // Assume it's a context mount
189            Ok(MountSpace::Context(input.to_string()))
190        }
191    }
192
193    /// Get the string identifier for this mount space
194    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    /// Get the relative path under .thoughts-data for this mount
210    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    /// Check if this mount space should be read-only
231    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        // Test thoughts mount
267        let thoughts = MountSpace::parse("thoughts").unwrap();
268        assert_eq!(thoughts, MountSpace::Thoughts);
269
270        // Test context mount
271        let context = MountSpace::parse("api-docs").unwrap();
272        assert_eq!(context, MountSpace::Context("api-docs".to_string()));
273
274        // Test reference mount
275        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        // Test invalid reference format
286        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        // Both should produce the same normalized result
372        assert_eq!(ms_prefixed, ms_plain);
373
374        // Empty after prefix should error
375        assert!(MountSpace::parse("context/").is_err());
376    }
377}