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
+32 -9
View File
@@ -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
`<projectname>/project.yaml` etc. rather than scattering
files into the recipient's CWD). Default output is the
active data root with the filename pattern
`YYYYMMDD-<projectname>-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 <path>` overrides
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
project directory and excludes: