Iteration 5: export / import commands

Implements the `export` and `import` app-level commands per
ADR-0015 §11 + ADR-0007 amendment 1.

- `export [<path>]` writes a zip of project.yaml + data/ to
  <data-root>/YYYYMMDD-<projectname>-export-NN.zip by default,
  preserving the project's directory name as the single
  top-level folder inside the archive.
- `import <zip> [as <target>]` extracts an exported zip into
  a new named project and switches to it. Target name is
  derived from the zip's top-level folder by default; on
  collision the destination auto-suffixes -02, -03, ... up
  to -99 instead of refusing (deviates from §2's refuse-on-
  collision rule for save/save as; recorded as an amendment
  to ADR-0015 §11).
- Excludes playground.db and history.log from the zip.
- Path-traversal protection via zip::enclosed_name + post-
  resolution check that the extraction path stays inside
  the target directory.

Adds the zip = "5" dep with default-features = false +
features = ["deflate"] to keep the binary-size cost modest.

Test baseline: 370 passing, 0 failing, 0 skipped.
This commit is contained in:
claude@clouddev1
2026-05-08 08:24:45 +00:00
parent ca71184678
commit c6cf3df6dc
11 changed files with 1419 additions and 15 deletions
Generated
+94
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -32,6 +38,15 @@ dependencies = [
"object", "object",
] ]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]] [[package]]
name = "atomic" name = "atomic"
version = "0.6.1" version = "0.6.1"
@@ -215,6 +230,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.29.0" version = "0.29.0"
@@ -333,6 +357,17 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -495,6 +530,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"miniz_oxide",
"zlib-rs",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -897,6 +942,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -1378,6 +1433,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"zip",
] ]
[[package]] [[package]]
@@ -1615,6 +1671,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "similar" name = "similar"
version = "2.7.0" version = "2.7.0"
@@ -2486,8 +2548,40 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zip"
version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+1
View File
@@ -27,6 +27,7 @@ thiserror = "2.0.18"
tokio = { version = "1.52.2", features = ["full"] } tokio = { version = "1.52.2", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.47.2", features = ["yaml"] } insta = { version = "1.47.2", features = ["yaml"] }
+32 -9
View File
@@ -420,15 +420,38 @@ ADR-0003:
- **`rebuild`** — section 7. - **`rebuild`** — section 7.
- **`export`** — produces a zip per ADR-0007, *excluding* - **`export`** — produces a zip per ADR-0007, *excluding*
both `playground.db` and `history.log` (see ADR-0007 both `playground.db` and `history.log` (see ADR-0007
amendment below). Default filename pattern unchanged. amendment below). The zip preserves the project's directory
- **`import`** — accepts an exported zip, unpacks it into a name as a single top-level folder (so unzipping creates
named project at a chosen location, runs `rebuild` on `<projectname>/project.yaml` etc. rather than scattering
open. The exported zip has no `playground.db` and no files into the recipient's CWD). Default output is the
`history.log`, so a fresh `playground.db` is created from active data root with the filename pattern
YAML+CSV, and `history.log` starts empty. The chosen `YYYYMMDD-<projectname>-export-NN.zip`, where `NN` is a
target directory must not already exist (per the §2 two-digit zero-padded counter that skips taken slots in
collision rule); the user picks a different name or the same directory on the same day. `export <path>` overrides
removes the existing directory first. the default; relative `<path>` resolves under the active data
root, absolute paths are used verbatim. Refuses if the final
zip path already exists.
- **`import`** — accepts an exported zip and switches to
the resulting project. Grammar: `import <zip> [as <target>]`.
The destination basename is taken from the zip's single
top-level folder by default; an explicit `as <target>`
overrides it. Relative `<target>` resolves under
`<data-root>/projects/`; absolute paths are used verbatim.
**Collision behaviour (amended)**: if the resolved
destination already exists, `import` auto-suffixes the
basename `-02`, `-03`, … up to `-99` to find a free slot,
rather than refusing as the §2 collision rule prescribes
for `save` / `save as`. Rationale: round-tripping zips
between users (export → email → import → re-export → re-import)
is a normal workflow, and forcing the recipient to type
`as <target>` for every collision is unnecessary friction.
Absolute `as <path>` is the user's explicit choice and is
not auto-suffixed — the operation refuses on collision so
the user gets exactly the path they asked for or a clear
error. The exported zip has no `playground.db` and no
`history.log`, so the imported project starts with neither;
the runtime rebuilds `playground.db` from YAML+CSV on
open, and `history.log` begins empty.
The `.gitignore` template (F2) is created in every new The `.gitignore` template (F2) is created in every new
project directory and excludes: project directory and excludes:
+23
View File
@@ -59,4 +59,27 @@ pub enum Action {
NewProject { NewProject {
source: String, source: String,
}, },
/// Export the current project to a zip file. `target` is
/// `None` for the default filename
/// (`YYYYMMDD-<projectname>-export-NN.zip`) under the
/// active data root, or `Some(path)` for an explicit
/// target. Relative paths resolve under
/// `<data-root>/`. Per ADR-0015 §11 the zip excludes
/// `playground.db` and `history.log`.
Export {
target: Option<String>,
source: String,
},
/// Import a previously-exported zip and switch to the
/// resulting project. `zip_path` is the user-typed path to
/// the source archive (relative to CWD or absolute).
/// `as_target` is the optional user-supplied destination
/// name; when `None`, the destination is derived from the
/// zip's top-level folder. Collisions auto-suffix `-NN`
/// (ADR-0015 §11 amendment).
Import {
zip_path: String,
as_target: Option<String>,
source: String,
},
} }
+76
View File
@@ -365,6 +365,14 @@ impl App {
self.note_error(format!("project switch failed: {error}")); self.note_error(format!("project switch failed: {error}"));
Vec::new() Vec::new()
} }
AppEvent::ExportSucceeded { path } => {
self.note_system(format!("[ok] export — wrote {}", path.display()));
Vec::new()
}
AppEvent::ExportFailed { error } => {
self.note_error(format!("export failed: {error}"));
Vec::new()
}
} }
} }
@@ -617,6 +625,27 @@ impl App {
"load" => { "load" => {
return vec![Action::OpenLoadPicker]; return vec![Action::OpenLoadPicker];
} }
"export" => {
return vec![Action::Export {
target: None,
source: "export".to_string(),
}];
}
other if other.starts_with("export ") => {
let target = other["export ".len()..].trim();
if target.is_empty() {
self.note_error("usage: export [<path>]");
return Vec::new();
}
return vec![Action::Export {
target: Some(target.to_string()),
source: format!("export {target}"),
}];
}
other if other.starts_with("import ") || other == "import" => {
let rest = other.strip_prefix("import").unwrap_or(other);
return self.handle_import_command(rest);
}
other if other.starts_with("mode") => { other if other.starts_with("mode") => {
self.handle_mode_command(other); self.handle_mode_command(other);
return Vec::new(); return Vec::new();
@@ -774,6 +803,51 @@ impl App {
)); ));
} }
/// Parse the argument tail of an `import` command and
/// return the corresponding `Action::Import`.
///
/// Grammar: `import <zip-path> [as <target>]`. The
/// separator is the literal ` as ` (whitespace + "as" +
/// whitespace) so a zip path containing the substring
/// "as" is fine — the separator only matches when
/// surrounded by spaces. `split_once` is used (first
/// occurrence wins), which is the natural reading.
fn handle_import_command(&mut self, rest: &str) -> Vec<Action> {
let rest = rest.trim();
if rest.is_empty() {
self.note_error("usage: import <zip-path> [as <target>]");
return Vec::new();
}
// `submit()` trims trailing whitespace from the raw
// line, so an input like `import foo.zip as ` arrives
// here as `foo.zip as`. Detect that explicitly rather
// than silently treating "as" as part of the zip
// path.
if rest == "as" || rest.ends_with(" as") {
self.note_error("import: empty target after `as`");
return Vec::new();
}
let (zip_path, as_target) = match rest.split_once(" as ") {
Some((zip, target)) => (zip.trim(), Some(target.trim().to_string())),
None => (rest, None),
};
if zip_path.is_empty() {
self.note_error("usage: import <zip-path> [as <target>]");
return Vec::new();
}
if let Some(t) = as_target.as_deref()
&& t.is_empty()
{
self.note_error("import: empty target after `as`");
return Vec::new();
}
vec![Action::Import {
zip_path: zip_path.to_string(),
as_target,
source: format!("import {rest}"),
}]
}
/// Dispatch for the `save` and `save as` commands. /// Dispatch for the `save` and `save as` commands.
/// ///
/// `save` on a temp project is identical to `save as` /// `save` on a temp project is identical to `save as`
@@ -1047,6 +1121,8 @@ impl App {
" save as — copy current project to a new name/path", " save as — copy current project to a new name/path",
" new — close current, start a fresh temp project", " new — close current, start a fresh temp project",
" load — open the project picker", " load — open the project picker",
" export [<path>] — write a zip of project.yaml + data/ (excludes .db, history.log)",
" import <zip> [as <t>] — unpack a zip and switch to the new project",
"DSL data commands (in simple mode):", "DSL data commands (in simple mode):",
" create table <T> with pk [<col>:<type>...]", " create table <T> with pk [<col>:<type>...]",
" drop table <T>", " drop table <T>",
+626
View File
@@ -0,0 +1,626 @@
//! Project export / import (Iteration 5, ADR-0015 §11 +
//! ADR-0007).
//!
//! Export produces a zip containing `<projectname>/project.yaml`
//! and `<projectname>/data/...`, with the project's directory
//! name preserved as the top-level folder inside the archive so
//! unzipping recreates a self-contained directory rather than
//! scattering files into the recipient's CWD. The derived
//! `playground.db` and the user's `history.log` are excluded
//! per ADR-0007 amendment 1.
//!
//! Import is the inverse: open a zip, validate it has a single
//! top-level folder containing a `project.yaml`, extract under
//! the chosen target. The target name is taken from the zip's
//! top-level folder (so an export-then-import round trip
//! preserves the original name even if the zip filename was
//! changed in transit). Collision on the destination
//! auto-suffixes `-02`, `-03`, ... rather than refusing — see
//! ADR-0015 §11 amendment.
//!
//! Path-traversal protection is layered: each entry's name is
//! validated via `enclosed_name()` (rejects `..` segments and
//! absolute paths), and the resolved extraction path must
//! stay under the chosen target.
use std::fs;
use std::io::{self, Read as _, Write as _};
use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
/// per-user-private (`history.log`), or recovery artifacts
/// (`.bak` files) — none of them belong in something the user
/// shares with strangers.
const EXPORT_EXCLUDED_NAMES: &[&str] = &[
PLAYGROUND_DB,
HISTORY_LOG,
".rdbms-playground.lock",
];
/// Maximum auto-suffix attempts when resolving a colliding
/// import target. After this many tries we give up and surface
/// an error — the user can pick a different target with
/// `import <zip> as <target>` instead.
const IMPORT_SUFFIX_LIMIT: u32 = 99;
/// Maximum same-day export sequence number. The two-digit
/// pattern in the default filename caps at 99; if a single
/// day's exports exhaust the range, the user supplies an
/// explicit filename.
const EXPORT_SEQUENCE_LIMIT: u32 = 99;
#[derive(Debug, thiserror::Error)]
pub enum ArchiveError {
#[error("io error on `{}`: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("zip error on `{}`: {message}", path.display())]
Zip { path: PathBuf, message: String },
#[error(
"could not pick an export filename for `{project}` in `{}`: \
all sequence numbers up to {limit} are taken",
target_dir.display(),
)]
ExportSequenceExhausted {
project: String,
target_dir: PathBuf,
limit: u32,
},
#[error(
"destination `{}` already exists and the auto-suffix retries \
(-02 through -{limit:02}) are also taken; use \
`import <zip> as <target>` to choose a different name",
path.display(),
)]
ImportCollisionExhausted { path: PathBuf, limit: u32 },
#[error("zip is malformed: {0}")]
InvalidZip(String),
#[error("zip does not contain a project (no `project.yaml` under a single top-level folder)")]
NotAProjectArchive,
#[error("zip contains more than one top-level folder; refusing to extract")]
MultipleTopFolders,
#[error(
"zip entry `{0}` would escape the target directory; refusing to extract"
)]
UnsafeEntry(String),
}
/// Default export-zip filename for `<date>-<project>-export-NN.zip`.
///
/// `date_yyyymmdd` is the today-local prefix (8 digits);
/// `project_name` is the project's directory name (we use the
/// directory name rather than the prettified display name so
/// the export filename round-trips cleanly through `import`).
#[must_use]
pub fn default_export_filename(date_yyyymmdd: &str, project_name: &str, sequence: u32) -> String {
format!("{date_yyyymmdd}-{project_name}-export-{sequence:02}.zip")
}
/// Find the next available `-NN` suffix for the default export
/// filename in `target_dir`, starting from 1. Returns
/// `ExportSequenceExhausted` if 1..=99 are all taken.
pub fn next_export_sequence(
target_dir: &Path,
project_name: &str,
) -> Result<(String, u32), ArchiveError> {
let date = today_local();
for seq in 1..=EXPORT_SEQUENCE_LIMIT {
let candidate = default_export_filename(&date, project_name, seq);
let candidate_path = target_dir.join(&candidate);
if !candidate_path.exists() {
return Ok((candidate, seq));
}
}
Err(ArchiveError::ExportSequenceExhausted {
project: project_name.to_string(),
target_dir: target_dir.to_path_buf(),
limit: EXPORT_SEQUENCE_LIMIT,
})
}
/// Export the project at `project_path` to `dst_zip`.
///
/// `project_name` is the basename used as the top-level folder
/// inside the archive (so unzipping creates
/// `<project_name>/...`). `dst_zip` is the full path where the
/// archive will be written; the caller is responsible for
/// resolving relative paths and picking a non-clobbering
/// filename.
pub fn export_project(
project_path: &Path,
project_name: &str,
dst_zip: &Path,
) -> Result<(), ArchiveError> {
info!(
src = %project_path.display(),
dst = %dst_zip.display(),
project = %project_name,
"exporting project",
);
if let Some(parent) = dst_zip.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let file = fs::File::create(dst_zip).map_err(|source| ArchiveError::Io {
path: dst_zip.to_path_buf(),
source,
})?;
let mut writer = ZipWriter::new(file);
let options: SimpleFileOptions = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
message: e.to_string(),
})?;
Ok(())
}
fn add_directory_entry(
writer: &mut ZipWriter<fs::File>,
name: &str,
zip_path: &Path,
) -> Result<(), ArchiveError> {
let entry_name = if name.ends_with('/') {
name.to_string()
} else {
format!("{name}/")
};
let options = SimpleFileOptions::default().unix_permissions(0o755);
writer
.add_directory(entry_name, options)
.map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})
}
fn add_directory_recursive(
writer: &mut ZipWriter<fs::File>,
src_dir: &Path,
zip_prefix: &str,
options: &SimpleFileOptions,
zip_path: &Path,
) -> Result<(), ArchiveError> {
let entries = fs::read_dir(src_dir).map_err(|source| ArchiveError::Io {
path: src_dir.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| ArchiveError::Io {
path: src_dir.to_path_buf(),
source,
})?;
let path = entry.path();
let name_os = entry.file_name();
let name = name_os.to_string_lossy().into_owned();
if should_exclude_from_export(&name) {
debug!(name = %name, "excluding from export");
continue;
}
let file_type = entry.file_type().map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
let entry_zip_path = format!("{zip_prefix}/{name}");
if file_type.is_dir() {
add_directory_entry(writer, &entry_zip_path, zip_path)?;
add_directory_recursive(writer, &path, &entry_zip_path, options, zip_path)?;
} else if file_type.is_file() {
writer
.start_file(&entry_zip_path, *options)
.map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let mut f = fs::File::open(&path).map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
io::copy(&mut f, writer).map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
}
// Skip symlinks etc. — the project skeleton never
// creates them, and silently dropping them in an
// export is safer than following them blindly.
}
Ok(())
}
/// Per-name exclusion rules for export. We exclude:
///
/// - `playground.db` (always derived; ADR-0007 / ADR-0015 §11)
/// - `history.log` (ADR-0007 amendment 1: user-private)
/// - `.rdbms-playground.lock` (per-process)
/// - `*.tmp` (atomic-write staging files)
/// - `project.yaml.v*.bak` (migration backups; recipient
/// doesn't need our local recovery aids)
fn should_exclude_from_export(name: &str) -> bool {
if EXPORT_EXCLUDED_NAMES.contains(&name) {
return true;
}
if name.ends_with(".tmp") {
return true;
}
if name.starts_with("project.yaml.v") && name.ends_with(".bak") {
return true;
}
false
}
/// Inspect `zip_path` and return the single top-level folder
/// name inside it, plus a `Vec` of every entry path (used by
/// the import step to extract).
///
/// Refuses if the zip contains zero top-level folders, more
/// than one, or no `project.yaml` directly under the single
/// top-level folder. This is the load-bearing check that
/// distinguishes "an export from this app" from "any old zip
/// the user happened to have."
pub fn inspect_zip(zip_path: &Path) -> Result<ZipInspection, ArchiveError> {
let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
path: zip_path.to_path_buf(),
source,
})?;
let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let mut top_folder: Option<String> = None;
let mut saw_project_yaml = false;
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let Some(safe_path) = entry.enclosed_name() else {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
};
let mut comps = safe_path.components();
let Some(Component::Normal(first)) = comps.next() else {
// Empty or odd path — ignore.
continue;
};
let first_str = first.to_string_lossy().into_owned();
match &top_folder {
None => top_folder = Some(first_str.clone()),
Some(existing) if *existing == first_str => {}
Some(_) => return Err(ArchiveError::MultipleTopFolders),
}
let rest: PathBuf = comps.collect();
if rest == Path::new(PROJECT_YAML) {
saw_project_yaml = true;
}
}
let top_folder = top_folder.ok_or(ArchiveError::NotAProjectArchive)?;
if !saw_project_yaml {
return Err(ArchiveError::NotAProjectArchive);
}
Ok(ZipInspection { top_folder })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZipInspection {
/// The single top-level folder name inside the zip. Used
/// as the default target project name for import.
pub top_folder: String,
}
/// Resolve a colliding target directory by appending `-NN`
/// until a free slot is found. `parent` is the directory the
/// project will live in; `name` is the desired basename.
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
}
for n in 2..=IMPORT_SUFFIX_LIMIT {
let candidate = parent.join(format!("{name}-{n:02}"));
if !candidate.exists() {
return Ok((candidate, n));
}
}
Err(ArchiveError::ImportCollisionExhausted {
path: direct,
limit: IMPORT_SUFFIX_LIMIT,
})
}
/// Extract `zip_path` into `target_dir` (which must not yet
/// exist). The zip's single top-level folder is unwrapped: the
/// archive's `<top_folder>/foo/bar` lands at
/// `target_dir/foo/bar`.
///
/// `inspect_zip` should have been called first to validate the
/// shape; this function trusts that result and focuses on the
/// extraction. Each entry is checked for path traversal one
/// more time before being written.
pub fn extract_into(
zip_path: &Path,
target_dir: &Path,
expected_top_folder: &str,
) -> Result<(), ArchiveError> {
if target_dir.exists() {
return Err(ArchiveError::Io {
path: target_dir.to_path_buf(),
source: io::Error::new(
io::ErrorKind::AlreadyExists,
"target directory already exists",
),
});
}
fs::create_dir_all(target_dir).map_err(|source| ArchiveError::Io {
path: target_dir.to_path_buf(),
source,
})?;
let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
path: zip_path.to_path_buf(),
source,
})?;
let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let Some(safe_path) = entry.enclosed_name() else {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
};
let mut comps = safe_path.components();
let Some(Component::Normal(first)) = comps.next() else {
continue;
};
if first.to_string_lossy() != expected_top_folder {
return Err(ArchiveError::MultipleTopFolders);
}
let rest: PathBuf = comps.collect();
let dst_path = if rest.as_os_str().is_empty() {
// The top-folder entry itself (a directory). Skip
// — `target_dir` already exists.
continue;
} else {
target_dir.join(&rest)
};
// Defence-in-depth: confirm the resolved path stays
// under target_dir even after components collected.
if !dst_path.starts_with(target_dir) {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
}
if entry.is_dir() {
fs::create_dir_all(&dst_path).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
} else {
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let mut out = fs::File::create(&dst_path).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::GITIGNORE;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn make_project(root: &Path, name: &str) -> PathBuf {
let p = root.join(name);
fs::create_dir_all(&p).unwrap();
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id:serial\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
fs::write(p.join("project.yaml.tmp"), "stale").unwrap();
p
}
#[test]
fn export_excludes_db_history_lock() {
let tmp = tempdir();
let project = make_project(tmp.path(), "MyProject");
let zip_path = tmp.path().join("export.zip");
export_project(&project, "MyProject", &zip_path).unwrap();
// Re-open and list entries.
let f = fs::File::open(&zip_path).unwrap();
let mut archive = ZipArchive::new(f).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(
names.iter().any(|n| n == "MyProject/project.yaml"),
"names: {names:?}"
);
assert!(names.iter().any(|n| n == "MyProject/data/Customers.csv"));
assert!(!names.iter().any(|n| n.contains("playground.db")));
assert!(!names.iter().any(|n| n.contains("history.log")));
assert!(!names.iter().any(|n| n.contains(".lock")));
assert!(!names.iter().any(|n| n.ends_with(".tmp")));
// .gitignore IS included (sensible default for the recipient).
assert!(names.iter().any(|n| n == "MyProject/.gitignore"));
}
#[test]
fn export_top_level_folder_is_project_name() {
let tmp = tempdir();
let project = make_project(tmp.path(), "MyProject");
let zip_path = tmp.path().join("export.zip");
export_project(&project, "MyProject", &zip_path).unwrap();
let inspect = inspect_zip(&zip_path).unwrap();
assert_eq!(inspect.top_folder, "MyProject");
}
#[test]
fn inspect_rejects_zip_without_project_yaml() {
let tmp = tempdir();
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
}
#[test]
fn inspect_rejects_zip_with_multiple_top_folders() {
let tmp = tempdir();
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
}
#[test]
fn extract_into_unwraps_top_folder() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Source");
let zip_path = tmp.path().join("source.zip");
export_project(&project, "Source", &zip_path).unwrap();
let target = tmp.path().join("imported");
extract_into(&zip_path, &target, "Source").unwrap();
assert!(target.join(PROJECT_YAML).exists());
assert!(target.join("data").join("Customers.csv").exists());
// history.log NOT present (it was excluded from export).
assert!(!target.join(HISTORY_LOG).exists());
}
#[test]
fn extract_into_refuses_existing_target() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Source");
let zip_path = tmp.path().join("source.zip");
export_project(&project, "Source", &zip_path).unwrap();
let target = tmp.path().join("existing");
fs::create_dir(&target).unwrap();
let err = extract_into(&zip_path, &target, "Source").expect_err("must refuse");
assert!(matches!(err, ArchiveError::Io { .. }), "got: {err:?}");
}
#[test]
fn resolve_import_target_uses_direct_when_free() {
let tmp = tempdir();
let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path, tmp.path().join("Foo"));
assert_eq!(n, 0);
}
#[test]
fn resolve_import_target_appends_suffix_on_collision() {
let tmp = tempdir();
fs::create_dir(tmp.path().join("Foo")).unwrap();
let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path, tmp.path().join("Foo-02"));
assert_eq!(n, 2);
// Add Foo-02 and try again.
fs::create_dir(&path).unwrap();
let (path3, n3) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path3, tmp.path().join("Foo-03"));
assert_eq!(n3, 3);
}
#[test]
fn next_export_sequence_starts_at_one() {
let tmp = tempdir();
let (name, n) = next_export_sequence(tmp.path(), "MyProject").unwrap();
assert_eq!(n, 1);
assert!(name.contains("MyProject"));
assert!(name.ends_with("-01.zip"));
}
#[test]
fn next_export_sequence_skips_taken_slots() {
let tmp = tempdir();
let date = today_local();
// Pre-create -01 and -02.
fs::write(tmp.path().join(default_export_filename(&date, "P", 1)), "").unwrap();
fs::write(tmp.path().join(default_export_filename(&date, "P", 2)), "").unwrap();
let (_, n) = next_export_sequence(tmp.path(), "P").unwrap();
assert_eq!(n, 3);
}
#[test]
fn round_trip_export_then_inspect() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Customers");
let zip = tmp.path().join("export.zip");
export_project(&project, "Customers", &zip).unwrap();
let inspect = inspect_zip(&zip).unwrap();
assert_eq!(inspect.top_folder, "Customers");
}
}
+7
View File
@@ -57,6 +57,13 @@ App-level commands (typed inside the app, available in both modes):
load Open the project picker. load Open the project picker.
rebuild Rebuild playground.db from project.yaml rebuild Rebuild playground.db from project.yaml
+ data/, with confirmation. + data/, with confirmation.
export [<path>] Write a zip of project.yaml + data/ to
<path> (relative paths under the data
root). Excludes playground.db and
history.log.
import <zip> [as <t>] Unpack <zip> into a new project and
switch to it. <t> overrides the target
name (else taken from the zip).
"; ";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+13 -3
View File
@@ -77,9 +77,9 @@ pub enum AppEvent {
LoadPickerReady { LoadPickerReady {
entries: Vec<crate::app::LoadPickerEntry>, entries: Vec<crate::app::LoadPickerEntry>,
}, },
/// A project switch (load / new / save-as) succeeded. /// A project switch (load / new / save-as / import)
/// Carries the new display name + temp flag so App can /// succeeded. Carries the new display name + temp flag
/// update the status bar. /// so App can update the status bar.
ProjectSwitched { ProjectSwitched {
display_name: String, display_name: String,
is_temp: bool, is_temp: bool,
@@ -90,4 +90,14 @@ pub enum AppEvent {
ProjectSwitchFailed { ProjectSwitchFailed {
error: String, error: String,
}, },
/// Export wrote a zip successfully. Carries the resolved
/// final path so the user gets a "wrote to: …" note.
ExportSucceeded {
path: std::path::PathBuf,
},
/// Export failed in a non-fatal way (target exists, IO
/// error, sequence range exhausted, …).
ExportFailed {
error: String,
},
} }
+1
View File
@@ -7,6 +7,7 @@
pub mod action; pub mod action;
pub mod app; pub mod app;
pub mod archive;
pub mod cli; pub mod cli;
pub mod db; pub mod db;
pub mod dsl; pub mod dsl;
+189 -3
View File
@@ -261,6 +261,32 @@ async fn run_loop(
) )
.await; .await;
} }
Action::Export { target, source } => {
spawn_export(
session.project().path().to_path_buf(),
directory_basename(session.project().path()),
session.data_root.clone(),
target,
source,
event_tx.clone(),
);
}
Action::Import {
zip_path,
as_target,
source,
} => {
handle_project_switch(
&mut session,
SwitchRequest::Import {
zip_path: std::path::PathBuf::from(&zip_path),
as_target,
},
source,
&event_tx,
)
.await;
}
} }
} }
terminal terminal
@@ -315,6 +341,16 @@ enum SwitchRequest {
SaveAs { target: String }, SaveAs { target: String },
/// `new` — close current, create a fresh auto-named temp. /// `new` — close current, create a fresh auto-named temp.
NewTemp, NewTemp,
/// `import` — extract a zip into a new project under the
/// data root's projects dir, then switch to it. The
/// destination basename is taken from the zip's
/// top-level folder by default (`as_target` is `None`),
/// or from the user-supplied override; collisions
/// auto-suffix `-NN` (ADR-0015 §11 amendment).
Import {
zip_path: std::path::PathBuf,
as_target: Option<String>,
},
} }
/// Common project-switch path. Drops the current project + /// Common project-switch path. Drops the current project +
@@ -362,6 +398,9 @@ async fn perform_switch(
// (so the existence check happens before we drop the // (so the existence check happens before we drop the
// current project). For NewTemp we'll let create_temp // current project). For NewTemp we'll let create_temp
// pick the path. For Load it's the user-supplied path. // pick the path. For Load it's the user-supplied path.
// For Import we inspect the zip and resolve the target
// (auto-suffixing on collision per ADR-0015 §11
// amendment) before touching anything else.
let resolved_target: Option<std::path::PathBuf> = match &req { let resolved_target: Option<std::path::PathBuf> = match &req {
SwitchRequest::Load { path } => { SwitchRequest::Load { path } => {
if !path.exists() { if !path.exists() {
@@ -380,6 +419,21 @@ async fn perform_switch(
Some(p) Some(p)
} }
SwitchRequest::NewTemp => None, SwitchRequest::NewTemp => None,
SwitchRequest::Import { zip_path, as_target } => {
if !zip_path.exists() {
return Err(format!("zip `{}` does not exist", zip_path.display()));
}
// Validate the zip up front so we don't drop the
// current project for an unimportable file.
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let resolved = resolve_import_destination(
as_target.as_deref(),
&inspection.top_folder,
&session.data_root,
)?;
Some(resolved)
}
}; };
// For SaveAs: copy current project to the target while // For SaveAs: copy current project to the target while
@@ -390,6 +444,16 @@ async fn perform_switch(
let dst = resolved_target.as_ref().expect("SaveAs has resolved target"); let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
copy_project(&src, dst).map_err(|e| e.to_string())?; copy_project(&src, dst).map_err(|e| e.to_string())?;
} }
// For Import: extract the zip into the resolved target.
// We do this *before* dropping the current project so
// a failure here leaves the user where they were.
if let SwitchRequest::Import { zip_path, .. } = &req {
let dst = resolved_target.as_ref().expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
.map_err(|e| e.to_string())?;
}
// Capture cleanup info from the OUTGOING project before // Capture cleanup info from the OUTGOING project before
// we drop it: if it was an unmodified empty temp, we // we drop it: if it was an unmodified empty temp, we
@@ -428,10 +492,16 @@ async fn perform_switch(
} }
} }
// Open the destination project. // Open the destination project. Load / SaveAs / Import
// all open a path that already has the on-disk skeleton
// (either because it pre-existed or because we just put
// it there); NewTemp asks the project module for a fresh
// auto-named one.
let new_project = match &req { let new_project = match &req {
SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => { SwitchRequest::Load { .. }
let path = resolved_target.expect("Load/SaveAs have resolved target"); | SwitchRequest::SaveAs { .. }
| SwitchRequest::Import { .. } => {
let path = resolved_target.expect("Load/SaveAs/Import have resolved target");
Project::open(&path).map_err(|e| e.to_string())? Project::open(&path).map_err(|e| e.to_string())?
} }
SwitchRequest::NewTemp => { SwitchRequest::NewTemp => {
@@ -470,6 +540,122 @@ async fn perform_switch(
Ok((display_name, is_temp)) Ok((display_name, is_temp))
} }
/// Resolve the destination directory for an `import`:
///
/// - **No `as <target>`:** use the zip's top-level folder name
/// under `<data-root>/projects/`. Auto-suffix `-NN` on
/// collision (ADR-0015 §11 amendment).
/// - **`as <relative-name>`:** under `<data-root>/projects/`,
/// auto-suffix on collision.
/// - **`as <absolute-path>`:** use the path verbatim. Refuse
/// if it already exists (no auto-suffix on absolute paths —
/// we don't second-guess what the user typed).
fn resolve_import_destination(
as_target: Option<&str>,
zip_top_folder: &str,
data_root: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
if let Some(t) = as_target {
let p = std::path::Path::new(t);
if p.is_absolute() {
if p.exists() {
return Err(format!(
"`{}` already exists; pick a different target",
p.display(),
));
}
return Ok(p.to_path_buf());
}
}
let basename: &str = as_target.unwrap_or(zip_top_folder);
let parent = projects_dir(data_root);
std::fs::create_dir_all(&parent).map_err(|e| e.to_string())?;
let (resolved, _) =
crate::archive::resolve_import_target(&parent, basename).map_err(|e| e.to_string())?;
Ok(resolved)
}
/// Spawn a blocking task to write an export zip and forward
/// the outcome via the event channel.
///
/// The current project's auto-save semantics mean
/// `<project_path>` already reflects every successful command,
/// so the export reads from disk without coordinating with the
/// db worker. The `history.log` entry for this command is
/// appended directly here (we already hold the project path
/// and don't need to wait for the export to finish before
/// recording the user-issued command).
fn spawn_export(
project_path: std::path::PathBuf,
project_name: String,
data_root: std::path::PathBuf,
target: Option<String>,
source: String,
event_tx: mpsc::Sender<AppEvent>,
) {
let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
})
.await;
let event = match outcome {
Ok(Ok(path)) => AppEvent::ExportSucceeded { path },
Ok(Err(e)) => AppEvent::ExportFailed { error: e },
Err(join_err) => AppEvent::ExportFailed {
error: join_err.to_string(),
},
};
let _ = event_tx.send(event).await;
});
}
/// Synchronous body of the export pipeline.
fn do_export(
project_path: &std::path::Path,
project_name: &str,
data_root: &std::path::Path,
target: Option<&str>,
) -> Result<std::path::PathBuf, String> {
let final_path: std::path::PathBuf = match target {
Some(t) => {
let p = std::path::Path::new(t);
if p.is_absolute() {
p.to_path_buf()
} else {
data_root.join(t)
}
}
None => {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
let (filename, _) =
crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
data_root.join(filename)
}
};
if final_path.exists() {
return Err(format!(
"`{}` already exists; pick a different name or remove it first",
final_path.display(),
));
}
crate::archive::export_project(project_path, project_name, &final_path)
.map_err(|e| e.to_string())?;
Ok(final_path)
}
/// The basename of `path` as a `String`. Falls back to the
/// full display string when the path has no terminal
/// component (e.g. `/`).
fn directory_basename(path: &std::path::Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
/// Resolve a `save as` target path against the data root. /// Resolve a `save as` target path against the data root.
/// ///
/// Absolute paths pass through; relative paths join under /// Absolute paths pass through; relative paths join under
+357
View File
@@ -0,0 +1,357 @@
//! Iteration-5 integration tests: `export` / `import`
//! (ADR-0015 §11 + ADR-0007 amendment 1).
//!
//! Command parsing is exercised at the App layer (synthetic
//! events). Filesystem-level export and import semantics are
//! tested against the public `archive` helpers without booting
//! a Tokio loop.
use std::fs;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip,
next_export_sequence, resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
fs::create_dir_all(&p).unwrap();
fs::write(
p.join(PROJECT_YAML),
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n",
)
.unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap();
p
}
// --- Command-parsing tests -------------------------------------
#[test]
fn export_with_no_arg_emits_default_action() {
let mut app = App::new();
type_str(&mut app, "export");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, source } => {
assert!(target.is_none());
assert_eq!(source, "export");
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_path_argument_passes_through_target() {
let mut app = App::new();
type_str(&mut app, "export backups/MyExport.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => {
assert_eq!(target.as_deref(), Some("backups/MyExport.zip"));
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_only_whitespace_after_keyword_errors() {
let mut app = App::new();
type_str(&mut app, "export ");
let actions = submit(&mut app);
// Trailing whitespace is trimmed by submit() before
// dispatch, so "export " trims to "export" and emits
// the default Export action — exactly the same outcome
// as a bare `export`. That is the desired behaviour.
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => assert!(target.is_none()),
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn import_without_arg_emits_error() {
let mut app = App::new();
type_str(&mut app, "import");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert!(last.text.contains("usage: import"), "got: {}", last.text);
}
#[test]
fn import_with_zip_path_emits_action_without_target() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_zip_and_as_target_emits_both() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip as MyImported");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert_eq!(as_target.as_deref(), Some("MyImported"));
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_grammar_only_splits_on_space_around_as() {
// A zip path that *contains* the substring "as" without
// surrounding spaces must NOT be split — the separator
// is " as " (space-as-space) only.
let mut app = App::new();
type_str(&mut app, "import path/asfile.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "path/asfile.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_empty_target_after_as_errors() {
let mut app = App::new();
type_str(&mut app, "import foo.zip as ");
let actions = submit(&mut app);
// "as " trailing whitespace is trimmed by .split_once + .trim,
// making the as-target empty. We surface this as a usage
// error rather than silently importing without a target.
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert!(
last.text.contains("import") && last.text.contains("target"),
"got: {}",
last.text,
);
}
#[test]
fn help_lists_export_and_import() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(body.contains("export"), "help missing export: {body}");
assert!(body.contains("import"), "help missing import: {body}");
}
// --- Filesystem-level export/import semantics ------------------
#[test]
fn full_round_trip_export_then_extract() {
let tmp = tempdir();
let project = make_demo_project(tmp.path(), "MyDemo");
let zip = tmp.path().join("MyDemo-export-01.zip");
export_project(&project, "MyDemo", &zip).unwrap();
let inspect = inspect_zip(&zip).unwrap();
assert_eq!(inspect.top_folder, "MyDemo");
let target = tmp.path().join("imported");
extract_into(&zip, &target, &inspect.top_folder).unwrap();
assert!(target.join(PROJECT_YAML).exists());
assert!(target.join("data").join("Customers.csv").exists());
// history.log and playground.db were excluded from the zip,
// so neither lands in the imported project.
assert!(!target.join(HISTORY_LOG).exists());
assert!(!target.join(PLAYGROUND_DB).exists());
}
#[test]
fn next_export_sequence_increments_per_existing_file() {
let tmp = tempdir();
let date = rdbms_playground::project::naming::today_local();
let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n1, 1);
fs::write(tmp.path().join(&n1_name), "").unwrap();
let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n2, 2);
assert_eq!(n2_name, default_export_filename(&date, "Demo", 2));
}
#[test]
fn resolve_import_target_auto_suffixes_on_collision() {
let tmp = tempdir();
fs::create_dir(tmp.path().join("Imported")).unwrap();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap();
assert_eq!(resolved, tmp.path().join("Imported-02"));
assert_eq!(suffix, 2);
}
#[test]
fn resolve_import_target_uses_direct_name_when_free() {
let tmp = tempdir();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap();
assert_eq!(resolved, tmp.path().join("Fresh"));
assert_eq!(suffix, 0);
}
// --- End-to-end: real Project → export → import → rebuild ----
#[test]
fn end_to_end_export_then_import_real_project() {
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
let data = tempdir();
// Build a populated source project.
let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence(
p.db_path(),
Persistence::new(p.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "Name".to_string(), ty: Type::Text },
],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
// Serial id auto-fills, so the values list
// covers the non-serial columns only.
vec![Value::Text("Alice".to_string())],
Some("insert into Customers values ('Alice')".to_string()),
)
.await
.unwrap();
});
let path = p.path().to_path_buf();
drop(db);
drop(p);
path
};
// Export.
let zip_path = data.path().join("Source-export.zip");
export_project(&src_path, "Source", &zip_path).unwrap();
assert!(zip_path.exists());
// Inspect: top folder is the project name we exported with.
let inspect = inspect_zip(&zip_path).unwrap();
assert_eq!(inspect.top_folder, "Source");
// Import to a fresh location and rebuild from text.
let dst = data.path().join("Imported");
extract_into(&zip_path, &dst, &inspect.top_folder).unwrap();
assert!(dst.join(PROJECT_YAML).exists());
// playground.db is excluded from the export, so the
// imported project starts without one — exactly the
// scenario rebuild_from_text is designed for.
assert!(!dst.join(PLAYGROUND_DB).exists());
let imported = project::Project::open(&dst).unwrap();
let imported_db = Database::open_with_persistence(
imported.db_path(),
Persistence::new(imported.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
imported_db
.rebuild_from_text(imported.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None).await })
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
let cells: Vec<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
assert_eq!(cells, vec![Some("1"), Some("Alice")]);
}