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