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
+189 -3
View File
@@ -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<String>,
},
}
/// 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<std::path::PathBuf> = 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 <target>`:** use the zip's top-level folder name
/// under `<data-root>/projects/`. Auto-suffix `-NN` on
/// collision (ADR-0015 §11 amendment).
/// - **`as <relative-name>`:** under `<data-root>/projects/`,
/// auto-suffix on collision.
/// - **`as <absolute-path>`:** 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<std::path::PathBuf, String> {
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
/// `<project_path>` 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<String>,
source: String,
event_tx: mpsc::Sender<AppEvent>,
) {
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<std::path::PathBuf, String> {
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