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
+76
View File
@@ -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 [<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") => {
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 <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.
///
/// `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 [<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):",
" create table <T> with pk [<col>:<type>...]",
" drop table <T>",