From c6cf3df6dcb9ec3fe1119f6c666e8da0f98937f4 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 8 May 2026 08:24:45 +0000 Subject: [PATCH] Iteration 5: export / import commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `export` and `import` app-level commands per ADR-0015 §11 + ADR-0007 amendment 1. - `export []` writes a zip of project.yaml + data/ to /YYYYMMDD--export-NN.zip by default, preserving the project's directory name as the single top-level folder inside the archive. - `import [as ]` 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. --- Cargo.lock | 94 ++++ Cargo.toml | 1 + docs/adr/0015-project-storage-runtime.md | 41 +- src/action.rs | 23 + src/app.rs | 76 +++ src/archive.rs | 626 +++++++++++++++++++++++ src/cli.rs | 7 + src/event.rs | 16 +- src/lib.rs | 1 + src/runtime.rs | 192 ++++++- tests/iteration5_export_import.rs | 357 +++++++++++++ 11 files changed, 1419 insertions(+), 15 deletions(-) create mode 100644 src/archive.rs create mode 100644 tests/iteration5_export_import.rs diff --git a/Cargo.lock b/Cargo.lock index ee98f6d..f96a7f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +38,15 @@ dependencies = [ "object", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic" version = "0.6.1" @@ -215,6 +230,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -333,6 +357,17 @@ dependencies = [ "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]] name = "derive_more" version = "2.1.1" @@ -495,6 +530,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fnv" version = "1.0.7" @@ -897,6 +942,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "mio" version = "1.2.0" @@ -1378,6 +1433,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "zip", ] [[package]] @@ -1615,6 +1671,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -2486,8 +2548,40 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/Cargo.toml b/Cargo.toml index ecb5730..126c6af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ thiserror = "2.0.18" tokio = { version = "1.52.2", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +zip = { version = "5.1.1", default-features = false, features = ["deflate"] } [dev-dependencies] insta = { version = "1.47.2", features = ["yaml"] } diff --git a/docs/adr/0015-project-storage-runtime.md b/docs/adr/0015-project-storage-runtime.md index f61347b..48c5877 100644 --- a/docs/adr/0015-project-storage-runtime.md +++ b/docs/adr/0015-project-storage-runtime.md @@ -420,15 +420,38 @@ ADR-0003: - **`rebuild`** — section 7. - **`export`** — produces a zip per ADR-0007, *excluding* both `playground.db` and `history.log` (see ADR-0007 - amendment below). Default filename pattern unchanged. -- **`import`** — accepts an exported zip, unpacks it into a - named project at a chosen location, runs `rebuild` on - open. The exported zip has no `playground.db` and no - `history.log`, so a fresh `playground.db` is created from - YAML+CSV, and `history.log` starts empty. The chosen - target directory must not already exist (per the §2 - collision rule); the user picks a different name or - removes the existing directory first. + amendment below). The zip preserves the project's directory + name as a single top-level folder (so unzipping creates + `/project.yaml` etc. rather than scattering + files into the recipient's CWD). Default output is the + active data root with the filename pattern + `YYYYMMDD--export-NN.zip`, where `NN` is a + two-digit zero-padded counter that skips taken slots in + the same directory on the same day. `export ` overrides + the default; relative `` 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 [as ]`. + The destination basename is taken from the zip's single + top-level folder by default; an explicit `as ` + overrides it. Relative `` resolves under + `/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 ` for every collision is unnecessary friction. + Absolute `as ` 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 project directory and excludes: diff --git a/src/action.rs b/src/action.rs index b282555..a699ff4 100644 --- a/src/action.rs +++ b/src/action.rs @@ -59,4 +59,27 @@ pub enum Action { NewProject { source: String, }, + /// Export the current project to a zip file. `target` is + /// `None` for the default filename + /// (`YYYYMMDD--export-NN.zip`) under the + /// active data root, or `Some(path)` for an explicit + /// target. Relative paths resolve under + /// `/`. Per ADR-0015 §11 the zip excludes + /// `playground.db` and `history.log`. + Export { + target: Option, + 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, + source: String, + }, } diff --git a/src/app.rs b/src/app.rs index cc83b54..a74dad8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -365,6 +365,14 @@ impl App { self.note_error(format!("project switch failed: {error}")); 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" => { 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 []"); + 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") => { self.handle_mode_command(other); 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 [as ]`. 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 { + let rest = rest.trim(); + if rest.is_empty() { + self.note_error("usage: import [as ]"); + 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 [as ]"); + 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. /// /// `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", " new — close current, start a fresh temp project", " load — open the project picker", + " export [] — write a zip of project.yaml + data/ (excludes .db, history.log)", + " import [as ] — unpack a zip and switch to the new project", "DSL data commands (in simple mode):", " create table with pk [:...]", " drop table ", diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..b296ce9 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,626 @@ +//! Project export / import (Iteration 5, ADR-0015 §11 + +//! ADR-0007). +//! +//! Export produces a zip containing `/project.yaml` +//! and `/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 as ` 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 as ` 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 `--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 +/// `/...`). `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, + 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, + 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 { + 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 = 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 `/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 = (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"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 0e668c2..60f680f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -57,6 +57,13 @@ App-level commands (typed inside the app, available in both modes): load Open the project picker. rebuild Rebuild playground.db from project.yaml + data/, with confirmation. + export [] Write a zip of project.yaml + data/ to + (relative paths under the data + root). Excludes playground.db and + history.log. + import [as ] Unpack into a new project and + switch to it. overrides the target + name (else taken from the zip). "; #[derive(Debug, thiserror::Error)] diff --git a/src/event.rs b/src/event.rs index 3c9a0d4..385ef64 100644 --- a/src/event.rs +++ b/src/event.rs @@ -77,9 +77,9 @@ pub enum AppEvent { LoadPickerReady { entries: Vec, }, - /// A project switch (load / new / save-as) succeeded. - /// Carries the new display name + temp flag so App can - /// update the status bar. + /// A project switch (load / new / save-as / import) + /// succeeded. Carries the new display name + temp flag + /// so App can update the status bar. ProjectSwitched { display_name: String, is_temp: bool, @@ -90,4 +90,14 @@ pub enum AppEvent { ProjectSwitchFailed { 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, + }, } diff --git a/src/lib.rs b/src/lib.rs index c196d93..de3fb9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod action; pub mod app; +pub mod archive; pub mod cli; pub mod db; pub mod dsl; diff --git a/src/runtime.rs b/src/runtime.rs index 8d15920..e91acf9 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -261,6 +261,32 @@ async fn run_loop( ) .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 @@ -315,6 +341,16 @@ enum SwitchRequest { SaveAs { target: String }, /// `new` — close current, create a fresh auto-named temp. 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, + }, } /// 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 // current project). For NewTemp we'll let create_temp // 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 = match &req { SwitchRequest::Load { path } => { if !path.exists() { @@ -380,6 +419,21 @@ async fn perform_switch( Some(p) } 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 @@ -390,6 +444,16 @@ async fn perform_switch( let dst = resolved_target.as_ref().expect("SaveAs has resolved target"); 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 // 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 { - SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => { - let path = resolved_target.expect("Load/SaveAs have resolved target"); + SwitchRequest::Load { .. } + | SwitchRequest::SaveAs { .. } + | SwitchRequest::Import { .. } => { + let path = resolved_target.expect("Load/SaveAs/Import have resolved target"); Project::open(&path).map_err(|e| e.to_string())? } SwitchRequest::NewTemp => { @@ -470,6 +540,122 @@ async fn perform_switch( Ok((display_name, is_temp)) } +/// Resolve the destination directory for an `import`: +/// +/// - **No `as `:** use the zip's top-level folder name +/// under `/projects/`. Auto-suffix `-NN` on +/// collision (ADR-0015 §11 amendment). +/// - **`as `:** under `/projects/`, +/// auto-suffix on collision. +/// - **`as `:** 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 { + 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 +/// `` 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, + source: String, + event_tx: mpsc::Sender, +) { + 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 { + 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. /// /// Absolute paths pass through; relative paths join under diff --git a/tests/iteration5_export_import.rs b/tests/iteration5_export_import.rs new file mode 100644 index 0000000..5fbb012 --- /dev/null +++ b/tests/iteration5_export_import.rs @@ -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 { + 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::>() + .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> = data_view.rows[0].iter().map(|c| c.as_deref()).collect(); + assert_eq!(cells, vec![Some("1"), Some("Alice")]); +}