Iteration 5: export / import commands

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

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

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

Test baseline: 370 passing, 0 failing, 0 skipped.
This commit is contained in:
claude@clouddev1
2026-05-08 08:24:45 +00:00
parent ca71184678
commit c6cf3df6dc
11 changed files with 1419 additions and 15 deletions
+357
View File
@@ -0,0 +1,357 @@
//! Iteration-5 integration tests: `export` / `import`
//! (ADR-0015 §11 + ADR-0007 amendment 1).
//!
//! Command parsing is exercised at the App layer (synthetic
//! events). Filesystem-level export and import semantics are
//! tested against the public `archive` helpers without booting
//! a Tokio loop.
use std::fs;
use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip,
next_export_sequence, resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn make_demo_project(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
fs::create_dir_all(&p).unwrap();
fs::write(
p.join(PROJECT_YAML),
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n",
)
.unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|seed\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 16]).unwrap();
p
}
// --- Command-parsing tests -------------------------------------
#[test]
fn export_with_no_arg_emits_default_action() {
let mut app = App::new();
type_str(&mut app, "export");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, source } => {
assert!(target.is_none());
assert_eq!(source, "export");
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_path_argument_passes_through_target() {
let mut app = App::new();
type_str(&mut app, "export backups/MyExport.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => {
assert_eq!(target.as_deref(), Some("backups/MyExport.zip"));
}
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn export_with_only_whitespace_after_keyword_errors() {
let mut app = App::new();
type_str(&mut app, "export ");
let actions = submit(&mut app);
// Trailing whitespace is trimmed by submit() before
// dispatch, so "export " trims to "export" and emits
// the default Export action — exactly the same outcome
// as a bare `export`. That is the desired behaviour.
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Export { target, .. } => assert!(target.is_none()),
other => panic!("expected Export, got {other:?}"),
}
}
#[test]
fn import_without_arg_emits_error() {
let mut app = App::new();
type_str(&mut app, "import");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert!(last.text.contains("usage: import"), "got: {}", last.text);
}
#[test]
fn import_with_zip_path_emits_action_without_target() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_zip_and_as_target_emits_both() {
let mut app = App::new();
type_str(&mut app, "import some/file.zip as MyImported");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "some/file.zip");
assert_eq!(as_target.as_deref(), Some("MyImported"));
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_grammar_only_splits_on_space_around_as() {
// A zip path that *contains* the substring "as" without
// surrounding spaces must NOT be split — the separator
// is " as " (space-as-space) only.
let mut app = App::new();
type_str(&mut app, "import path/asfile.zip");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Import {
zip_path,
as_target,
..
} => {
assert_eq!(zip_path, "path/asfile.zip");
assert!(as_target.is_none());
}
other => panic!("expected Import, got {other:?}"),
}
}
#[test]
fn import_with_empty_target_after_as_errors() {
let mut app = App::new();
type_str(&mut app, "import foo.zip as ");
let actions = submit(&mut app);
// "as " trailing whitespace is trimmed by .split_once + .trim,
// making the as-target empty. We surface this as a usage
// error rather than silently importing without a target.
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert!(
last.text.contains("import") && last.text.contains("target"),
"got: {}",
last.text,
);
}
#[test]
fn help_lists_export_and_import() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(body.contains("export"), "help missing export: {body}");
assert!(body.contains("import"), "help missing import: {body}");
}
// --- Filesystem-level export/import semantics ------------------
#[test]
fn full_round_trip_export_then_extract() {
let tmp = tempdir();
let project = make_demo_project(tmp.path(), "MyDemo");
let zip = tmp.path().join("MyDemo-export-01.zip");
export_project(&project, "MyDemo", &zip).unwrap();
let inspect = inspect_zip(&zip).unwrap();
assert_eq!(inspect.top_folder, "MyDemo");
let target = tmp.path().join("imported");
extract_into(&zip, &target, &inspect.top_folder).unwrap();
assert!(target.join(PROJECT_YAML).exists());
assert!(target.join("data").join("Customers.csv").exists());
// history.log and playground.db were excluded from the zip,
// so neither lands in the imported project.
assert!(!target.join(HISTORY_LOG).exists());
assert!(!target.join(PLAYGROUND_DB).exists());
}
#[test]
fn next_export_sequence_increments_per_existing_file() {
let tmp = tempdir();
let date = rdbms_playground::project::naming::today_local();
let (n1_name, n1) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n1, 1);
fs::write(tmp.path().join(&n1_name), "").unwrap();
let (n2_name, n2) = next_export_sequence(tmp.path(), "Demo").unwrap();
assert_eq!(n2, 2);
assert_eq!(n2_name, default_export_filename(&date, "Demo", 2));
}
#[test]
fn resolve_import_target_auto_suffixes_on_collision() {
let tmp = tempdir();
fs::create_dir(tmp.path().join("Imported")).unwrap();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Imported").unwrap();
assert_eq!(resolved, tmp.path().join("Imported-02"));
assert_eq!(suffix, 2);
}
#[test]
fn resolve_import_target_uses_direct_name_when_free() {
let tmp = tempdir();
let (resolved, suffix) = resolve_import_target(tmp.path(), "Fresh").unwrap();
assert_eq!(resolved, tmp.path().join("Fresh"));
assert_eq!(suffix, 0);
}
// --- End-to-end: real Project → export → import → rebuild ----
#[test]
fn end_to_end_export_then_import_real_project() {
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
let data = tempdir();
// Build a populated source project.
let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence(
p.db_path(),
Persistence::new(p.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
ColumnSpec { name: "Name".to_string(), ty: Type::Text },
],
vec!["id".to_string()],
Some("create table Customers with pk id:serial".to_string()),
)
.await
.unwrap();
db.insert(
"Customers".to_string(),
None,
// Serial id auto-fills, so the values list
// covers the non-serial columns only.
vec![Value::Text("Alice".to_string())],
Some("insert into Customers values ('Alice')".to_string()),
)
.await
.unwrap();
});
let path = p.path().to_path_buf();
drop(db);
drop(p);
path
};
// Export.
let zip_path = data.path().join("Source-export.zip");
export_project(&src_path, "Source", &zip_path).unwrap();
assert!(zip_path.exists());
// Inspect: top folder is the project name we exported with.
let inspect = inspect_zip(&zip_path).unwrap();
assert_eq!(inspect.top_folder, "Source");
// Import to a fresh location and rebuild from text.
let dst = data.path().join("Imported");
extract_into(&zip_path, &dst, &inspect.top_folder).unwrap();
assert!(dst.join(PROJECT_YAML).exists());
// playground.db is excluded from the export, so the
// imported project starts without one — exactly the
// scenario rebuild_from_text is designed for.
assert!(!dst.join(PLAYGROUND_DB).exists());
let imported = project::Project::open(&dst).unwrap();
let imported_db = Database::open_with_persistence(
imported.db_path(),
Persistence::new(imported.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
imported_db
.rebuild_from_text(imported.path().to_path_buf(), None)
.await
.expect("rebuild");
});
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None).await })
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
let cells: Vec<Option<&str>> = data_view.rows[0].iter().map(|c| c.as_deref()).collect();
assert_eq!(cells, vec![Some("1"), Some("Alice")]);
}