1use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value as JsonValue;
14
15use crate::value::VmValue;
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum MountMode {
26 #[default]
27 ReadOnly,
28 Extend,
29 Sandboxed,
30}
31
32impl MountMode {
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::ReadOnly => "read_only",
36 Self::Extend => "extend",
37 Self::Sandboxed => "sandboxed",
38 }
39 }
40
41 pub fn parse(value: &str) -> Result<Self, String> {
42 match value.trim() {
43 "read_only" => Ok(Self::ReadOnly),
44 "extend" => Ok(Self::Extend),
45 "sandboxed" => Ok(Self::Sandboxed),
46 other => Err(format!(
47 "unknown mount mode '{other}'; expected one of: read_only, extend, sandboxed"
48 )),
49 }
50 }
51}
52
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
55pub struct WorkspacePolicy {
56 pub default_mount_mode: MountMode,
57}
58
59impl Default for WorkspacePolicy {
60 fn default() -> Self {
61 Self {
62 default_mount_mode: MountMode::ReadOnly,
63 }
64 }
65}
66
67impl WorkspacePolicy {
68 pub fn to_json(&self) -> JsonValue {
69 serde_json::to_value(self).unwrap_or(JsonValue::Null)
70 }
71
72 pub fn to_vm_value(&self) -> VmValue {
73 crate::stdlib::json_to_vm_value(&self.to_json())
74 }
75}
76
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct MountedRoot {
80 pub path: PathBuf,
81 pub mount_mode: MountMode,
82 pub mounted_at: String,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct WorkspaceAnchor {
88 pub primary: PathBuf,
89 #[serde(default)]
90 pub additional_roots: Vec<MountedRoot>,
91 pub anchored_at: String,
92}
93
94impl WorkspaceAnchor {
95 pub fn to_json(&self) -> JsonValue {
96 serde_json::to_value(self).unwrap_or(JsonValue::Null)
97 }
98
99 pub fn from_json(value: &JsonValue) -> Result<Self, String> {
100 serde_json::from_value(value.clone())
101 .map_err(|err| format!("invalid workspace_anchor: {err}"))
102 }
103
104 pub fn to_vm_value(&self) -> VmValue {
105 crate::stdlib::json_to_vm_value(&self.to_json())
106 }
107}
108
109pub fn parse_anchor_dict(value: &VmValue) -> Result<WorkspaceAnchor, String> {
115 parse_anchor_dict_with_default_mount_mode(value, MountMode::default())
116}
117
118pub fn parse_anchor_dict_with_default_mount_mode(
119 value: &VmValue,
120 default_mount_mode: MountMode,
121) -> Result<WorkspaceAnchor, String> {
122 let dict = value
123 .as_dict()
124 .ok_or_else(|| "workspace_anchor must be a dict".to_string())?;
125
126 let primary = match dict.get("primary") {
127 Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
128 Some(_) => {
129 return Err("workspace_anchor.primary must be a non-empty string".to_string());
130 }
131 None => return Err("workspace_anchor.primary is required".to_string()),
132 };
133
134 let anchored_at = match dict.get("anchored_at") {
135 Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
136 Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
137 _ => return Err("workspace_anchor.anchored_at must be a string".to_string()),
138 };
139
140 let additional_roots = match dict.get("additional_roots") {
141 None | Some(VmValue::Nil) => Vec::new(),
142 Some(VmValue::List(items)) => items
143 .iter()
144 .map(|value| parse_mounted_root_value(value, default_mount_mode))
145 .collect::<Result<Vec<_>, _>>()?,
146 _ => return Err("workspace_anchor.additional_roots must be a list of dicts".to_string()),
147 };
148
149 Ok(WorkspaceAnchor {
150 primary,
151 additional_roots,
152 anchored_at,
153 })
154}
155
156fn parse_mounted_root_value(
157 value: &VmValue,
158 default_mount_mode: MountMode,
159) -> Result<MountedRoot, String> {
160 let dict = value
161 .as_dict()
162 .ok_or_else(|| "workspace_anchor.additional_roots entry must be a dict".to_string())?;
163
164 let path = match dict.get("path") {
165 Some(VmValue::String(s)) if !s.trim().is_empty() => PathBuf::from(s.to_string()),
166 Some(_) => {
167 return Err(
168 "workspace_anchor.additional_roots[*].path must be a non-empty string".to_string(),
169 );
170 }
171 None => {
172 return Err("workspace_anchor.additional_roots[*].path is required".to_string());
173 }
174 };
175
176 let mount_mode = match dict.get("mount_mode") {
177 None | Some(VmValue::Nil) => default_mount_mode,
178 Some(VmValue::String(s)) => MountMode::parse(s)?,
179 _ => {
180 return Err(
181 "workspace_anchor.additional_roots[*].mount_mode must be a string".to_string(),
182 );
183 }
184 };
185
186 let mounted_at = match dict.get("mounted_at") {
187 Some(VmValue::String(s)) if !s.trim().is_empty() => s.to_string(),
188 Some(VmValue::Nil) | None => crate::orchestration::now_rfc3339(),
189 _ => {
190 return Err(
191 "workspace_anchor.additional_roots[*].mounted_at must be a string".to_string(),
192 );
193 }
194 };
195
196 Ok(MountedRoot {
197 path,
198 mount_mode,
199 mounted_at,
200 })
201}
202
203pub fn parse_workspace_policy_dict(value: &VmValue) -> Result<WorkspacePolicy, String> {
204 let dict = value
205 .as_dict()
206 .ok_or_else(|| "workspace_policy must be a dict".to_string())?;
207 let mut policy = WorkspacePolicy::default();
208 match dict.get("default_mount_mode") {
209 None | Some(VmValue::Nil) => {}
210 Some(VmValue::String(value)) => {
211 policy.default_mount_mode = MountMode::parse(value)?;
212 }
213 Some(_) => return Err("workspace_policy.default_mount_mode must be a string".to_string()),
214 }
215 Ok(policy)
216}
217
218pub const WORKSPACE_ANCHOR_METADATA_KEY: &str = "workspace_anchor";
221
222pub fn anchor_from_transcript_metadata_json(metadata: &JsonValue) -> Option<WorkspaceAnchor> {
224 metadata
225 .get(WORKSPACE_ANCHOR_METADATA_KEY)
226 .and_then(|value| WorkspaceAnchor::from_json(value).ok())
227}
228
229#[cfg(test)]
230mod tests {
231 use std::collections::BTreeMap;
232
233 use super::*;
234
235 #[test]
236 fn mount_mode_round_trips() {
237 for mode in [MountMode::ReadOnly, MountMode::Extend, MountMode::Sandboxed] {
238 let parsed = MountMode::parse(mode.as_str()).expect("parse");
239 assert_eq!(parsed, mode);
240 }
241 assert!(MountMode::parse("unknown").is_err());
242 }
243
244 #[test]
245 fn anchor_json_round_trips() {
246 let anchor = WorkspaceAnchor {
247 primary: PathBuf::from("/workspace/main"),
248 additional_roots: vec![MountedRoot {
249 path: PathBuf::from("/workspace/lib"),
250 mount_mode: MountMode::Extend,
251 mounted_at: "2026-05-23T00:00:00Z".to_string(),
252 }],
253 anchored_at: "2026-05-23T00:00:00Z".to_string(),
254 };
255 let json = anchor.to_json();
256 let back = WorkspaceAnchor::from_json(&json).expect("round trip");
257 assert_eq!(anchor, back);
258 }
259
260 #[test]
261 fn parse_anchor_dict_accepts_minimal_shape() {
262 let dict = VmValue::dict(BTreeMap::from([(
263 "primary".to_string(),
264 VmValue::String(arcstr::ArcStr::from("/workspace/main")),
265 )]));
266 let anchor = parse_anchor_dict(&dict).expect("parse minimal");
267 assert_eq!(anchor.primary, PathBuf::from("/workspace/main"));
268 assert!(anchor.additional_roots.is_empty());
269 assert!(!anchor.anchored_at.is_empty());
270 }
271
272 #[test]
273 fn parse_anchor_dict_rejects_missing_primary() {
274 let dict = VmValue::dict_map(Default::default());
275 let err = parse_anchor_dict(&dict).expect_err("missing primary should fail");
276 assert!(err.contains("primary"));
277 }
278
279 #[test]
280 fn parse_anchor_dict_accepts_additional_roots() {
281 let roots = vec![VmValue::dict(BTreeMap::from([
282 (
283 "path".to_string(),
284 VmValue::String(arcstr::ArcStr::from("/workspace/lib")),
285 ),
286 (
287 "mount_mode".to_string(),
288 VmValue::String(arcstr::ArcStr::from("extend")),
289 ),
290 (
291 "mounted_at".to_string(),
292 VmValue::String(arcstr::ArcStr::from("2026-05-23T00:00:00Z")),
293 ),
294 ]))];
295 let dict = VmValue::dict(BTreeMap::from([
296 (
297 "primary".to_string(),
298 VmValue::String(arcstr::ArcStr::from("/workspace/main")),
299 ),
300 (
301 "additional_roots".to_string(),
302 VmValue::List(std::sync::Arc::new(roots)),
303 ),
304 ]));
305 let anchor = parse_anchor_dict(&dict).expect("parse with roots");
306 assert_eq!(anchor.additional_roots.len(), 1);
307 assert_eq!(anchor.additional_roots[0].mount_mode, MountMode::Extend);
308 }
309
310 #[test]
311 fn parse_anchor_dict_uses_supplied_default_mount_mode() {
312 let roots = vec![VmValue::dict(BTreeMap::from([(
313 "path".to_string(),
314 VmValue::String(arcstr::ArcStr::from("/workspace/lib")),
315 )]))];
316 let dict = VmValue::dict(BTreeMap::from([
317 (
318 "primary".to_string(),
319 VmValue::String(arcstr::ArcStr::from("/workspace/main")),
320 ),
321 (
322 "additional_roots".to_string(),
323 VmValue::List(std::sync::Arc::new(roots)),
324 ),
325 ]));
326 let anchor =
327 parse_anchor_dict_with_default_mount_mode(&dict, MountMode::Extend).expect("parse");
328 assert_eq!(anchor.additional_roots[0].mount_mode, MountMode::Extend);
329 }
330
331 #[test]
332 fn parse_workspace_policy_accepts_default_mount_mode() {
333 let dict = VmValue::dict(BTreeMap::from([(
334 "default_mount_mode".to_string(),
335 VmValue::String(arcstr::ArcStr::from("sandboxed")),
336 )]));
337 let policy = parse_workspace_policy_dict(&dict).expect("parse policy");
338 assert_eq!(policy.default_mount_mode, MountMode::Sandboxed);
339 }
340}