Skip to main content

spreadsheet_kit/cli/commands/
session.rs

1//! CLI commands for `asp session` subcommand tree.
2//!
3//! These commands expose the event-sourced session mechanics to the user/agent
4//! via stateless, path-driven CLI invocations.
5
6use 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
13// ---------------------------------------------------------------------------
14// Session start
15// ---------------------------------------------------------------------------
16
17pub 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
40// ---------------------------------------------------------------------------
41// Session log
42// ---------------------------------------------------------------------------
43
44pub 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                // Skip events before the since marker
58                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
104// ---------------------------------------------------------------------------
105// Session branches
106// ---------------------------------------------------------------------------
107
108pub 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
133// ---------------------------------------------------------------------------
134// Session switch
135// ---------------------------------------------------------------------------
136
137pub 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
153// ---------------------------------------------------------------------------
154// Session checkout
155// ---------------------------------------------------------------------------
156
157pub 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
171// ---------------------------------------------------------------------------
172// Session undo / redo
173// ---------------------------------------------------------------------------
174
175pub 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
197// ---------------------------------------------------------------------------
198// Session fork (create branch)
199// ---------------------------------------------------------------------------
200
201pub 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    // If no explicit fork point, use current HEAD
211    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
224// ---------------------------------------------------------------------------
225// Session op (stage)
226// ---------------------------------------------------------------------------
227
228pub 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    // Load and validate the ops payload
236    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    // Compute dry-run impact
242    let dry_run_impact = compute_staging_impact(&handle, &kind, &payload_json);
243
244    // Create a staged artifact
245    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
279// ---------------------------------------------------------------------------
280// Session apply (from staged)
281// ---------------------------------------------------------------------------
282
283pub 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    // Load staged artifact
291    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    // Verify CAS: HEAD must match head_at_stage
300    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    // Normalize: None and null/"" are equivalent (both mean "at base")
307    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    // Build the event, attaching dry_run_impact from the staged artifact if present
325    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    // Clean up staged file
357    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
368// ---------------------------------------------------------------------------
369// Session materialize
370// ---------------------------------------------------------------------------
371
372pub 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    // Count events actually replayed (up to and including HEAD)
393    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 // at base, nothing replayed
401    };
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// ---------------------------------------------------------------------------
413// Session payload discoverability
414// ---------------------------------------------------------------------------
415
416#[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
706// ---------------------------------------------------------------------------
707// Helpers
708// ---------------------------------------------------------------------------
709
710fn 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
827/// Compute dry-run impact for a staged operation payload.
828///
829/// Returns a `DryRunImpact` value as JSON, or `null` if impact cannot be
830/// determined (e.g. unknown op kind or materialization failure).
831fn 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            // Structure ops: use compute_structure_impact for detailed analysis
839            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            // write_matrix: compute cell count from dimensions
883            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            // Generic batch ops: estimate from ops count
907            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}