use serde::Deserialize;
use std::io::Cursor;
use tiny_http::Response;
use lex_store::{Store, StoreError};
use crate::handlers::{error_response, error_with_detail, json_response, State};
pub const UNATTRIBUTED_PROMPT: &str = "(unattributed: published without --intent-prompt)";
pub fn split_model_ref(m: Option<&str>) -> (String, String) {
match m {
None => ("cli".to_string(), "unknown".to_string()),
Some(s) => match s.split_once('/') {
Some((p, n)) if !p.is_empty() && !n.is_empty() => (p.to_string(), n.to_string()),
_ => ("cli".to_string(), s.to_string()),
},
}
}
pub fn build_intent(
prompt: Option<String>,
model: Option<String>,
session: Option<String>,
issue: Option<String>,
default_session: impl FnOnce() -> String,
) -> lex_vcs::Intent {
let prompt = prompt.unwrap_or_else(|| UNATTRIBUTED_PROMPT.to_string());
let (provider, name) = split_model_ref(model.as_deref());
let intent = lex_vcs::Intent::new(
prompt,
session.unwrap_or_else(default_session),
lex_vcs::ModelDescriptor { provider, name, version: None },
None,
);
match issue {
Some(id) => intent.with_issue(id),
None => intent,
}
}
pub fn default_http_session() -> String {
let started = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("http-{}-{started}", std::process::id())
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntentSpec {
pub prompt: Option<String>,
pub model: Option<String>,
pub session: Option<String>,
pub issue_id: Option<String>,
}
impl IntentSpec {
pub fn into_intent(
self,
default_session: impl FnOnce() -> String,
) -> Result<lex_vcs::Intent, String> {
if let Some(p) = &self.prompt {
if p.trim().is_empty() {
return Err("intent.prompt must not be blank (omit it to record the write as unattributed)".into());
}
}
Ok(build_intent(self.prompt, self.model, self.session, self.issue_id, default_session))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TransformSpec {
ReplaceMatchArm {
from_stage_id: String,
match_node: String,
arm_index: usize,
new_body: lex_ast::CExpr,
},
RenameLocal {
from_stage_id: String,
let_node: String,
new_name: String,
},
InlineLet {
from_stage_id: String,
let_node: String,
},
ExtractFunction {
from_stage_id: String,
expr_node: String,
spec: ExtractSpec,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExtractSpec {
pub name: String,
#[serde(default)]
pub type_params: Vec<String>,
pub params: Vec<lex_ast::Param>,
pub return_type: lex_ast::TypeExpr,
#[serde(default)]
pub effects: Vec<lex_ast::Effect>,
}
impl TransformSpec {
pub fn kind(&self) -> &'static str {
match self {
TransformSpec::ReplaceMatchArm { .. } => "replace_match_arm",
TransformSpec::RenameLocal { .. } => "rename_local",
TransformSpec::InlineLet { .. } => "inline_let",
TransformSpec::ExtractFunction { .. } => "extract_function",
}
}
fn source_stage_id(&self) -> &str {
match self {
TransformSpec::ReplaceMatchArm { from_stage_id, .. }
| TransformSpec::RenameLocal { from_stage_id, .. }
| TransformSpec::InlineLet { from_stage_id, .. }
| TransformSpec::ExtractFunction { from_stage_id, .. } => from_stage_id,
}
}
}
#[derive(Debug, Clone)]
pub struct Applied {
pub branch: String,
pub kind: &'static str,
pub op_ids: Vec<lex_vcs::OpId>,
pub prev_head: Option<lex_vcs::OpId>,
pub new_head: Option<lex_vcs::OpId>,
pub new_stage_id: Option<String>,
pub extracted: Option<(String, String)>,
pub intent_id: String,
pub session_id: String,
pub unattributed: bool,
}
impl Applied {
pub fn to_json(&self) -> serde_json::Value {
let mut v = serde_json::json!({
"ok": true,
"branch": self.branch,
"kind": self.kind,
"op_id": self.op_ids.last(),
"op_ids": self.op_ids,
"prev_head": self.prev_head,
"new_head": self.new_head,
"new_stage_id": self.new_stage_id,
"intent": {
"intent_id": self.intent_id,
"session_id": self.session_id,
"unattributed": self.unattributed,
},
});
if let Some((sig, stage)) = &self.extracted {
v["extracted"] = serde_json::json!({ "sig_id": sig, "stage_id": stage });
}
v
}
}
pub fn apply_transform(
store: &Store,
branch: &str,
spec: &TransformSpec,
intent: &lex_vcs::Intent,
) -> Result<Applied, StoreError> {
if !store.list_branches()?.iter().any(|b| b == branch) {
return Err(StoreError::UnknownBranch(branch.to_string()));
}
let from = spec.source_stage_id();
let from_ast = store.get_ast(from)?;
if let Some(sig) = lex_ast::sig_id(&from_ast) {
let head = store.branch_head(branch)?;
match head.get(&sig) {
Some(cur) if cur != from => {
return Err(StoreError::InvalidTransition(format!(
"stale from_stage_id `{from}`: branch `{branch}` head binds sig `{sig}` to `{cur}`"
)));
}
_ => {}
}
}
let prev_head = store.get_branch(branch)?.and_then(|b| b.head_op);
let node = |s: &str| lex_ast::NodeId(s.to_string());
let op_ids = match spec {
TransformSpec::ReplaceMatchArm { from_stage_id, match_node, arm_index, new_body } => {
vec![store.apply_replace_match_arm_with_intent(
branch,
from_stage_id,
&node(match_node),
*arm_index,
new_body.clone(),
Some(intent),
)?]
}
TransformSpec::RenameLocal { from_stage_id, let_node, new_name } => {
vec![store.apply_rename_local_with_intent(
branch,
from_stage_id,
&node(let_node),
new_name,
Some(intent),
)?]
}
TransformSpec::InlineLet { from_stage_id, let_node } => {
vec![store.apply_inline_let_with_intent(
branch,
from_stage_id,
&node(let_node),
Some(intent),
)?]
}
TransformSpec::ExtractFunction { from_stage_id, expr_node, spec } => {
let (add, modify) = store.apply_extract_function_with_intent(
branch,
from_stage_id,
&node(expr_node),
lex_ast::ExtractFnSpec {
name: spec.name.clone(),
type_params: spec.type_params.clone(),
params: spec.params.clone(),
return_type: spec.return_type.clone(),
effects: spec.effects.clone(),
},
Some(intent),
)?;
vec![add, modify]
}
};
let new_head = store.get_branch(branch)?.and_then(|b| b.head_op);
let new_stage_id = lex_ast::sig_id(&from_ast)
.and_then(|sig| store.branch_head(branch).ok().and_then(|h| h.get(&sig).cloned()));
let extracted = match spec {
TransformSpec::ExtractFunction { .. } => {
let log = lex_vcs::OpLog::open(store.root())?;
match op_ids.first().and_then(|id| log.get(id).ok().flatten()) {
Some(rec) => match rec.op.kind {
lex_vcs::OperationKind::AddFunction { sig_id, stage_id, .. } => {
Some((sig_id, stage_id))
}
_ => None,
},
None => None,
}
}
_ => None,
};
Ok(Applied {
branch: branch.to_string(),
kind: spec.kind(),
op_ids,
prev_head,
new_head,
new_stage_id,
extracted,
intent_id: intent.intent_id.clone(),
session_id: intent.session_id.clone(),
unattributed: intent.prompt == UNATTRIBUTED_PROMPT,
})
}
#[derive(Deserialize)]
struct TransformReq {
branch: Option<String>,
#[serde(default)]
intent: Option<IntentSpec>,
transform: TransformSpec,
}
pub(crate) fn transform_error_response(err: StoreError) -> Response<Cursor<Vec<u8>>> {
match err {
StoreError::UnknownBranch(_) | StoreError::UnknownStage(_) | StoreError::UnknownSig(_) => {
error_response(404, err.to_string())
}
StoreError::TransformError(ref e) => error_with_detail(
422,
format!("transform failed: {e}"),
serde_json::json!({ "kind": "transform_error", "message": e.to_string() }),
),
StoreError::TypeError(ref errs) => error_with_detail(
422,
"type errors after transform",
serde_json::json!({
"kind": "type_errors",
"errors": serde_json::to_value(errs).unwrap_or_default(),
}),
),
StoreError::InvalidTransition(_) => error_response(409, err.to_string()),
other => crate::handlers::write_error_response("transform", other),
}
}
pub(crate) fn transform_handler(state: &State, body: &str) -> Response<Cursor<Vec<u8>>> {
let req: TransformReq = match serde_json::from_str(body) {
Ok(r) => r,
Err(e) => return error_response(400, format!("bad request: {e}")),
};
let Some(branch) = req.branch.filter(|b| !b.is_empty()) else {
return error_response(
400,
"bad request: `branch` is required (the server's current branch is never assumed)",
);
};
let intent = match req.intent.unwrap_or_default().into_intent(default_http_session) {
Ok(i) => i,
Err(e) => return error_response(400, format!("bad request: {e}")),
};
let store = state.store.lock().unwrap();
match apply_transform(&store, &branch, &req.transform, &intent) {
Ok(applied) => json_response(200, &applied.to_json()),
Err(e) => transform_error_response(e),
}
}