1use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48
49use crate::core::agent_context::AgentContextView;
50
51const TASK_ID_PLACEHOLDER: &str = "{task_id}";
54
55const DEFAULT_DIR_TEMPLATE: &str = "workspace/tasks/{task_id}/ctx";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
63pub enum RootPreference {
64 #[default]
69 WorkDir,
70 ProjectRoot,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct ProjectionPlacement {
87 pub root_preference: RootPreference,
89 pub dir_template: String,
95}
96
97impl Default for ProjectionPlacement {
98 fn default() -> Self {
100 Self {
101 root_preference: RootPreference::default(),
102 dir_template: DEFAULT_DIR_TEMPLATE.to_string(),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
112pub enum ProjectionPlacementError {
113 #[error("projection_placement.dir_template must not be empty")]
115 EmptyDirTemplate,
116 #[error("projection_placement.dir_template must contain the '{{task_id}}' placeholder: {0:?}")]
118 MissingTaskIdPlaceholder(String),
119 #[error(
121 "projection_placement.dir_template must be a relative path, got an absolute one: {0:?}"
122 )]
123 AbsolutePath(String),
124 #[error("projection_placement.dir_template must not contain '..' path segments: {0:?}")]
126 ParentDirComponent(String),
127 #[error(r#"projection_placement.root must be "work_dir" or "project_root", got {0:?}"#)]
129 InvalidRoot(String),
130}
131
132impl ProjectionPlacement {
133 pub fn from_spec(
144 spec: Option<&mlua_swarm_schema::ProjectionPlacementSpec>,
145 ) -> Result<Self, ProjectionPlacementError> {
146 let Some(spec) = spec else {
147 return Ok(Self::default());
148 };
149 let root_preference = match spec.root.as_deref() {
150 None => RootPreference::default(),
151 Some("work_dir") => RootPreference::WorkDir,
152 Some("project_root") => RootPreference::ProjectRoot,
153 Some(other) => return Err(ProjectionPlacementError::InvalidRoot(other.to_string())),
154 };
155 let dir_template = match &spec.dir_template {
156 None => DEFAULT_DIR_TEMPLATE.to_string(),
157 Some(template) => {
158 Self::validate_dir_template(template)?;
159 template.clone()
160 }
161 };
162 Ok(Self {
163 root_preference,
164 dir_template,
165 })
166 }
167
168 pub fn validate_dir_template(template: &str) -> Result<(), ProjectionPlacementError> {
175 if template.is_empty() {
176 return Err(ProjectionPlacementError::EmptyDirTemplate);
177 }
178 if !template.contains(TASK_ID_PLACEHOLDER) {
179 return Err(ProjectionPlacementError::MissingTaskIdPlaceholder(
180 template.to_string(),
181 ));
182 }
183 if template.starts_with('/') || Path::new(template).is_absolute() {
184 return Err(ProjectionPlacementError::AbsolutePath(template.to_string()));
185 }
186 if template.split('/').any(|segment| segment == "..") {
187 return Err(ProjectionPlacementError::ParentDirComponent(
188 template.to_string(),
189 ));
190 }
191 Ok(())
192 }
193
194 pub fn resolve_root(&self, view: &AgentContextView) -> Option<String> {
201 match self.root_preference {
202 RootPreference::WorkDir => view.work_dir.clone().or_else(|| view.project_root.clone()),
203 RootPreference::ProjectRoot => {
204 view.project_root.clone().or_else(|| view.work_dir.clone())
205 }
206 }
207 }
208
209 pub fn target_dir(&self, root: impl AsRef<Path>, task_id: &str) -> PathBuf {
217 let rendered = self.dir_template.replace(TASK_ID_PLACEHOLDER, task_id);
218 let mut path = root.as_ref().to_path_buf();
219 for segment in rendered.split('/') {
220 if !segment.is_empty() {
221 path = path.join(segment);
222 }
223 }
224 path
225 }
226
227 pub fn target_path(&self, root: impl AsRef<Path>, task_id: &str, stem: &str) -> PathBuf {
232 self.target_dir(root, task_id).join(format!("{stem}.md"))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 fn view(work_dir: Option<&str>, project_root: Option<&str>) -> AgentContextView {
241 AgentContextView {
242 work_dir: work_dir.map(String::from),
243 project_root: project_root.map(String::from),
244 ..AgentContextView::default()
245 }
246 }
247
248 #[test]
251 fn from_spec_none_is_byte_compat_default() {
252 let placement = ProjectionPlacement::from_spec(None).expect("no spec is Ok");
253 assert_eq!(placement, ProjectionPlacement::default());
254 assert_eq!(placement.root_preference, RootPreference::WorkDir);
255 assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
256 }
257
258 #[test]
259 fn from_spec_declared_root_project_root() {
260 let spec = mlua_swarm_schema::ProjectionPlacementSpec {
261 root: Some("project_root".to_string()),
262 dir_template: None,
263 };
264 let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
265 assert_eq!(placement.root_preference, RootPreference::ProjectRoot);
266 assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
268 }
269
270 #[test]
271 fn from_spec_declared_custom_dir_template() {
272 let spec = mlua_swarm_schema::ProjectionPlacementSpec {
273 root: None,
274 dir_template: Some("custom/{task_id}/out".to_string()),
275 };
276 let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
277 assert_eq!(placement.root_preference, RootPreference::WorkDir);
278 assert_eq!(placement.dir_template, "custom/{task_id}/out");
279 }
280
281 #[test]
282 fn from_spec_rejects_invalid_root_literal() {
283 let spec = mlua_swarm_schema::ProjectionPlacementSpec {
284 root: Some("nope".to_string()),
285 dir_template: None,
286 };
287 let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("invalid root rejected");
288 assert!(matches!(err, ProjectionPlacementError::InvalidRoot(r) if r == "nope"));
289 }
290
291 #[test]
294 fn validate_dir_template_rejects_empty() {
295 let err = ProjectionPlacement::validate_dir_template("").unwrap_err();
296 assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
297 }
298
299 #[test]
300 fn validate_dir_template_rejects_missing_task_id_placeholder() {
301 let err = ProjectionPlacement::validate_dir_template("workspace/tasks/ctx").unwrap_err();
302 assert!(
303 matches!(err, ProjectionPlacementError::MissingTaskIdPlaceholder(t) if t == "workspace/tasks/ctx")
304 );
305 }
306
307 #[test]
308 fn validate_dir_template_rejects_absolute_path() {
309 let err = ProjectionPlacement::validate_dir_template("/abs/{task_id}").unwrap_err();
310 assert!(matches!(err, ProjectionPlacementError::AbsolutePath(t) if t == "/abs/{task_id}"));
311 }
312
313 #[test]
314 fn validate_dir_template_rejects_parent_dir_component() {
315 let err = ProjectionPlacement::validate_dir_template("../{task_id}/ctx").unwrap_err();
316 assert!(
317 matches!(err, ProjectionPlacementError::ParentDirComponent(t) if t == "../{task_id}/ctx")
318 );
319 }
320
321 #[test]
322 fn validate_dir_template_accepts_default() {
323 ProjectionPlacement::validate_dir_template("workspace/tasks/{task_id}/ctx")
324 .expect("default template is valid");
325 }
326
327 #[test]
328 fn from_spec_propagates_dir_template_validation_error() {
329 let spec = mlua_swarm_schema::ProjectionPlacementSpec {
330 root: None,
331 dir_template: Some(String::new()),
332 };
333 let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("empty template rejected");
334 assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
335 }
336
337 #[test]
340 fn resolve_root_work_dir_preference_prefers_work_dir() {
341 let placement = ProjectionPlacement::default();
342 let v = view(Some("/work"), Some("/proj"));
343 assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
344 }
345
346 #[test]
347 fn resolve_root_work_dir_preference_falls_back_to_project_root() {
348 let placement = ProjectionPlacement::default();
349 let v = view(None, Some("/proj"));
350 assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
351 }
352
353 #[test]
354 fn resolve_root_project_root_preference_prefers_project_root() {
355 let placement = ProjectionPlacement {
356 root_preference: RootPreference::ProjectRoot,
357 ..ProjectionPlacement::default()
358 };
359 let v = view(Some("/work"), Some("/proj"));
360 assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
361 }
362
363 #[test]
364 fn resolve_root_project_root_preference_falls_back_to_work_dir() {
365 let placement = ProjectionPlacement {
366 root_preference: RootPreference::ProjectRoot,
367 ..ProjectionPlacement::default()
368 };
369 let v = view(Some("/work"), None);
370 assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
371 }
372
373 #[test]
374 fn resolve_root_neither_present_is_none() {
375 let placement = ProjectionPlacement::default();
376 let v = view(None, None);
377 assert_eq!(placement.resolve_root(&v), None);
378 }
379
380 #[test]
383 fn target_path_default_matches_pre_gh24_hardcoded_layout() {
384 let placement = ProjectionPlacement::default();
385 let path = placement.target_path("/root", "T-1", "_ctx");
386 assert_eq!(
387 path,
388 std::path::Path::new("/root/workspace/tasks/T-1/ctx/_ctx.md")
389 );
390 }
391
392 #[test]
393 fn target_path_custom_template_substitutes_task_id() {
394 let placement = ProjectionPlacement {
395 root_preference: RootPreference::default(),
396 dir_template: "custom/{task_id}/out".to_string(),
397 };
398 let path = placement.target_path("/root", "T-2", "planner");
399 assert_eq!(
400 path,
401 std::path::Path::new("/root/custom/T-2/out/planner.md")
402 );
403 }
404
405 #[test]
406 fn target_dir_and_target_path_are_write_read_consistent() {
407 let placement = ProjectionPlacement {
413 root_preference: RootPreference::ProjectRoot,
414 dir_template: "custom/{task_id}/out".to_string(),
415 };
416 let written = placement.target_path("/proj", "T-3", "reviewer");
417 let read_back = placement.target_path("/proj", "T-3", "reviewer");
418 assert_eq!(written, read_back);
419 assert_eq!(
420 written,
421 std::path::Path::new("/proj/custom/T-3/out/reviewer.md")
422 );
423 }
424
425 #[test]
433 fn serde_round_trip_preserves_custom_placement() {
434 let placement = ProjectionPlacement {
435 root_preference: RootPreference::ProjectRoot,
436 dir_template: "custom/{task_id}/out".to_string(),
437 };
438 let json = serde_json::to_value(&placement).expect("serializes");
439 let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
440 assert_eq!(back, placement);
441 }
442
443 #[test]
444 fn serde_round_trip_preserves_default_placement() {
445 let placement = ProjectionPlacement::default();
446 let json = serde_json::to_value(&placement).expect("serializes");
447 let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
448 assert_eq!(back, placement);
449 }
450}