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:
Generated
+94
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user