1use serde::Deserialize;
59use std::io::Cursor;
60use tiny_http::Response;
61
62use lex_store::{Store, StoreError};
63
64use crate::handlers::{error_response, error_with_detail, json_response, State};
65
66pub const UNATTRIBUTED_PROMPT: &str = "(unattributed: published without --intent-prompt)";
73
74pub fn split_model_ref(m: Option<&str>) -> (String, String) {
78 match m {
79 None => ("cli".to_string(), "unknown".to_string()),
80 Some(s) => match s.split_once('/') {
81 Some((p, n)) if !p.is_empty() && !n.is_empty() => (p.to_string(), n.to_string()),
82 _ => ("cli".to_string(), s.to_string()),
83 },
84 }
85}
86
87pub fn build_intent(
92 prompt: Option<String>,
93 model: Option<String>,
94 session: Option<String>,
95 issue: Option<String>,
96 default_session: impl FnOnce() -> String,
97) -> lex_vcs::Intent {
98 let prompt = prompt.unwrap_or_else(|| UNATTRIBUTED_PROMPT.to_string());
99 let (provider, name) = split_model_ref(model.as_deref());
100 let intent = lex_vcs::Intent::new(
101 prompt,
102 session.unwrap_or_else(default_session),
103 lex_vcs::ModelDescriptor { provider, name, version: None },
104 None,
105 );
106 match issue {
107 Some(id) => intent.with_issue(id),
108 None => intent,
109 }
110}
111
112pub fn default_http_session() -> String {
116 let started = std::time::SystemTime::now()
117 .duration_since(std::time::UNIX_EPOCH)
118 .map(|d| d.as_secs())
119 .unwrap_or(0);
120 format!("http-{}-{started}", std::process::id())
121}
122
123#[derive(Debug, Default, Clone, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct IntentSpec {
127 pub prompt: Option<String>,
128 pub model: Option<String>,
130 pub session: Option<String>,
131 pub issue_id: Option<String>,
132}
133
134impl IntentSpec {
135 pub fn into_intent(
138 self,
139 default_session: impl FnOnce() -> String,
140 ) -> Result<lex_vcs::Intent, String> {
141 if let Some(p) = &self.prompt {
142 if p.trim().is_empty() {
143 return Err("intent.prompt must not be blank (omit it to record the write as unattributed)".into());
144 }
145 }
146 Ok(build_intent(self.prompt, self.model, self.session, self.issue_id, default_session))
147 }
148}
149
150#[derive(Debug, Clone, Deserialize)]
154#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
155pub enum TransformSpec {
156 ReplaceMatchArm {
157 from_stage_id: String,
158 match_node: String,
159 arm_index: usize,
160 new_body: lex_ast::CExpr,
161 },
162 RenameLocal {
163 from_stage_id: String,
164 let_node: String,
165 new_name: String,
166 },
167 InlineLet {
168 from_stage_id: String,
169 let_node: String,
170 },
171 ExtractFunction {
172 from_stage_id: String,
173 expr_node: String,
174 spec: ExtractSpec,
175 },
176}
177
178#[derive(Debug, Clone, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct ExtractSpec {
182 pub name: String,
183 #[serde(default)]
184 pub type_params: Vec<String>,
185 pub params: Vec<lex_ast::Param>,
186 pub return_type: lex_ast::TypeExpr,
187 #[serde(default)]
188 pub effects: Vec<lex_ast::Effect>,
189}
190
191impl TransformSpec {
192 pub fn kind(&self) -> &'static str {
193 match self {
194 TransformSpec::ReplaceMatchArm { .. } => "replace_match_arm",
195 TransformSpec::RenameLocal { .. } => "rename_local",
196 TransformSpec::InlineLet { .. } => "inline_let",
197 TransformSpec::ExtractFunction { .. } => "extract_function",
198 }
199 }
200
201 fn source_stage_id(&self) -> &str {
202 match self {
203 TransformSpec::ReplaceMatchArm { from_stage_id, .. }
204 | TransformSpec::RenameLocal { from_stage_id, .. }
205 | TransformSpec::InlineLet { from_stage_id, .. }
206 | TransformSpec::ExtractFunction { from_stage_id, .. } => from_stage_id,
207 }
208 }
209}
210
211#[derive(Debug, Clone)]
213pub struct Applied {
214 pub branch: String,
215 pub kind: &'static str,
216 pub op_ids: Vec<lex_vcs::OpId>,
217 pub prev_head: Option<lex_vcs::OpId>,
218 pub new_head: Option<lex_vcs::OpId>,
219 pub new_stage_id: Option<String>,
221 pub extracted: Option<(String, String)>,
223 pub intent_id: String,
224 pub session_id: String,
225 pub unattributed: bool,
226}
227
228impl Applied {
229 pub fn to_json(&self) -> serde_json::Value {
231 let mut v = serde_json::json!({
232 "ok": true,
233 "branch": self.branch,
234 "kind": self.kind,
235 "op_id": self.op_ids.last(),
236 "op_ids": self.op_ids,
237 "prev_head": self.prev_head,
238 "new_head": self.new_head,
239 "new_stage_id": self.new_stage_id,
240 "intent": {
241 "intent_id": self.intent_id,
242 "session_id": self.session_id,
243 "unattributed": self.unattributed,
244 },
245 });
246 if let Some((sig, stage)) = &self.extracted {
247 v["extracted"] = serde_json::json!({ "sig_id": sig, "stage_id": stage });
248 }
249 v
250 }
251}
252
253pub fn apply_transform(
262 store: &Store,
263 branch: &str,
264 spec: &TransformSpec,
265 intent: &lex_vcs::Intent,
266) -> Result<Applied, StoreError> {
267 if !store.list_branches()?.iter().any(|b| b == branch) {
270 return Err(StoreError::UnknownBranch(branch.to_string()));
271 }
272
273 let from = spec.source_stage_id();
277 let from_ast = store.get_ast(from)?;
278 if let Some(sig) = lex_ast::sig_id(&from_ast) {
279 let head = store.branch_head(branch)?;
280 match head.get(&sig) {
281 Some(cur) if cur != from => {
282 return Err(StoreError::InvalidTransition(format!(
283 "stale from_stage_id `{from}`: branch `{branch}` head binds sig `{sig}` to `{cur}`"
284 )));
285 }
286 _ => {}
287 }
288 }
289
290 let prev_head = store.get_branch(branch)?.and_then(|b| b.head_op);
291 let node = |s: &str| lex_ast::NodeId(s.to_string());
292 let op_ids = match spec {
293 TransformSpec::ReplaceMatchArm { from_stage_id, match_node, arm_index, new_body } => {
294 vec![store.apply_replace_match_arm_with_intent(
295 branch,
296 from_stage_id,
297 &node(match_node),
298 *arm_index,
299 new_body.clone(),
300 Some(intent),
301 )?]
302 }
303 TransformSpec::RenameLocal { from_stage_id, let_node, new_name } => {
304 vec![store.apply_rename_local_with_intent(
305 branch,
306 from_stage_id,
307 &node(let_node),
308 new_name,
309 Some(intent),
310 )?]
311 }
312 TransformSpec::InlineLet { from_stage_id, let_node } => {
313 vec![store.apply_inline_let_with_intent(
314 branch,
315 from_stage_id,
316 &node(let_node),
317 Some(intent),
318 )?]
319 }
320 TransformSpec::ExtractFunction { from_stage_id, expr_node, spec } => {
321 let (add, modify) = store.apply_extract_function_with_intent(
322 branch,
323 from_stage_id,
324 &node(expr_node),
325 lex_ast::ExtractFnSpec {
326 name: spec.name.clone(),
327 type_params: spec.type_params.clone(),
328 params: spec.params.clone(),
329 return_type: spec.return_type.clone(),
330 effects: spec.effects.clone(),
331 },
332 Some(intent),
333 )?;
334 vec![add, modify]
335 }
336 };
337
338 let new_head = store.get_branch(branch)?.and_then(|b| b.head_op);
339 let new_stage_id = lex_ast::sig_id(&from_ast)
340 .and_then(|sig| store.branch_head(branch).ok().and_then(|h| h.get(&sig).cloned()));
341 let extracted = match spec {
342 TransformSpec::ExtractFunction { .. } => {
343 let log = lex_vcs::OpLog::open(store.root())?;
344 match op_ids.first().and_then(|id| log.get(id).ok().flatten()) {
345 Some(rec) => match rec.op.kind {
346 lex_vcs::OperationKind::AddFunction { sig_id, stage_id, .. } => {
347 Some((sig_id, stage_id))
348 }
349 _ => None,
350 },
351 None => None,
352 }
353 }
354 _ => None,
355 };
356 Ok(Applied {
357 branch: branch.to_string(),
358 kind: spec.kind(),
359 op_ids,
360 prev_head,
361 new_head,
362 new_stage_id,
363 extracted,
364 intent_id: intent.intent_id.clone(),
365 session_id: intent.session_id.clone(),
366 unattributed: intent.prompt == UNATTRIBUTED_PROMPT,
367 })
368}
369
370#[derive(Deserialize)]
373struct TransformReq {
374 branch: Option<String>,
375 #[serde(default)]
376 intent: Option<IntentSpec>,
377 transform: TransformSpec,
378}
379
380pub(crate) fn transform_error_response(err: StoreError) -> Response<Cursor<Vec<u8>>> {
384 match err {
385 StoreError::UnknownBranch(_) | StoreError::UnknownStage(_) | StoreError::UnknownSig(_) => {
386 error_response(404, err.to_string())
387 }
388 StoreError::TransformError(ref e) => error_with_detail(
389 422,
390 format!("transform failed: {e}"),
391 serde_json::json!({ "kind": "transform_error", "message": e.to_string() }),
392 ),
393 StoreError::TypeError(ref errs) => error_with_detail(
394 422,
395 "type errors after transform",
396 serde_json::json!({
397 "kind": "type_errors",
398 "errors": serde_json::to_value(errs).unwrap_or_default(),
399 }),
400 ),
401 StoreError::InvalidTransition(_) => error_response(409, err.to_string()),
402 other => crate::handlers::write_error_response("transform", other),
403 }
404}
405
406pub(crate) fn transform_handler(state: &State, body: &str) -> Response<Cursor<Vec<u8>>> {
408 let req: TransformReq = match serde_json::from_str(body) {
409 Ok(r) => r,
410 Err(e) => return error_response(400, format!("bad request: {e}")),
411 };
412 let Some(branch) = req.branch.filter(|b| !b.is_empty()) else {
413 return error_response(
414 400,
415 "bad request: `branch` is required (the server's current branch is never assumed)",
416 );
417 };
418 let intent = match req.intent.unwrap_or_default().into_intent(default_http_session) {
419 Ok(i) => i,
420 Err(e) => return error_response(400, format!("bad request: {e}")),
421 };
422 let store = state.store.lock().unwrap();
423 match apply_transform(&store, &branch, &req.transform, &intent) {
424 Ok(applied) => json_response(200, &applied.to_json()),
425 Err(e) => transform_error_response(e),
426 }
427}