use crate::core::projection_placement::ProjectionPlacement;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ProjectionKey {
pub task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl ProjectionKey {
pub fn resolve<'a>(&self, ctx_data: &'a Value) -> Option<&'a Value> {
let mut current = match &self.step {
Some(step) => ctx_data.get(step)?,
None => ctx_data,
};
if let Some(path) = &self.path {
let path = path.strip_prefix("$.").unwrap_or(path.as_str());
for segment in path.split('.').filter(|s| !s.is_empty()) {
current = current.get(segment)?;
}
}
Some(current)
}
fn step_slug(&self) -> &str {
self.step.as_deref().unwrap_or("_ctx")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub enum ProjectionRef {
File {
path: String,
},
Query {
endpoint: String,
key: ProjectionKey,
},
}
#[derive(Debug, Error)]
pub enum ProjectionError {
#[error("projection not found for key {0:?}")]
NotFound(ProjectionKey),
#[error("projection io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid projection key: {0}")]
InvalidKey(String),
#[error("projection serialize error: {0}")]
Serialize(String),
}
pub trait ProjectionAdapter: Send + Sync {
fn name(&self) -> &'static str;
fn project(
&self,
key: &ProjectionKey,
ctx_data: &Value,
) -> Result<ProjectionRef, ProjectionError>;
fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError>;
fn pointer_line(&self, r: &ProjectionRef) -> String;
}
pub struct FileProjectionAdapter {
root: PathBuf,
placement: ProjectionPlacement,
}
impl FileProjectionAdapter {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
placement: ProjectionPlacement::default(),
}
}
pub fn with_placement(root: impl Into<PathBuf>, placement: ProjectionPlacement) -> Self {
Self {
root: root.into(),
placement,
}
}
fn target_path(&self, key: &ProjectionKey) -> PathBuf {
self.placement
.target_path(&self.root, &key.task_id, key.step_slug())
}
pub fn materialize_submission(
&self,
key: &ProjectionKey,
value: &Value,
attempt: u32,
ok: bool,
) -> Result<ProjectionRef, ProjectionError> {
if key.task_id.is_empty() {
return Err(ProjectionError::InvalidKey(
"task_id must not be empty".to_string(),
));
}
let target = self.target_path(key);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, render_submission_file(key, value, attempt, ok)?)?;
Ok(ProjectionRef::File {
path: target.to_string_lossy().into_owned(),
})
}
}
#[derive(Serialize)]
struct SubmissionFrontMatter<'a> {
#[serde(flatten)]
key: &'a ProjectionKey,
attempt: u32,
ok: bool,
}
fn render_submission_file(
key: &ProjectionKey,
value: &Value,
attempt: u32,
ok: bool,
) -> Result<String, ProjectionError> {
let front = SubmissionFrontMatter { key, attempt, ok };
let front_matter =
serde_yaml::to_string(&front).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
let body = serde_json::to_string_pretty(value)
.map_err(|err| ProjectionError::Serialize(err.to_string()))?;
Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
}
impl ProjectionAdapter for FileProjectionAdapter {
fn name(&self) -> &'static str {
"file"
}
fn project(
&self,
key: &ProjectionKey,
ctx_data: &Value,
) -> Result<ProjectionRef, ProjectionError> {
if key.task_id.is_empty() {
return Err(ProjectionError::InvalidKey(
"task_id must not be empty".to_string(),
));
}
let value = key
.resolve(ctx_data)
.ok_or_else(|| ProjectionError::NotFound(key.clone()))?;
let target = self.target_path(key);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, render_projection_file(key, value)?)?;
Ok(ProjectionRef::File {
path: target.to_string_lossy().into_owned(),
})
}
fn fetch(&self, key: &ProjectionKey) -> Result<Value, ProjectionError> {
let target = self.target_path(key);
let body = fs::read_to_string(&target).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ProjectionError::NotFound(key.clone())
} else {
ProjectionError::Io(err)
}
})?;
parse_projection_file(&body)
}
fn pointer_line(&self, r: &ProjectionRef) -> String {
match r {
ProjectionRef::File { path } => format!("projection(file): {path}"),
ProjectionRef::Query { endpoint, key } => {
format!("projection(mcp-query): {endpoint} task_id={}", key.task_id)
}
}
}
}
fn render_projection_file(key: &ProjectionKey, value: &Value) -> Result<String, ProjectionError> {
let front_matter =
serde_yaml::to_string(key).map_err(|err| ProjectionError::Serialize(err.to_string()))?;
let body = serde_json::to_string_pretty(value)
.map_err(|err| ProjectionError::Serialize(err.to_string()))?;
Ok(format!("---\n{front_matter}---\n\n```json\n{body}\n```\n"))
}
fn parse_projection_file(text: &str) -> Result<Value, ProjectionError> {
const FENCE_OPEN: &str = "```json";
const FENCE_CLOSE: &str = "```";
let after_open = text.find(FENCE_OPEN).ok_or_else(|| {
ProjectionError::Serialize("materialized projection file missing ```json block".into())
})?;
let body_start = after_open + FENCE_OPEN.len();
let close_offset = text[body_start..].find(FENCE_CLOSE).ok_or_else(|| {
ProjectionError::Serialize("materialized projection file missing closing ``` fence".into())
})?;
let json_body = text[body_start..body_start + close_offset].trim();
serde_json::from_str(json_body).map_err(|err| ProjectionError::Serialize(err.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::projection_placement::RootPreference;
use serde_json::json;
use std::path::Path;
use tempfile::TempDir;
fn sample_ctx_data() -> Value {
json!({
"planner": {
"plan": "do the thing",
"nested": { "field": 42 }
},
"other_step": { "value": "x" }
})
}
fn key(step: Option<&str>, path: Option<&str>) -> ProjectionKey {
ProjectionKey {
task_id: "T-1".to_string(),
run_id: None,
step: step.map(str::to_string),
path: path.map(str::to_string),
}
}
#[test]
fn resolve_step_only_returns_step_value() {
let ctx_data = sample_ctx_data();
let resolved = key(Some("planner"), None).resolve(&ctx_data).unwrap();
assert_eq!(
resolved,
&json!({"plan": "do the thing", "nested": {"field": 42}})
);
}
#[test]
fn resolve_step_and_path_narrows_to_field() {
let ctx_data = sample_ctx_data();
let resolved = key(Some("planner"), Some("$.nested.field"))
.resolve(&ctx_data)
.unwrap();
assert_eq!(resolved, &json!(42));
}
#[test]
fn resolve_missing_step_returns_none() {
assert!(key(Some("does-not-exist"), None)
.resolve(&sample_ctx_data())
.is_none());
}
#[test]
fn resolve_missing_path_returns_none() {
assert!(key(Some("planner"), Some("$.nested.missing"))
.resolve(&sample_ctx_data())
.is_none());
}
#[test]
fn file_adapter_project_then_fetch_round_trips() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("planner"), None);
let ctx_data = sample_ctx_data();
let reference = adapter.project(&k, &ctx_data).unwrap();
let path = match &reference {
ProjectionRef::File { path } => path.clone(),
other => panic!("expected File ref, got {other:?}"),
};
assert!(Path::new(&path).exists());
let fetched = adapter.fetch(&k).unwrap();
assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
}
#[test]
fn file_adapter_with_placement_uses_custom_dir_template() {
let dir = TempDir::new().unwrap();
let placement = ProjectionPlacement {
root_preference: RootPreference::WorkDir,
dir_template: "custom/{task_id}/out".to_string(),
};
let adapter = FileProjectionAdapter::with_placement(dir.path(), placement);
let k = key(Some("planner"), None);
let ctx_data = sample_ctx_data();
let reference = adapter.project(&k, &ctx_data).unwrap();
let path = match &reference {
ProjectionRef::File { path } => path.clone(),
other => panic!("expected File ref, got {other:?}"),
};
assert!(
path.ends_with("custom/T-1/out/planner.md"),
"path must follow the custom dir_template: {path}"
);
assert!(Path::new(&path).exists());
let fetched = adapter.fetch(&k).unwrap();
assert_eq!(&fetched, k.resolve(&ctx_data).unwrap());
}
#[test]
fn file_adapter_reproject_is_idempotent() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("planner"), None);
let ctx_data = sample_ctx_data();
let first = adapter.project(&k, &ctx_data).unwrap();
let second = adapter.project(&k, &ctx_data).unwrap();
assert_eq!(first, second);
assert_eq!(adapter.fetch(&k).unwrap(), adapter.fetch(&k).unwrap());
}
#[test]
fn pointer_line_carries_path_not_value() {
let reference = ProjectionRef::File {
path: "/tmp/some/materialized.md".to_string(),
};
let adapter = FileProjectionAdapter::new("/unused");
let line = adapter.pointer_line(&reference);
assert!(line.contains("/tmp/some/materialized.md"));
assert!(!line.contains('{'));
}
#[test]
fn fetch_missing_projection_returns_not_found() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("nope"), None);
let err = adapter.fetch(&k).unwrap_err();
assert!(matches!(err, ProjectionError::NotFound(_)));
}
#[test]
fn project_rejects_empty_task_id() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let mut k = key(Some("planner"), None);
k.task_id = String::new();
let err = adapter.project(&k, &sample_ctx_data()).unwrap_err();
assert!(matches!(err, ProjectionError::InvalidKey(_)));
}
#[test]
fn materialize_submission_writes_value_directly_and_fetch_reads_it_back() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("planner"), None);
let value = json!({"plan": "do the thing"});
let reference = adapter.materialize_submission(&k, &value, 1, true).unwrap();
let path = match &reference {
ProjectionRef::File { path } => path.clone(),
other => panic!("expected File ref, got {other:?}"),
};
assert!(Path::new(&path).exists());
let fetched = adapter.fetch(&k).unwrap();
assert_eq!(fetched, value);
}
#[test]
fn materialize_submission_front_matter_carries_attempt_and_ok() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("reviewer"), None);
let reference = adapter
.materialize_submission(&k, &json!("hi"), 2, false)
.unwrap();
let path = match &reference {
ProjectionRef::File { path } => path.clone(),
other => panic!("expected File ref, got {other:?}"),
};
let body = std::fs::read_to_string(path).unwrap();
assert!(body.contains("attempt: 2"), "front matter: {body}");
assert!(body.contains("ok: false"), "front matter: {body}");
assert!(body.contains("step: reviewer"), "front matter: {body}");
}
#[test]
fn materialize_submission_resubmit_overwrites_with_latest() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let k = key(Some("planner"), None);
adapter
.materialize_submission(&k, &json!("first"), 1, true)
.unwrap();
adapter
.materialize_submission(&k, &json!("second"), 1, true)
.unwrap();
assert_eq!(adapter.fetch(&k).unwrap(), json!("second"));
}
#[test]
fn materialize_submission_rejects_empty_task_id() {
let dir = TempDir::new().unwrap();
let adapter = FileProjectionAdapter::new(dir.path());
let mut k = key(Some("planner"), None);
k.task_id = String::new();
let err = adapter
.materialize_submission(&k, &json!("x"), 1, true)
.unwrap_err();
assert!(matches!(err, ProjectionError::InvalidKey(_)));
}
}