Skip to main content

basalt/
workspace.rs

1//! Portable workspace lifecycle for local structured-data workflows.
2//!
3//! A workspace is a directory with a versioned manifest and one Basalt
4//! database. Import and export deliberately use common text formats so a
5//! workspace is inspectable and recoverable without Basalt-specific tooling.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::ffi::OsStr;
9use std::fmt;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Read, Write};
12use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use csv::{ReaderBuilder, StringRecord, Writer};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value as JsonValue};
20use sha2::{Digest, Sha256};
21
22use crate::database::Database;
23use crate::db::{Column, DbError, StatementResult};
24use crate::engine::{ExecutionBudget, MCP_EXECUTION_WORK_LIMIT};
25use crate::sql::ast::Statement;
26use crate::sql::parser::parse;
27use crate::types::{ColumnType, Value};
28
29const MANIFEST_FILE: &str = "workspace.json";
30const DATABASE_FILE: &str = "data.basalt";
31const WORKSPACE_LOCK_FILE: &str = ".workspace.lock";
32const FORMAT_VERSION: u32 = 1;
33const MAX_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
34pub(crate) const MAX_MCP_IMPORT_BYTES: usize = 16 * 1024 * 1024;
35const MAX_MCP_IMPORT_ROWS: usize = 10_000;
36const MAX_MCP_IMPORT_COLUMNS: usize = 256;
37const MAX_MCP_IMPORT_CELLS: usize = 1_000_000;
38const IMPORT_BATCH_SIZE: usize = 256;
39const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
40const MAX_PREVIEW_STATEMENTS: usize = 64;
41const MAX_PREVIEW_MUTATIONS: usize = 32;
42const MAX_PREVIEW_ROWS: usize = 10_000;
43const MAX_MCP_MUTATION_ROWS: usize = 10_000;
44const MAX_MCP_DIFF_ROWS: usize = 10_000;
45const MAX_MCP_EXPORT_ROWS: usize = 10_000;
46const HISTORY_DIR: &str = "history";
47const PLANS_DIR: &str = "plans";
48const CHANGES_DIR: &str = "changes";
49const SNAPSHOTS_DIR: &str = "snapshots";
50
51pub const HELP: &str = "Basalt workspace — local, portable SQL workspaces\n\n\
52Usage:\n  basalt workspace <COMMAND> [OPTIONS]\n\n\
53Commands:\n  init PATH                         Create a workspace\n  inspect [--json] PATH             Show workspace metadata and schema\n  query [OPTIONS] PATH SQL          Run a read-only query\n  preview [--json] PATH SQL         Preview a write and save its plan\n  plan [--json] PATH PLAN_ID        Load a saved preview plan\n  apply [--json] PATH PLAN_ID       Apply one exact preview plan\n  history [--json] PATH             List applied and recoverable changes\n  diff [--json] PATH [CHANGE_ID]    Compare a change recovery point\n  undo [--json] PATH CHANGE_ID      Undo the latest change safely\n  import [OPTIONS] WORKSPACE SOURCE Import CSV, JSON, JSONL, or SQL\n  export [OPTIONS] WORKSPACE TABLE OUTPUT\n                                     Export CSV, JSONL, or SQL\n\n\
54Import options:\n  --table NAME                      Table name (required for stdin)\n  --format csv|json|jsonl|sql       Override format inference\n  --json                            Emit a machine-readable import report\n\n\
55Export options:\n  --format csv|jsonl|sql             Override format inference\n  --json                            Emit a machine-readable export report\n\n\
56Query options:\n  --output table|csv|json             Result format (table by default)\n\n\
57State options:\n  --json                             Emit machine-readable JSON\n\n\
58SOURCE and OUTPUT may be '-' for stdin/stdout. File extensions infer formats.\n\
59Imports are atomic. Workspace data stays local and uses a versioned manifest.\n";
60
61#[derive(Debug)]
62pub enum WorkspaceError {
63    Usage(String),
64    Invalid(String),
65    Io(io::Error),
66    Database(DbError),
67    Json(serde_json::Error),
68    Csv(csv::Error),
69}
70
71impl WorkspaceError {
72    pub fn exit_code(&self) -> i32 {
73        match self {
74            WorkspaceError::Usage(_) => 2,
75            WorkspaceError::Invalid(_)
76            | WorkspaceError::Io(_)
77            | WorkspaceError::Database(_)
78            | WorkspaceError::Json(_)
79            | WorkspaceError::Csv(_) => 1,
80        }
81    }
82}
83
84impl fmt::Display for WorkspaceError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            WorkspaceError::Usage(message) | WorkspaceError::Invalid(message) => {
88                write!(f, "{message}")
89            }
90            WorkspaceError::Io(error) => write!(f, "{error}"),
91            WorkspaceError::Database(error) => write!(f, "{error}"),
92            WorkspaceError::Json(error) => write!(f, "{error}"),
93            WorkspaceError::Csv(error) => write!(f, "{error}"),
94        }
95    }
96}
97
98impl std::error::Error for WorkspaceError {}
99
100impl From<io::Error> for WorkspaceError {
101    fn from(error: io::Error) -> Self {
102        WorkspaceError::Io(error)
103    }
104}
105
106impl From<DbError> for WorkspaceError {
107    fn from(error: DbError) -> Self {
108        WorkspaceError::Database(error)
109    }
110}
111
112impl From<serde_json::Error> for WorkspaceError {
113    fn from(error: serde_json::Error) -> Self {
114        WorkspaceError::Json(error)
115    }
116}
117
118impl From<csv::Error> for WorkspaceError {
119    fn from(error: csv::Error) -> Self {
120        WorkspaceError::Csv(error)
121    }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct WorkspaceManifest {
126    pub format_version: u32,
127    pub database: String,
128}
129
130#[derive(Debug, Clone)]
131pub struct Workspace {
132    root: PathBuf,
133    manifest: WorkspaceManifest,
134    _lock: Arc<File>,
135}
136
137impl Workspace {
138    pub fn init(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
139        let root = path.as_ref().to_path_buf();
140        if root.as_os_str().is_empty() {
141            return Err(WorkspaceError::Invalid(
142                "workspace path cannot be empty".to_string(),
143            ));
144        }
145        if path_is_symlink(&root)? {
146            return Err(WorkspaceError::Invalid(
147                "workspace path cannot be a symbolic link".to_string(),
148            ));
149        }
150        if root.exists() && !root.is_dir() {
151            return Err(WorkspaceError::Invalid(format!(
152                "workspace path is not a directory: {}",
153                root.display()
154            )));
155        }
156        fs::create_dir_all(&root)?;
157        let lock = acquire_workspace_lock(&root)?;
158        let manifest_path = root.join(MANIFEST_FILE);
159        let database_path = root.join(DATABASE_FILE);
160        if path_is_symlink(&manifest_path)? || manifest_path.exists() {
161            return Err(WorkspaceError::Invalid(format!(
162                "workspace already exists: {}",
163                root.display()
164            )));
165        }
166        if path_is_symlink(&database_path)? || database_path.exists() {
167            return Err(WorkspaceError::Invalid(format!(
168                "reserved database path already exists: {}",
169                database_path.display()
170            )));
171        }
172
173        let manifest = WorkspaceManifest {
174            format_version: FORMAT_VERSION,
175            database: DATABASE_FILE.to_string(),
176        };
177        write_new_file(&manifest_path, &manifest_bytes(&manifest)?)?;
178
179        let database = Database::open_in_workspace(&database_path)?;
180        database.checkpoint()?;
181        drop(database);
182
183        Ok(Workspace {
184            root,
185            manifest,
186            _lock: lock,
187        })
188    }
189
190    pub fn open(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
191        let root = path.as_ref().to_path_buf();
192        if path_is_symlink(&root)? {
193            return Err(WorkspaceError::Invalid(
194                "workspace path cannot be a symbolic link".to_string(),
195            ));
196        }
197        if !root.is_dir() {
198            return Err(WorkspaceError::Invalid(format!(
199                "workspace directory does not exist: {}",
200                root.display()
201            )));
202        }
203        let manifest_path = root.join(MANIFEST_FILE);
204        if path_is_symlink(&manifest_path)? {
205            return Err(WorkspaceError::Invalid(
206                "workspace manifest cannot be a symbolic link".to_string(),
207            ));
208        }
209        let bytes = fs::read(&manifest_path).map_err(|error| {
210            if error.kind() == io::ErrorKind::NotFound {
211                WorkspaceError::Invalid(format!(
212                    "not a Basalt workspace: missing {}",
213                    manifest_path.display()
214                ))
215            } else {
216                WorkspaceError::Io(error)
217            }
218        })?;
219        let manifest: WorkspaceManifest = serde_json::from_slice(&bytes).map_err(|error| {
220            WorkspaceError::Invalid(format!(
221                "invalid workspace manifest {}: {error}",
222                manifest_path.display()
223            ))
224        })?;
225        if manifest.format_version != FORMAT_VERSION {
226            return Err(WorkspaceError::Invalid(format!(
227                "unsupported workspace format version {}; expected {}",
228                manifest.format_version, FORMAT_VERSION
229            )));
230        }
231        if manifest.database != DATABASE_FILE {
232            return Err(WorkspaceError::Invalid(format!(
233                "workspace database must be {DATABASE_FILE:?}, got {:?}",
234                manifest.database
235            )));
236        }
237        validate_database_paths(&root)?;
238        let lock = acquire_workspace_lock(&root)?;
239        Ok(Workspace {
240            root,
241            manifest,
242            _lock: lock,
243        })
244    }
245
246    /// Open a workspace, creating it only when the requested path is missing.
247    pub fn open_or_init(path: impl AsRef<Path>) -> Result<Workspace, WorkspaceError> {
248        let path = path.as_ref().to_path_buf();
249        match Self::open(&path) {
250            Ok(workspace) => Ok(workspace),
251            Err(_error) if !path.exists() => Self::init(path),
252            Err(error) => Err(error),
253        }
254    }
255
256    pub fn root(&self) -> &Path {
257        &self.root
258    }
259
260    pub fn manifest(&self) -> &WorkspaceManifest {
261        &self.manifest
262    }
263
264    pub fn database_path(&self) -> PathBuf {
265        self.root.join(DATABASE_FILE)
266    }
267
268    pub fn database(&self) -> Result<Database, WorkspaceError> {
269        let path = self.database_path();
270        validate_database_paths(&self.root)?;
271        Ok(Database::open_in_workspace(path)?)
272    }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276enum DataFormat {
277    Csv,
278    Json,
279    JsonLines,
280    Sql,
281}
282
283impl DataFormat {
284    fn parse(value: &str) -> Result<DataFormat, WorkspaceError> {
285        match value.to_ascii_lowercase().as_str() {
286            "csv" => Ok(DataFormat::Csv),
287            "json" => Ok(DataFormat::Json),
288            "jsonl" | "ndjson" => Ok(DataFormat::JsonLines),
289            "sql" => Ok(DataFormat::Sql),
290            _ => Err(WorkspaceError::Usage(format!(
291                "unknown format {value:?}; expected csv, json, jsonl, or sql"
292            ))),
293        }
294    }
295
296    fn from_path(path: &Path) -> Option<DataFormat> {
297        let extension = path
298            .extension()
299            .and_then(OsStr::to_str)?
300            .to_ascii_lowercase();
301        match extension.as_str() {
302            "csv" => Some(DataFormat::Csv),
303            "json" => Some(DataFormat::Json),
304            "jsonl" | "ndjson" => Some(DataFormat::JsonLines),
305            "sql" => Some(DataFormat::Sql),
306            _ => None,
307        }
308    }
309
310    fn name(self) -> &'static str {
311        match self {
312            DataFormat::Csv => "csv",
313            DataFormat::Json => "json",
314            DataFormat::JsonLines => "jsonl",
315            DataFormat::Sql => "sql",
316        }
317    }
318}
319
320#[derive(Debug)]
321enum Command {
322    Help,
323    Init(PathBuf),
324    Inspect {
325        json: bool,
326        workspace: PathBuf,
327    },
328    Query {
329        workspace: PathBuf,
330        sql: String,
331        output: crate::cli::OutputMode,
332    },
333    Preview {
334        workspace: PathBuf,
335        sql: String,
336        json: bool,
337    },
338    Plan {
339        workspace: PathBuf,
340        plan_id: String,
341        json: bool,
342    },
343    Apply {
344        workspace: PathBuf,
345        plan_id: String,
346        json: bool,
347    },
348    History {
349        workspace: PathBuf,
350        json: bool,
351    },
352    Diff {
353        workspace: PathBuf,
354        change_id: Option<String>,
355        json: bool,
356    },
357    Undo {
358        workspace: PathBuf,
359        change_id: String,
360        json: bool,
361    },
362    Import {
363        workspace: PathBuf,
364        source: PathBuf,
365        table: Option<String>,
366        format: Option<DataFormat>,
367        json: bool,
368    },
369    Export {
370        workspace: PathBuf,
371        table: String,
372        output: PathBuf,
373        format: Option<DataFormat>,
374        json: bool,
375    },
376}
377
378pub fn run<R: Read, W: Write>(
379    args: &[String],
380    input: &mut R,
381    output: &mut W,
382) -> Result<(), WorkspaceError> {
383    if args.is_empty() || args.iter().any(|arg| arg == "--help") {
384        output.write_all(HELP.as_bytes())?;
385        return Ok(());
386    }
387    let command = parse_command(args)?;
388    match command {
389        Command::Help => output.write_all(HELP.as_bytes())?,
390        Command::Init(path) => {
391            let workspace = Workspace::init(path)?;
392            writeln!(output, "created workspace {}", workspace.root().display())?;
393        }
394        Command::Inspect { json, workspace } => {
395            let workspace = Workspace::open(workspace)?;
396            let database = workspace.database()?;
397            let report = inspect(&workspace, &database)?;
398            if json {
399                serde_json::to_writer_pretty(&mut *output, &report)?;
400                output.write_all(b"\n")?;
401            } else {
402                render_inspect(&report, output)?;
403            }
404        }
405        Command::Query {
406            workspace,
407            sql,
408            output: output_mode,
409        } => {
410            let workspace = Workspace::open(workspace)?;
411            let statements = parse(&sql).map_err(|error| {
412                WorkspaceError::Invalid(format!(
413                    "query parse error at byte {}: {}",
414                    error.offset, error.message
415                ))
416            })?;
417            if statements.is_empty() || statements.iter().any(|statement| !is_read_only(statement))
418            {
419                return Err(WorkspaceError::Invalid(
420                    "workspace query only accepts SELECT and EXPLAIN SELECT".to_string(),
421                ));
422            }
423            let options = crate::cli::CliOptions {
424                database: workspace.database_path().display().to_string(),
425                actions: vec![crate::cli::InputAction::Command(sql)],
426                output: output_mode,
427                headers: true,
428                quiet: false,
429                help: false,
430                version: false,
431            };
432            let database = workspace.database()?;
433            let mut empty_input = io::Cursor::new(Vec::<u8>::new());
434            crate::cli::run(&options, database, &mut empty_input, output)
435                .map_err(|error| WorkspaceError::Invalid(error.to_string()))?;
436        }
437        Command::Preview {
438            workspace,
439            sql,
440            json,
441        } => {
442            let workspace = Workspace::open(workspace)?;
443            let plan = preview_plan(&workspace, &sql)?;
444            if json {
445                let report = PlanReport::from(&plan);
446                serde_json::to_writer_pretty(&mut *output, &report)?;
447                output.write_all(b"\n")?;
448            } else {
449                render_plan(&workspace, &plan, output)?;
450            }
451        }
452        Command::Plan {
453            workspace,
454            plan_id,
455            json,
456        } => {
457            let workspace = Workspace::open(workspace)?;
458            let plan = load_plan(&workspace, &plan_id)?;
459            if json {
460                let report = PlanReport::from(&plan);
461                serde_json::to_writer_pretty(&mut *output, &report)?;
462                output.write_all(b"\n")?;
463            } else {
464                render_plan(&workspace, &plan, output)?;
465            }
466        }
467        Command::Apply {
468            workspace,
469            plan_id,
470            json,
471        } => {
472            let workspace = Workspace::open(workspace)?;
473            let report = apply_plan(&workspace, &plan_id, None, None)?;
474            if json {
475                serde_json::to_writer_pretty(&mut *output, &report)?;
476                output.write_all(b"\n")?;
477            } else {
478                render_apply(&report, output)?;
479            }
480        }
481        Command::History { workspace, json } => {
482            let workspace = Workspace::open(workspace)?;
483            let entries = history(&workspace)?;
484            if json {
485                serde_json::to_writer_pretty(&mut *output, &entries)?;
486                output.write_all(b"\n")?;
487            } else {
488                render_history(&entries, output)?;
489            }
490        }
491        Command::Diff {
492            workspace,
493            change_id,
494            json,
495        } => {
496            let workspace = Workspace::open(workspace)?;
497            let report = diff(&workspace, change_id.as_deref())?;
498            if json {
499                serde_json::to_writer_pretty(&mut *output, &report)?;
500                output.write_all(b"\n")?;
501            } else {
502                render_diff(&report, output)?;
503            }
504        }
505        Command::Undo {
506            workspace,
507            change_id,
508            json,
509        } => {
510            let workspace = Workspace::open(workspace)?;
511            let report = undo(&workspace, &change_id)?;
512            if json {
513                serde_json::to_writer_pretty(&mut *output, &report)?;
514                output.write_all(b"\n")?;
515            } else {
516                render_undo(&report, output)?;
517            }
518        }
519        Command::Import {
520            workspace,
521            source,
522            table,
523            format,
524            json,
525        } => {
526            let workspace = Workspace::open(workspace)?;
527            let format = format
528                .or_else(|| DataFormat::from_path(&source))
529                .ok_or_else(|| {
530                    WorkspaceError::Usage(
531                        "cannot infer import format; provide --format or use a supported extension"
532                            .to_string(),
533                    )
534                })?;
535            let bytes = read_source(&source, input)?;
536            let table_name = if format == DataFormat::Sql {
537                None
538            } else {
539                table.or_else(|| inferred_table_name(&source))
540            };
541            let imported = import_with_recovery(
542                &workspace,
543                table_name.as_deref(),
544                format,
545                &bytes,
546                ImportLimits::unbounded(),
547            )?;
548            if json {
549                let report = CliImportReport {
550                    operation: "import",
551                    workspace: workspace.root.display().to_string(),
552                    source: source.display().to_string(),
553                    format: imported.format.clone(),
554                    table: imported.table.clone(),
555                    bytes: bytes.len(),
556                    change_id: imported.change_id.clone(),
557                    summary: imported.summary.clone(),
558                };
559                serde_json::to_writer_pretty(&mut *output, &report)?;
560                output.write_all(b"\n")?;
561            } else {
562                writeln!(
563                    output,
564                    "imported {}: {} (change {})",
565                    imported.format, imported.summary, imported.change_id
566                )?;
567            }
568        }
569        Command::Export {
570            workspace,
571            table,
572            output: destination,
573            format,
574            json,
575        } => {
576            let workspace = Workspace::open(workspace)?;
577            let format = format
578                .or_else(|| DataFormat::from_path(&destination))
579                .ok_or_else(|| {
580                    WorkspaceError::Usage(
581                        "cannot infer export format; provide --format or use a supported extension"
582                            .to_string(),
583                    )
584                })?;
585            if format == DataFormat::Json {
586                return Err(WorkspaceError::Usage(
587                    "JSON export is JSON Lines; use --format jsonl or a .jsonl file".to_string(),
588                ));
589            }
590            if json && destination.as_os_str() == OsStr::new("-") {
591                return Err(WorkspaceError::Usage(
592                    "--json cannot be combined with stdout export; write the export to a file"
593                        .to_string(),
594                ));
595            }
596            let database = workspace.database()?;
597            let (columns, rows) = select_table(&database, &table)?;
598            let bytes = match format {
599                DataFormat::Csv => export_csv(&columns, &rows)?,
600                DataFormat::JsonLines => export_json_lines(&columns, &rows)?,
601                DataFormat::Sql => export_sql(&database, &table, &rows)?,
602                DataFormat::Json => unreachable!(),
603            };
604            write_output(&workspace, &destination, &bytes, output)?;
605            if json {
606                let report = CliExportReport {
607                    operation: "export",
608                    workspace: workspace.root.display().to_string(),
609                    table,
610                    format: format.name().to_string(),
611                    output: destination.display().to_string(),
612                    rows: rows.len(),
613                    bytes: bytes.len(),
614                };
615                serde_json::to_writer_pretty(&mut *output, &report)?;
616                output.write_all(b"\n")?;
617            } else if destination.as_os_str() == OsStr::new("-") {
618                output.flush()?;
619            } else {
620                writeln!(
621                    output,
622                    "exported {} rows to {}",
623                    rows.len(),
624                    destination.display()
625                )?;
626            }
627        }
628    }
629    output.flush()?;
630    Ok(())
631}
632
633fn parse_command(args: &[String]) -> Result<Command, WorkspaceError> {
634    match args.first().map(String::as_str) {
635        Some("init") => parse_init(&args[1..]),
636        Some("inspect") => parse_inspect(&args[1..]),
637        Some("query") => parse_query(&args[1..]),
638        Some("preview") => parse_preview(&args[1..]),
639        Some("plan") => parse_plan(&args[1..]),
640        Some("apply") => parse_apply(&args[1..]),
641        Some("history") => parse_history(&args[1..]),
642        Some("diff") => parse_diff(&args[1..]),
643        Some("undo") => parse_undo(&args[1..]),
644        Some("import") => parse_import(&args[1..]),
645        Some("export") => parse_export(&args[1..]),
646        Some("--help") => Ok(Command::Help),
647        Some(command) => Err(WorkspaceError::Usage(format!(
648            "unknown workspace command {command:?}; run `basalt workspace --help`"
649        ))),
650        None => Err(WorkspaceError::Usage(
651            "missing workspace command".to_string(),
652        )),
653    }
654}
655
656fn parse_init(args: &[String]) -> Result<Command, WorkspaceError> {
657    if args.len() == 1 && args[0] != "--help" {
658        return Ok(Command::Init(PathBuf::from(&args[0])));
659    }
660    Err(WorkspaceError::Usage(
661        "usage: basalt workspace init PATH".to_string(),
662    ))
663}
664
665fn parse_inspect(args: &[String]) -> Result<Command, WorkspaceError> {
666    let mut json = false;
667    let mut positional = Vec::new();
668    let mut options = true;
669    for arg in args {
670        if options && arg == "--" {
671            options = false;
672        } else if options && arg == "--json" {
673            json = true;
674        } else if options && arg == "--help" {
675            return Err(WorkspaceError::Usage(HELP.to_string()));
676        } else if options && arg.starts_with('-') {
677            return Err(WorkspaceError::Usage(format!(
678                "unknown inspect option {arg:?}"
679            )));
680        } else {
681            positional.push(arg);
682        }
683    }
684    if positional.len() != 1 {
685        return Err(WorkspaceError::Usage(
686            "usage: basalt workspace inspect [--json] PATH".to_string(),
687        ));
688    }
689    Ok(Command::Inspect {
690        json,
691        workspace: PathBuf::from(positional[0]),
692    })
693}
694
695fn parse_query(args: &[String]) -> Result<Command, WorkspaceError> {
696    let mut output = crate::cli::OutputMode::Table;
697    let mut positional = Vec::new();
698    let mut index = 0;
699    let mut options = true;
700    while index < args.len() {
701        let arg = &args[index];
702        if options && arg == "--" {
703            options = false;
704        } else if options && arg == "--json" {
705            output = crate::cli::OutputMode::Json;
706        } else if options && arg == "--csv" {
707            output = crate::cli::OutputMode::Csv;
708        } else if options && arg == "--table" {
709            output = crate::cli::OutputMode::Table;
710        } else if options && arg == "--output" {
711            index += 1;
712            output = parse_query_output(&option_value(args, index, "--output")?)?;
713        } else if options && arg == "--help" {
714            return Err(WorkspaceError::Usage(HELP.to_string()));
715        } else if options && arg.starts_with('-') {
716            return Err(WorkspaceError::Usage(format!(
717                "unknown query option {arg:?}"
718            )));
719        } else {
720            positional.push(arg.clone());
721        }
722        index += 1;
723    }
724    if positional.len() != 2 {
725        return Err(WorkspaceError::Usage(
726            "usage: basalt workspace query [OPTIONS] PATH SQL".to_string(),
727        ));
728    }
729    Ok(Command::Query {
730        workspace: PathBuf::from(&positional[0]),
731        sql: positional[1].clone(),
732        output,
733    })
734}
735
736fn parse_query_output(value: &str) -> Result<crate::cli::OutputMode, WorkspaceError> {
737    match value.to_ascii_lowercase().as_str() {
738        "table" => Ok(crate::cli::OutputMode::Table),
739        "csv" => Ok(crate::cli::OutputMode::Csv),
740        "json" | "jsonl" | "ndjson" => Ok(crate::cli::OutputMode::Json),
741        _ => Err(WorkspaceError::Usage(format!(
742            "unknown query output format {value:?}; expected table, csv, or json"
743        ))),
744    }
745}
746
747fn parse_json_flagged_command(
748    args: &[String],
749    usage: &str,
750    positionals: std::ops::RangeInclusive<usize>,
751) -> Result<(bool, Vec<String>), WorkspaceError> {
752    let mut json = false;
753    let mut positional = Vec::new();
754    let mut options = true;
755    for arg in args {
756        if options && arg == "--" {
757            options = false;
758        } else if options && arg == "--json" {
759            json = true;
760        } else if options && arg == "--help" {
761            return Err(WorkspaceError::Usage(HELP.to_string()));
762        } else if options && arg.starts_with('-') {
763            return Err(WorkspaceError::Usage(format!("unknown option {arg:?}")));
764        } else {
765            positional.push(arg.clone());
766        }
767    }
768    if !positionals.contains(&positional.len()) {
769        return Err(WorkspaceError::Usage(usage.to_string()));
770    }
771    Ok((json, positional))
772}
773
774fn parse_preview(args: &[String]) -> Result<Command, WorkspaceError> {
775    let (json, positional) = parse_json_flagged_command(
776        args,
777        "usage: basalt workspace preview [--json] PATH SQL",
778        2..=2,
779    )?;
780    Ok(Command::Preview {
781        workspace: PathBuf::from(&positional[0]),
782        sql: positional[1].clone(),
783        json,
784    })
785}
786
787fn parse_apply(args: &[String]) -> Result<Command, WorkspaceError> {
788    let (json, positional) = parse_json_flagged_command(
789        args,
790        "usage: basalt workspace apply [--json] PATH PLAN_ID",
791        2..=2,
792    )?;
793    Ok(Command::Apply {
794        workspace: PathBuf::from(&positional[0]),
795        plan_id: positional[1].clone(),
796        json,
797    })
798}
799
800fn parse_plan(args: &[String]) -> Result<Command, WorkspaceError> {
801    let (json, positional) = parse_json_flagged_command(
802        args,
803        "usage: basalt workspace plan [--json] PATH PLAN_ID",
804        2..=2,
805    )?;
806    Ok(Command::Plan {
807        workspace: PathBuf::from(&positional[0]),
808        plan_id: positional[1].clone(),
809        json,
810    })
811}
812
813fn parse_history(args: &[String]) -> Result<Command, WorkspaceError> {
814    let (json, positional) =
815        parse_json_flagged_command(args, "usage: basalt workspace history [--json] PATH", 1..=1)?;
816    Ok(Command::History {
817        workspace: PathBuf::from(&positional[0]),
818        json,
819    })
820}
821
822fn parse_diff(args: &[String]) -> Result<Command, WorkspaceError> {
823    let (json, positional) = parse_json_flagged_command(
824        args,
825        "usage: basalt workspace diff [--json] PATH [CHANGE_ID]",
826        1..=2,
827    )?;
828    Ok(Command::Diff {
829        workspace: PathBuf::from(&positional[0]),
830        change_id: positional.get(1).cloned(),
831        json,
832    })
833}
834
835fn parse_undo(args: &[String]) -> Result<Command, WorkspaceError> {
836    let (json, positional) = parse_json_flagged_command(
837        args,
838        "usage: basalt workspace undo [--json] PATH CHANGE_ID",
839        2..=2,
840    )?;
841    Ok(Command::Undo {
842        workspace: PathBuf::from(&positional[0]),
843        change_id: positional[1].clone(),
844        json,
845    })
846}
847
848fn parse_import(args: &[String]) -> Result<Command, WorkspaceError> {
849    let mut table = None;
850    let mut format = None;
851    let mut json = false;
852    let mut positional = Vec::new();
853    let mut index = 0;
854    let mut options = true;
855    while index < args.len() {
856        let arg = &args[index];
857        if options && arg == "--" {
858            options = false;
859        } else if options && arg == "--table" {
860            index += 1;
861            table = Some(option_value(args, index, "--table")?);
862        } else if options && arg == "--format" {
863            index += 1;
864            format = Some(DataFormat::parse(&option_value(args, index, "--format")?)?);
865        } else if options && arg == "--json" {
866            json = true;
867        } else if options && arg == "--help" {
868            return Err(WorkspaceError::Usage(HELP.to_string()));
869        } else if options && arg.starts_with('-') && arg != "-" {
870            return Err(WorkspaceError::Usage(format!(
871                "unknown import option {arg:?}"
872            )));
873        } else {
874            positional.push(arg.clone());
875        }
876        index += 1;
877    }
878    if positional.len() != 2 {
879        return Err(WorkspaceError::Usage(
880            "usage: basalt workspace import [OPTIONS] WORKSPACE SOURCE".to_string(),
881        ));
882    }
883    let source = PathBuf::from(&positional[1]);
884    if source.as_os_str() == OsStr::new("-") && table.is_none() && format != Some(DataFormat::Sql) {
885        return Err(WorkspaceError::Usage(
886            "stdin imports require --table NAME".to_string(),
887        ));
888    }
889    if format == Some(DataFormat::Sql) && table.is_some() {
890        return Err(WorkspaceError::Usage(
891            "--table is not valid for SQL imports".to_string(),
892        ));
893    }
894    Ok(Command::Import {
895        workspace: PathBuf::from(&positional[0]),
896        source,
897        table,
898        format,
899        json,
900    })
901}
902
903fn parse_export(args: &[String]) -> Result<Command, WorkspaceError> {
904    let mut format = None;
905    let mut json = false;
906    let mut positional = Vec::new();
907    let mut index = 0;
908    let mut options = true;
909    while index < args.len() {
910        let arg = &args[index];
911        if options && arg == "--" {
912            options = false;
913        } else if options && arg == "--format" {
914            index += 1;
915            format = Some(DataFormat::parse(&option_value(args, index, "--format")?)?);
916        } else if options && arg == "--json" {
917            json = true;
918        } else if options && arg == "--help" {
919            return Err(WorkspaceError::Usage(HELP.to_string()));
920        } else if options && arg.starts_with('-') && arg != "-" {
921            return Err(WorkspaceError::Usage(format!(
922                "unknown export option {arg:?}"
923            )));
924        } else {
925            positional.push(arg.clone());
926        }
927        index += 1;
928    }
929    if positional.len() != 3 {
930        return Err(WorkspaceError::Usage(
931            "usage: basalt workspace export [OPTIONS] WORKSPACE TABLE OUTPUT".to_string(),
932        ));
933    }
934    let output = PathBuf::from(&positional[2]);
935    if output.as_os_str() == OsStr::new("-") && format.is_none() {
936        return Err(WorkspaceError::Usage(
937            "stdout exports require --format FORMAT".to_string(),
938        ));
939    }
940    Ok(Command::Export {
941        workspace: PathBuf::from(&positional[0]),
942        table: positional[1].clone(),
943        output,
944        format,
945        json,
946    })
947}
948
949fn option_value(args: &[String], index: usize, option: &str) -> Result<String, WorkspaceError> {
950    args.get(index)
951        .cloned()
952        .filter(|value| !value.starts_with('-') || value == "-")
953        .ok_or_else(|| WorkspaceError::Usage(format!("{option} requires a value")))
954}
955
956fn read_source<R: Read>(source: &Path, input: &mut R) -> Result<Vec<u8>, WorkspaceError> {
957    if source.as_os_str() == OsStr::new("-") {
958        return read_limited(input);
959    }
960    let mut file = File::open(source)?;
961    read_limited(&mut file)
962}
963
964fn read_limited<R: Read>(reader: &mut R) -> Result<Vec<u8>, WorkspaceError> {
965    let mut limited = reader.take(MAX_IMPORT_BYTES + 1);
966    let mut bytes = Vec::new();
967    limited.read_to_end(&mut bytes)?;
968    if bytes.len() as u64 > MAX_IMPORT_BYTES {
969        return Err(WorkspaceError::Invalid(format!(
970            "input exceeds the {} MiB limit",
971            MAX_IMPORT_BYTES / (1024 * 1024)
972        )));
973    }
974    Ok(bytes)
975}
976
977fn inferred_table_name(source: &Path) -> Option<String> {
978    if source.as_os_str() == OsStr::new("-") {
979        return None;
980    }
981    source
982        .file_stem()
983        .and_then(OsStr::to_str)
984        .map(str::to_string)
985}
986
987#[derive(Debug, Clone)]
988enum ImportedCell {
989    Null,
990    Empty,
991    Integer(i64),
992    Real(f64),
993    Boolean(bool),
994    Text(String),
995}
996
997#[derive(Debug, Clone)]
998struct ImportedRows {
999    table: String,
1000    columns: Vec<String>,
1001    types: Vec<ColumnType>,
1002    rows: Vec<Vec<ImportedCell>>,
1003}
1004
1005#[derive(Debug, Clone, Copy)]
1006struct ImportLimits {
1007    max_rows: Option<usize>,
1008    max_columns: Option<usize>,
1009    max_cells: Option<usize>,
1010    max_work: Option<usize>,
1011}
1012
1013impl ImportLimits {
1014    fn unbounded() -> Self {
1015        Self {
1016            max_rows: None,
1017            max_columns: None,
1018            max_cells: None,
1019            max_work: None,
1020        }
1021    }
1022
1023    fn mcp() -> Self {
1024        Self {
1025            max_rows: Some(MAX_MCP_IMPORT_ROWS),
1026            max_columns: Some(MAX_MCP_IMPORT_COLUMNS),
1027            max_cells: Some(MAX_MCP_IMPORT_CELLS),
1028            max_work: Some(MCP_EXECUTION_WORK_LIMIT),
1029        }
1030    }
1031}
1032
1033#[derive(Debug, Serialize)]
1034struct CliImportReport {
1035    operation: &'static str,
1036    workspace: String,
1037    source: String,
1038    format: String,
1039    table: Option<String>,
1040    bytes: usize,
1041    change_id: String,
1042    summary: String,
1043}
1044
1045#[derive(Debug, Serialize)]
1046struct CliExportReport {
1047    operation: &'static str,
1048    workspace: String,
1049    table: String,
1050    format: String,
1051    output: String,
1052    rows: usize,
1053    bytes: usize,
1054}
1055
1056fn import_csv(
1057    database: &Database,
1058    table: Option<&str>,
1059    bytes: &[u8],
1060    limits: ImportLimits,
1061) -> Result<Option<String>, WorkspaceError> {
1062    let table = required_table(table)?;
1063    let mut reader = ReaderBuilder::new()
1064        .has_headers(true)
1065        .flexible(false)
1066        .from_reader(bytes);
1067    let headers = reader.headers()?.clone();
1068    let columns = validate_headers(&headers)?;
1069    enforce_import_limits(0, columns.len(), limits)?;
1070    let mut rows = Vec::new();
1071    for record in reader.records() {
1072        if let Some(max_rows) = limits.max_rows
1073            && rows.len() >= max_rows
1074        {
1075            return Err(WorkspaceError::Invalid(format!(
1076                "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1077            )));
1078        }
1079        let record = record?;
1080        enforce_import_limits(rows.len().saturating_add(1), columns.len(), limits)?;
1081        rows.push(record.iter().map(parse_csv_cell).collect());
1082    }
1083    let imported = ImportedRows {
1084        table: table.to_string(),
1085        types: infer_types(&rows, columns.len()),
1086        columns,
1087        rows,
1088    };
1089    let summary = imported.summary();
1090    import_rows(database, &imported, limits.max_work)?;
1091    Ok(Some(summary))
1092}
1093
1094fn import_json(
1095    database: &Database,
1096    table: Option<&str>,
1097    bytes: &[u8],
1098    limits: ImportLimits,
1099) -> Result<Option<String>, WorkspaceError> {
1100    let value: JsonValue = serde_json::from_slice(bytes)?;
1101    let objects = match value {
1102        JsonValue::Array(values) => values,
1103        JsonValue::Object(object) => vec![JsonValue::Object(object)],
1104        _ => {
1105            return Err(WorkspaceError::Invalid(
1106                "JSON import expects an object or an array of objects".to_string(),
1107            ));
1108        }
1109    };
1110    import_json_objects(database, table, objects, limits)
1111}
1112
1113fn import_json_lines(
1114    database: &Database,
1115    table: Option<&str>,
1116    bytes: &[u8],
1117    limits: ImportLimits,
1118) -> Result<Option<String>, WorkspaceError> {
1119    let text = std::str::from_utf8(bytes).map_err(|error| {
1120        WorkspaceError::Invalid(format!("JSON Lines input is not UTF-8: {error}"))
1121    })?;
1122    let mut objects = Vec::new();
1123    for (line_number, line) in text.lines().enumerate() {
1124        if line.trim().is_empty() {
1125            continue;
1126        }
1127        if let Some(max_rows) = limits.max_rows
1128            && objects.len() >= max_rows
1129        {
1130            return Err(WorkspaceError::Invalid(format!(
1131                "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1132            )));
1133        }
1134        let value: JsonValue = serde_json::from_str(line).map_err(|error| {
1135            WorkspaceError::Invalid(format!("invalid JSON on line {}: {error}", line_number + 1))
1136        })?;
1137        if !value.is_object() {
1138            return Err(WorkspaceError::Invalid(format!(
1139                "JSON Lines line {} is not an object",
1140                line_number + 1
1141            )));
1142        }
1143        objects.push(value);
1144    }
1145    import_json_objects(database, table, objects, limits)
1146}
1147
1148fn import_json_objects(
1149    database: &Database,
1150    table: Option<&str>,
1151    objects: Vec<JsonValue>,
1152    limits: ImportLimits,
1153) -> Result<Option<String>, WorkspaceError> {
1154    let table = required_table(table)?;
1155    let object_count = objects.len();
1156    if objects.is_empty() {
1157        return Err(WorkspaceError::Invalid(
1158            "JSON import contains no objects; a table schema cannot be inferred".to_string(),
1159        ));
1160    }
1161    enforce_import_limits(object_count, 0, limits)?;
1162    let mut names = BTreeSet::new();
1163    let mut parsed_objects = Vec::with_capacity(objects.len());
1164    for value in objects {
1165        let JsonValue::Object(object) = value else {
1166            return Err(WorkspaceError::Invalid(
1167                "JSON import expects every row to be an object".to_string(),
1168            ));
1169        };
1170        for name in object.keys() {
1171            validate_name(name, "JSON key")?;
1172            names.insert(name.clone());
1173        }
1174        parsed_objects.push(object);
1175    }
1176    if names.is_empty() {
1177        return Err(WorkspaceError::Invalid(
1178            "JSON objects contain no fields; a table schema cannot be inferred".to_string(),
1179        ));
1180    }
1181    let columns: Vec<String> = names.into_iter().collect();
1182    enforce_import_limits(object_count, columns.len(), limits)?;
1183    let rows = parsed_objects
1184        .iter()
1185        .map(|object| {
1186            columns
1187                .iter()
1188                .map(|name| {
1189                    object
1190                        .get(name)
1191                        .map(json_cell)
1192                        .unwrap_or(ImportedCell::Null)
1193                })
1194                .collect()
1195        })
1196        .collect::<Vec<Vec<ImportedCell>>>();
1197    let imported = ImportedRows {
1198        table: table.to_string(),
1199        types: infer_types(&rows, columns.len()),
1200        columns,
1201        rows,
1202    };
1203    let summary = imported.summary();
1204    import_rows(database, &imported, limits.max_work)?;
1205    Ok(Some(summary))
1206}
1207
1208fn enforce_import_limits(
1209    rows: usize,
1210    columns: usize,
1211    limits: ImportLimits,
1212) -> Result<(), WorkspaceError> {
1213    if let Some(max_rows) = limits.max_rows
1214        && rows > max_rows
1215    {
1216        return Err(WorkspaceError::Invalid(format!(
1217            "MCP import is limited to {max_rows} rows; use the CLI for larger imports"
1218        )));
1219    }
1220    if let Some(max_columns) = limits.max_columns
1221        && columns > max_columns
1222    {
1223        return Err(WorkspaceError::Invalid(format!(
1224            "MCP import is limited to {max_columns} columns; use the CLI for wider imports"
1225        )));
1226    }
1227    if let Some(max_cells) = limits.max_cells {
1228        let cells = rows.saturating_mul(columns);
1229        if cells > max_cells {
1230            return Err(WorkspaceError::Invalid(format!(
1231                "MCP import is limited to {max_cells} cells; use the CLI for larger imports"
1232            )));
1233        }
1234    }
1235    Ok(())
1236}
1237
1238fn parse_sql_import(bytes: &[u8]) -> Result<(&str, Vec<Statement>), WorkspaceError> {
1239    let sql = std::str::from_utf8(bytes)
1240        .map_err(|error| WorkspaceError::Invalid(format!("SQL input is not UTF-8: {error}")))?;
1241    let statements = parse(sql).map_err(|error| {
1242        WorkspaceError::Invalid(format!(
1243            "SQL import parse error at byte {}: {}",
1244            error.offset, error.message
1245        ))
1246    })?;
1247    if statements.is_empty() {
1248        return Err(WorkspaceError::Invalid(
1249            "SQL import contains no statements".to_string(),
1250        ));
1251    }
1252    if statements.iter().any(statement_contains_control) {
1253        return Err(WorkspaceError::Invalid(
1254            "SQL imports must not contain BEGIN, COMMIT, ROLLBACK, or CHECKPOINT".to_string(),
1255        ));
1256    }
1257    Ok((sql, statements))
1258}
1259
1260fn import_sql(database: &Database, bytes: &[u8]) -> Result<Option<String>, WorkspaceError> {
1261    let (sql, _statements) = parse_sql_import(bytes)?;
1262
1263    let mut connection = database.connect();
1264    connection.execute_sql("BEGIN")?;
1265    let result = connection.execute_sql(sql);
1266    match result {
1267        Ok(results) => {
1268            connection.execute_sql("COMMIT")?;
1269            let mutations = results
1270                .iter()
1271                .filter(|result| {
1272                    matches!(
1273                        result,
1274                        StatementResult::Insert { .. }
1275                            | StatementResult::Update { .. }
1276                            | StatementResult::Delete { .. }
1277                            | StatementResult::CreateTable { .. }
1278                            | StatementResult::DropTable { .. }
1279                            | StatementResult::CreateIndex { .. }
1280                            | StatementResult::DropIndex { .. }
1281                    )
1282                })
1283                .count();
1284            Ok(Some(format!("{mutations} statements from SQL")))
1285        }
1286        Err(error) => {
1287            let _ = connection.execute_sql("ROLLBACK");
1288            Err(error.into())
1289        }
1290    }
1291}
1292
1293fn statement_contains_control(statement: &Statement) -> bool {
1294    match statement {
1295        Statement::Begin | Statement::Commit | Statement::Rollback | Statement::Checkpoint => true,
1296        Statement::Explain(inner) => statement_contains_control(inner),
1297        _ => false,
1298    }
1299}
1300
1301fn is_read_only(statement: &Statement) -> bool {
1302    match statement {
1303        Statement::Select { .. } => true,
1304        Statement::Explain(inner) => is_read_only(inner),
1305        _ => false,
1306    }
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1310enum ChangeKind {
1311    #[serde(rename = "apply")]
1312    Apply,
1313    #[serde(rename = "undo")]
1314    Undo,
1315}
1316
1317#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1318enum ChangeStatus {
1319    #[serde(rename = "prepared")]
1320    Prepared,
1321    #[serde(rename = "committed")]
1322    Committed,
1323    #[serde(rename = "recovered")]
1324    Recovered,
1325    #[serde(rename = "failed")]
1326    Failed,
1327    #[serde(rename = "unresolved")]
1328    Unresolved,
1329}
1330
1331impl ChangeStatus {
1332    fn is_committed(&self) -> bool {
1333        matches!(self, ChangeStatus::Committed | ChangeStatus::Recovered)
1334    }
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1338struct PreviewItem {
1339    statement: usize,
1340    kind: String,
1341    mutating: bool,
1342    rows_affected: Option<usize>,
1343    rows_returned: Option<usize>,
1344    object: Option<String>,
1345}
1346
1347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1348struct PlanRecord {
1349    format_version: u32,
1350    plan_id: String,
1351    base_generation: u64,
1352    base_state: String,
1353    sql: String,
1354    statements: Vec<PreviewItem>,
1355}
1356
1357#[derive(Debug, Serialize, JsonSchema)]
1358pub(crate) struct PlanReport {
1359    plan_id: String,
1360    sql: String,
1361    base_generation: u64,
1362    base_state: String,
1363    statement_count: usize,
1364    mutating_statements: usize,
1365    statements: Vec<PreviewItem>,
1366}
1367
1368impl From<&PlanRecord> for PlanReport {
1369    fn from(plan: &PlanRecord) -> Self {
1370        Self {
1371            plan_id: plan.plan_id.clone(),
1372            sql: plan.sql.clone(),
1373            base_generation: plan.base_generation,
1374            base_state: plan.base_state.clone(),
1375            statement_count: plan.statements.len(),
1376            mutating_statements: plan.statements.iter().filter(|item| item.mutating).count(),
1377            statements: plan.statements.clone(),
1378        }
1379    }
1380}
1381
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1383struct ImportMetadata {
1384    request_key: String,
1385    format: String,
1386    #[serde(default)]
1387    table: Option<String>,
1388    bytes: usize,
1389    summary: String,
1390}
1391
1392#[derive(Debug, Clone, Serialize, Deserialize)]
1393struct ChangeRecord {
1394    format_version: u32,
1395    sequence: u64,
1396    change_id: String,
1397    kind: ChangeKind,
1398    plan_id: Option<String>,
1399    target_change_id: Option<String>,
1400    base_generation: u64,
1401    base_state: String,
1402    expected_state: Option<String>,
1403    snapshot_id: String,
1404    sql: Option<String>,
1405    status: ChangeStatus,
1406    committed_generation: Option<u64>,
1407    after_state: Option<String>,
1408    error: Option<String>,
1409    #[serde(default)]
1410    import: Option<ImportMetadata>,
1411}
1412
1413#[derive(Debug, Serialize, JsonSchema)]
1414pub(crate) struct ApplyReport {
1415    change_id: String,
1416    plan_id: String,
1417    base_state: String,
1418    after_state: String,
1419    generation: u64,
1420}
1421
1422#[derive(Debug, Serialize, JsonSchema)]
1423pub(crate) struct HistoryEntry {
1424    sequence: u64,
1425    change_id: String,
1426    kind: ChangeKind,
1427    status: ChangeStatus,
1428    plan_id: Option<String>,
1429    target_change_id: Option<String>,
1430    base_state: String,
1431    after_state: Option<String>,
1432    committed_generation: Option<u64>,
1433    error: Option<String>,
1434    import: Option<HistoryImport>,
1435}
1436
1437#[derive(Debug, Serialize, JsonSchema)]
1438struct HistoryImport {
1439    format: String,
1440    table: Option<String>,
1441    bytes: usize,
1442    summary: String,
1443}
1444
1445#[derive(Debug, Serialize, JsonSchema)]
1446pub(crate) struct DiffReport {
1447    change_id: String,
1448    kind: ChangeKind,
1449    precision: &'static str,
1450    before_state: String,
1451    current_state: String,
1452    state_changed: bool,
1453    tables: Vec<TableDiff>,
1454}
1455
1456#[derive(Debug, Serialize, JsonSchema)]
1457struct TableDiff {
1458    table: String,
1459    before_rows: Option<usize>,
1460    after_rows: Option<usize>,
1461    added_rows: usize,
1462    removed_rows: usize,
1463    schema_changed: bool,
1464    data_changed: bool,
1465}
1466
1467#[derive(Debug, Serialize, JsonSchema)]
1468pub(crate) struct UndoReport {
1469    change_id: String,
1470    undone_change_id: String,
1471    restored_state: String,
1472    generation: u64,
1473}
1474
1475#[derive(Debug, Clone, PartialEq)]
1476struct TableSnapshot {
1477    columns: Vec<String>,
1478    rows: Vec<Vec<Value>>,
1479}
1480
1481fn preview_plan(workspace: &Workspace, sql: &str) -> Result<PlanRecord, WorkspaceError> {
1482    preview_plan_with_output_limit(workspace, sql, None, None, None)
1483}
1484
1485fn preview_plan_with_output_limit(
1486    workspace: &Workspace,
1487    sql: &str,
1488    max_output_bytes: Option<usize>,
1489    max_work: Option<usize>,
1490    max_mutation_rows: Option<usize>,
1491) -> Result<PlanRecord, WorkspaceError> {
1492    if sql.len() > MAX_PREVIEW_BYTES {
1493        return Err(WorkspaceError::Invalid(format!(
1494            "SQL exceeds the {} MiB preview limit",
1495            MAX_PREVIEW_BYTES / (1024 * 1024)
1496        )));
1497    }
1498    let statements = parse(sql).map_err(|error| {
1499        WorkspaceError::Invalid(format!(
1500            "preview parse error at byte {}: {}",
1501            error.offset, error.message
1502        ))
1503    })?;
1504    if statements.is_empty() {
1505        return Err(WorkspaceError::Invalid(
1506            "preview requires at least one SQL statement".to_string(),
1507        ));
1508    }
1509    if statements.len() > MAX_PREVIEW_STATEMENTS {
1510        return Err(WorkspaceError::Invalid(format!(
1511            "preview accepts at most {MAX_PREVIEW_STATEMENTS} statements"
1512        )));
1513    }
1514    let mutating_statements = statements
1515        .iter()
1516        .filter(|statement| is_mutation_statement(statement))
1517        .count();
1518    if mutating_statements > MAX_PREVIEW_MUTATIONS {
1519        return Err(WorkspaceError::Invalid(format!(
1520            "preview accepts at most {MAX_PREVIEW_MUTATIONS} mutating statements"
1521        )));
1522    }
1523    if statements.iter().any(statement_contains_control) {
1524        return Err(WorkspaceError::Invalid(
1525            "preview SQL must not contain BEGIN, COMMIT, ROLLBACK, or CHECKPOINT".to_string(),
1526        ));
1527    }
1528    if !statements.iter().any(is_mutation_statement) {
1529        return Err(WorkspaceError::Invalid(
1530            "preview requires at least one mutating statement".to_string(),
1531        ));
1532    }
1533
1534    let database = workspace.database()?;
1535    let mut budget = max_work
1536        .map(ExecutionBudget::bounded)
1537        .unwrap_or_else(ExecutionBudget::unlimited);
1538    database.checkpoint_with_budget(&mut budget)?;
1539    let base_generation = database.generation();
1540    let base_state = state_fingerprint(&workspace.database_path())?;
1541    let mut connection = database.connect();
1542    connection.execute_with_budget(&Statement::Begin, &mut budget)?;
1543    let result = (|| {
1544        let mut items = Vec::with_capacity(statements.len());
1545        let mut mutation_rows = 0;
1546        for (index, statement) in statements.iter().enumerate() {
1547            let result = connection.execute_with_budget(statement, &mut budget)?;
1548            enforce_mutation_row_limit(&mut mutation_rows, &result, max_mutation_rows)?;
1549            if let StatementResult::Select { rows, .. } = &result
1550                && rows.len() > MAX_PREVIEW_ROWS
1551            {
1552                return Err(WorkspaceError::Invalid(format!(
1553                    "preview query result exceeds the {MAX_PREVIEW_ROWS}-row limit"
1554                )));
1555            }
1556            items.push(preview_item(index + 1, &result));
1557        }
1558        Ok::<Vec<PreviewItem>, WorkspaceError>(items)
1559    })();
1560    let rollback = connection.execute_sql("ROLLBACK");
1561    let preview_items = match result {
1562        Ok(items) => {
1563            rollback?;
1564            items
1565        }
1566        Err(error) => {
1567            let _ = rollback;
1568            return Err(error);
1569        }
1570    };
1571    let plan_id = plan_id_for(&base_state, sql);
1572    let plan = PlanRecord {
1573        format_version: FORMAT_VERSION,
1574        plan_id,
1575        base_generation,
1576        base_state,
1577        sql: sql.to_string(),
1578        statements: preview_items,
1579    };
1580    if let Some(max_output_bytes) = max_output_bytes {
1581        let report = PlanReport::from(&plan);
1582        let output_size = serde_json::to_vec(&report)?.len();
1583        if output_size > max_output_bytes {
1584            return Err(WorkspaceError::Invalid(format!(
1585                "workspace preview is {output_size} bytes; response limit is {max_output_bytes} bytes"
1586            )));
1587        }
1588    }
1589    // Keep the database handle live through persistence so another process
1590    // cannot change the state between the captured fingerprint and plan write.
1591    persist_plan(workspace, &plan)?;
1592    Ok(plan)
1593}
1594
1595fn persist_plan(workspace: &Workspace, plan: &PlanRecord) -> Result<(), WorkspaceError> {
1596    ensure_history_dirs(workspace)?;
1597    let path = plan_path(workspace, &plan.plan_id);
1598    if path.exists() {
1599        let existing: PlanRecord = read_json(&path)?;
1600        if existing != *plan {
1601            return Err(WorkspaceError::Invalid(
1602                "plan identifier collision; refusing to replace an existing plan".to_string(),
1603            ));
1604        }
1605    } else {
1606        write_new_json(&path, plan)?;
1607    }
1608    Ok(())
1609}
1610
1611fn preview_item(statement: usize, result: &StatementResult) -> PreviewItem {
1612    let (kind, mutating, rows_affected, rows_returned, object) = match result {
1613        StatementResult::Select { rows, .. } => ("select", false, None, Some(rows.len()), None),
1614        StatementResult::Insert { rows_affected } => {
1615            ("insert", true, Some(*rows_affected), None, None)
1616        }
1617        StatementResult::Update { rows_affected } => {
1618            ("update", true, Some(*rows_affected), None, None)
1619        }
1620        StatementResult::Delete { rows_affected } => {
1621            ("delete", true, Some(*rows_affected), None, None)
1622        }
1623        StatementResult::CreateTable { name } => {
1624            ("create_table", true, None, None, Some(name.clone()))
1625        }
1626        StatementResult::DropTable { name } => ("drop_table", true, None, None, Some(name.clone())),
1627        StatementResult::CreateIndex { name, .. } => {
1628            ("create_index", true, None, None, Some(name.clone()))
1629        }
1630        StatementResult::DropIndex { name } => ("drop_index", true, None, None, Some(name.clone())),
1631        StatementResult::Explain(_) => ("explain", false, None, None, None),
1632        StatementResult::Begin => ("begin", false, None, None, None),
1633        StatementResult::Commit => ("commit", false, None, None, None),
1634        StatementResult::Rollback => ("rollback", false, None, None, None),
1635        StatementResult::Checkpoint => ("checkpoint", false, None, None, None),
1636        StatementResult::Echo(_) => ("echo", false, None, None, None),
1637    };
1638    PreviewItem {
1639        statement,
1640        kind: kind.to_string(),
1641        mutating,
1642        rows_affected,
1643        rows_returned,
1644        object,
1645    }
1646}
1647
1648fn enforce_mutation_row_limit(
1649    total_rows: &mut usize,
1650    result: &StatementResult,
1651    max_rows: Option<usize>,
1652) -> Result<(), WorkspaceError> {
1653    let rows = match result {
1654        StatementResult::Insert { rows_affected }
1655        | StatementResult::Update { rows_affected }
1656        | StatementResult::Delete { rows_affected } => *rows_affected,
1657        _ => return Ok(()),
1658    };
1659    *total_rows = total_rows.saturating_add(rows);
1660    if let Some(max_rows) = max_rows
1661        && *total_rows > max_rows
1662    {
1663        return Err(WorkspaceError::Invalid(format!(
1664            "MCP workspace mutations are limited to {max_rows} affected rows per plan; split the operation into smaller reviewed plans"
1665        )));
1666    }
1667    Ok(())
1668}
1669
1670fn is_mutation_statement(statement: &Statement) -> bool {
1671    matches!(
1672        statement,
1673        Statement::CreateTable { .. }
1674            | Statement::DropTable { .. }
1675            | Statement::CreateIndex { .. }
1676            | Statement::DropIndex { .. }
1677            | Statement::Insert { .. }
1678            | Statement::InsertSelect { .. }
1679            | Statement::Update { .. }
1680            | Statement::Delete { .. }
1681    )
1682}
1683
1684fn ensure_history_dirs(workspace: &Workspace) -> Result<(), WorkspaceError> {
1685    let directories = history_directories(workspace);
1686    validate_history_dirs(workspace)?;
1687    for directory in directories {
1688        fs::create_dir_all(directory)?;
1689    }
1690    validate_history_dirs(workspace)?;
1691    Ok(())
1692}
1693
1694fn history_directories(workspace: &Workspace) -> [PathBuf; 4] {
1695    let history = workspace.root.join(HISTORY_DIR);
1696    [
1697        history.clone(),
1698        history.join(PLANS_DIR),
1699        history.join(CHANGES_DIR),
1700        history.join(SNAPSHOTS_DIR),
1701    ]
1702}
1703
1704fn validate_history_dirs(workspace: &Workspace) -> Result<(), WorkspaceError> {
1705    for directory in history_directories(workspace) {
1706        if path_is_symlink(&directory)? {
1707            return Err(WorkspaceError::Invalid(format!(
1708                "workspace history directory cannot be a symbolic link: {}",
1709                directory.display()
1710            )));
1711        }
1712        if directory.exists() && !directory.is_dir() {
1713            return Err(WorkspaceError::Invalid(format!(
1714                "workspace history path is not a directory: {}",
1715                directory.display()
1716            )));
1717        }
1718    }
1719    Ok(())
1720}
1721
1722fn valid_id(id: &str) -> Result<(), WorkspaceError> {
1723    if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1724        return Err(WorkspaceError::Invalid(
1725            "identifier must be a 64-character hexadecimal value".to_string(),
1726        ));
1727    }
1728    Ok(())
1729}
1730
1731fn plan_path(workspace: &Workspace, plan_id: &str) -> PathBuf {
1732    workspace
1733        .root
1734        .join(HISTORY_DIR)
1735        .join(PLANS_DIR)
1736        .join(format!("{plan_id}.json"))
1737}
1738
1739fn change_path(workspace: &Workspace, change_id: &str) -> PathBuf {
1740    workspace
1741        .root
1742        .join(HISTORY_DIR)
1743        .join(CHANGES_DIR)
1744        .join(format!("{change_id}.json"))
1745}
1746
1747fn snapshot_path(workspace: &Workspace, snapshot_id: &str) -> PathBuf {
1748    workspace
1749        .root
1750        .join(HISTORY_DIR)
1751        .join(SNAPSHOTS_DIR)
1752        .join(format!("{snapshot_id}.basalt"))
1753}
1754
1755fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, WorkspaceError> {
1756    if path_is_symlink(path)? {
1757        return Err(WorkspaceError::Invalid(format!(
1758            "workspace metadata file cannot be a symbolic link: {}",
1759            path.display()
1760        )));
1761    }
1762    let bytes = fs::read(path)?;
1763    Ok(serde_json::from_slice(&bytes)?)
1764}
1765
1766fn write_new_json<T: Serialize>(path: &Path, value: &T) -> Result<(), WorkspaceError> {
1767    let mut bytes = serde_json::to_vec_pretty(value)?;
1768    bytes.push(b'\n');
1769    write_new_file(path, &bytes)
1770}
1771
1772fn write_atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), WorkspaceError> {
1773    let mut bytes = serde_json::to_vec_pretty(value)?;
1774    bytes.push(b'\n');
1775    atomic_write_file(path, &bytes)
1776}
1777
1778fn state_fingerprint(path: &Path) -> Result<String, WorkspaceError> {
1779    if path_is_symlink(path)? {
1780        return Err(WorkspaceError::Invalid(format!(
1781            "workspace state file cannot be a symbolic link: {}",
1782            path.display()
1783        )));
1784    }
1785    Ok(format!("sha256:{}", sha256_bytes(&fs::read(path)?)))
1786}
1787
1788fn sha256_bytes(bytes: &[u8]) -> String {
1789    let digest = Sha256::digest(bytes);
1790    digest.iter().map(|byte| format!("{byte:02x}")).collect()
1791}
1792
1793fn plan_id_for(base_state: &str, sql: &str) -> String {
1794    let mut hasher = Sha256::new();
1795    hasher.update(b"basalt-plan-v1\0");
1796    hasher.update(base_state.as_bytes());
1797    hasher.update(b"\0");
1798    hasher.update(sql.as_bytes());
1799    hasher
1800        .finalize()
1801        .iter()
1802        .map(|byte| format!("{byte:02x}"))
1803        .collect()
1804}
1805
1806fn import_change_id(base_state: &str, format: DataFormat, table: &str, content: &[u8]) -> String {
1807    let mut hasher = Sha256::new();
1808    hasher.update(b"basalt-import-v1\0");
1809    hasher.update(base_state.as_bytes());
1810    hasher.update(b"\0");
1811    hasher.update(format.name().as_bytes());
1812    hasher.update(b"\0");
1813    hasher.update(table.as_bytes());
1814    hasher.update(b"\0");
1815    hasher.update(content);
1816    hasher
1817        .finalize()
1818        .iter()
1819        .map(|byte| format!("{byte:02x}"))
1820        .collect()
1821}
1822
1823fn import_request_key(format: DataFormat, table: &str, content: &[u8]) -> String {
1824    let mut hasher = Sha256::new();
1825    hasher.update(b"basalt-import-request-v1\0");
1826    hasher.update(format.name().as_bytes());
1827    hasher.update(b"\0");
1828    hasher.update(table.as_bytes());
1829    hasher.update(b"\0");
1830    hasher.update(content);
1831    hasher
1832        .finalize()
1833        .iter()
1834        .map(|byte| format!("{byte:02x}"))
1835        .collect()
1836}
1837
1838fn apply_change_id(plan_id: &str, base_state: &str) -> String {
1839    let mut hasher = Sha256::new();
1840    hasher.update(b"basalt-apply-v1\0");
1841    hasher.update(plan_id.as_bytes());
1842    hasher.update(b"\0");
1843    hasher.update(base_state.as_bytes());
1844    hasher
1845        .finalize()
1846        .iter()
1847        .map(|byte| format!("{byte:02x}"))
1848        .collect()
1849}
1850
1851fn undo_change_id(change_id: &str, base_state: &str) -> String {
1852    let mut hasher = Sha256::new();
1853    hasher.update(b"basalt-undo-v1\0");
1854    hasher.update(change_id.as_bytes());
1855    hasher.update(b"\0");
1856    hasher.update(base_state.as_bytes());
1857    hasher
1858        .finalize()
1859        .iter()
1860        .map(|byte| format!("{byte:02x}"))
1861        .collect()
1862}
1863
1864fn next_sequence(changes: &[ChangeRecord]) -> Result<u64, WorkspaceError> {
1865    changes
1866        .iter()
1867        .map(|change| change.sequence)
1868        .max()
1869        .unwrap_or(0)
1870        .checked_add(1)
1871        .ok_or_else(|| WorkspaceError::Invalid("change sequence exhausted".to_string()))
1872}
1873
1874fn copy_atomic(source: &Path, destination: &Path) -> Result<(), WorkspaceError> {
1875    if path_is_symlink(source)? || path_is_symlink(destination)? {
1876        return Err(WorkspaceError::Invalid(
1877            "workspace recovery files cannot be symbolic links".to_string(),
1878        ));
1879    }
1880    let bytes = fs::read(source)?;
1881    atomic_write_file(destination, &bytes)
1882}
1883
1884fn truncate_wal(database_path: &Path) -> Result<(), WorkspaceError> {
1885    let mut path = database_path.as_os_str().to_os_string();
1886    path.push(".wal");
1887    let file = File::create(PathBuf::from(path))?;
1888    file.sync_all()?;
1889    Ok(())
1890}
1891
1892fn load_plan(workspace: &Workspace, plan_id: &str) -> Result<PlanRecord, WorkspaceError> {
1893    valid_id(plan_id)?;
1894    validate_history_dirs(workspace)?;
1895    let path = plan_path(workspace, plan_id);
1896    if !path.is_file() {
1897        return Err(WorkspaceError::Invalid(format!(
1898            "plan does not exist: {plan_id}"
1899        )));
1900    }
1901    let plan: PlanRecord = read_json(&path)?;
1902    if plan.format_version != FORMAT_VERSION
1903        || plan.plan_id != plan_id
1904        || plan_id_for(&plan.base_state, &plan.sql) != plan.plan_id
1905    {
1906        return Err(WorkspaceError::Invalid(format!(
1907            "plan is invalid or has been modified: {plan_id}"
1908        )));
1909    }
1910    Ok(plan)
1911}
1912
1913fn load_changes(workspace: &Workspace) -> Result<Vec<ChangeRecord>, WorkspaceError> {
1914    validate_history_dirs(workspace)?;
1915    let directory = workspace.root.join(HISTORY_DIR).join(CHANGES_DIR);
1916    if !directory.exists() {
1917        return Ok(Vec::new());
1918    }
1919    let mut changes = Vec::new();
1920    for entry in fs::read_dir(&directory)? {
1921        let path = entry?.path();
1922        if path_is_symlink(&path)? {
1923            return Err(WorkspaceError::Invalid(format!(
1924                "workspace history record cannot be a symbolic link: {}",
1925                path.display()
1926            )));
1927        }
1928        if path.extension() != Some(OsStr::new("json")) {
1929            continue;
1930        }
1931        if !path.is_file() {
1932            return Err(WorkspaceError::Invalid(format!(
1933                "workspace history record is not a file: {}",
1934                path.display()
1935            )));
1936        }
1937        let change: ChangeRecord = read_json(&path)?;
1938        valid_id(&change.change_id)?;
1939        if path.file_stem() != Some(OsStr::new(&change.change_id)) {
1940            return Err(WorkspaceError::Invalid(format!(
1941                "change filename does not match its identifier: {}",
1942                path.display()
1943            )));
1944        }
1945        if change.format_version != FORMAT_VERSION {
1946            return Err(WorkspaceError::Invalid(format!(
1947                "unsupported change format in {}",
1948                path.display()
1949            )));
1950        }
1951        changes.push(change);
1952    }
1953    changes.sort_by_key(|change| change.sequence);
1954    Ok(changes)
1955}
1956
1957fn reconcile_change(
1958    change: &mut ChangeRecord,
1959    current_state: &str,
1960    current_generation: u64,
1961) -> bool {
1962    if change.status != ChangeStatus::Prepared {
1963        return false;
1964    }
1965    match change.kind {
1966        ChangeKind::Apply => {
1967            if current_generation == change.base_generation.saturating_add(1)
1968                && current_state != change.base_state
1969            {
1970                change.status = ChangeStatus::Recovered;
1971                change.committed_generation = Some(current_generation);
1972                change.after_state = Some(current_state.to_string());
1973                change.error =
1974                    Some("commit completed before the history record was finalized".to_string());
1975            } else if current_generation == change.base_generation
1976                && current_state == change.base_state
1977            {
1978                change.status = ChangeStatus::Failed;
1979                change.error =
1980                    Some("operation was not observed as committed after interruption".to_string());
1981            } else {
1982                change.status = ChangeStatus::Unresolved;
1983                change.error = Some(
1984                    "workspace state does not match either side of the prepared operation"
1985                        .to_string(),
1986                );
1987            }
1988        }
1989        ChangeKind::Undo => {
1990            if change.expected_state.as_deref() == Some(current_state) {
1991                change.status = ChangeStatus::Recovered;
1992                change.committed_generation = Some(current_generation);
1993                change.after_state = Some(current_state.to_string());
1994                change.error =
1995                    Some("restore completed before the history record was finalized".to_string());
1996            } else if current_generation == change.base_generation
1997                && current_state == change.base_state
1998            {
1999                change.status = ChangeStatus::Failed;
2000                change.error =
2001                    Some("restore was not observed as completed after interruption".to_string());
2002            } else {
2003                change.status = ChangeStatus::Unresolved;
2004                change.error = Some(
2005                    "workspace state does not match either side of the prepared restore"
2006                        .to_string(),
2007                );
2008            }
2009        }
2010    }
2011    true
2012}
2013
2014fn reconcile_changes(
2015    workspace: &Workspace,
2016    changes: &mut [ChangeRecord],
2017    current_state: &str,
2018    current_generation: u64,
2019) -> Result<(), WorkspaceError> {
2020    for change in changes {
2021        if reconcile_change(change, current_state, current_generation) {
2022            write_atomic_json(&change_path(workspace, &change.change_id), change)?;
2023        }
2024    }
2025    Ok(())
2026}
2027
2028fn history(workspace: &Workspace) -> Result<Vec<HistoryEntry>, WorkspaceError> {
2029    let database = workspace.database()?;
2030    let current_state = state_fingerprint(&workspace.database_path())?;
2031    let current_generation = database.generation();
2032    let mut changes = load_changes(workspace)?;
2033    reconcile_changes(workspace, &mut changes, &current_state, current_generation)?;
2034    Ok(changes
2035        .into_iter()
2036        .map(|change| {
2037            let import = change.import.map(|import| HistoryImport {
2038                format: import.format,
2039                table: import.table,
2040                bytes: import.bytes,
2041                summary: import.summary,
2042            });
2043            HistoryEntry {
2044                sequence: change.sequence,
2045                change_id: change.change_id,
2046                kind: change.kind,
2047                status: change.status,
2048                plan_id: change.plan_id,
2049                target_change_id: change.target_change_id,
2050                base_state: change.base_state,
2051                after_state: change.after_state,
2052                committed_generation: change.committed_generation,
2053                error: change.error,
2054                import,
2055            }
2056        })
2057        .collect())
2058}
2059
2060fn latest_committed(changes: &[ChangeRecord]) -> Option<&ChangeRecord> {
2061    changes
2062        .iter()
2063        .filter(|change| change.status.is_committed())
2064        .max_by_key(|change| change.sequence)
2065}
2066
2067fn apply_plan(
2068    workspace: &Workspace,
2069    requested_plan_id: &str,
2070    max_work: Option<usize>,
2071    max_mutation_rows: Option<usize>,
2072) -> Result<ApplyReport, WorkspaceError> {
2073    let plan = load_plan(workspace, requested_plan_id)?;
2074    if !plan.statements.iter().any(|item| item.mutating) {
2075        return Err(WorkspaceError::Invalid(
2076            "plan does not contain a mutating statement".to_string(),
2077        ));
2078    }
2079    ensure_history_dirs(workspace)?;
2080    let database = workspace.database()?;
2081    let mut budget = max_work
2082        .map(ExecutionBudget::bounded)
2083        .unwrap_or_else(ExecutionBudget::unlimited);
2084    database.checkpoint_with_budget(&mut budget)?;
2085    let current_state = state_fingerprint(&workspace.database_path())?;
2086    let change_id = apply_change_id(&plan.plan_id, &plan.base_state);
2087    let change_file = change_path(workspace, &change_id);
2088    let mut changes = load_changes(workspace)?;
2089    if let Some(existing) = changes
2090        .iter_mut()
2091        .find(|change| change.change_id == change_id)
2092    {
2093        if reconcile_change(existing, &current_state, database.generation()) {
2094            write_atomic_json(&change_file, existing)?;
2095        }
2096        if existing.status.is_committed() {
2097            if existing.after_state.as_deref() == Some(current_state.as_str()) {
2098                return Ok(ApplyReport {
2099                    change_id,
2100                    plan_id: plan.plan_id,
2101                    base_state: existing.base_state.clone(),
2102                    after_state: existing.after_state.clone().ok_or_else(|| {
2103                        WorkspaceError::Invalid(
2104                            "committed plan is missing its after-state".to_string(),
2105                        )
2106                    })?,
2107                    generation: existing.committed_generation.ok_or_else(|| {
2108                        WorkspaceError::Invalid(
2109                            "committed plan is missing its generation".to_string(),
2110                        )
2111                    })?,
2112                });
2113            }
2114            return Err(WorkspaceError::Invalid(format!(
2115                "plan has already been applied as change {change_id}; workspace state moved, so it will not be replayed"
2116            )));
2117        }
2118        if existing.status == ChangeStatus::Unresolved {
2119            return Err(WorkspaceError::Invalid(format!(
2120                "change {change_id} is unresolved; inspect its recovery point before continuing"
2121            )));
2122        }
2123    }
2124    if current_state != plan.base_state {
2125        return Err(WorkspaceError::Invalid(
2126            "plan is stale; preview the operation again against the current workspace".to_string(),
2127        ));
2128    }
2129    let snapshot = snapshot_path(workspace, &change_id);
2130    if snapshot.exists() {
2131        if state_fingerprint(&snapshot)? != plan.base_state {
2132            return Err(WorkspaceError::Invalid(format!(
2133                "recovery point for change {change_id} does not match the plan"
2134            )));
2135        }
2136    } else {
2137        copy_atomic(&workspace.database_path(), &snapshot)?;
2138    }
2139    let sequence = changes
2140        .iter()
2141        .find(|change| change.change_id == change_id)
2142        .map(|change| change.sequence)
2143        .unwrap_or(next_sequence(&changes)?);
2144    let mut change = ChangeRecord {
2145        format_version: FORMAT_VERSION,
2146        sequence,
2147        change_id: change_id.clone(),
2148        kind: ChangeKind::Apply,
2149        plan_id: Some(plan.plan_id.clone()),
2150        target_change_id: None,
2151        base_generation: plan.base_generation,
2152        base_state: plan.base_state.clone(),
2153        expected_state: None,
2154        snapshot_id: change_id.clone(),
2155        sql: Some(plan.sql.clone()),
2156        status: ChangeStatus::Prepared,
2157        committed_generation: None,
2158        after_state: None,
2159        error: None,
2160        import: None,
2161    };
2162    if change_file.exists() {
2163        write_atomic_json(&change_file, &change)?;
2164    } else {
2165        write_new_json(&change_file, &change)?;
2166    }
2167
2168    let mut connection = database.connect();
2169    if let Err(error) = connection.execute_with_budget(&Statement::Begin, &mut budget) {
2170        change.status = ChangeStatus::Failed;
2171        change.error = Some(error.to_string());
2172        write_atomic_json(&change_file, &change)?;
2173        return Err(error.into());
2174    }
2175    let execution = if max_work.is_some() {
2176        connection.execute_sql_using_budget(&plan.sql, &mut budget)
2177    } else {
2178        connection.execute_sql(&plan.sql)
2179    };
2180    let results = match execution {
2181        Ok(results) => results,
2182        Err(error) => {
2183            let _ = connection.execute_sql("ROLLBACK");
2184            change.status = ChangeStatus::Failed;
2185            change.error = Some(error.to_string());
2186            write_atomic_json(&change_file, &change)?;
2187            return Err(error.into());
2188        }
2189    };
2190    let mut mutation_rows = 0;
2191    for result in &results {
2192        if let Err(error) =
2193            enforce_mutation_row_limit(&mut mutation_rows, result, max_mutation_rows)
2194        {
2195            let _ = connection.execute_sql("ROLLBACK");
2196            change.status = ChangeStatus::Failed;
2197            change.error = Some(error.to_string());
2198            write_atomic_json(&change_file, &change)?;
2199            return Err(error);
2200        }
2201    }
2202    if let Err(error) = connection.execute_with_budget(&Statement::Commit, &mut budget) {
2203        change.status = ChangeStatus::Unresolved;
2204        change.error = Some(error.to_string());
2205        write_atomic_json(&change_file, &change)?;
2206        return Err(error.into());
2207    }
2208    drop(connection);
2209    if let Err(error) = database.checkpoint() {
2210        change.status = ChangeStatus::Unresolved;
2211        change.error = Some(format!(
2212            "operation committed but checkpoint failed: {error}"
2213        ));
2214        write_atomic_json(&change_file, &change)?;
2215        return Err(error.into());
2216    }
2217    let after_state = state_fingerprint(&workspace.database_path())?;
2218    let generation = database.generation();
2219    if std::env::var_os("BASALT_CRASH_TEST_AFTER_APPLY_CHECKPOINT").is_some() {
2220        std::process::abort();
2221    }
2222    change.status = ChangeStatus::Committed;
2223    change.committed_generation = Some(generation);
2224    change.after_state = Some(after_state.clone());
2225    write_atomic_json(&change_file, &change)?;
2226    Ok(ApplyReport {
2227        change_id,
2228        plan_id: plan.plan_id,
2229        base_state: plan.base_state,
2230        after_state,
2231        generation,
2232    })
2233}
2234
2235fn diff(
2236    workspace: &Workspace,
2237    requested_change_id: Option<&str>,
2238) -> Result<DiffReport, WorkspaceError> {
2239    diff_with_row_limit(workspace, requested_change_id, None)
2240}
2241
2242fn diff_with_row_limit(
2243    workspace: &Workspace,
2244    requested_change_id: Option<&str>,
2245    max_total_rows: Option<usize>,
2246) -> Result<DiffReport, WorkspaceError> {
2247    let mut changes = load_changes(workspace)?;
2248    let database = workspace.database()?;
2249    let current_state = state_fingerprint(&workspace.database_path())?;
2250    let current_generation = database.generation();
2251    reconcile_changes(workspace, &mut changes, &current_state, current_generation)?;
2252    let change = match requested_change_id {
2253        Some(change_id) => {
2254            valid_id(change_id)?;
2255            changes
2256                .iter()
2257                .find(|change| change.change_id == change_id)
2258                .cloned()
2259                .ok_or_else(|| {
2260                    WorkspaceError::Invalid(format!("change does not exist: {change_id}"))
2261                })?
2262        }
2263        None => latest_committed(&changes)
2264            .cloned()
2265            .ok_or_else(|| WorkspaceError::Invalid("no committed changes to diff".to_string()))?,
2266    };
2267    if !change.status.is_committed() {
2268        return Err(WorkspaceError::Invalid(format!(
2269            "change {} is not committed; status is {:?}",
2270            change.change_id, change.status
2271        )));
2272    }
2273    let snapshot = snapshot_path(workspace, &change.snapshot_id);
2274    if !snapshot.is_file() {
2275        return Err(WorkspaceError::Invalid(format!(
2276            "recovery point is missing for change {}",
2277            change.change_id
2278        )));
2279    }
2280    let before_database = Database::open(&snapshot)?;
2281    let before = logical_snapshot(&before_database, max_total_rows)?;
2282    let after = logical_snapshot(&database, max_total_rows)?;
2283    let mut names = BTreeSet::new();
2284    names.extend(before.keys().cloned());
2285    names.extend(after.keys().cloned());
2286    let tables = names
2287        .into_iter()
2288        .filter_map(|name| {
2289            let before_table = before.get(&name);
2290            let after_table = after.get(&name);
2291            let schema_changed = match (before_table, after_table) {
2292                (Some(before), Some(after)) => before.columns != after.columns,
2293                (None, None) => false,
2294                _ => true,
2295            };
2296            let (added_rows, removed_rows) = match (before_table, after_table) {
2297                (Some(before), Some(after)) => row_delta_counts(&before.rows, &after.rows),
2298                (None, Some(after)) => (after.rows.len(), 0),
2299                (Some(before), None) => (0, before.rows.len()),
2300                (None, None) => (0, 0),
2301            };
2302            let data_changed = added_rows > 0 || removed_rows > 0;
2303            (schema_changed || data_changed).then(|| TableDiff {
2304                table: name,
2305                before_rows: before_table.map(|table| table.rows.len()),
2306                after_rows: after_table.map(|table| table.rows.len()),
2307                added_rows,
2308                removed_rows,
2309                schema_changed,
2310                data_changed,
2311            })
2312        })
2313        .collect::<Vec<_>>();
2314    Ok(DiffReport {
2315        change_id: change.change_id,
2316        kind: change.kind,
2317        precision: "table schema and row-multiset comparison",
2318        before_state: change.base_state,
2319        current_state,
2320        state_changed: !tables.is_empty(),
2321        tables,
2322    })
2323}
2324
2325fn row_delta_counts(before: &[Vec<Value>], after: &[Vec<Value>]) -> (usize, usize) {
2326    let mut before_counts = HashMap::<Vec<u8>, usize>::new();
2327    for row in before {
2328        let key = row_key(row);
2329        *before_counts.entry(key).or_default() += 1;
2330    }
2331    let mut after_counts = HashMap::<Vec<u8>, usize>::new();
2332    for row in after {
2333        let key = row_key(row);
2334        *after_counts.entry(key).or_default() += 1;
2335    }
2336
2337    let removed = before_counts.iter().fold(0usize, |total, (key, count)| {
2338        total.saturating_add(count.saturating_sub(after_counts.get(key).copied().unwrap_or(0)))
2339    });
2340    let added = after_counts.iter().fold(0usize, |total, (key, count)| {
2341        total.saturating_add(count.saturating_sub(before_counts.get(key).copied().unwrap_or(0)))
2342    });
2343    (added, removed)
2344}
2345
2346fn row_key(row: &[Value]) -> Vec<u8> {
2347    let mut key = Vec::new();
2348    key.extend_from_slice(b"basalt-row-v1\0");
2349    for value in row {
2350        match value {
2351            Value::Null => key.push(0),
2352            Value::Integer(value) => {
2353                key.push(1);
2354                key.extend_from_slice(&value.to_le_bytes());
2355            }
2356            Value::Real(value) => {
2357                key.push(2);
2358                key.extend_from_slice(&value.to_bits().to_le_bytes());
2359            }
2360            Value::Text(value) => {
2361                key.push(3);
2362                key.extend_from_slice(&(value.len() as u64).to_le_bytes());
2363                key.extend_from_slice(value.as_bytes());
2364            }
2365            Value::Boolean(value) => {
2366                key.push(4);
2367                key.push(*value as u8);
2368            }
2369        }
2370    }
2371    key
2372}
2373
2374fn logical_snapshot(
2375    database: &Database,
2376    max_total_rows: Option<usize>,
2377) -> Result<BTreeMap<String, TableSnapshot>, WorkspaceError> {
2378    let mut tables = BTreeMap::new();
2379    let mut total_rows = 0usize;
2380    for table in database.table_names()? {
2381        let row_count = database.row_count(&table)?;
2382        if let Some(max_total_rows) = max_total_rows {
2383            total_rows = total_rows.saturating_add(row_count);
2384            if total_rows > max_total_rows {
2385                return Err(WorkspaceError::Invalid(format!(
2386                    "MCP diff is limited to {max_total_rows} rows across a compared database; use the CLI diff for larger workspaces"
2387                )));
2388            }
2389        }
2390        let (columns, rows) = select_table(database, &table)?;
2391        debug_assert_eq!(rows.len(), row_count);
2392        tables.insert(table, TableSnapshot { columns, rows });
2393    }
2394    Ok(tables)
2395}
2396
2397fn undo(workspace: &Workspace, requested_change_id: &str) -> Result<UndoReport, WorkspaceError> {
2398    valid_id(requested_change_id)?;
2399    ensure_history_dirs(workspace)?;
2400    let mut changes = load_changes(workspace)?;
2401    let database = workspace.database()?;
2402    database.checkpoint()?;
2403    let current_state = state_fingerprint(&workspace.database_path())?;
2404    let current_generation = database.generation();
2405    reconcile_changes(workspace, &mut changes, &current_state, current_generation)?;
2406    if let Some(existing) = changes.iter().find(|change| {
2407        change.kind == ChangeKind::Undo
2408            && change.target_change_id.as_deref() == Some(requested_change_id)
2409            && change.status.is_committed()
2410    }) {
2411        if existing.after_state.as_deref() == Some(current_state.as_str()) {
2412            return Ok(UndoReport {
2413                change_id: existing.change_id.clone(),
2414                undone_change_id: requested_change_id.to_string(),
2415                restored_state: existing.after_state.clone().ok_or_else(|| {
2416                    WorkspaceError::Invalid(
2417                        "committed undo is missing its restored state".to_string(),
2418                    )
2419                })?,
2420                generation: existing.committed_generation.ok_or_else(|| {
2421                    WorkspaceError::Invalid("committed undo is missing its generation".to_string())
2422                })?,
2423            });
2424        }
2425        return Err(WorkspaceError::Invalid(format!(
2426            "change {requested_change_id} has already been undone as {}; workspace state moved, so it will not be replayed",
2427            existing.change_id
2428        )));
2429    }
2430    let target = changes
2431        .iter()
2432        .find(|change| change.change_id == requested_change_id)
2433        .cloned()
2434        .ok_or_else(|| {
2435            WorkspaceError::Invalid(format!("change does not exist: {requested_change_id}"))
2436        })?;
2437    if !target.status.is_committed() {
2438        return Err(WorkspaceError::Invalid(format!(
2439            "change {} is not committed; status is {:?}",
2440            target.change_id, target.status
2441        )));
2442    }
2443    let latest = latest_committed(&changes).ok_or_else(|| {
2444        WorkspaceError::Invalid("there are no committed changes to undo".to_string())
2445    })?;
2446    if latest.change_id != target.change_id {
2447        return Err(WorkspaceError::Invalid(
2448            "only the latest committed change can be undone; undo later changes first".to_string(),
2449        ));
2450    }
2451    if target.after_state.as_deref() != Some(current_state.as_str()) {
2452        return Err(WorkspaceError::Invalid(
2453            "workspace state moved after this change; refusing to discard later work".to_string(),
2454        ));
2455    }
2456    let target_snapshot = snapshot_path(workspace, &target.snapshot_id);
2457    if !target_snapshot.is_file() {
2458        return Err(WorkspaceError::Invalid(format!(
2459            "recovery point is missing for change {}",
2460            target.change_id
2461        )));
2462    }
2463    if state_fingerprint(&target_snapshot)? != target.base_state {
2464        return Err(WorkspaceError::Invalid(format!(
2465            "recovery point for change {} failed integrity verification",
2466            target.change_id
2467        )));
2468    }
2469    let undo_id = undo_change_id(&target.change_id, &current_state);
2470    let undo_file = change_path(workspace, &undo_id);
2471    if let Some(existing) = changes.iter().find(|change| change.change_id == undo_id) {
2472        if existing.status.is_committed() {
2473            return Err(WorkspaceError::Invalid(format!(
2474                "change has already been undone as {undo_id}"
2475            )));
2476        }
2477        if existing.status == ChangeStatus::Unresolved {
2478            return Err(WorkspaceError::Invalid(format!(
2479                "undo change {undo_id} is unresolved; inspect its recovery point first"
2480            )));
2481        }
2482    }
2483    let undo_snapshot = snapshot_path(workspace, &undo_id);
2484    if undo_snapshot.exists() {
2485        if state_fingerprint(&undo_snapshot)? != current_state {
2486            return Err(WorkspaceError::Invalid(format!(
2487                "recovery point for undo {undo_id} does not match the current state"
2488            )));
2489        }
2490    } else {
2491        copy_atomic(&workspace.database_path(), &undo_snapshot)?;
2492    }
2493    let sequence = changes
2494        .iter()
2495        .find(|change| change.change_id == undo_id)
2496        .map(|change| change.sequence)
2497        .unwrap_or(next_sequence(&changes)?);
2498    let mut undo_record = ChangeRecord {
2499        format_version: FORMAT_VERSION,
2500        sequence,
2501        change_id: undo_id.clone(),
2502        kind: ChangeKind::Undo,
2503        plan_id: None,
2504        target_change_id: Some(target.change_id.clone()),
2505        base_generation: current_generation,
2506        base_state: current_state.clone(),
2507        expected_state: Some(target.base_state.clone()),
2508        snapshot_id: undo_id.clone(),
2509        sql: None,
2510        status: ChangeStatus::Prepared,
2511        committed_generation: None,
2512        after_state: None,
2513        error: None,
2514        import: None,
2515    };
2516    if undo_file.exists() {
2517        write_atomic_json(&undo_file, &undo_record)?;
2518    } else {
2519        write_new_json(&undo_file, &undo_record)?;
2520    }
2521    let database_path = workspace.database_path();
2522    drop(database);
2523    if let Err(error) = truncate_wal(&database_path) {
2524        undo_record.status = ChangeStatus::Unresolved;
2525        undo_record.error = Some(error.to_string());
2526        write_atomic_json(&undo_file, &undo_record)?;
2527        return Err(error);
2528    }
2529    if let Err(error) = copy_atomic(&target_snapshot, &database_path) {
2530        undo_record.status = ChangeStatus::Unresolved;
2531        undo_record.error = Some(error.to_string());
2532        write_atomic_json(&undo_file, &undo_record)?;
2533        return Err(error);
2534    }
2535    let restored = match Database::open_in_workspace(&database_path) {
2536        Ok(database) => database,
2537        Err(error) => {
2538            undo_record.status = ChangeStatus::Unresolved;
2539            undo_record.error = Some(error.to_string());
2540            write_atomic_json(&undo_file, &undo_record)?;
2541            return Err(error.into());
2542        }
2543    };
2544    if let Err(error) = restored.checkpoint() {
2545        undo_record.status = ChangeStatus::Unresolved;
2546        undo_record.error = Some(error.to_string());
2547        drop(restored);
2548        write_atomic_json(&undo_file, &undo_record)?;
2549        return Err(error.into());
2550    }
2551    let restored_state = state_fingerprint(&database_path)?;
2552    let restored_generation = restored.generation();
2553    if std::env::var_os("BASALT_CRASH_TEST_AFTER_UNDO_RESTORE").is_some() {
2554        std::process::abort();
2555    }
2556    drop(restored);
2557    undo_record.status = ChangeStatus::Committed;
2558    undo_record.committed_generation = Some(restored_generation);
2559    undo_record.after_state = Some(restored_state.clone());
2560    write_atomic_json(&undo_file, &undo_record)?;
2561    Ok(UndoReport {
2562        change_id: undo_id,
2563        undone_change_id: target.change_id,
2564        restored_state,
2565        generation: restored_generation,
2566    })
2567}
2568
2569pub(crate) fn mcp_preview(
2570    workspace: &Workspace,
2571    sql: &str,
2572    max_output_bytes: usize,
2573) -> Result<PlanReport, WorkspaceError> {
2574    let plan = preview_plan_with_output_limit(
2575        workspace,
2576        sql,
2577        Some(max_output_bytes),
2578        Some(MCP_EXECUTION_WORK_LIMIT),
2579        Some(MAX_MCP_MUTATION_ROWS),
2580    )?;
2581    Ok(PlanReport::from(&plan))
2582}
2583
2584pub(crate) fn mcp_plan(
2585    workspace: &Workspace,
2586    plan_id: &str,
2587    max_output_bytes: usize,
2588) -> Result<PlanReport, WorkspaceError> {
2589    let plan = load_plan(workspace, plan_id)?;
2590    let report = PlanReport::from(&plan);
2591    let output_size = serde_json::to_vec(&report)?.len();
2592    if output_size > max_output_bytes {
2593        return Err(WorkspaceError::Invalid(format!(
2594            "workspace plan is {output_size} bytes; response limit is {max_output_bytes} bytes"
2595        )));
2596    }
2597    Ok(report)
2598}
2599
2600pub(crate) fn mcp_apply(
2601    workspace: &Workspace,
2602    plan_id: &str,
2603) -> Result<ApplyReport, WorkspaceError> {
2604    apply_plan(
2605        workspace,
2606        plan_id,
2607        Some(MCP_EXECUTION_WORK_LIMIT),
2608        Some(MAX_MCP_MUTATION_ROWS),
2609    )
2610}
2611
2612pub(crate) fn mcp_history(workspace: &Workspace) -> Result<Vec<HistoryEntry>, WorkspaceError> {
2613    history(workspace)
2614}
2615
2616pub(crate) fn mcp_diff(
2617    workspace: &Workspace,
2618    change_id: Option<&str>,
2619) -> Result<DiffReport, WorkspaceError> {
2620    diff_with_row_limit(workspace, change_id, Some(MAX_MCP_DIFF_ROWS))
2621}
2622
2623pub(crate) fn mcp_undo(
2624    workspace: &Workspace,
2625    change_id: &str,
2626) -> Result<UndoReport, WorkspaceError> {
2627    undo(workspace, change_id)
2628}
2629
2630fn import_with_recovery(
2631    workspace: &Workspace,
2632    requested_table: Option<&str>,
2633    format: DataFormat,
2634    bytes: &[u8],
2635    limits: ImportLimits,
2636) -> Result<ImportReport, WorkspaceError> {
2637    let table = match format {
2638        DataFormat::Sql => {
2639            if requested_table.is_some() {
2640                return Err(WorkspaceError::Usage(
2641                    "--table is not valid for SQL imports".to_string(),
2642                ));
2643            }
2644            None
2645        }
2646        DataFormat::Csv | DataFormat::Json | DataFormat::JsonLines => {
2647            Some(required_table(requested_table)?.to_string())
2648        }
2649    };
2650    let table_name = table.as_deref();
2651    let validated_summary = match format {
2652        DataFormat::Csv | DataFormat::Json | DataFormat::JsonLines => validate_import(
2653            format,
2654            table_name.ok_or_else(|| {
2655                WorkspaceError::Invalid("row import is missing its table name".to_string())
2656            })?,
2657            bytes,
2658            limits,
2659        )?,
2660        DataFormat::Sql => {
2661            let (_, statements) = parse_sql_import(bytes)?;
2662            format!("{} statements from SQL", statements.len())
2663        }
2664    };
2665
2666    let database = workspace.database()?;
2667    let mut preflight_budget = limits
2668        .max_work
2669        .map(ExecutionBudget::bounded)
2670        .unwrap_or_else(ExecutionBudget::unlimited);
2671    database.checkpoint_with_budget(&mut preflight_budget)?;
2672    let base_state = state_fingerprint(&workspace.database_path())?;
2673    let base_generation = database.generation();
2674    ensure_history_dirs(workspace)?;
2675    let mut changes = load_changes(workspace)?;
2676    reconcile_changes(workspace, &mut changes, &base_state, base_generation)?;
2677
2678    let request_table = table_name.unwrap_or("");
2679    let request_key = import_request_key(format, request_table, bytes);
2680    if let Some(existing) = changes.iter().rev().find(|change| {
2681        change
2682            .import
2683            .as_ref()
2684            .is_some_and(|import| import.request_key == request_key)
2685    }) && existing.status.is_committed()
2686    {
2687        if existing.after_state.as_deref() != Some(base_state.as_str()) {
2688            return Err(WorkspaceError::Invalid(format!(
2689                "import has already been committed as {}; workspace state moved, so it will not be replayed",
2690                existing.change_id
2691            )));
2692        }
2693        let import = existing.import.as_ref().ok_or_else(|| {
2694            WorkspaceError::Invalid("committed import is missing its metadata".to_string())
2695        })?;
2696        let after_state = existing.after_state.clone().ok_or_else(|| {
2697            WorkspaceError::Invalid("committed import is missing its after-state".to_string())
2698        })?;
2699        let generation = existing.committed_generation.ok_or_else(|| {
2700            WorkspaceError::Invalid("committed import is missing its generation".to_string())
2701        })?;
2702        return Ok(ImportReport {
2703            change_id: existing.change_id.clone(),
2704            format: import.format.clone(),
2705            table: import.table.clone(),
2706            bytes: import.bytes,
2707            summary: import.summary.clone(),
2708            base_state: existing.base_state.clone(),
2709            after_state,
2710            generation,
2711        });
2712    }
2713
2714    let change_id = import_change_id(&base_state, format, request_table, bytes);
2715    let retry_sequence = if let Some(existing) =
2716        changes.iter().find(|change| change.change_id == change_id)
2717    {
2718        if existing.status.is_committed() {
2719            return Err(WorkspaceError::Invalid(format!(
2720                "import has already been committed as change {change_id}"
2721            )));
2722        }
2723        if existing.status == ChangeStatus::Failed
2724            && existing.base_state == base_state
2725            && existing.after_state.is_none()
2726            && existing
2727                .import
2728                .as_ref()
2729                .is_some_and(|import| import.request_key == request_key)
2730        {
2731            Some(existing.sequence)
2732        } else {
2733            return Err(WorkspaceError::Invalid(format!(
2734                "import already has a history record with status {:?}; inspect workspace history before retrying",
2735                existing.status
2736            )));
2737        }
2738    } else {
2739        None
2740    };
2741
2742    let snapshot = snapshot_path(workspace, &change_id);
2743    if snapshot.exists() {
2744        if state_fingerprint(&snapshot)? != base_state {
2745            return Err(WorkspaceError::Invalid(format!(
2746                "recovery point for import {change_id} does not match the workspace"
2747            )));
2748        }
2749    } else {
2750        copy_atomic(&workspace.database_path(), &snapshot)?;
2751    }
2752
2753    let change_file = change_path(workspace, &change_id);
2754    let mut change = ChangeRecord {
2755        format_version: FORMAT_VERSION,
2756        sequence: match retry_sequence {
2757            Some(sequence) => sequence,
2758            None => next_sequence(&changes)?,
2759        },
2760        change_id: change_id.clone(),
2761        kind: ChangeKind::Apply,
2762        plan_id: None,
2763        target_change_id: None,
2764        base_generation,
2765        base_state: base_state.clone(),
2766        expected_state: None,
2767        snapshot_id: change_id.clone(),
2768        sql: None,
2769        status: ChangeStatus::Prepared,
2770        committed_generation: None,
2771        after_state: None,
2772        error: None,
2773        import: Some(ImportMetadata {
2774            request_key,
2775            format: format.name().to_string(),
2776            table: table.clone(),
2777            bytes: bytes.len(),
2778            summary: validated_summary,
2779        }),
2780    };
2781    if change_file.exists() {
2782        write_atomic_json(&change_file, &change)?;
2783    } else {
2784        write_new_json(&change_file, &change)?;
2785    }
2786
2787    let imported = match format {
2788        DataFormat::Csv => import_csv(&database, table_name, bytes, limits),
2789        DataFormat::Json => import_json(&database, table_name, bytes, limits),
2790        DataFormat::JsonLines => import_json_lines(&database, table_name, bytes, limits),
2791        DataFormat::Sql => import_sql(&database, bytes),
2792    };
2793    let summary = match imported {
2794        Ok(summary) => summary.unwrap_or_else(|| "import completed".to_string()),
2795        Err(error) => {
2796            change.status = ChangeStatus::Failed;
2797            change.error = Some(error.to_string());
2798            write_atomic_json(&change_file, &change)?;
2799            return Err(error);
2800        }
2801    };
2802    if let Some(import) = change.import.as_mut() {
2803        import.summary = summary.clone();
2804    }
2805
2806    if let Err(error) = database.checkpoint() {
2807        change.status = ChangeStatus::Unresolved;
2808        change.error = Some(format!("import committed but checkpoint failed: {error}"));
2809        write_atomic_json(&change_file, &change)?;
2810        return Err(error.into());
2811    }
2812    let after_state = state_fingerprint(&workspace.database_path())?;
2813    let generation = database.generation();
2814    if std::env::var_os("BASALT_CRASH_TEST_AFTER_IMPORT_CHECKPOINT").is_some() {
2815        std::process::abort();
2816    }
2817    change.status = ChangeStatus::Committed;
2818    change.committed_generation = Some(generation);
2819    change.after_state = Some(after_state.clone());
2820    write_atomic_json(&change_file, &change)?;
2821
2822    Ok(ImportReport {
2823        change_id,
2824        format: format.name().to_string(),
2825        table,
2826        bytes: bytes.len(),
2827        summary,
2828        base_state,
2829        after_state,
2830        generation,
2831    })
2832}
2833
2834#[derive(Debug, Serialize, JsonSchema)]
2835pub(crate) struct ImportReport {
2836    change_id: String,
2837    format: String,
2838    table: Option<String>,
2839    bytes: usize,
2840    summary: String,
2841    base_state: String,
2842    after_state: String,
2843    generation: u64,
2844}
2845
2846pub(crate) fn mcp_import(
2847    workspace: &Workspace,
2848    table: Option<&str>,
2849    format: &str,
2850    content: &str,
2851) -> Result<ImportReport, WorkspaceError> {
2852    if content.len() > MAX_MCP_IMPORT_BYTES {
2853        return Err(WorkspaceError::Invalid(format!(
2854            "MCP import content exceeds the {} MiB limit",
2855            MAX_MCP_IMPORT_BYTES / (1024 * 1024)
2856        )));
2857    }
2858    let format = DataFormat::parse(format)?;
2859    if format == DataFormat::Sql {
2860        return Err(WorkspaceError::Usage(
2861            "workspace_import accepts csv, json, or jsonl; use the CLI for SQL dump imports"
2862                .to_string(),
2863        ));
2864    }
2865    let table = required_mcp_table(table)?;
2866    import_with_recovery(
2867        workspace,
2868        Some(table),
2869        format,
2870        content.as_bytes(),
2871        ImportLimits::mcp(),
2872    )
2873}
2874
2875fn required_mcp_table(table: Option<&str>) -> Result<&str, WorkspaceError> {
2876    let table = table.ok_or_else(|| {
2877        WorkspaceError::Usage("workspace_import requires an explicit table name".to_string())
2878    })?;
2879    validate_name(table, "table name")?;
2880    Ok(table)
2881}
2882
2883fn validate_import(
2884    format: DataFormat,
2885    table: &str,
2886    bytes: &[u8],
2887    limits: ImportLimits,
2888) -> Result<String, WorkspaceError> {
2889    let database = Database::in_memory();
2890    match format {
2891        DataFormat::Csv => import_csv(&database, Some(table), bytes, limits),
2892        DataFormat::Json => import_json(&database, Some(table), bytes, limits),
2893        DataFormat::JsonLines => import_json_lines(&database, Some(table), bytes, limits),
2894        DataFormat::Sql => unreachable!(),
2895    }
2896    .map(|summary| summary.unwrap_or_else(|| "import completed".to_string()))
2897}
2898
2899#[derive(Debug, Serialize, JsonSchema)]
2900pub(crate) struct ExportReport {
2901    table: String,
2902    format: String,
2903    content: String,
2904    bytes: usize,
2905}
2906
2907pub(crate) fn mcp_export(
2908    workspace: &Workspace,
2909    table: &str,
2910    format: &str,
2911    max_content_bytes: usize,
2912) -> Result<ExportReport, WorkspaceError> {
2913    let format = DataFormat::parse(format)?;
2914    if format == DataFormat::Json {
2915        return Err(WorkspaceError::Usage(
2916            "JSON export is JSON Lines; use jsonl".to_string(),
2917        ));
2918    }
2919    let database = workspace.database()?;
2920    let row_count = database.row_count(table)?;
2921    if row_count > MAX_MCP_EXPORT_ROWS {
2922        return Err(WorkspaceError::Invalid(format!(
2923            "MCP export is limited to {MAX_MCP_EXPORT_ROWS} rows; use the CLI export for larger tables"
2924        )));
2925    }
2926    let (columns, rows) = select_table(&database, table)?;
2927    debug_assert_eq!(rows.len(), row_count);
2928    let mut output = LimitedBuffer::new(max_content_bytes);
2929    let result = match format {
2930        DataFormat::Csv => write_csv(&columns, &rows, &mut output),
2931        DataFormat::JsonLines => write_json_lines(&columns, &rows, &mut output),
2932        DataFormat::Sql => write_sql(&database, table, &rows, &mut output),
2933        DataFormat::Json => unreachable!(),
2934    };
2935    if output.exceeded {
2936        return Err(WorkspaceError::Invalid(format!(
2937            "MCP export exceeds the {max_content_bytes}-byte content limit"
2938        )));
2939    }
2940    result?;
2941    let content = String::from_utf8(output.into_inner())
2942        .map_err(|error| WorkspaceError::Invalid(format!("export is not valid UTF-8: {error}")))?;
2943    let bytes = content.len();
2944    Ok(ExportReport {
2945        table: table.to_string(),
2946        format: format.name().to_string(),
2947        bytes,
2948        content,
2949    })
2950}
2951
2952pub(crate) fn mcp_inspect(workspace: &Workspace) -> Result<InspectReport, WorkspaceError> {
2953    let database = workspace.database()?;
2954    inspect(workspace, &database)
2955}
2956
2957fn required_table(table: Option<&str>) -> Result<&str, WorkspaceError> {
2958    let table = table.ok_or_else(|| {
2959        WorkspaceError::Usage("row imports require --table NAME or a source filename".to_string())
2960    })?;
2961    validate_name(table, "table name")?;
2962    Ok(table)
2963}
2964
2965fn validate_headers(headers: &StringRecord) -> Result<Vec<String>, WorkspaceError> {
2966    let mut seen = HashMap::new();
2967    let mut columns = Vec::with_capacity(headers.len());
2968    for header in headers {
2969        validate_name(header, "CSV header")?;
2970        let key = header.to_ascii_lowercase();
2971        if seen.insert(key, ()).is_some() {
2972            return Err(WorkspaceError::Invalid(format!(
2973                "duplicate CSV header: {header:?}"
2974            )));
2975        }
2976        columns.push(header.to_string());
2977    }
2978    if columns.is_empty() {
2979        return Err(WorkspaceError::Invalid(
2980            "CSV input must contain a header row".to_string(),
2981        ));
2982    }
2983    Ok(columns)
2984}
2985
2986fn validate_name(name: &str, label: &str) -> Result<(), WorkspaceError> {
2987    if name.trim().is_empty() {
2988        return Err(WorkspaceError::Invalid(format!("{label} cannot be empty")));
2989    }
2990    if name.contains('\0') {
2991        return Err(WorkspaceError::Invalid(format!(
2992            "{label} cannot contain NUL bytes"
2993        )));
2994    }
2995    Ok(())
2996}
2997
2998fn parse_csv_cell(value: &str) -> ImportedCell {
2999    if value.is_empty() {
3000        return ImportedCell::Empty;
3001    }
3002    if value.eq_ignore_ascii_case("true") {
3003        return ImportedCell::Boolean(true);
3004    }
3005    if value.eq_ignore_ascii_case("false") {
3006        return ImportedCell::Boolean(false);
3007    }
3008    if let Ok(integer) = value.parse::<i64>() {
3009        return ImportedCell::Integer(integer);
3010    }
3011    if let Ok(real) = value.parse::<f64>()
3012        && real.is_finite()
3013    {
3014        return ImportedCell::Real(real);
3015    }
3016    ImportedCell::Text(value.to_string())
3017}
3018
3019fn json_cell(value: &JsonValue) -> ImportedCell {
3020    match value {
3021        JsonValue::Null => ImportedCell::Null,
3022        JsonValue::Bool(value) => ImportedCell::Boolean(*value),
3023        JsonValue::Number(value) => value
3024            .as_i64()
3025            .map(ImportedCell::Integer)
3026            .or_else(|| {
3027                value
3028                    .as_f64()
3029                    .filter(|value| value.is_finite())
3030                    .map(ImportedCell::Real)
3031            })
3032            .unwrap_or_else(|| ImportedCell::Text(value.to_string())),
3033        JsonValue::String(value) => ImportedCell::Text(value.clone()),
3034        JsonValue::Array(_) | JsonValue::Object(_) => ImportedCell::Text(value.to_string()),
3035    }
3036}
3037
3038fn infer_types(rows: &[Vec<ImportedCell>], width: usize) -> Vec<ColumnType> {
3039    (0..width)
3040        .map(|column| {
3041            let mut inferred = None;
3042            for row in rows {
3043                let kind = match row.get(column) {
3044                    Some(ImportedCell::Null | ImportedCell::Empty) | None => None,
3045                    Some(ImportedCell::Integer(_)) => Some(ColumnType::Integer),
3046                    Some(ImportedCell::Real(_)) => Some(ColumnType::Real),
3047                    Some(ImportedCell::Boolean(_)) => Some(ColumnType::Boolean),
3048                    Some(ImportedCell::Text(_)) => Some(ColumnType::Text),
3049                };
3050                inferred = merge_inferred(inferred, kind);
3051                if inferred == Some(ColumnType::Text) {
3052                    break;
3053                }
3054            }
3055            inferred.unwrap_or(ColumnType::Text)
3056        })
3057        .collect()
3058}
3059
3060fn merge_inferred(current: Option<ColumnType>, next: Option<ColumnType>) -> Option<ColumnType> {
3061    match (current, next) {
3062        (None, value) | (value, None) => value,
3063        (Some(ColumnType::Integer), Some(ColumnType::Integer)) => Some(ColumnType::Integer),
3064        (Some(ColumnType::Integer), Some(ColumnType::Real))
3065        | (Some(ColumnType::Real), Some(ColumnType::Integer))
3066        | (Some(ColumnType::Real), Some(ColumnType::Real)) => Some(ColumnType::Real),
3067        (Some(ColumnType::Boolean), Some(ColumnType::Boolean)) => Some(ColumnType::Boolean),
3068        _ => Some(ColumnType::Text),
3069    }
3070}
3071
3072impl ImportedRows {
3073    fn summary(&self) -> String {
3074        format!(
3075            "table {} ({} rows, {} columns)",
3076            self.table,
3077            self.rows.len(),
3078            self.columns.len()
3079        )
3080    }
3081}
3082
3083fn import_rows(
3084    database: &Database,
3085    imported: &ImportedRows,
3086    max_work: Option<usize>,
3087) -> Result<(), WorkspaceError> {
3088    validate_name(&imported.table, "table name")?;
3089    if imported.columns.is_empty() {
3090        return Err(WorkspaceError::Invalid(
3091            "cannot import a table with no columns".to_string(),
3092        ));
3093    }
3094    if imported
3095        .rows
3096        .iter()
3097        .any(|row| row.len() != imported.columns.len())
3098    {
3099        return Err(WorkspaceError::Invalid(
3100            "imported row width does not match the header".to_string(),
3101        ));
3102    }
3103    let definitions = imported
3104        .columns
3105        .iter()
3106        .zip(&imported.types)
3107        .map(|(name, ty)| format!("{} {}", quote_identifier(name), column_type_name(ty)))
3108        .collect::<Vec<_>>();
3109    let create = format!(
3110        "CREATE TABLE {} ({})",
3111        quote_identifier(&imported.table),
3112        definitions.join(", ")
3113    );
3114
3115    let mut budget = max_work
3116        .map(ExecutionBudget::bounded)
3117        .unwrap_or_else(ExecutionBudget::unlimited);
3118    let mut connection = database.connect();
3119    connection.execute_with_budget(&Statement::Begin, &mut budget)?;
3120    let result = (|| {
3121        connection.execute_sql_using_budget(&create, &mut budget)?;
3122        for chunk in imported.rows.chunks(IMPORT_BATCH_SIZE) {
3123            if chunk.is_empty() {
3124                continue;
3125            }
3126            let rows = chunk
3127                .iter()
3128                .map(|row| {
3129                    row.iter()
3130                        .zip(&imported.types)
3131                        .map(|(cell, ty)| cell_to_value(cell, ty).map(|value| sql_literal(&value)))
3132                        .collect::<Result<Vec<_>, WorkspaceError>>()
3133                        .map(|values| format!("({})", values.join(", ")))
3134                })
3135                .collect::<Result<Vec<_>, WorkspaceError>>()?;
3136            let insert = format!(
3137                "INSERT INTO {} VALUES {}",
3138                quote_identifier(&imported.table),
3139                rows.join(", ")
3140            );
3141            connection.execute_sql_using_budget(&insert, &mut budget)?;
3142        }
3143        connection.execute_with_budget(&Statement::Commit, &mut budget)?;
3144        Ok::<(), WorkspaceError>(())
3145    })();
3146    if let Err(error) = result {
3147        let _ = connection.execute_sql("ROLLBACK");
3148        return Err(error);
3149    }
3150    Ok(())
3151}
3152
3153fn cell_to_value(cell: &ImportedCell, ty: &ColumnType) -> Result<Value, WorkspaceError> {
3154    let value = match (cell, ty) {
3155        (ImportedCell::Null, _) => Value::Null,
3156        (ImportedCell::Empty, ColumnType::Text) => Value::Text(String::new()),
3157        (ImportedCell::Empty, _) => Value::Null,
3158        (ImportedCell::Integer(value), ColumnType::Integer) => Value::Integer(*value),
3159        (ImportedCell::Integer(value), ColumnType::Real) => Value::Real(*value as f64),
3160        (ImportedCell::Integer(value), ColumnType::Boolean) => Value::Boolean(*value != 0),
3161        (ImportedCell::Integer(value), ColumnType::Text) => Value::Text(value.to_string()),
3162        (ImportedCell::Real(value), ColumnType::Real) => Value::Real(*value),
3163        (ImportedCell::Real(value), ColumnType::Text) => Value::Text(value.to_string()),
3164        (ImportedCell::Real(value), ColumnType::Integer) => {
3165            return Err(WorkspaceError::Invalid(format!(
3166                "cannot store REAL value {value} in inferred INTEGER column"
3167            )));
3168        }
3169        (ImportedCell::Real(value), ColumnType::Boolean) => {
3170            return Err(WorkspaceError::Invalid(format!(
3171                "cannot store REAL value {value} in inferred BOOLEAN column"
3172            )));
3173        }
3174        (ImportedCell::Boolean(value), ColumnType::Boolean) => Value::Boolean(*value),
3175        (ImportedCell::Boolean(value), ColumnType::Text) => Value::Text(value.to_string()),
3176        (ImportedCell::Boolean(value), ColumnType::Integer) => Value::Integer(*value as i64),
3177        (ImportedCell::Boolean(value), ColumnType::Real) => Value::Real(*value as u8 as f64),
3178        (ImportedCell::Text(value), ColumnType::Text) => Value::Text(value.clone()),
3179        (ImportedCell::Text(value), _) => {
3180            return Err(WorkspaceError::Invalid(format!(
3181                "text value {value:?} conflicts with inferred {:?} column",
3182                ty
3183            )));
3184        }
3185        (_, ColumnType::Any | ColumnType::Null) => Value::Null,
3186    };
3187    Ok(value)
3188}
3189
3190fn select_table(
3191    database: &Database,
3192    table: &str,
3193) -> Result<(Vec<String>, Vec<Vec<Value>>), WorkspaceError> {
3194    validate_name(table, "table name")?;
3195    let sql = format!("SELECT * FROM {}", quote_identifier(table));
3196    let results = database.execute_sql(&sql)?;
3197    let Some(StatementResult::Select { columns, rows }) = results.into_iter().next() else {
3198        return Err(WorkspaceError::Invalid(
3199            "table query did not return rows".to_string(),
3200        ));
3201    };
3202    Ok((columns, rows))
3203}
3204
3205fn export_csv(columns: &[String], rows: &[Vec<Value>]) -> Result<Vec<u8>, WorkspaceError> {
3206    let mut output = Vec::new();
3207    write_csv(columns, rows, &mut output)?;
3208    Ok(output)
3209}
3210
3211fn write_csv<W: Write>(
3212    columns: &[String],
3213    rows: &[Vec<Value>],
3214    output: &mut W,
3215) -> Result<(), WorkspaceError> {
3216    let mut writer = Writer::from_writer(output);
3217    writer.write_record(columns)?;
3218    for row in rows {
3219        ensure_row_width(columns, row)?;
3220        let values = row.iter().map(csv_field).collect::<Vec<_>>();
3221        writer.write_record(values)?;
3222    }
3223    writer.flush()?;
3224    Ok(())
3225}
3226
3227fn export_json_lines(columns: &[String], rows: &[Vec<Value>]) -> Result<Vec<u8>, WorkspaceError> {
3228    let mut output = Vec::new();
3229    write_json_lines(columns, rows, &mut output)?;
3230    Ok(output)
3231}
3232
3233fn write_json_lines<W: Write>(
3234    columns: &[String],
3235    rows: &[Vec<Value>],
3236    output: &mut W,
3237) -> Result<(), WorkspaceError> {
3238    for row in rows {
3239        ensure_row_width(columns, row)?;
3240        let mut object = Map::new();
3241        for (column, value) in columns.iter().zip(row) {
3242            object.insert(column.clone(), value_to_json(value)?);
3243        }
3244        serde_json::to_writer(&mut *output, &JsonValue::Object(object))?;
3245        output.write_all(b"\n")?;
3246    }
3247    Ok(())
3248}
3249
3250fn export_sql(
3251    database: &Database,
3252    table: &str,
3253    rows: &[Vec<Value>],
3254) -> Result<Vec<u8>, WorkspaceError> {
3255    let mut output = Vec::new();
3256    write_sql(database, table, rows, &mut output)?;
3257    Ok(output)
3258}
3259
3260fn write_sql<W: Write>(
3261    database: &Database,
3262    table: &str,
3263    rows: &[Vec<Value>],
3264    output: &mut W,
3265) -> Result<(), WorkspaceError> {
3266    let columns = database.columns(table)?;
3267    write!(output, "CREATE TABLE {} (", quote_identifier(table))?;
3268    for (index, column) in columns.iter().enumerate() {
3269        if index > 0 {
3270            output.write_all(b", ")?;
3271        }
3272        output.write_all(column_definition(column).as_bytes())?;
3273    }
3274    output.write_all(b");\n")?;
3275    for row in rows {
3276        ensure_row_width(
3277            &columns
3278                .iter()
3279                .map(|column| column.name.clone())
3280                .collect::<Vec<_>>(),
3281            row,
3282        )?;
3283        write!(output, "INSERT INTO {} VALUES (", quote_identifier(table))?;
3284        for (index, value) in row.iter().enumerate() {
3285            if index > 0 {
3286                output.write_all(b", ")?;
3287            }
3288            output.write_all(sql_literal(value).as_bytes())?;
3289        }
3290        output.write_all(b");\n")?;
3291    }
3292    Ok(())
3293}
3294
3295struct LimitedBuffer {
3296    bytes: Vec<u8>,
3297    limit: usize,
3298    exceeded: bool,
3299}
3300
3301impl LimitedBuffer {
3302    fn new(limit: usize) -> Self {
3303        Self {
3304            bytes: Vec::new(),
3305            limit,
3306            exceeded: false,
3307        }
3308    }
3309
3310    fn into_inner(self) -> Vec<u8> {
3311        self.bytes
3312    }
3313}
3314
3315impl Write for LimitedBuffer {
3316    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3317        if bytes.len() > self.limit.saturating_sub(self.bytes.len()) {
3318            self.exceeded = true;
3319            return Err(io::Error::new(
3320                io::ErrorKind::WriteZero,
3321                "export content limit exceeded",
3322            ));
3323        }
3324        self.bytes.extend_from_slice(bytes);
3325        Ok(bytes.len())
3326    }
3327
3328    fn flush(&mut self) -> io::Result<()> {
3329        Ok(())
3330    }
3331}
3332
3333fn column_definition(column: &Column) -> String {
3334    let mut definition = format!(
3335        "{} {}",
3336        quote_identifier(&column.name),
3337        column_type_name(&column.ty)
3338    );
3339    if column.primary_key {
3340        definition.push_str(" PRIMARY KEY");
3341    } else if column.not_null {
3342        definition.push_str(" NOT NULL");
3343    }
3344    if column.unique && !column.primary_key {
3345        definition.push_str(" UNIQUE");
3346    }
3347    definition
3348}
3349
3350fn ensure_row_width(columns: &[String], row: &[Value]) -> Result<(), WorkspaceError> {
3351    if columns.len() != row.len() {
3352        return Err(WorkspaceError::Invalid(format!(
3353            "row width mismatch: expected {}, got {}",
3354            columns.len(),
3355            row.len()
3356        )));
3357    }
3358    Ok(())
3359}
3360
3361fn csv_field(value: &Value) -> String {
3362    match value {
3363        Value::Null => String::new(),
3364        Value::Integer(value) => value.to_string(),
3365        Value::Real(value) => value.to_string(),
3366        Value::Text(value) => value.clone(),
3367        Value::Boolean(value) => value.to_string(),
3368    }
3369}
3370
3371fn value_to_json(value: &Value) -> Result<JsonValue, WorkspaceError> {
3372    match value {
3373        Value::Null => Ok(JsonValue::Null),
3374        Value::Integer(value) => Ok(JsonValue::Number((*value).into())),
3375        Value::Real(value) => serde_json::Number::from_f64(*value)
3376            .map(JsonValue::Number)
3377            .ok_or_else(|| WorkspaceError::Invalid("cannot export non-finite REAL".to_string())),
3378        Value::Text(value) => Ok(JsonValue::String(value.clone())),
3379        Value::Boolean(value) => Ok(JsonValue::Bool(*value)),
3380    }
3381}
3382
3383fn quote_identifier(value: &str) -> String {
3384    format!("\"{}\"", value.replace('"', "\"\""))
3385}
3386
3387fn column_type_name(ty: &ColumnType) -> &'static str {
3388    match ty {
3389        ColumnType::Integer => "INTEGER",
3390        ColumnType::Real => "REAL",
3391        ColumnType::Text => "TEXT",
3392        ColumnType::Boolean => "BOOLEAN",
3393        ColumnType::Any => "TEXT",
3394        ColumnType::Null => "TEXT",
3395    }
3396}
3397
3398fn sql_literal(value: &Value) -> String {
3399    match value {
3400        Value::Null => "NULL".to_string(),
3401        Value::Integer(value) => value.to_string(),
3402        Value::Real(value) => value.to_string(),
3403        Value::Text(value) => format!("'{}'", value.replace('\'', "''")),
3404        Value::Boolean(value) => value.to_string(),
3405    }
3406}
3407
3408#[derive(Debug, Serialize, JsonSchema)]
3409pub(crate) struct InspectReport {
3410    path: String,
3411    format_version: u32,
3412    database: String,
3413    tables: Vec<InspectTable>,
3414}
3415
3416#[derive(Debug, Serialize, JsonSchema)]
3417struct InspectTable {
3418    name: String,
3419    rows: usize,
3420    columns: Vec<InspectColumn>,
3421}
3422
3423#[derive(Debug, Serialize, JsonSchema)]
3424struct InspectColumn {
3425    name: String,
3426    data_type: &'static str,
3427    not_null: bool,
3428    unique: bool,
3429    primary_key: bool,
3430}
3431
3432fn inspect(workspace: &Workspace, database: &Database) -> Result<InspectReport, WorkspaceError> {
3433    let mut tables = Vec::new();
3434    for table_name in database.table_names()? {
3435        let columns = database.columns(&table_name)?;
3436        let rows = table_row_count(database, &table_name)?;
3437        tables.push(InspectTable {
3438            name: table_name,
3439            rows,
3440            columns: columns
3441                .iter()
3442                .map(|column| InspectColumn {
3443                    name: column.name.clone(),
3444                    data_type: column_type_name(&column.ty),
3445                    not_null: column.not_null,
3446                    unique: column.unique,
3447                    primary_key: column.primary_key,
3448                })
3449                .collect(),
3450        });
3451    }
3452    Ok(InspectReport {
3453        path: workspace.root.display().to_string(),
3454        format_version: workspace.manifest.format_version,
3455        database: workspace.manifest.database.clone(),
3456        tables,
3457    })
3458}
3459
3460fn table_row_count(database: &Database, table: &str) -> Result<usize, WorkspaceError> {
3461    Ok(database.row_count(table)?)
3462}
3463
3464fn render_inspect(report: &InspectReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3465    writeln!(output, "Workspace: {}", report.path)?;
3466    writeln!(output, "Workspace format: {}", report.format_version)?;
3467    writeln!(output, "Database: {}", report.database)?;
3468    if report.tables.is_empty() {
3469        writeln!(output, "Tables: none")?;
3470        return Ok(());
3471    }
3472    writeln!(output, "Tables: {}", report.tables.len())?;
3473    for table in &report.tables {
3474        let columns = table
3475            .columns
3476            .iter()
3477            .map(|column| format!("{} {}", column.name, column.data_type))
3478            .collect::<Vec<_>>()
3479            .join(", ");
3480        writeln!(
3481            output,
3482            "- {} ({} rows): {}",
3483            table.name, table.rows, columns
3484        )?;
3485    }
3486    Ok(())
3487}
3488
3489fn render_plan(
3490    workspace: &Workspace,
3491    plan: &PlanRecord,
3492    output: &mut dyn Write,
3493) -> Result<(), WorkspaceError> {
3494    writeln!(output, "Plan: {}", plan.plan_id)?;
3495    writeln!(output, "Workspace: {}", workspace.root.display())?;
3496    writeln!(output, "Base state: {}", plan.base_state)?;
3497    writeln!(output, "Statements: {}", plan.statements.len())?;
3498    for item in &plan.statements {
3499        let detail = item
3500            .rows_affected
3501            .map(|rows| format!("{rows} row(s) affected"))
3502            .or_else(|| {
3503                item.rows_returned
3504                    .map(|rows| format!("{rows} row(s) returned"))
3505            })
3506            .or_else(|| item.object.clone())
3507            .unwrap_or_default();
3508        if detail.is_empty() {
3509            writeln!(output, "- {}: {}", item.statement, item.kind)?;
3510        } else {
3511            writeln!(output, "- {}: {} ({detail})", item.statement, item.kind)?;
3512        }
3513    }
3514    writeln!(
3515        output,
3516        "Apply: basalt workspace apply {} {}",
3517        workspace.root.display(),
3518        plan.plan_id
3519    )?;
3520    Ok(())
3521}
3522
3523fn render_apply(report: &ApplyReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3524    writeln!(output, "Applied change {}", report.change_id)?;
3525    writeln!(output, "Plan: {}", report.plan_id)?;
3526    writeln!(output, "State: {}", report.after_state)?;
3527    writeln!(output, "Generation: {}", report.generation)?;
3528    Ok(())
3529}
3530
3531fn render_history(entries: &[HistoryEntry], output: &mut dyn Write) -> Result<(), WorkspaceError> {
3532    if entries.is_empty() {
3533        writeln!(output, "No changes.")?;
3534        return Ok(());
3535    }
3536    for entry in entries {
3537        writeln!(
3538            output,
3539            "#{} {} {} {}",
3540            entry.sequence,
3541            entry.change_id,
3542            change_kind_name(&entry.kind),
3543            change_status_name(&entry.status)
3544        )?;
3545        if let Some(error) = &entry.error {
3546            writeln!(output, "  {error}")?;
3547        }
3548    }
3549    Ok(())
3550}
3551
3552fn render_diff(report: &DiffReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3553    writeln!(output, "Diff for change {}", report.change_id)?;
3554    writeln!(output, "Precision: {}", report.precision)?;
3555    writeln!(output, "Before: {}", report.before_state)?;
3556    writeln!(output, "Current: {}", report.current_state)?;
3557    if report.tables.is_empty() {
3558        writeln!(output, "No logical table changes.")?;
3559        return Ok(());
3560    }
3561    for table in &report.tables {
3562        let before = table
3563            .before_rows
3564            .map(|rows| rows.to_string())
3565            .unwrap_or_else(|| "absent".to_string());
3566        let after = table
3567            .after_rows
3568            .map(|rows| rows.to_string())
3569            .unwrap_or_else(|| "absent".to_string());
3570        writeln!(
3571            output,
3572            "- {}: {} -> {} row(s), schema_changed={}, data_changed={}",
3573            table.table, before, after, table.schema_changed, table.data_changed
3574        )?;
3575        writeln!(
3576            output,
3577            "  rows added: {}, rows removed: {}",
3578            table.added_rows, table.removed_rows
3579        )?;
3580    }
3581    Ok(())
3582}
3583
3584fn render_undo(report: &UndoReport, output: &mut dyn Write) -> Result<(), WorkspaceError> {
3585    writeln!(
3586        output,
3587        "Undid change {} as {}",
3588        report.undone_change_id, report.change_id
3589    )?;
3590    writeln!(output, "Restored state: {}", report.restored_state)?;
3591    writeln!(output, "Generation: {}", report.generation)?;
3592    Ok(())
3593}
3594
3595fn change_kind_name(kind: &ChangeKind) -> &'static str {
3596    match kind {
3597        ChangeKind::Apply => "apply",
3598        ChangeKind::Undo => "undo",
3599    }
3600}
3601
3602fn change_status_name(status: &ChangeStatus) -> &'static str {
3603    match status {
3604        ChangeStatus::Prepared => "prepared",
3605        ChangeStatus::Committed => "committed",
3606        ChangeStatus::Recovered => "recovered",
3607        ChangeStatus::Failed => "failed",
3608        ChangeStatus::Unresolved => "unresolved",
3609    }
3610}
3611
3612fn write_output(
3613    workspace: &Workspace,
3614    destination: &Path,
3615    bytes: &[u8],
3616    stdout: &mut dyn Write,
3617) -> Result<(), WorkspaceError> {
3618    if destination.as_os_str() == OsStr::new("-") {
3619        stdout.write_all(bytes)?;
3620        return Ok(());
3621    }
3622    if same_path(destination, &workspace.database_path())
3623        || same_path(destination, &workspace.root.join(MANIFEST_FILE))
3624    {
3625        return Err(WorkspaceError::Invalid(
3626            "refusing to overwrite workspace metadata or database".to_string(),
3627        ));
3628    }
3629    if let Some(parent) = destination
3630        .parent()
3631        .filter(|parent| !parent.as_os_str().is_empty())
3632    {
3633        fs::create_dir_all(parent)?;
3634    }
3635    atomic_write_file(destination, bytes)
3636}
3637
3638fn atomic_write_file(path: &Path, bytes: &[u8]) -> Result<(), WorkspaceError> {
3639    if path_is_symlink(path)? {
3640        return Err(WorkspaceError::Invalid(format!(
3641            "refusing to write through a symbolic link: {}",
3642            path.display()
3643        )));
3644    }
3645    if let Some(parent) = path
3646        .parent()
3647        .filter(|parent| !parent.as_os_str().is_empty())
3648    {
3649        fs::create_dir_all(parent)?;
3650    }
3651    let temporary = temporary_path(path);
3652    let write_result = (|| {
3653        let mut file = OpenOptions::new()
3654            .create_new(true)
3655            .write(true)
3656            .open(&temporary)?;
3657        file.write_all(bytes)?;
3658        file.sync_all()?;
3659        drop(file);
3660        match fs::rename(&temporary, path) {
3661            Ok(()) => Ok(()),
3662            Err(error) if path.exists() => {
3663                fs::remove_file(path)?;
3664                fs::rename(&temporary, path).map_err(|_| error)
3665            }
3666            Err(error) => Err(error),
3667        }
3668    })();
3669    if write_result.is_err() {
3670        let _ = fs::remove_file(&temporary);
3671    }
3672    write_result.map_err(WorkspaceError::Io)
3673}
3674
3675fn write_new_file(path: &Path, bytes: &[u8]) -> Result<(), WorkspaceError> {
3676    if path_is_symlink(path)? {
3677        return Err(WorkspaceError::Invalid(format!(
3678            "refusing to create through a symbolic link: {}",
3679            path.display()
3680        )));
3681    }
3682    let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
3683    file.write_all(bytes)?;
3684    file.sync_all()?;
3685    Ok(())
3686}
3687
3688fn path_is_symlink(path: &Path) -> Result<bool, WorkspaceError> {
3689    match fs::symlink_metadata(path) {
3690        Ok(metadata) => Ok(metadata.file_type().is_symlink()),
3691        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3692        Err(error) => Err(WorkspaceError::Io(error)),
3693    }
3694}
3695
3696fn acquire_workspace_lock(root: &Path) -> Result<Arc<File>, WorkspaceError> {
3697    let path = root.join(WORKSPACE_LOCK_FILE);
3698    if path_is_symlink(&path)? {
3699        return Err(WorkspaceError::Invalid(
3700            "workspace lock cannot be a symbolic link".to_string(),
3701        ));
3702    }
3703    let file = OpenOptions::new()
3704        .create(true)
3705        .truncate(false)
3706        .read(true)
3707        .write(true)
3708        .open(&path)?;
3709    match fs4::FileExt::try_lock(&file) {
3710        Ok(()) => Ok(Arc::new(file)),
3711        Err(fs4::TryLockError::WouldBlock) => Err(WorkspaceError::Invalid(format!(
3712            "workspace is already open: {}",
3713            root.display()
3714        ))),
3715        Err(fs4::TryLockError::Error(error)) => Err(WorkspaceError::Io(error)),
3716    }
3717}
3718
3719fn validate_database_paths(root: &Path) -> Result<(), WorkspaceError> {
3720    let database = root.join(DATABASE_FILE);
3721    if path_is_symlink(&database)? {
3722        return Err(WorkspaceError::Invalid(
3723            "workspace database cannot be a symbolic link".to_string(),
3724        ));
3725    }
3726    if database.exists() && !database.is_file() {
3727        return Err(WorkspaceError::Invalid(format!(
3728            "workspace database is not a file: {}",
3729            database.display()
3730        )));
3731    }
3732    for suffix in [".wal", ".lock", ".tmp"] {
3733        let mut value = database.as_os_str().to_os_string();
3734        value.push(suffix);
3735        let path = PathBuf::from(value);
3736        if path_is_symlink(&path)? {
3737            return Err(WorkspaceError::Invalid(format!(
3738                "workspace database sidecar cannot be a symbolic link: {}",
3739                path.display()
3740            )));
3741        }
3742        if path.exists() && !path.is_file() {
3743            return Err(WorkspaceError::Invalid(format!(
3744                "workspace database sidecar is not a file: {}",
3745                path.display()
3746            )));
3747        }
3748    }
3749    let mut wal_value = database.as_os_str().to_os_string();
3750    wal_value.push(".wal");
3751    if !database.exists() && !Path::new(&wal_value).exists() {
3752        return Err(WorkspaceError::Invalid(format!(
3753            "workspace database is missing: {}",
3754            database.display()
3755        )));
3756    }
3757    Ok(())
3758}
3759
3760fn manifest_bytes(manifest: &WorkspaceManifest) -> Result<Vec<u8>, WorkspaceError> {
3761    let mut bytes = serde_json::to_vec_pretty(manifest)?;
3762    bytes.push(b'\n');
3763    Ok(bytes)
3764}
3765
3766fn temporary_path(destination: &Path) -> PathBuf {
3767    let stamp = SystemTime::now()
3768        .duration_since(UNIX_EPOCH)
3769        .map(|duration| duration.as_nanos())
3770        .unwrap_or_default();
3771    let name = destination
3772        .file_name()
3773        .and_then(OsStr::to_str)
3774        .unwrap_or("export");
3775    destination.with_file_name(format!(".{name}.basalt-tmp-{}-{stamp}", std::process::id()))
3776}
3777
3778fn same_path(left: &Path, right: &Path) -> bool {
3779    if left == right {
3780        return true;
3781    }
3782    match (resolved_path(left), resolved_path(right)) {
3783        (Some(left), Some(right)) => left == right,
3784        _ => normalized_path(left) == normalized_path(right),
3785    }
3786}
3787
3788fn resolved_path(path: &Path) -> Option<PathBuf> {
3789    let mut candidate = path.to_path_buf();
3790    let mut suffix = Vec::new();
3791    loop {
3792        match fs::canonicalize(&candidate) {
3793            Ok(mut resolved) => {
3794                for component in suffix.iter().rev() {
3795                    resolved.push(component);
3796                }
3797                return Some(resolved);
3798            }
3799            Err(error) if error.kind() == io::ErrorKind::NotFound => {
3800                let name = candidate.file_name()?.to_os_string();
3801                suffix.push(name);
3802                if !candidate.pop() {
3803                    return None;
3804                }
3805            }
3806            Err(_) => return None,
3807        }
3808    }
3809}
3810
3811fn normalized_path(path: &Path) -> PathBuf {
3812    let absolute = if path.is_absolute() {
3813        path.to_path_buf()
3814    } else {
3815        std::env::current_dir()
3816            .map(|directory| directory.join(path))
3817            .unwrap_or_else(|_| path.to_path_buf())
3818    };
3819    let mut components = Vec::new();
3820    for component in absolute.components() {
3821        match component {
3822            Component::CurDir => {}
3823            Component::ParentDir => {
3824                if matches!(components.last(), Some(Component::Normal(_))) {
3825                    components.pop();
3826                }
3827            }
3828            _ => components.push(component),
3829        }
3830    }
3831    components
3832        .into_iter()
3833        .fold(PathBuf::new(), |mut path, component| {
3834            path.push(component.as_os_str());
3835            path
3836        })
3837}
3838
3839#[cfg(test)]
3840mod tests {
3841    use super::*;
3842
3843    #[test]
3844    fn infers_numeric_and_text_columns() {
3845        let rows = vec![
3846            vec![ImportedCell::Integer(1), ImportedCell::Text("a".into())],
3847            vec![ImportedCell::Real(2.5), ImportedCell::Empty],
3848        ];
3849        assert_eq!(
3850            infer_types(&rows, 2),
3851            vec![ColumnType::Real, ColumnType::Text]
3852        );
3853    }
3854
3855    #[test]
3856    fn quotes_identifiers_and_literals() {
3857        assert_eq!(quote_identifier("a\"b"), "\"a\"\"b\"");
3858        assert_eq!(sql_literal(&Value::Text("a'b".into())), "'a''b'");
3859    }
3860
3861    #[test]
3862    fn manifest_is_stable() {
3863        let manifest = WorkspaceManifest {
3864            format_version: FORMAT_VERSION,
3865            database: DATABASE_FILE.to_string(),
3866        };
3867        assert_eq!(
3868            String::from_utf8(manifest_bytes(&manifest).unwrap()).unwrap(),
3869            "{\n  \"format_version\": 1,\n  \"database\": \"data.basalt\"\n}\n"
3870        );
3871    }
3872
3873    #[test]
3874    fn open_or_init_does_not_replace_existing_directories() {
3875        let root = std::env::temp_dir().join(format!(
3876            "basalt-workspace-open-or-init-test-{}-{}",
3877            std::process::id(),
3878            SystemTime::now()
3879                .duration_since(UNIX_EPOCH)
3880                .unwrap()
3881                .as_nanos()
3882        ));
3883        fs::create_dir_all(&root).unwrap();
3884        let marker = root.join("keep.txt");
3885        fs::write(&marker, b"keep").unwrap();
3886
3887        let error = Workspace::open_or_init(&root).unwrap_err();
3888        assert!(error.to_string().contains("not a Basalt workspace"));
3889        assert_eq!(fs::read(&marker).unwrap(), b"keep");
3890        fs::remove_dir_all(root).unwrap();
3891    }
3892
3893    #[test]
3894    fn rejects_a_workspace_with_a_missing_database() {
3895        let root = std::env::temp_dir().join(format!(
3896            "basalt-workspace-missing-database-test-{}-{}",
3897            std::process::id(),
3898            SystemTime::now()
3899                .duration_since(UNIX_EPOCH)
3900                .unwrap()
3901                .as_nanos()
3902        ));
3903        let workspace = Workspace::init(&root).unwrap();
3904        drop(workspace);
3905        fs::remove_file(root.join(DATABASE_FILE)).unwrap();
3906
3907        let error = Workspace::open(&root).unwrap_err();
3908        assert!(error.to_string().contains("workspace database is missing"));
3909        assert!(!root.join(DATABASE_FILE).exists());
3910        fs::remove_dir_all(root).unwrap();
3911    }
3912
3913    #[test]
3914    fn resolves_path_aliases() {
3915        let root = std::env::temp_dir().join(format!(
3916            "basalt-same-path-test-{}-{}",
3917            std::process::id(),
3918            SystemTime::now()
3919                .duration_since(UNIX_EPOCH)
3920                .unwrap()
3921                .as_nanos()
3922        ));
3923        fs::create_dir_all(root.join("workspace")).unwrap();
3924        let direct = root.join("workspace/data.basalt");
3925        let alias = root.join("workspace/../workspace/data.basalt");
3926        fs::write(&direct, b"database").unwrap();
3927        assert!(same_path(&alias, &direct));
3928        fs::remove_dir_all(root).unwrap();
3929    }
3930
3931    #[test]
3932    fn json_values_keep_nested_data_as_text() {
3933        assert!(matches!(
3934            json_cell(&serde_json::json!({"nested": true})),
3935            ImportedCell::Text(value) if value == "{\"nested\":true}"
3936        ));
3937    }
3938
3939    #[test]
3940    fn mcp_import_limits_cover_rows_columns_and_cells() {
3941        let limits = ImportLimits::mcp();
3942
3943        let rows = enforce_import_limits(MAX_MCP_IMPORT_ROWS + 1, 1, limits).unwrap_err();
3944        assert!(rows.to_string().contains("limited to 10000 rows"));
3945
3946        let columns = enforce_import_limits(1, MAX_MCP_IMPORT_COLUMNS + 1, limits).unwrap_err();
3947        assert!(columns.to_string().contains("limited to 256 columns"));
3948
3949        let cells = enforce_import_limits(10_000, 101, limits).unwrap_err();
3950        assert!(cells.to_string().contains("limited to 1000000 cells"));
3951    }
3952
3953    #[test]
3954    fn mcp_mutation_row_limit_is_cumulative() {
3955        let mut total = 0;
3956        enforce_mutation_row_limit(
3957            &mut total,
3958            &StatementResult::Update {
3959                rows_affected: 6_000,
3960            },
3961            Some(MAX_MCP_MUTATION_ROWS),
3962        )
3963        .unwrap();
3964        let error = enforce_mutation_row_limit(
3965            &mut total,
3966            &StatementResult::Delete {
3967                rows_affected: 4_001,
3968            },
3969            Some(MAX_MCP_MUTATION_ROWS),
3970        )
3971        .unwrap_err();
3972        assert!(error.to_string().contains("limited to 10000 affected rows"));
3973    }
3974
3975    #[test]
3976    fn row_delta_counts_ignore_order_and_preserve_duplicates() {
3977        let before = vec![
3978            vec![Value::Integer(1), Value::Text("Ada".into())],
3979            vec![Value::Integer(2), Value::Text("Grace".into())],
3980            vec![Value::Integer(2), Value::Text("Grace".into())],
3981        ];
3982        let after = vec![
3983            vec![Value::Integer(2), Value::Text("Grace".into())],
3984            vec![Value::Integer(1), Value::Text("Augusta".into())],
3985        ];
3986
3987        assert_eq!(row_delta_counts(&before, &after), (1, 2));
3988        assert_eq!(row_delta_counts(&before, &before), (0, 0));
3989    }
3990
3991    #[test]
3992    fn mcp_rejects_an_over_limit_mutation_without_persisting_or_committing_it() {
3993        let root = std::env::temp_dir().join(format!(
3994            "basalt-workspace-mutation-limit-test-{}-{}",
3995            std::process::id(),
3996            SystemTime::now()
3997                .duration_since(UNIX_EPOCH)
3998                .unwrap()
3999                .as_nanos()
4000        ));
4001        let workspace = Workspace::init(&root).unwrap();
4002        let database = workspace.database().unwrap();
4003        database
4004            .execute_sql("CREATE TABLE events (id INTEGER)")
4005            .unwrap();
4006        let values = (1..=MAX_MCP_MUTATION_ROWS + 1)
4007            .map(|id| format!("({id})"))
4008            .collect::<Vec<_>>()
4009            .join(", ");
4010        database
4011            .execute_sql(&format!("INSERT INTO events VALUES {values}"))
4012            .unwrap();
4013        database.checkpoint().unwrap();
4014        drop(database);
4015
4016        let error = mcp_preview(&workspace, "DELETE FROM events", 1_048_576).unwrap_err();
4017        assert!(error.to_string().contains("limited to 10000 affected rows"));
4018        assert!(!root.join(HISTORY_DIR).exists());
4019
4020        let plan = preview_plan(&workspace, "DELETE FROM events").unwrap();
4021        let error = mcp_apply(&workspace, &plan.plan_id).unwrap_err();
4022        assert!(error.to_string().contains("limited to 10000 affected rows"));
4023        let changes = load_changes(&workspace).unwrap();
4024        assert_eq!(changes.len(), 1);
4025        assert_eq!(changes[0].status, ChangeStatus::Failed);
4026
4027        let database = workspace.database().unwrap();
4028        assert_eq!(
4029            database.row_count("events").unwrap(),
4030            MAX_MCP_MUTATION_ROWS + 1
4031        );
4032        drop(database);
4033        drop(workspace);
4034        fs::remove_dir_all(root).unwrap();
4035    }
4036
4037    #[test]
4038    fn rejects_an_oversized_mcp_preview_before_persisting_its_plan() {
4039        let root = std::env::temp_dir().join(format!(
4040            "basalt-workspace-preview-limit-test-{}-{}",
4041            std::process::id(),
4042            SystemTime::now()
4043                .duration_since(UNIX_EPOCH)
4044                .unwrap()
4045                .as_nanos()
4046        ));
4047        let workspace = Workspace::init(&root).unwrap();
4048        let error = mcp_preview(&workspace, "CREATE TABLE users (id INTEGER)", 1).unwrap_err();
4049        assert!(error.to_string().contains("response limit is 1 bytes"));
4050        assert!(!root.join(HISTORY_DIR).exists());
4051        drop(workspace);
4052        fs::remove_dir_all(root).unwrap();
4053    }
4054
4055    #[test]
4056    fn retries_a_failed_mcp_import_when_the_base_state_is_unchanged() {
4057        let root = std::env::temp_dir().join(format!(
4058            "basalt-workspace-retry-test-{}-{}",
4059            std::process::id(),
4060            SystemTime::now()
4061                .duration_since(UNIX_EPOCH)
4062                .unwrap()
4063                .as_nanos()
4064        ));
4065        let workspace = Workspace::init(&root).unwrap();
4066        let content = "id,name\n1,Ada\n";
4067        let format = DataFormat::Csv;
4068        let table = "users";
4069        let database = workspace.database().unwrap();
4070        database.checkpoint().unwrap();
4071        let base_state = state_fingerprint(&workspace.database_path()).unwrap();
4072        let base_generation = database.generation();
4073        drop(database);
4074
4075        ensure_history_dirs(&workspace).unwrap();
4076        let change_id = import_change_id(base_state.as_str(), format, table, content.as_bytes());
4077        copy_atomic(
4078            &workspace.database_path(),
4079            &snapshot_path(&workspace, &change_id),
4080        )
4081        .unwrap();
4082        let request_key = import_request_key(format, table, content.as_bytes());
4083        let summary =
4084            validate_import(format, table, content.as_bytes(), ImportLimits::mcp()).unwrap();
4085        let failed = ChangeRecord {
4086            format_version: FORMAT_VERSION,
4087            sequence: 1,
4088            change_id: change_id.clone(),
4089            kind: ChangeKind::Apply,
4090            plan_id: None,
4091            target_change_id: None,
4092            base_generation,
4093            base_state: base_state.clone(),
4094            expected_state: None,
4095            snapshot_id: change_id.clone(),
4096            sql: None,
4097            status: ChangeStatus::Failed,
4098            committed_generation: None,
4099            after_state: None,
4100            error: Some("transient test failure".to_string()),
4101            import: Some(ImportMetadata {
4102                request_key,
4103                format: format.name().to_string(),
4104                table: Some(table.to_string()),
4105                bytes: content.len(),
4106                summary,
4107            }),
4108        };
4109        write_new_json(&change_path(&workspace, &change_id), &failed).unwrap();
4110
4111        let report = mcp_import(&workspace, Some(table), format.name(), content).unwrap();
4112        assert_eq!(report.change_id, change_id);
4113        assert_eq!(report.summary, "table users (1 rows, 2 columns)");
4114        let changes = load_changes(&workspace).unwrap();
4115        assert_eq!(changes[0].status, ChangeStatus::Committed);
4116        assert_eq!(changes[0].sequence, 1);
4117
4118        let database = workspace.database().unwrap();
4119        let rows = database.execute_sql("SELECT id, name FROM users").unwrap();
4120        assert!(matches!(
4121            &rows[0],
4122            StatementResult::Select { rows, .. } if rows.len() == 1
4123        ));
4124        drop(database);
4125        drop(workspace);
4126        fs::remove_dir_all(root).unwrap();
4127    }
4128}