Files
improvise/crates/improvise-command/src/command/cmd/cell.rs
T
Edward Langley aab62a362a improvise-enz6: pin the commit door directly, and write up the evidence
The advance no longer reaches the commit door on a fold or a formula's
cell, so the two older advance tests stopped seeing it (measured: the
cell_key rival passes them). Two tests seat the editor there directly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 17:56:41 -07:00

725 lines
26 KiB
Rust

use crate::model::cell::{CellKey, CellValue};
use crate::ui::effect::{self, Effect, ViewTarget};
use crate::view::synthetic_record_info;
use super::core::{AGGREGATE_REFUSAL, Cmd, CmdContext, INSIDE_A_DRILL_REFUSAL, NO_VALUE_REFUSAL};
#[cfg(test)]
mod tests {
use super::*;
use crate::command::cmd::test_helpers::*;
use crate::model::cell::{CellKey, CellValue};
#[test]
fn clear_selected_cell_produces_clear() {
let mut m = two_cat_model();
let key = CellKey::new(vec![
("Type".to_string(), "Food".to_string()),
("Month".to_string(), "Jan".to_string()),
]);
m.model.set_cell(key, CellValue::Number(42.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = ClearCellCommand {
key: ctx.cell_key().clone().unwrap(),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
}
#[test]
fn yank_cell_produces_set_yanked() {
let mut m = two_cat_model();
let key = CellKey::new(vec![
("Type".to_string(), "Food".to_string()),
("Month".to_string(), "Jan".to_string()),
]);
m.model.set_cell(key, CellValue::Number(99.0));
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let cmd = YankCell {
key: ctx.cell_key().clone().unwrap(),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 2);
}
#[test]
fn paste_with_yanked_value_produces_set_cell() {
let mut m = two_cat_model();
m.model.set_cell(
CellKey::new(vec![
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]),
CellValue::Number(42.0),
);
let layout = make_layout(&m);
let reg = make_registry();
let yanked = Some(CellValue::Number(99.0));
let mut ctx = make_ctx(&m, &layout, &reg);
ctx.yanked = &yanked;
let key = CellKey::new(vec![
("Type".into(), "Clothing".into()),
("Month".into(), "Feb".into()),
]);
let cmd = PasteCell {
at: PasteAt::Spelled(key.clone()),
};
let effects = cmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let mut app = crate::ui::app::App::new(two_cat_model(), None);
app.apply_effects(effects);
assert_eq!(
app.model_state.workbook.model.get_cell(&key),
Some(&CellValue::Number(99.0)),
"paste must write the YANKED value at the command's own key"
);
}
#[test]
fn paste_without_yanked_value_produces_nothing() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let key = CellKey::new(vec![
("Type".into(), "Food".into()),
("Month".into(), "Jan".into()),
]);
let cmd = PasteCell {
at: PasteAt::Spelled(key),
};
let effects = cmd.execute(&ctx);
assert!(effects.is_empty());
}
use crate::command::key::{KeyCode, KeyEvent, KeyModifiers};
use crate::ui::app::App;
use crate::workbook::Workbook;
fn press(app: &mut App, c: char) {
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
.unwrap();
}
fn record(region: &str, measure: &str) -> CellKey {
CellKey::new(vec![
("Region".into(), region.into()),
("_Measure".into(), measure.into()),
])
}
/// Two records by two measures, every value distinct, in records mode.
fn records_app() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
for (region, measure, n) in [
("East", "Revenue", 1200.0),
("East", "Cost", 700.0),
("West", "Revenue", 900.0),
("West", "Cost", 400.0),
] {
wb.model
.set_cell(record(region, measure), CellValue::Number(n));
}
let mut app = App::new(wb, None);
press(&mut app, 'R');
assert!(app.layout.is_records_mode(), "setup: in records mode");
app
}
fn col_of(app: &App, label: &str) -> usize {
(0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == label)
.unwrap_or_else(|| panic!("no records column {label}"))
}
fn synthetic_cells(wb: &Workbook) -> Vec<CellKey> {
wb.model
.data
.iter_cells()
.map(|(k, _)| k)
.filter(|k| k.get("_Index").is_some() || k.get("_Dim").is_some())
.collect()
}
#[test]
fn yy_then_p_in_records_mode_copies_between_real_records() {
let mut app = records_app();
let (east, west) = (0, 1);
assert_eq!(
app.layout.record_row(west).unwrap().coords.get("Region"),
Some("West")
);
app.cursor_mut().selected = (east, col_of(&app, "Cost"));
press(&mut app, 'y');
press(&mut app, 'y');
assert_eq!(
app.view_state.yanked,
Some(CellValue::Number(700.0)),
"yy must yank the record's stored value, not the synthetic key's"
);
app.cursor_mut().selected = (west, col_of(&app, "Revenue"));
press(&mut app, 'p');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Revenue")),
Some(&CellValue::Number(700.0)),
"p must write the real record under the cursor"
);
assert_eq!(
wb.model.get_cell(&record("East", "Cost")),
Some(&CellValue::Number(700.0))
);
assert_eq!(synthetic_cells(wb), Vec::<CellKey>::new());
let text = crate::persistence::format_md(wb);
let data = text.split("## Data").nth(1).expect("a data section");
assert!(
!data.contains("_Index") && !data.contains("_Dim"),
"the saved document carries a synthetic-keyed line:\n{text}"
);
let reloaded = crate::persistence::parse_md(&text).expect("round trip");
assert_eq!(
reloaded.model.get_cell(&record("West", "Revenue")),
Some(&CellValue::Number(700.0))
);
assert_eq!(synthetic_cells(&reloaded), Vec::<CellKey>::new());
}
#[test]
fn yy_on_a_records_coordinate_column_yanks_the_item_as_text() {
let mut app = records_app();
app.cursor_mut().selected = (1, col_of(&app, "Region"));
press(&mut app, 'y');
press(&mut app, 'y');
assert_eq!(app.view_state.yanked, Some(CellValue::Text("West".into())));
}
#[test]
fn p_on_a_records_column_that_stores_no_value_changes_nothing() {
for label in ["Region", "+cat", "+"] {
let mut app = records_app();
app.view_state.yanked = Some(CellValue::Number(5.5));
let before = crate::persistence::format_md(&app.model_state.workbook);
app.cursor_mut().selected = (1, col_of(&app, label));
press(&mut app, 'p');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before,
"p on the {label} column changed the document"
);
assert!(
!app.view_state.status_msg.is_empty(),
"p on the {label} column declined silently"
);
}
// The control: the same paste onto a measure column does write.
let mut app = records_app();
app.view_state.yanked = Some(CellValue::Number(5.5));
app.cursor_mut().selected = (1, col_of(&app, "Revenue"));
press(&mut app, 'p');
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&record("West", "Revenue")),
Some(&CellValue::Number(5.5)),
"the control never wrote, so the declines above prove nothing"
);
}
#[test]
fn p_inside_a_drill_neither_writes_nor_stages() {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.model
.set_cell(record("East", "Revenue"), CellValue::Number(1.0));
wb.model
.set_cell(record("East", "Cost"), CellValue::Number(2.0));
let mut app = App::new(wb, None);
press(&mut app, '>');
assert!(app.drill_state.is_some(), "setup: drilled");
let before = crate::persistence::format_md(&app.model_state.workbook);
app.view_state.yanked = Some(CellValue::Number(5.5));
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
press(&mut app, 'p');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before
);
assert!(
app.drill_state.as_ref().unwrap().pending_edits.is_empty(),
"a paste was staged as text, which loses the value's type"
);
assert_eq!(
synthetic_cells(&app.model_state.workbook),
Vec::<CellKey>::new()
);
assert!(!app.view_state.status_msg.is_empty(), "declined silently");
}
/// Region on rows, Product aggregated away on None: every cell is an aggregate.
fn aggregated_app(product_axis: crate::view::Axis) -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_category("Product").unwrap();
wb.add_item("Product", "Widget").unwrap();
wb.add_item("Product", "Gadget").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.active_view_mut()
.set_axis("Region", crate::view::Axis::Row);
wb.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
wb.active_view_mut().set_axis("Product", product_axis);
wb.model.set_cell(
record("East", "Revenue").with("Product", "Widget"),
CellValue::Number(10.0),
);
let mut app = App::new(wb, None);
app.view_state.yanked = Some(CellValue::Number(5.5));
app.cursor_mut().selected = (1, 0);
app
}
#[test]
fn p_onto_a_none_axis_aggregate_writes_nothing() {
let mut app = aggregated_app(crate::view::Axis::None);
let before = crate::persistence::format_md(&app.model_state.workbook);
press(&mut app, 'p');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before,
"p wrote a cell at the aggregate's partial key"
);
assert!(!app.view_state.status_msg.is_empty(), "declined silently");
// The control: with Product paged rather than aggregated, p writes.
let mut app = aggregated_app(crate::view::Axis::Page);
press(&mut app, 'p');
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&record("West", "Revenue").with("Product", "Widget")),
Some(&CellValue::Number(5.5))
);
}
#[test]
fn the_aggregate_decline_does_not_send_the_user_where_paste_also_declines() {
let mut app = aggregated_app(crate::view::Axis::None);
app.model_state.workbook.model.set_cell(
record("West", "Revenue").with("Product", "Gadget"),
CellValue::Number(3.0),
);
app.rebuild_layout();
press(&mut app, 'p');
let advice = app.view_state.status_msg.clone();
press(&mut app, '>');
assert!(app.drill_state.is_some(), "setup: the aggregate drills");
app.cursor_mut().selected = (0, col_of(&app, "Revenue"));
press(&mut app, 'p');
let in_drill = app.view_state.status_msg.clone();
assert!(
in_drill.ends_with(INSIDE_A_DRILL_REFUSAL),
"setup: the drilled paste declines, got {in_drill:?}"
);
assert!(
!advice.to_lowercase().contains("drill"),
"{advice:?} sends the user into a drill, where p answers {in_drill:?}"
);
}
#[test]
fn paste_with_an_explicit_full_key_writes_even_where_the_view_aggregates() {
let app = aggregated_app(crate::view::Axis::None);
let wb = &app.model_state.workbook;
let layout = make_layout(wb);
let reg = make_registry();
let yanked = Some(CellValue::Number(5.5));
let mut ctx = make_ctx(wb, &layout, &reg);
ctx.yanked = &yanked;
assert!(!ctx.none_cats().is_empty(), "setup: the view aggregates");
let key = record("West", "Revenue").with("Product", "Gadget");
let effects = PasteCell {
at: PasteAt::Spelled(key.clone()),
}
.execute(&ctx);
let mut app = aggregated_app(crate::view::Axis::None);
app.apply_effects(effects);
assert_eq!(
app.model_state.workbook.model.get_cell(&key),
Some(&CellValue::Number(5.5)),
"a key naming every coordinate is a record, whatever the view shows"
);
}
#[test]
fn transpose_produces_transpose() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = TransposeAxes.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
assert!(
dbg.contains("TransposeAxes"),
"Expected TransposeAxes, got: {dbg}"
);
}
#[test]
fn save_produces_save_effect() {
let m = two_cat_model();
let layout = make_layout(&m);
let reg = make_registry();
let ctx = make_ctx(&m, &layout, &reg);
let effects = SaveCmd.execute(&ctx);
assert_eq!(effects.len(), 1);
let dbg = effects_debug(&effects);
assert!(dbg.contains("Save"), "Expected Save, got: {dbg}");
}
/// improvise-q9eg: a record that lacks a later category is still a record —
/// see context/test-evidence/command.md.
#[test]
fn p_onto_a_sparse_record_writes_it() {
let mut app = records_app();
app.model_state.workbook.add_category("Season").unwrap();
app.rebuild_layout();
assert!(
app.layout.none_cats.iter().any(|c| c == "Season"),
"setup: the new category is aggregated away, so every record lacks it"
);
app.cursor_mut().selected = (0, col_of(&app, "Cost"));
press(&mut app, 'y');
press(&mut app, 'y');
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'p');
assert_eq!(
app.model_state
.workbook
.model
.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(700.0)),
"status: {:?}",
app.view_state.status_msg
);
}
/// improvise-lx7w — see context/test-evidence/command.md.
#[test]
fn x_in_records_mode_clears_the_real_record_under_the_cursor() {
let mut app = records_app();
app.cursor_mut().selected = (1, col_of(&app, "Cost"));
press(&mut app, 'x');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
None,
"West/Cost"
);
assert_eq!(
wb.model.get_cell(&record("East", "Cost")),
Some(&CellValue::Number(700.0)),
"the neighbouring record is untouched"
);
let (text, back) = saved_and_reloaded(wb);
assert!(!text.contains("West, _Measure=Cost"), "saved:\n{text}");
assert_eq!(back.model.get_cell(&record("West", "Cost")), None);
assert_eq!(synthetic_cells(&back), Vec::<CellKey>::new());
}
/// improvise-lx7w: a coordinate column stores no value, so `x` declines there.
#[test]
fn x_on_a_records_coordinate_column_changes_nothing_and_says_so() {
let mut app = records_app();
let before = crate::persistence::format_md(&app.model_state.workbook);
app.cursor_mut().selected = (1, col_of(&app, "Region"));
press(&mut app, 'x');
assert_eq!(
crate::persistence::format_md(&app.model_state.workbook),
before
);
assert!(
app.view_state.status_msg.ends_with(NO_VALUE_REFUSAL),
"declined out loud, got {:?}",
app.view_state.status_msg
);
}
/// Two regions by two raw measures and `Profit = Revenue - Cost`, the formula
/// in the third column, so the cursor on it is off row 0 and column 0.
fn formula_app() -> App {
let mut wb = Workbook::new("T");
wb.add_category("Region").unwrap();
wb.add_item("Region", "East").unwrap();
wb.add_item("Region", "West").unwrap();
wb.add_item("_Measure", "Revenue").unwrap();
wb.add_item("_Measure", "Cost").unwrap();
wb.model.add_formula(
crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(),
);
wb.active_view_mut()
.set_axis("Region", crate::view::Axis::Row);
wb.active_view_mut()
.set_axis("_Measure", crate::view::Axis::Column);
for (region, measure, n) in [
("East", "Revenue", 10.0),
("East", "Cost", 4.0),
("West", "Revenue", 9.0),
("West", "Cost", 2.0),
] {
wb.model
.set_cell(record(region, measure), CellValue::Number(n));
}
let mut app = App::new(wb, None);
app.view_state.yanked = Some(CellValue::Number(99.0));
app
}
/// improvise-54g1 — see context/test-evidence/command.md.
#[test]
fn p_onto_a_formula_owned_cell_writes_nothing_and_names_the_formula() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 2);
assert_eq!(app.layout.col_label(2), "Profit", "setup: on the formula");
press(&mut app, 'p');
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
assert!(
app.view_state
.status_msg
.contains("Profit is computed by a formula"),
"declined out loud, got {:?}",
app.view_state.status_msg
);
// The control: one column left is a stored cell, and p writes it.
app.cursor_mut().selected = (1, 1);
press(&mut app, 'p');
let wb = &app.model_state.workbook;
assert_eq!(
wb.model.get_cell(&record("West", "Cost")),
Some(&CellValue::Number(99.0))
);
}
/// The commit door alone on a formula's cell, with no advance to reach it by.
/// See context/test-evidence/command.md.
#[test]
fn an_editor_already_open_on_a_formula_cell_commits_nothing_under_it() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 2);
app.view_state.mode = crate::ui::app::AppMode::editing();
app.view_state.buffers.insert("edit".into(), "7".into());
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
}
/// improvise-enz6's formula twin: Tab does not re-open the editor on a cell `i`
/// would decline. See context/test-evidence/command.md.
#[test]
fn tab_advancing_onto_a_formula_cell_does_not_reopen_the_editor_there() {
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let mut app = formula_app();
app.cursor_mut().selected = (1, 0);
press(&mut app, 'i');
app.handle_key(tab).unwrap();
assert_eq!(app.cursor().selected, (1, 1), "setup: Tab moved to Cost");
assert!(
app.view_state.mode.is_editing(),
"the control: Cost is stored, so the editor re-opens there"
);
app.handle_key(tab).unwrap();
assert_eq!(app.cursor().selected, (1, 2), "Tab still moves onto Profit");
assert!(
!app.view_state.mode.is_editing(),
"an editor was opened on the formula: {:?}",
app.view_state.mode
);
assert!(
app.view_state
.status_msg
.contains("Profit is computed by a formula"),
"the decline is out loud, got {:?}",
app.view_state.status_msg
);
}
/// The second door onto a formula cell: Tab advances the editor onto it, where
/// `i` would have declined. See context/test-evidence/command.md.
#[test]
fn tab_advancing_onto_a_formula_cell_commits_nothing_under_it() {
let mut app = formula_app();
app.cursor_mut().selected = (1, 1);
press(&mut app, 'i');
assert!(app.view_state.mode.is_editing(), "setup: editing West/Cost");
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
.unwrap();
assert_eq!(
app.cursor().selected,
(1, 2),
"setup: Tab moved onto Profit"
);
for c in "7".chars() {
press(&mut app, c);
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.unwrap();
let (text, back) = saved_and_reloaded(&app.model_state.workbook);
assert!(
!text.contains("_Measure=Profit"),
"saved under the formula:\n{text}"
);
assert_eq!(back.model.get_cell(&record("West", "Profit")), None);
}
}
// ── Cell operations ──────────────────────────────────────────────────────────
// All cell commands take an explicit CellKey; see `context/code/command.md`.
/// Clear a cell.
#[derive(Debug)]
pub struct ClearCellCommand {
pub key: crate::model::cell::CellKey,
}
impl Cmd for ClearCellCommand {
fn name(&self) -> &'static str {
"clear-cell"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::ClearCell(self.key.clone()))]
}
}
/// Yank (copy) a cell value.
#[derive(Debug)]
pub struct YankCell {
pub key: crate::model::cell::CellKey,
}
impl Cmd for YankCell {
fn name(&self) -> &'static str {
"yank"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
// A records cell is the layout's one resolution, as the browser yanks it (gotcha 1).
let value = match synthetic_record_info(&self.key) {
Some((row, col)) => ctx.layout.records_value(row, col),
None => ctx.layout.evaluate(ctx.model, &self.key),
};
vec![
Box::new(effect::SetYanked(value)),
effect::set_status("Yanked"),
]
}
}
/// Where a paste lands: a key the cursor's door already resolved, or one a
/// script spelled out, which only the key's own shape can vet (improvise-q9eg).
#[derive(Debug)]
pub enum PasteAt {
Resolved(CellKey),
Spelled(CellKey),
}
/// Paste the yanked value into a cell.
#[derive(Debug)]
pub struct PasteCell {
pub at: PasteAt,
}
impl Cmd for PasteCell {
fn name(&self) -> &'static str {
"paste"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let Some(value) = ctx.yanked.clone() else {
return vec![];
};
vec![match &self.at {
PasteAt::Resolved(key) => Box::new(effect::SetCell(key.clone(), value)),
PasteAt::Spelled(key) => paste_into(ctx, key, value),
}]
}
}
/// Where a paste at `key` lands. The reasoning, and why a drill declines:
/// `context/code/command.md`, "Paste lands on a record or nowhere".
fn paste_into(ctx: &CmdContext, key: &CellKey, value: CellValue) -> Box<dyn Effect> {
match synthetic_record_info(key) {
Some((row, col)) => paste_into_record(ctx, row, col, value),
None if aggregates(ctx, key) => effect::refuse(AGGREGATE_REFUSAL),
None => Box::new(effect::SetCell(key.clone(), value)),
}
}
fn paste_into_record(
ctx: &CmdContext,
row: usize,
col: usize,
value: CellValue,
) -> Box<dyn Effect> {
match (ctx.layout.record_cell_key(row, col), ctx.has_drill_state) {
(Some(_), true) => effect::refuse(INSIDE_A_DRILL_REFUSAL),
(Some(real), false) => Box::new(effect::SetCell(real, value)),
(None, _) => effect::refuse(NO_VALUE_REFUSAL),
}
}
/// Whether `key` leaves out a regular category the layout aggregates over.
fn aggregates(ctx: &CmdContext, key: &CellKey) -> bool {
ctx.none_cats().iter().any(|cat| {
key.get(cat).is_none() && ctx.model.category(cat).is_some_and(|c| c.kind.is_regular())
})
}
// ── View commands ────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct TransposeAxes;
impl Cmd for TransposeAxes {
fn name(&self) -> &'static str {
"transpose"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::TransposeAxes(ViewTarget::Active))]
}
}
#[derive(Debug)]
pub struct SaveCmd;
impl Cmd for SaveCmd {
fn name(&self) -> &'static str {
"save"
}
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
vec![Box::new(effect::Save)]
}
}