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:
+189
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user