use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::SystemTime;
use crate::platform::common::MAX_MOUNT_RETRIES;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountInfo {
pub target: PathBuf,
pub sources: Vec<PathBuf>,
pub status: MountStatus,
pub fs_type: String,
pub options: Vec<String>,
pub mounted_at: Option<SystemTime>,
pub pid: Option<u32>,
pub metadata: MountMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MountStatus {
Mounted,
Unmounted,
Degraded(String),
Error(String),
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MountMetadata {
Linux {
mount_id: Option<u32>,
parent_id: Option<u32>,
major_minor: Option<String>,
},
MacOS {
volume_name: Option<String>,
volume_uuid: Option<String>,
disk_identifier: Option<String>,
},
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountOptions {
pub read_only: bool,
pub allow_other: bool,
pub volume_name: Option<String>,
pub extra_options: Vec<String>,
pub timeout: Option<std::time::Duration>,
pub retries: u32,
}
impl Default for MountOptions {
fn default() -> Self {
Self {
read_only: false,
allow_other: false,
volume_name: None,
extra_options: Vec::new(),
timeout: None,
retries: MAX_MOUNT_RETRIES,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MountStateCache {
pub version: String,
pub mounts: HashMap<PathBuf, CachedMountInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedMountInfo {
pub target: PathBuf,
pub sources: Vec<PathBuf>,
pub mount_options: MountOptions,
pub created_at: SystemTime,
pub mount_command: String,
pub pid: Option<u32>,
}
use anyhow::Result;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MountSpace {
Thoughts,
Context(String),
Reference {
org_path: String,
repo: String,
ref_key: Option<String>,
},
}
impl MountSpace {
pub fn parse(input: &str) -> Result<Self> {
if input == "thoughts" {
Ok(Self::Thoughts)
} else if input.starts_with("references/") {
let rest = input.trim_start_matches("references/");
let (org_path, repo_segment) = rest
.rsplit_once('/')
.ok_or_else(|| anyhow::anyhow!("Invalid reference format: {input}"))?;
if org_path.is_empty() || repo_segment.is_empty() {
anyhow::bail!("Invalid reference format: {input}");
}
let (repo, ref_key) = match repo_segment.rsplit_once('@') {
Some((repo, ref_key)) if !repo.is_empty() && !ref_key.is_empty() => {
(repo.to_string(), Some(ref_key.to_string()))
}
_ => (repo_segment.to_string(), None),
};
Ok(Self::Reference {
org_path: org_path.to_string(),
repo,
ref_key,
})
} else if let Some(rest) = input.strip_prefix("context/") {
if rest.is_empty() {
anyhow::bail!(
"Invalid context mount name '{input}': missing mount path after 'context/'"
);
}
Ok(Self::Context(rest.to_string()))
} else {
Ok(Self::Context(input.to_string()))
}
}
pub fn as_str(&self) -> String {
match self {
Self::Thoughts => "thoughts".to_string(),
Self::Context(path) => path.clone(),
Self::Reference {
org_path,
repo,
ref_key,
} => match ref_key {
Some(ref_key) => format!("references/{org_path}/{repo}@{ref_key}"),
None => format!("references/{org_path}/{repo}"),
},
}
}
pub fn relative_path(&self, mount_dirs: &crate::config::MountDirsV2) -> String {
match self {
Self::Thoughts => mount_dirs.thoughts.clone(),
Self::Context(path) => format!("{}/{}", mount_dirs.context, path),
Self::Reference {
org_path,
repo,
ref_key,
} => match ref_key {
Some(ref_key) => {
format!(
"{}/{}/{}@{}",
mount_dirs.references, org_path, repo, ref_key
)
}
None => format!("{}/{}/{}", mount_dirs.references, org_path, repo),
},
}
}
pub fn is_read_only(&self) -> bool {
matches!(self, Self::Reference { .. })
}
}
impl fmt::Display for MountSpace {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mount_options_default() {
let options = MountOptions::default();
assert!(!options.read_only);
assert!(!options.allow_other);
assert_eq!(options.retries, MAX_MOUNT_RETRIES);
assert_eq!(options.volume_name, None);
}
#[test]
fn test_mount_status_serialization() {
let status = MountStatus::Mounted;
let json = serde_json::to_string(&status).unwrap();
let deserialized: MountStatus = serde_json::from_str(&json).unwrap();
assert_eq!(status, deserialized);
}
#[test]
fn test_mount_space_parse() {
let thoughts = MountSpace::parse("thoughts").unwrap();
assert_eq!(thoughts, MountSpace::Thoughts);
let context = MountSpace::parse("api-docs").unwrap();
assert_eq!(context, MountSpace::Context("api-docs".to_string()));
let reference = MountSpace::parse("references/github/example").unwrap();
assert_eq!(
reference,
MountSpace::Reference {
org_path: "github".to_string(),
repo: "example".to_string(),
ref_key: None,
}
);
assert!(MountSpace::parse("references/invalid").is_err());
}
#[test]
fn test_mount_space_parse_reference_with_multi_segment_org_and_ref() {
let reference =
MountSpace::parse("references/gitlab/group/subgroup/repo@r-refs~2ftags~2fv1.0.0")
.unwrap();
assert_eq!(
reference,
MountSpace::Reference {
org_path: "gitlab/group/subgroup".to_string(),
repo: "repo".to_string(),
ref_key: Some("r-refs~2ftags~2fv1.0.0".to_string()),
}
);
}
#[test]
fn test_mount_space_as_str() {
assert_eq!(MountSpace::Thoughts.as_str(), "thoughts");
assert_eq!(MountSpace::Context("docs".to_string()).as_str(), "docs");
assert_eq!(
MountSpace::Reference {
org_path: "org".to_string(),
repo: "repo".to_string(),
ref_key: None,
}
.as_str(),
"references/org/repo"
);
}
#[test]
fn test_mount_space_round_trip() {
let cases = vec![
("thoughts", MountSpace::Thoughts),
("api-docs", MountSpace::Context("api-docs".to_string())),
(
"references/github/example",
MountSpace::Reference {
org_path: "github".to_string(),
repo: "example".to_string(),
ref_key: None,
},
),
(
"references/gitlab/group/repo@r-main",
MountSpace::Reference {
org_path: "gitlab/group".to_string(),
repo: "repo".to_string(),
ref_key: Some("r-main".to_string()),
},
),
];
for (input, expected) in cases {
let parsed = MountSpace::parse(input).unwrap();
assert_eq!(parsed, expected);
assert_eq!(parsed.as_str(), input);
}
}
#[test]
fn test_mount_space_is_read_only() {
assert!(!MountSpace::Thoughts.is_read_only());
assert!(!MountSpace::Context("test".to_string()).is_read_only());
assert!(
MountSpace::Reference {
org_path: "test".to_string(),
repo: "repo".to_string(),
ref_key: None,
}
.is_read_only()
);
}
#[test]
fn test_mount_space_parse_context_prefix_normalization() {
let ms_prefixed = MountSpace::parse("context/api-docs").unwrap();
assert_eq!(ms_prefixed, MountSpace::Context("api-docs".to_string()));
let ms_plain = MountSpace::parse("api-docs").unwrap();
assert_eq!(ms_plain, MountSpace::Context("api-docs".to_string()));
assert_eq!(ms_prefixed, ms_plain);
assert!(MountSpace::parse("context/").is_err());
}
}