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:
+76
@@ -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>",
|
||||
|
||||
Reference in New Issue
Block a user