1use crate::core::events::{Actor, OpEvent, OpKind};
7use crate::core::session_store::{SessionHandle, SessionStore};
8use anyhow::{Result, bail};
9use schemars::{JsonSchema, schema_for};
10use serde_json::{Value, json};
11use std::path::{Path, PathBuf};
12
13pub async fn session_start(
18 base: PathBuf,
19 label: Option<String>,
20 workspace: Option<PathBuf>,
21) -> Result<Value> {
22 let workspace_root =
23 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
24
25 if !base.exists() {
26 bail!("base file not found: {}", base.display());
27 }
28
29 let store = SessionStore::open(&workspace_root)?;
30 let handle = store.create_session(&base, label.as_deref())?;
31
32 Ok(json!({
33 "session_id": handle.session_id,
34 "base_path": base.display().to_string(),
35 "label": label,
36 "workspace_root": workspace_root.display().to_string(),
37 }))
38}
39
40pub async fn session_log(
45 session_id: String,
46 workspace: Option<PathBuf>,
47 since: Option<String>,
48 kind_filter: Option<String>,
49) -> Result<Value> {
50 let handle = open_session(&session_id, workspace.as_deref())?;
51 let events = handle.read_events()?;
52
53 let filtered: Vec<&OpEvent> = events
54 .iter()
55 .filter(|e| {
56 if let Some(ref since_id) = since {
57 let pos = events.iter().position(|ev| ev.op_id == *since_id);
59 let cur = events.iter().position(|ev| ev.op_id == e.op_id);
60 match (pos, cur) {
61 (Some(p), Some(c)) => c >= p,
62 _ => true,
63 }
64 } else {
65 true
66 }
67 })
68 .filter(|e| {
69 if let Some(ref kind) = kind_filter {
70 e.kind.0.starts_with(kind.as_str())
71 } else {
72 true
73 }
74 })
75 .collect();
76
77 let entries: Vec<Value> = filtered
78 .into_iter()
79 .map(|e| {
80 json!({
81 "op_id": e.op_id,
82 "parent_id": e.parent_id,
83 "kind": e.kind.0,
84 "timestamp": e.timestamp.to_rfc3339(),
85 "actor": e.actor.id,
86 "has_impact": e.dry_run_impact.is_some(),
87 "status": e.apply_result.as_ref().map(|r| format!("{:?}", r.status)),
88 })
89 })
90 .collect();
91
92 let head = handle.read_head()?;
93 let branch = handle.current_branch()?;
94
95 Ok(json!({
96 "session_id": session_id,
97 "branch": branch,
98 "head": head,
99 "event_count": entries.len(),
100 "events": entries,
101 }))
102}
103
104pub async fn session_branches(session_id: String, workspace: Option<PathBuf>) -> Result<Value> {
109 let handle = open_session(&session_id, workspace.as_deref())?;
110 let branches = handle.list_branches()?;
111 let current = handle.current_branch()?;
112
113 let branch_list: Vec<Value> = branches
114 .into_iter()
115 .map(|b| {
116 json!({
117 "name": b.name,
118 "tip_op_id": b.tip_op_id,
119 "fork_point": b.fork_point,
120 "label": b.label,
121 "current": b.name == current,
122 })
123 })
124 .collect();
125
126 Ok(json!({
127 "session_id": session_id,
128 "current_branch": current,
129 "branches": branch_list,
130 }))
131}
132
133pub async fn session_switch(
138 session_id: String,
139 branch: String,
140 workspace: Option<PathBuf>,
141) -> Result<Value> {
142 let handle = open_session(&session_id, workspace.as_deref())?;
143 handle.switch_branch(&branch)?;
144
145 let head = handle.read_head()?;
146 Ok(json!({
147 "session_id": session_id,
148 "branch": branch,
149 "head": head,
150 }))
151}
152
153pub async fn session_checkout(
158 session_id: String,
159 op_id: String,
160 workspace: Option<PathBuf>,
161) -> Result<Value> {
162 let handle = open_session(&session_id, workspace.as_deref())?;
163 handle.checkout(&op_id)?;
164
165 Ok(json!({
166 "session_id": session_id,
167 "head": op_id,
168 }))
169}
170
171pub async fn session_undo(session_id: String, workspace: Option<PathBuf>) -> Result<Value> {
176 let handle = open_session(&session_id, workspace.as_deref())?;
177 let new_head = handle.undo()?;
178
179 Ok(json!({
180 "session_id": session_id,
181 "head": new_head,
182 "undone": true,
183 }))
184}
185
186pub async fn session_redo(session_id: String, workspace: Option<PathBuf>) -> Result<Value> {
187 let handle = open_session(&session_id, workspace.as_deref())?;
188 let new_head = handle.redo()?;
189
190 Ok(json!({
191 "session_id": session_id,
192 "head": new_head,
193 "redone": true,
194 }))
195}
196
197pub async fn session_fork(
202 session_id: String,
203 from: Option<String>,
204 label: Option<String>,
205 branch_name: String,
206 workspace: Option<PathBuf>,
207) -> Result<Value> {
208 let handle = open_session(&session_id, workspace.as_deref())?;
209
210 let head = handle.read_head()?;
212 let fork_point_str = from.as_deref().or(head.as_deref());
213
214 handle.create_branch(&branch_name, fork_point_str, label.as_deref())?;
215
216 Ok(json!({
217 "session_id": session_id,
218 "branch": branch_name,
219 "fork_point": fork_point_str,
220 "label": label,
221 }))
222}
223
224pub async fn session_op_stage(
229 session_id: String,
230 ops_ref: String,
231 workspace: Option<PathBuf>,
232) -> Result<Value> {
233 let handle = open_session(&session_id, workspace.as_deref())?;
234
235 let validated = load_ops_payload(&ops_ref)?;
237 let payload_json = validated.payload;
238 let kind = validated.kind;
239 let head = handle.read_head()?;
240
241 let dry_run_impact = compute_staging_impact(&handle, &kind, &payload_json);
243
244 let staged_id = format!(
246 "stg_{:013x}_{:08x}",
247 std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .unwrap_or_default()
250 .as_millis(),
251 rand::random::<u32>()
252 );
253
254 let staged_artifact = json!({
255 "staged_id": staged_id,
256 "session_id": session_id,
257 "head_at_stage": head,
258 "op_kind": kind.to_string(),
259 "ops_payload": payload_json,
260 "dry_run_impact": dry_run_impact,
261 "created_at": chrono::Utc::now().to_rfc3339(),
262 });
263
264 let staged_path = handle.staged_dir().join(format!("{}.json", staged_id));
265 std::fs::write(
266 &staged_path,
267 serde_json::to_string_pretty(&staged_artifact)?,
268 )?;
269
270 Ok(json!({
271 "staged_id": staged_id,
272 "session_id": session_id,
273 "head_at_stage": head,
274 "dry_run_impact": dry_run_impact,
275 "staged_path": staged_path.display().to_string(),
276 }))
277}
278
279pub async fn session_apply(
284 session_id: String,
285 staged_id: String,
286 workspace: Option<PathBuf>,
287) -> Result<Value> {
288 let handle = open_session(&session_id, workspace.as_deref())?;
289
290 let staged_path = handle.staged_dir().join(format!("{}.json", staged_id));
292 if !staged_path.exists() {
293 bail!("staged operation not found: {}", staged_id);
294 }
295
296 let staged_content = std::fs::read_to_string(&staged_path)?;
297 let staged: Value = serde_json::from_str(&staged_content)?;
298
299 let current_head = handle.read_head()?;
301 let staged_head = staged
302 .get("head_at_stage")
303 .and_then(|v| v.as_str())
304 .map(|s| s.to_string());
305
306 let current_head_normalized = current_head.as_deref().filter(|s| !s.is_empty());
308 let staged_head_normalized = staged_head.as_deref().filter(|s| !s.is_empty());
309
310 if current_head_normalized != staged_head_normalized {
311 bail!(
312 "CAS conflict: HEAD has advanced since staging. HEAD={:?}, staged expected={:?}. \
313 Re-stage the operation against the current HEAD.",
314 current_head,
315 staged_head,
316 );
317 }
318
319 let payload = staged.get("ops_payload").cloned().unwrap_or(json!({}));
320 let validated = validate_session_payload(payload)?;
321 let kind = validated.kind;
322 let payload = validated.payload;
323
324 let mut event = OpEvent::new(
326 session_id.clone(),
327 current_head.clone(),
328 Actor {
329 id: "cli:user".to_string(),
330 run_id: None,
331 source: "cli".to_string(),
332 },
333 kind,
334 payload,
335 );
336
337 if let Some(impact_val) = staged.get("dry_run_impact")
338 && !impact_val.is_null()
339 && let Ok(impact) =
340 serde_json::from_value::<crate::core::events::DryRunImpact>(impact_val.clone())
341 {
342 event = event.with_dry_run_impact(impact);
343 }
344
345 if let Some(precond_val) = staged.get("preconditions")
346 && !precond_val.is_null()
347 && let Ok(preconditions) =
348 serde_json::from_value::<crate::core::events::Preconditions>(precond_val.clone())
349 {
350 event = event.with_preconditions(preconditions);
351 }
352
353 let op_id = event.op_id.clone();
354 handle.append_event(event)?;
355
356 let _ = std::fs::remove_file(&staged_path);
358
359 Ok(json!({
360 "session_id": session_id,
361 "op_id": op_id,
362 "staged_id": staged_id,
363 "applied": true,
364 "head": op_id,
365 }))
366}
367
368pub async fn session_materialize(
373 session_id: String,
374 output: PathBuf,
375 workspace: Option<PathBuf>,
376 force: bool,
377) -> Result<Value> {
378 let handle = open_session(&session_id, workspace.as_deref())?;
379
380 if output.exists() && !force {
381 bail!(
382 "output file already exists: {}. Use --force to overwrite.",
383 output.display()
384 );
385 }
386
387 let bytes = handle.materialize()?;
388 std::fs::write(&output, &bytes)?;
389
390 let head = handle.read_head()?;
391 let all_events = handle.read_events()?;
392 let events_replayed = if let Some(ref head_id) = head {
394 all_events
395 .iter()
396 .position(|e| e.op_id == *head_id)
397 .map(|pos| pos + 1)
398 .unwrap_or(all_events.len())
399 } else {
400 0 };
402
403 Ok(json!({
404 "session_id": session_id,
405 "output_path": output.display().to_string(),
406 "head": head,
407 "events_replayed": events_replayed,
408 "output_size_bytes": bytes.len(),
409 }))
410}
411
412#[allow(dead_code)]
417#[derive(Debug, JsonSchema)]
418struct SessionOpsPayload<T> {
419 ops: Vec<T>,
420}
421
422#[allow(dead_code)]
423#[derive(Debug, JsonSchema)]
424struct SessionWriteMatrixPayloadSchema {
425 sheet_name: String,
426 anchor: String,
427 rows: Vec<Vec<Option<crate::core::session::SessionMatrixCell>>>,
428 overwrite_formulas: bool,
429}
430
431#[allow(dead_code)]
432#[derive(Debug, JsonSchema)]
433struct SessionColumnSizePayloadSchema {
434 sheet_name: String,
435 ops: Vec<crate::tools::fork::ColumnSizeOp>,
436}
437
438#[allow(dead_code)]
439#[derive(Debug, JsonSchema)]
440struct SessionNameDefinePayloadSchema {
441 name: String,
442 refers_to: String,
443 scope: Option<String>,
444 scope_sheet_name: Option<String>,
445}
446
447#[allow(dead_code)]
448#[derive(Debug, JsonSchema)]
449struct SessionNameUpdatePayloadSchema {
450 name: String,
451 refers_to: Option<String>,
452 scope: Option<String>,
453 scope_sheet_name: Option<String>,
454}
455
456#[allow(dead_code)]
457#[derive(Debug, JsonSchema)]
458struct SessionNameDeletePayloadSchema {
459 name: String,
460 scope: Option<String>,
461 scope_sheet_name: Option<String>,
462}
463
464pub fn session_payload_schema(kind: String) -> Result<Value> {
465 let kind = normalize_session_payload_kind(&kind)?;
466 Ok(json!({
467 "schema_kind": "session_ops_payload",
468 "op_kind": kind,
469 "schema": session_payload_schema_json(&kind)?,
470 "notes": session_payload_notes(&kind),
471 }))
472}
473
474pub fn session_payload_example(kind: String) -> Result<Value> {
475 let kind = normalize_session_payload_kind(&kind)?;
476 Ok(json!({
477 "example_kind": "session_ops_payload",
478 "op_kind": kind,
479 "example": session_payload_example_json(&kind)?,
480 "notes": session_payload_notes(&kind),
481 }))
482}
483
484fn normalize_session_payload_kind(kind: &str) -> Result<String> {
485 validate_session_payload(json!({"kind": kind, "name": "Example", "ops": [], "sheet_name": "Sheet1", "anchor": "A1", "rows": []}))
486 .map(|validated| validated.kind.to_string())
487 .or_else(|_| {
488 match kind {
489 k if k.starts_with("structure.")
490 || matches!(
491 k,
492 "transform.write_matrix"
493 | "transform.clear_range"
494 | "transform.fill_range"
495 | "transform.replace_in_range"
496 | "style.apply"
497 | "formula.apply_pattern"
498 | "formula.replace_in_formulas"
499 | "column.size"
500 | "layout.apply"
501 | "rules.apply"
502 | "name.define"
503 | "name.update"
504 | "name.delete"
505 ) => Ok(kind.to_string()),
506 _ => bail!(
507 "invalid argument: unsupported session payload kind '{kind}'. Try `asp example session op transform.write_matrix` or `asp schema session op structure.insert_rows`"
508 ),
509 }
510 })
511}
512
513fn session_payload_schema_json(kind: &str) -> Result<Value> {
514 let schema = match kind {
515 "transform.write_matrix" => {
516 serde_json::to_value(schema_for!(SessionWriteMatrixPayloadSchema))?
517 }
518 k if k.starts_with("structure.") => serde_json::to_value(schema_for!(
519 SessionOpsPayload<crate::tools::fork::StructureOp>
520 ))?,
521 "transform.clear_range" | "transform.fill_range" | "transform.replace_in_range" => {
522 serde_json::to_value(schema_for!(
523 SessionOpsPayload<crate::tools::fork::TransformOp>
524 ))?
525 }
526 "style.apply" => {
527 serde_json::to_value(schema_for!(SessionOpsPayload<crate::tools::fork::StyleOp>))?
528 }
529 "formula.apply_pattern" => serde_json::to_value(schema_for!(
530 SessionOpsPayload<crate::tools::fork::ApplyFormulaPatternOpInput>
531 ))?,
532 "formula.replace_in_formulas" => {
533 serde_json::to_value(schema_for!(crate::tools::fork::ReplaceInFormulasOp))?
534 }
535 "column.size" => serde_json::to_value(schema_for!(SessionColumnSizePayloadSchema))?,
536 "layout.apply" => serde_json::to_value(schema_for!(
537 SessionOpsPayload<crate::tools::sheet_layout::SheetLayoutOp>
538 ))?,
539 "rules.apply" => serde_json::to_value(schema_for!(
540 SessionOpsPayload<crate::tools::rules_batch::RulesOp>
541 ))?,
542 "name.define" => serde_json::to_value(schema_for!(SessionNameDefinePayloadSchema))?,
543 "name.update" => serde_json::to_value(schema_for!(SessionNameUpdatePayloadSchema))?,
544 "name.delete" => serde_json::to_value(schema_for!(SessionNameDeletePayloadSchema))?,
545 _ => bail!("invalid argument: unsupported session payload kind '{kind}'"),
546 };
547 Ok(inject_kind_property(schema, kind))
548}
549
550fn inject_kind_property(mut schema: Value, kind: &str) -> Value {
551 let Some(obj) = schema.as_object_mut() else {
552 return schema;
553 };
554
555 let properties = obj
556 .entry("properties")
557 .or_insert_with(|| json!({}))
558 .as_object_mut()
559 .expect("properties object");
560 properties.insert(
561 "kind".to_string(),
562 json!({
563 "type": "string",
564 "const": kind,
565 "description": "Top-level session op discriminator"
566 }),
567 );
568
569 let required = obj
570 .entry("required")
571 .or_insert_with(|| json!([]))
572 .as_array_mut()
573 .expect("required array");
574 if !required.iter().any(|entry| entry.as_str() == Some("kind")) {
575 required.push(json!("kind"));
576 }
577
578 schema
579}
580
581fn session_payload_example_json(kind: &str) -> Result<Value> {
582 Ok(match kind {
583 "transform.write_matrix" => json!({
584 "kind": kind,
585 "sheet_name": "Sheet1",
586 "anchor": "B7",
587 "rows": [[{"v": "Revenue"}, {"v": 100}]],
588 "overwrite_formulas": false
589 }),
590 "structure.insert_rows" => json!({
591 "kind": kind,
592 "ops": [{"kind": "insert_rows", "sheet_name": "Sheet1", "at_row": 12, "count": 2}]
593 }),
594 "structure.clone_row" => json!({
595 "kind": kind,
596 "ops": [{"kind": "clone_row", "sheet_name": "Sheet1", "source_row": 12, "insert_at": 13, "count": 1, "expand_adjacent_sums": true}]
597 }),
598 "structure.copy_range" => json!({
599 "kind": kind,
600 "ops": [{"kind": "copy_range", "sheet_name": "Sheet1", "src_range": "A1:C3", "dest_anchor": "E1", "include_styles": true, "include_formulas": true}]
601 }),
602 "structure.move_range" => json!({
603 "kind": kind,
604 "ops": [{"kind": "move_range", "sheet_name": "Sheet1", "src_range": "A10:B12", "dest_anchor": "D10"}]
605 }),
606 k if k.starts_with("structure.") => json!({
607 "kind": kind,
608 "ops": [{"kind": k.trim_start_matches("structure."), "sheet_name": "Sheet1"}]
609 }),
610 "transform.clear_range" => json!({
611 "kind": kind,
612 "ops": [{"kind": "clear_range", "sheet_name": "Sheet1", "target": {"kind": "range", "range": "A2:C10"}, "clear_values": true, "clear_formulas": false}]
613 }),
614 "transform.fill_range" => json!({
615 "kind": kind,
616 "ops": [{"kind": "fill_range", "sheet_name": "Sheet1", "target": {"kind": "range", "range": "B2:B10"}, "value": "Filled"}]
617 }),
618 "transform.replace_in_range" => json!({
619 "kind": kind,
620 "ops": [{"kind": "replace_in_range", "sheet_name": "Sheet1", "target": {"kind": "range", "range": "A2:A10"}, "find": "Old", "replace": "New", "match_mode": "exact"}]
621 }),
622 "style.apply" => json!({
623 "kind": kind,
624 "ops": [{"sheet_name": "Sheet1", "target": {"kind": "range", "range": "A1:C1"}, "patch": {"font": {"bold": true}}}]
625 }),
626 "formula.apply_pattern" => json!({
627 "kind": kind,
628 "ops": [{"sheet_name": "Sheet1", "target_range": "C2:C10", "anchor_cell": "C2", "base_formula": "=A2+B2", "fill_direction": "down", "relative_mode": "excel"}]
629 }),
630 "formula.replace_in_formulas" => json!({
631 "kind": kind,
632 "sheet_name": "Sheet1",
633 "find": "Sheet1!",
634 "replace": "Sheet2!",
635 "range": "A1:Z100",
636 "regex": false,
637 "case_sensitive": true
638 }),
639 "column.size" => json!({
640 "kind": kind,
641 "sheet_name": "Sheet1",
642 "ops": [{"target": {"kind": "columns", "range": "A:C"}, "size": {"kind": "width", "width_chars": 18.0}}]
643 }),
644 "layout.apply" => json!({
645 "kind": kind,
646 "ops": [{"kind": "freeze_panes", "sheet_name": "Sheet1", "freeze_rows": 1, "freeze_cols": 1}]
647 }),
648 "rules.apply" => json!({
649 "kind": kind,
650 "ops": [{"kind": "set_data_validation", "sheet_name": "Sheet1", "target_range": "B2:B10", "validation": {"kind": "list", "formula1": "\"A,B,C\""}}]
651 }),
652 "name.define" => json!({
653 "kind": kind,
654 "name": "SalesTotal",
655 "refers_to": "Sheet1!$C$100",
656 "scope": "workbook"
657 }),
658 "name.update" => json!({
659 "kind": kind,
660 "name": "SalesTotal",
661 "refers_to": "Sheet1!$C$101",
662 "scope": "workbook"
663 }),
664 "name.delete" => json!({
665 "kind": kind,
666 "name": "SalesTotal",
667 "scope": "workbook"
668 }),
669 _ => bail!("invalid argument: unsupported session payload kind '{kind}'"),
670 })
671}
672
673fn session_payload_notes(kind: &str) -> Vec<String> {
674 match kind {
675 "transform.write_matrix" => vec![
676 "Flat payload: do not wrap transform.write_matrix in an ops array.".to_string(),
677 "rows entries use {'v': ...} for values and {'f': ...} for formulas.".to_string(),
678 ],
679 k if k.starts_with("structure.")
680 || matches!(
681 k,
682 "transform.clear_range"
683 | "transform.fill_range"
684 | "transform.replace_in_range"
685 | "style.apply"
686 | "formula.apply_pattern"
687 | "layout.apply"
688 | "rules.apply"
689 ) =>
690 {
691 vec![
692 "Batch envelope: use a top-level kind plus an ops array.".to_string(),
693 "The inner ops array carries the operation-specific kind/value shape.".to_string(),
694 ]
695 }
696 "column.size" => {
697 vec!["column.size requires both a top-level sheet_name and an ops array.".to_string()]
698 }
699 "formula.replace_in_formulas" | "name.define" | "name.update" | "name.delete" => {
700 vec!["Flat payload: do not wrap this kind in an ops array.".to_string()]
701 }
702 _ => Vec::new(),
703 }
704}
705
706fn open_session(session_id: &str, workspace: Option<&Path>) -> Result<SessionHandle> {
711 let workspace_root = workspace
712 .map(|p| p.to_path_buf())
713 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
714 let store = SessionStore::open(&workspace_root)?;
715 store.open_session(session_id)
716}
717
718struct ValidatedSessionPayload {
719 kind: OpKind,
720 payload: Value,
721}
722
723fn parse_op_kind(kind: &str) -> Result<OpKind> {
724 let (namespace, action) = kind.split_once('.').ok_or_else(|| {
725 anyhow::anyhow!("session payload kind must be '<namespace>.<action>' (got '{kind}')")
726 })?;
727 Ok(OpKind::new(namespace, action))
728}
729
730fn load_ops_payload(ops_ref: &str) -> Result<ValidatedSessionPayload> {
731 let path = if let Some(stripped) = ops_ref.strip_prefix('@') {
732 stripped
733 } else {
734 ops_ref
735 };
736
737 let content = std::fs::read_to_string(path)
738 .map_err(|e| anyhow::anyhow!("failed to read ops payload from '{}': {}", path, e))?;
739
740 let payload: Value = serde_json::from_str(&content)
741 .map_err(|e| anyhow::anyhow!("failed to parse ops payload JSON from '{}': {}", path, e))?;
742 validate_session_payload(payload)
743 .map_err(|e| anyhow::anyhow!("invalid session ops payload in '{}': {}", path, e))
744}
745
746fn validate_session_payload(payload: Value) -> Result<ValidatedSessionPayload> {
747 let obj = payload.as_object().ok_or_else(|| {
748 anyhow::anyhow!("session ops payload must be a JSON object with a top-level 'kind' field")
749 })?;
750
751 let kind_str = obj.get("kind").and_then(|v| v.as_str()).ok_or_else(|| {
752 anyhow::anyhow!(
753 "session ops payload must include a top-level string 'kind'. Example write_matrix payload: {{\"kind\":\"transform.write_matrix\",\"sheet_name\":\"Sheet1\",\"anchor\":\"B7\",\"rows\":[[\"Revenue\",100]]}}. See `asp example session-op transform.write_matrix` or `asp schema session-op transform.write_matrix`."
754 )
755 })?;
756 let kind = parse_op_kind(kind_str)?;
757
758 match kind_str {
759 "transform.write_matrix" => {
760 if obj.contains_key("ops") {
761 bail!(
762 "transform.write_matrix uses a flat payload with sheet_name/anchor/rows; do not wrap it in an 'ops' array"
763 );
764 }
765 if !obj.contains_key("sheet_name") || !obj.contains_key("rows") {
766 bail!("transform.write_matrix requires 'sheet_name', 'anchor', and 'rows' fields");
767 }
768 }
769 "edit.batch" => {
770 bail!(
771 "session op does not support 'edit.batch'. Use 'transform.write_matrix' for matrix writes, or the stateless 'edit' CLI for direct cell edits"
772 );
773 }
774 k if k.starts_with("structure.")
775 || matches!(
776 k,
777 "transform.clear_range"
778 | "transform.fill_range"
779 | "transform.replace_in_range"
780 | "style.apply"
781 | "formula.apply_pattern"
782 | "layout.apply"
783 | "rules.apply"
784 ) =>
785 {
786 if !matches!(obj.get("ops"), Some(Value::Array(_))) {
787 bail!(
788 "{kind_str} requires an 'ops' array envelope. Example: {{\"kind\":\"{kind_str}\",\"ops\":[...]}}"
789 );
790 }
791 }
792 "column.size" => {
793 if !obj.contains_key("sheet_name") {
794 bail!("column.size requires a top-level 'sheet_name'");
795 }
796 if !matches!(obj.get("ops"), Some(Value::Array(_))) {
797 bail!(
798 "column.size requires an 'ops' array envelope. Example: {{\"kind\":\"column.size\",\"sheet_name\":\"Sheet1\",\"ops\":[...]}}"
799 );
800 }
801 }
802 "formula.replace_in_formulas" => {
803 if obj.contains_key("ops") {
804 bail!(
805 "formula.replace_in_formulas uses a flat payload; do not wrap it in an 'ops' array"
806 );
807 }
808 }
809 "name.define" | "name.update" | "name.delete" => {
810 if obj.contains_key("ops") {
811 bail!("{kind_str} uses a flat payload; do not wrap it in an 'ops' array");
812 }
813 if !obj.contains_key("name") {
814 bail!("{kind_str} requires a top-level 'name' field");
815 }
816 }
817 _ => {
818 bail!(
819 "unsupported session op kind '{kind_str}'. Supported kinds today: transform.write_matrix, structure.*, transform.clear_range, transform.fill_range, transform.replace_in_range, style.apply, formula.apply_pattern, formula.replace_in_formulas, column.size, layout.apply, rules.apply, name.define, name.update, name.delete"
820 );
821 }
822 }
823
824 Ok(ValidatedSessionPayload { kind, payload })
825}
826
827fn compute_staging_impact(handle: &SessionHandle, kind: &OpKind, payload: &Value) -> Value {
832 use crate::core::events::{DryRunImpact, ShiftedSpan};
833
834 let impact = (|| -> Result<DryRunImpact> {
835 let kind_str = kind.to_string();
836
837 if kind_str.starts_with("structure.") {
838 let ops: Vec<crate::tools::fork::StructureOp> =
840 if let Some(ops_val) = payload.get("ops") {
841 serde_json::from_value(ops_val.clone())?
842 } else {
843 vec![serde_json::from_value(payload.clone())?]
844 };
845
846 let wb_bytes = handle.materialize()?;
847 let mut tmp = tempfile::Builder::new().suffix(".xlsx").tempfile()?;
848 std::io::Write::write_all(&mut tmp, &wb_bytes)?;
849
850 let (report, _) =
851 crate::tools::structure_impact::compute_structure_impact(tmp.path(), &ops, false)?;
852
853 let shifted_spans = report
854 .shifted_spans
855 .into_iter()
856 .map(|s| ShiftedSpan {
857 op_index: s.op_index,
858 sheet_name: s.sheet_name,
859 axis: s.axis,
860 at: s.at,
861 count: s.count,
862 direction: s.direction,
863 description: s.description,
864 })
865 .collect();
866
867 let boundary_warnings: Vec<String> = report
868 .absolute_ref_warnings
869 .iter()
870 .map(|w| w.message.clone())
871 .collect();
872
873 Ok(DryRunImpact {
874 cells_changed: 0,
875 formulas_rewritten: report.tokens_affected,
876 shifted_spans,
877 ref_errors_generated: 0,
878 warnings: report.notes,
879 boundary_warnings,
880 })
881 } else if kind_str == "transform.write_matrix" {
882 let rows = payload
884 .get("rows")
885 .and_then(|v| v.as_array())
886 .map(|arr| {
887 let row_count = arr.len() as u64;
888 let col_count = arr
889 .first()
890 .and_then(|r| r.as_array())
891 .map(|c| c.len() as u64)
892 .unwrap_or(0);
893 row_count * col_count
894 })
895 .unwrap_or(0);
896
897 Ok(DryRunImpact {
898 cells_changed: rows,
899 formulas_rewritten: 0,
900 shifted_spans: vec![],
901 ref_errors_generated: 0,
902 warnings: vec![],
903 boundary_warnings: vec![],
904 })
905 } else if let Some(ops_val) = payload.get("ops").and_then(|v| v.as_array()) {
906 let ops_count = ops_val.len() as u64;
908 Ok(DryRunImpact {
909 cells_changed: ops_count,
910 formulas_rewritten: 0,
911 shifted_spans: vec![],
912 ref_errors_generated: 0,
913 warnings: vec![
914 "impact estimate based on ops count; precise analysis not available for this op kind".to_string(),
915 ],
916 boundary_warnings: vec![],
917 })
918 } else {
919 Ok(DryRunImpact {
920 cells_changed: 0,
921 formulas_rewritten: 0,
922 shifted_spans: vec![],
923 ref_errors_generated: 0,
924 warnings: vec![],
925 boundary_warnings: vec![],
926 })
927 }
928 })();
929
930 match impact {
931 Ok(i) => serde_json::to_value(i).unwrap_or(Value::Null),
932 Err(_) => Value::Null,
933 }
934}