use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::value::VmValue;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MountMode {
#[default]
ReadOnly,
Extend,
Sandboxed,
}
impl MountMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read_only",
Self::Extend => "extend",
Self::Sandboxed => "sandboxed",
}
}
pub fn parse(value: &str) -> Result<Self, String> {
match value.trim() {
"read_only" => Ok(Self::ReadOnly),
"extend" => Ok(Self::Extend),
"sandboxed" => Ok(Self::Sandboxed),
other => Err(format!(
"unknown mount mode '{other}'; expected one of: read_only, extend, sandboxed"
)),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MountedRoot {
pub path: PathBuf,
pub mount_mode: MountMode,
pub mounted_at: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceAnchor {
pub primary: PathBuf,
#[serde(default)]
pub additional_roots: Vec<MountedRoot>,
pub anchored_at: String,
}
impl WorkspaceAnchor {
pub fn to_json(&self) -> JsonValue {
serde_json::to_value(self).unwrap_or(JsonValue::Null)
}
pub fn from_json(value: &JsonValue) -> Result<Self, String> {
serde_json::from_value(value.clone())
.map_err(|err| format!("invalid workspace_anchor: {err}"))
}
pub fn to_vm_value(&self) -> VmValue {
crate::stdlib::json_to_vm_value(&self.to_json())
}
}
pub fn parse_anchor_dict(value: &VmValue) -> Result<WorkspaceAnchor, String> {
let dict = value
.as_dict()
.ok_or_else(|| "workspace_anchor must be a dict".to_string())?;
let primary = match dict.get("primary") {
Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
Some(_) => {
return Err("workspace_anchor.primary must be a non-empty string".to_string());
}
None => return Err("workspace_anchor.primary is required".to_string()),
};
let anchored_at = match dict.get("anchored_at") {
Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
_ => return Err("workspace_anchor.anchored_at must be a string".to_string()),
};
let additional_roots = match dict.get("additional_roots") {
None | Some(VmValue::Nil) => Vec::new(),
Some(VmValue::List(items)) => items
.iter()
.map(parse_mounted_root_value)
.collect::<Result<Vec<_>, _>>()?,
_ => return Err("workspace_anchor.additional_roots must be a list of dicts".to_string()),
};
Ok(WorkspaceAnchor {
primary,
additional_roots,
anchored_at,
})
}
fn parse_mounted_root_value(value: &VmValue) -> Result<MountedRoot, String> {
let dict = value
.as_dict()
.ok_or_else(|| "workspace_anchor.additional_roots entry must be a dict".to_string())?;
let path = match dict.get("path") {
Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
Some(_) => {
return Err(
"workspace_anchor.additional_roots[*].path must be a non-empty string".to_string(),
);
}
None => {
return Err("workspace_anchor.additional_roots[*].path is required".to_string());
}
};
let mount_mode = match dict.get("mount_mode") {
None | Some(VmValue::Nil) => MountMode::default(),
Some(VmValue::String(s)) => MountMode::parse(s)?,
_ => {
return Err(
"workspace_anchor.additional_roots[*].mount_mode must be a string".to_string(),
);
}
};
let mounted_at = match dict.get("mounted_at") {
Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
_ => {
return Err(
"workspace_anchor.additional_roots[*].mounted_at must be a string".to_string(),
);
}
};
Ok(MountedRoot {
path,
mount_mode,
mounted_at,
})
}
pub const WORKSPACE_ANCHOR_METADATA_KEY: &str = "workspace_anchor";
pub fn anchor_from_transcript_metadata_json(metadata: &JsonValue) -> Option<WorkspaceAnchor> {
metadata
.get(WORKSPACE_ANCHOR_METADATA_KEY)
.and_then(|value| WorkspaceAnchor::from_json(value).ok())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::rc::Rc;
use super::*;
#[test]
fn mount_mode_round_trips() {
for mode in [MountMode::ReadOnly, MountMode::Extend, MountMode::Sandboxed] {
let parsed = MountMode::parse(mode.as_str()).expect("parse");
assert_eq!(parsed, mode);
}
assert!(MountMode::parse("unknown").is_err());
}
#[test]
fn anchor_json_round_trips() {
let anchor = WorkspaceAnchor {
primary: PathBuf::from("/workspace/main"),
additional_roots: vec![MountedRoot {
path: PathBuf::from("/workspace/lib"),
mount_mode: MountMode::Extend,
mounted_at: "2026-05-23T00:00:00Z".to_string(),
}],
anchored_at: "2026-05-23T00:00:00Z".to_string(),
};
let json = anchor.to_json();
let back = WorkspaceAnchor::from_json(&json).expect("round trip");
assert_eq!(anchor, back);
}
#[test]
fn parse_anchor_dict_accepts_minimal_shape() {
let dict = VmValue::Dict(Rc::new(BTreeMap::from([(
"primary".to_string(),
VmValue::String(Rc::from("/workspace/main")),
)])));
let anchor = parse_anchor_dict(&dict).expect("parse minimal");
assert_eq!(anchor.primary, PathBuf::from("/workspace/main"));
assert!(anchor.additional_roots.is_empty());
assert!(!anchor.anchored_at.is_empty());
}
#[test]
fn parse_anchor_dict_rejects_missing_primary() {
let dict = VmValue::Dict(Rc::new(BTreeMap::new()));
let err = parse_anchor_dict(&dict).expect_err("missing primary should fail");
assert!(err.contains("primary"));
}
#[test]
fn parse_anchor_dict_accepts_additional_roots() {
let roots = vec![VmValue::Dict(Rc::new(BTreeMap::from([
(
"path".to_string(),
VmValue::String(Rc::from("/workspace/lib")),
),
(
"mount_mode".to_string(),
VmValue::String(Rc::from("extend")),
),
(
"mounted_at".to_string(),
VmValue::String(Rc::from("2026-05-23T00:00:00Z")),
),
])))];
let dict = VmValue::Dict(Rc::new(BTreeMap::from([
(
"primary".to_string(),
VmValue::String(Rc::from("/workspace/main")),
),
(
"additional_roots".to_string(),
VmValue::List(Rc::new(roots)),
),
])));
let anchor = parse_anchor_dict(&dict).expect("parse with roots");
assert_eq!(anchor.additional_roots.len(), 1);
assert_eq!(anchor.additional_roots[0].mount_mode, MountMode::Extend);
}
}