use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::agent_context::AgentContextView;
const TASK_ID_PLACEHOLDER: &str = "{task_id}";
const DEFAULT_DIR_TEMPLATE: &str = "workspace/tasks/{task_id}/ctx";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum RootPreference {
#[default]
WorkDir,
ProjectRoot,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectionPlacement {
pub root_preference: RootPreference,
pub dir_template: String,
}
impl Default for ProjectionPlacement {
fn default() -> Self {
Self {
root_preference: RootPreference::default(),
dir_template: DEFAULT_DIR_TEMPLATE.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ProjectionPlacementError {
#[error("projection_placement.dir_template must not be empty")]
EmptyDirTemplate,
#[error("projection_placement.dir_template must contain the '{{task_id}}' placeholder: {0:?}")]
MissingTaskIdPlaceholder(String),
#[error(
"projection_placement.dir_template must be a relative path, got an absolute one: {0:?}"
)]
AbsolutePath(String),
#[error("projection_placement.dir_template must not contain '..' path segments: {0:?}")]
ParentDirComponent(String),
#[error(r#"projection_placement.root must be "work_dir" or "project_root", got {0:?}"#)]
InvalidRoot(String),
}
impl ProjectionPlacement {
pub fn from_spec(
spec: Option<&mlua_swarm_schema::ProjectionPlacementSpec>,
) -> Result<Self, ProjectionPlacementError> {
let Some(spec) = spec else {
return Ok(Self::default());
};
let root_preference = match spec.root.as_deref() {
None => RootPreference::default(),
Some("work_dir") => RootPreference::WorkDir,
Some("project_root") => RootPreference::ProjectRoot,
Some(other) => return Err(ProjectionPlacementError::InvalidRoot(other.to_string())),
};
let dir_template = match &spec.dir_template {
None => DEFAULT_DIR_TEMPLATE.to_string(),
Some(template) => {
Self::validate_dir_template(template)?;
template.clone()
}
};
Ok(Self {
root_preference,
dir_template,
})
}
pub fn validate_dir_template(template: &str) -> Result<(), ProjectionPlacementError> {
if template.is_empty() {
return Err(ProjectionPlacementError::EmptyDirTemplate);
}
if !template.contains(TASK_ID_PLACEHOLDER) {
return Err(ProjectionPlacementError::MissingTaskIdPlaceholder(
template.to_string(),
));
}
if template.starts_with('/') || Path::new(template).is_absolute() {
return Err(ProjectionPlacementError::AbsolutePath(template.to_string()));
}
if template.split('/').any(|segment| segment == "..") {
return Err(ProjectionPlacementError::ParentDirComponent(
template.to_string(),
));
}
Ok(())
}
pub fn resolve_root(&self, view: &AgentContextView) -> Option<String> {
match self.root_preference {
RootPreference::WorkDir => view.work_dir.clone().or_else(|| view.project_root.clone()),
RootPreference::ProjectRoot => {
view.project_root.clone().or_else(|| view.work_dir.clone())
}
}
}
pub fn target_dir(&self, root: impl AsRef<Path>, task_id: &str) -> PathBuf {
let rendered = self.dir_template.replace(TASK_ID_PLACEHOLDER, task_id);
let mut path = root.as_ref().to_path_buf();
for segment in rendered.split('/') {
if !segment.is_empty() {
path = path.join(segment);
}
}
path
}
pub fn target_path(&self, root: impl AsRef<Path>, task_id: &str, stem: &str) -> PathBuf {
self.target_dir(root, task_id).join(format!("{stem}.md"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn view(work_dir: Option<&str>, project_root: Option<&str>) -> AgentContextView {
AgentContextView {
work_dir: work_dir.map(String::from),
project_root: project_root.map(String::from),
..AgentContextView::default()
}
}
#[test]
fn from_spec_none_is_byte_compat_default() {
let placement = ProjectionPlacement::from_spec(None).expect("no spec is Ok");
assert_eq!(placement, ProjectionPlacement::default());
assert_eq!(placement.root_preference, RootPreference::WorkDir);
assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
}
#[test]
fn from_spec_declared_root_project_root() {
let spec = mlua_swarm_schema::ProjectionPlacementSpec {
root: Some("project_root".to_string()),
dir_template: None,
};
let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
assert_eq!(placement.root_preference, RootPreference::ProjectRoot);
assert_eq!(placement.dir_template, "workspace/tasks/{task_id}/ctx");
}
#[test]
fn from_spec_declared_custom_dir_template() {
let spec = mlua_swarm_schema::ProjectionPlacementSpec {
root: None,
dir_template: Some("custom/{task_id}/out".to_string()),
};
let placement = ProjectionPlacement::from_spec(Some(&spec)).expect("valid spec");
assert_eq!(placement.root_preference, RootPreference::WorkDir);
assert_eq!(placement.dir_template, "custom/{task_id}/out");
}
#[test]
fn from_spec_rejects_invalid_root_literal() {
let spec = mlua_swarm_schema::ProjectionPlacementSpec {
root: Some("nope".to_string()),
dir_template: None,
};
let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("invalid root rejected");
assert!(matches!(err, ProjectionPlacementError::InvalidRoot(r) if r == "nope"));
}
#[test]
fn validate_dir_template_rejects_empty() {
let err = ProjectionPlacement::validate_dir_template("").unwrap_err();
assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
}
#[test]
fn validate_dir_template_rejects_missing_task_id_placeholder() {
let err = ProjectionPlacement::validate_dir_template("workspace/tasks/ctx").unwrap_err();
assert!(
matches!(err, ProjectionPlacementError::MissingTaskIdPlaceholder(t) if t == "workspace/tasks/ctx")
);
}
#[test]
fn validate_dir_template_rejects_absolute_path() {
let err = ProjectionPlacement::validate_dir_template("/abs/{task_id}").unwrap_err();
assert!(matches!(err, ProjectionPlacementError::AbsolutePath(t) if t == "/abs/{task_id}"));
}
#[test]
fn validate_dir_template_rejects_parent_dir_component() {
let err = ProjectionPlacement::validate_dir_template("../{task_id}/ctx").unwrap_err();
assert!(
matches!(err, ProjectionPlacementError::ParentDirComponent(t) if t == "../{task_id}/ctx")
);
}
#[test]
fn validate_dir_template_accepts_default() {
ProjectionPlacement::validate_dir_template("workspace/tasks/{task_id}/ctx")
.expect("default template is valid");
}
#[test]
fn from_spec_propagates_dir_template_validation_error() {
let spec = mlua_swarm_schema::ProjectionPlacementSpec {
root: None,
dir_template: Some(String::new()),
};
let err = ProjectionPlacement::from_spec(Some(&spec)).expect_err("empty template rejected");
assert_eq!(err, ProjectionPlacementError::EmptyDirTemplate);
}
#[test]
fn resolve_root_work_dir_preference_prefers_work_dir() {
let placement = ProjectionPlacement::default();
let v = view(Some("/work"), Some("/proj"));
assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
}
#[test]
fn resolve_root_work_dir_preference_falls_back_to_project_root() {
let placement = ProjectionPlacement::default();
let v = view(None, Some("/proj"));
assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
}
#[test]
fn resolve_root_project_root_preference_prefers_project_root() {
let placement = ProjectionPlacement {
root_preference: RootPreference::ProjectRoot,
..ProjectionPlacement::default()
};
let v = view(Some("/work"), Some("/proj"));
assert_eq!(placement.resolve_root(&v).as_deref(), Some("/proj"));
}
#[test]
fn resolve_root_project_root_preference_falls_back_to_work_dir() {
let placement = ProjectionPlacement {
root_preference: RootPreference::ProjectRoot,
..ProjectionPlacement::default()
};
let v = view(Some("/work"), None);
assert_eq!(placement.resolve_root(&v).as_deref(), Some("/work"));
}
#[test]
fn resolve_root_neither_present_is_none() {
let placement = ProjectionPlacement::default();
let v = view(None, None);
assert_eq!(placement.resolve_root(&v), None);
}
#[test]
fn target_path_default_matches_pre_gh24_hardcoded_layout() {
let placement = ProjectionPlacement::default();
let path = placement.target_path("/root", "T-1", "_ctx");
assert_eq!(
path,
std::path::Path::new("/root/workspace/tasks/T-1/ctx/_ctx.md")
);
}
#[test]
fn target_path_custom_template_substitutes_task_id() {
let placement = ProjectionPlacement {
root_preference: RootPreference::default(),
dir_template: "custom/{task_id}/out".to_string(),
};
let path = placement.target_path("/root", "T-2", "planner");
assert_eq!(
path,
std::path::Path::new("/root/custom/T-2/out/planner.md")
);
}
#[test]
fn target_dir_and_target_path_are_write_read_consistent() {
let placement = ProjectionPlacement {
root_preference: RootPreference::ProjectRoot,
dir_template: "custom/{task_id}/out".to_string(),
};
let written = placement.target_path("/proj", "T-3", "reviewer");
let read_back = placement.target_path("/proj", "T-3", "reviewer");
assert_eq!(written, read_back);
assert_eq!(
written,
std::path::Path::new("/proj/custom/T-3/out/reviewer.md")
);
}
#[test]
fn serde_round_trip_preserves_custom_placement() {
let placement = ProjectionPlacement {
root_preference: RootPreference::ProjectRoot,
dir_template: "custom/{task_id}/out".to_string(),
};
let json = serde_json::to_value(&placement).expect("serializes");
let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, placement);
}
#[test]
fn serde_round_trip_preserves_default_placement() {
let placement = ProjectionPlacement::default();
let json = serde_json::to_value(&placement).expect("serializes");
let back: ProjectionPlacement = serde_json::from_value(json).expect("deserializes");
assert_eq!(back, placement);
}
}