thoughts_tool/mount/
types.rs

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/// Information about an active mount
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MountInfo {
11    /// Target mount point
12    pub target: PathBuf,
13
14    /// Source directories being merged
15    pub sources: Vec<PathBuf>,
16
17    /// Mount status
18    pub status: MountStatus,
19
20    /// Filesystem type (e.g., "fuse.mergerfs")
21    pub fs_type: String,
22
23    /// Mount options used
24    pub options: Vec<String>,
25
26    /// When the mount was created
27    pub mounted_at: Option<SystemTime>,
28
29    /// Process ID of the mount process (if applicable)
30    pub pid: Option<u32>,
31
32    /// Additional platform-specific metadata
33    pub metadata: MountMetadata,
34}
35
36/// Mount status
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub enum MountStatus {
39    /// Successfully mounted and accessible
40    Mounted,
41
42    /// Not currently mounted
43    Unmounted,
44
45    /// Mount exists but may have issues
46    Degraded(String),
47
48    /// Mount failed with error
49    Error(String),
50
51    /// Status cannot be determined
52    Unknown,
53}
54
55/// Platform-specific mount metadata
56#[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/// Options for mount operations
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct MountOptions {
74    /// Read-only mount
75    pub read_only: bool,
76
77    /// Allow other users to access the mount
78    pub allow_other: bool,
79
80    /// Custom volume name (macOS)
81    pub volume_name: Option<String>,
82
83    /// Additional platform-specific options
84    pub extra_options: Vec<String>,
85
86    /// Timeout for mount operation
87    pub timeout: Option<std::time::Duration>,
88
89    /// Number of retries on failure
90    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/// Mount state cache for persistence (macOS FUSE-T)
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MountStateCache {
109    pub version: String,
110    pub mounts: HashMap<PathBuf, CachedMountInfo>,
111}
112
113/// Cached information about a mount
114#[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/// Represents the different types of mount spaces in thoughts_tool.
128///
129/// The three-space architecture consists of:
130/// - `Thoughts`: Single workspace for active development thoughts
131/// - `Context`: Multiple mounts for team-shared documentation
132/// - `Reference`: Read-only external repository references organized by org/repo
133#[derive(Debug, Clone, PartialEq, Eq, Hash)]
134pub enum MountSpace {
135    /// The primary thoughts workspace mount
136    Thoughts,
137
138    /// A context mount with its mount path
139    Context(String),
140
141    /// A reference mount organized by organization and repository
142    Reference {
143        /// Organization or user name
144        org: String,
145        /// Repository name
146        repo: String,
147    },
148}
149
150impl MountSpace {
151    /// Parse a mount identifier string into a MountSpace
152    pub fn parse(input: &str) -> Result<Self> {
153        if input == "thoughts" {
154            Ok(MountSpace::Thoughts)
155        } else if input.starts_with("references/") {
156            let parts: Vec<&str> = input.splitn(3, '/').collect();
157            if parts.len() == 3 && parts[0] == "references" {
158                Ok(MountSpace::Reference {
159                    org: parts[1].to_string(),
160                    repo: parts[2].to_string(),
161                })
162            } else {
163                anyhow::bail!("Invalid reference format: {}", input)
164            }
165        } else if let Some(rest) = input.strip_prefix("context/") {
166            if rest.is_empty() {
167                anyhow::bail!(
168                    "Invalid context mount name '{}': missing mount path after 'context/'",
169                    input
170                );
171            }
172            Ok(MountSpace::Context(rest.to_string()))
173        } else {
174            // Assume it's a context mount
175            Ok(MountSpace::Context(input.to_string()))
176        }
177    }
178
179    /// Get the string identifier for this mount space
180    pub fn as_str(&self) -> String {
181        match self {
182            MountSpace::Thoughts => "thoughts".to_string(),
183            MountSpace::Context(path) => path.clone(),
184            MountSpace::Reference { org, repo } => format!("references/{}/{}", org, repo),
185        }
186    }
187
188    /// Get the relative path under .thoughts-data for this mount
189    pub fn relative_path(&self, mount_dirs: &crate::config::MountDirsV2) -> String {
190        match self {
191            MountSpace::Thoughts => mount_dirs.thoughts.clone(),
192            MountSpace::Context(path) => format!("{}/{}", mount_dirs.context, path),
193            MountSpace::Reference { org, repo } => {
194                format!("{}/{}/{}", mount_dirs.references, org, repo)
195            }
196        }
197    }
198
199    /// Check if this mount space should be read-only
200    pub fn is_read_only(&self) -> bool {
201        matches!(self, MountSpace::Reference { .. })
202    }
203}
204
205impl fmt::Display for MountSpace {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        write!(f, "{}", self.as_str())
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_mount_options_default() {
217        let options = MountOptions::default();
218
219        assert!(!options.read_only);
220        assert!(!options.allow_other);
221        assert_eq!(options.retries, MAX_MOUNT_RETRIES);
222        assert_eq!(options.volume_name, None);
223    }
224
225    #[test]
226    fn test_mount_status_serialization() {
227        let status = MountStatus::Mounted;
228        let json = serde_json::to_string(&status).unwrap();
229        let deserialized: MountStatus = serde_json::from_str(&json).unwrap();
230        assert_eq!(status, deserialized);
231    }
232
233    #[test]
234    fn test_mount_space_parse() {
235        // Test thoughts mount
236        let thoughts = MountSpace::parse("thoughts").unwrap();
237        assert_eq!(thoughts, MountSpace::Thoughts);
238
239        // Test context mount
240        let context = MountSpace::parse("api-docs").unwrap();
241        assert_eq!(context, MountSpace::Context("api-docs".to_string()));
242
243        // Test reference mount
244        let reference = MountSpace::parse("references/github/example").unwrap();
245        assert_eq!(
246            reference,
247            MountSpace::Reference {
248                org: "github".to_string(),
249                repo: "example".to_string(),
250            }
251        );
252
253        // Test invalid reference format
254        assert!(MountSpace::parse("references/invalid").is_err());
255    }
256
257    #[test]
258    fn test_mount_space_as_str() {
259        assert_eq!(MountSpace::Thoughts.as_str(), "thoughts");
260        assert_eq!(MountSpace::Context("docs".to_string()).as_str(), "docs");
261        assert_eq!(
262            MountSpace::Reference {
263                org: "org".to_string(),
264                repo: "repo".to_string(),
265            }
266            .as_str(),
267            "references/org/repo"
268        );
269    }
270
271    #[test]
272    fn test_mount_space_round_trip() {
273        let cases = vec![
274            ("thoughts", MountSpace::Thoughts),
275            ("api-docs", MountSpace::Context("api-docs".to_string())),
276            (
277                "references/github/example",
278                MountSpace::Reference {
279                    org: "github".to_string(),
280                    repo: "example".to_string(),
281                },
282            ),
283        ];
284
285        for (input, expected) in cases {
286            let parsed = MountSpace::parse(input).unwrap();
287            assert_eq!(parsed, expected);
288            assert_eq!(parsed.as_str(), input);
289        }
290    }
291
292    #[test]
293    fn test_mount_space_is_read_only() {
294        assert!(!MountSpace::Thoughts.is_read_only());
295        assert!(!MountSpace::Context("test".to_string()).is_read_only());
296        assert!(
297            MountSpace::Reference {
298                org: "test".to_string(),
299                repo: "repo".to_string(),
300            }
301            .is_read_only()
302        );
303    }
304
305    #[test]
306    fn test_mount_space_parse_context_prefix_normalization() {
307        let ms_prefixed = MountSpace::parse("context/api-docs").unwrap();
308        assert_eq!(ms_prefixed, MountSpace::Context("api-docs".to_string()));
309
310        let ms_plain = MountSpace::parse("api-docs").unwrap();
311        assert_eq!(ms_plain, MountSpace::Context("api-docs".to_string()));
312
313        // Both should produce the same normalized result
314        assert_eq!(ms_prefixed, ms_plain);
315
316        // Empty after prefix should error
317        assert!(MountSpace::parse("context/").is_err());
318    }
319}