Files
rdbms-playground/src/archive.rs
T
claude@clouddev1 4cd574b909 feat: persist & restore per-project input mode (#14)
The input mode always started in simple; a learner who quit in advanced
had to re-toggle every launch. Store the mode per-project in project.yaml
(project.mode:, optional, default simple) and restore it on every open.

Mode is live UI state, not schema: the worker stamps the current mode
into project.yaml on every write, so a later command rewrites the live
value rather than clobbering it — no db round-trip needed. The mode is
persisted on unload (quit + project switch) so the mode you leave a
project in is always what reopens; the `mode` command also persists
immediately. A switch saves the outgoing mode, then restores the
incoming project's stored mode.

New --mode simple|advanced CLI flag (precedence --mode > stored >
simple; combines with --resume). A teacher can ship a project that
opens in advanced mode and export it to students (the mode travels in
the zip).

ADR-0015 Amendment 1; ADR-0003 note; help banner; requirements L1b.
2026-06-02 06:47:34 +00:00

723 lines
26 KiB
Rust

//! Project export / import (Iteration 5, ADR-0015 §11 +
//! ADR-0007).
//!
//! Export produces a zip containing `<projectname>/project.yaml`
//! and `<projectname>/data/...`, with the project's directory
//! name preserved as the top-level folder inside the archive so
//! unzipping recreates a self-contained directory rather than
//! scattering files into the recipient's CWD. The derived
//! `playground.db` and the user's `history.log` are excluded
//! per ADR-0007 amendment 1.
//!
//! Import is the inverse: open a zip, validate it has a single
//! top-level folder containing a `project.yaml`, extract under
//! the chosen target. The target name is taken from the zip's
//! top-level folder (so an export-then-import round trip
//! preserves the original name even if the zip filename was
//! changed in transit). Collision on the destination
//! auto-suffixes `-02`, `-03`, ... rather than refusing — see
//! ADR-0015 §11 amendment.
//!
//! Path-traversal protection is layered: each entry's name is
//! validated via `enclosed_name()` (rejects `..` segments and
//! absolute paths), and the resolved extraction path must
//! stay under the chosen target.
use std::fs;
use std::io::{self, Read as _, Write as _};
use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
/// per-user-private (`history.log`), or recovery artifacts
/// (`.bak` files) — none of them belong in something the user
/// shares with strangers.
const EXPORT_EXCLUDED_NAMES: &[&str] = &[
PLAYGROUND_DB,
HISTORY_LOG,
".rdbms-playground.lock",
// Undo snapshot ring (ADR-0006 Amendment 1): local working
// state, never shared.
crate::undo::SNAPSHOTS_DIR,
];
/// Maximum auto-suffix attempts when resolving a colliding
/// import target. After this many tries we give up and surface
/// an error — the user can pick a different target with
/// `import <zip> as <target>` instead.
const IMPORT_SUFFIX_LIMIT: u32 = 99;
/// Maximum same-day export sequence number. The two-digit
/// pattern in the default filename caps at 99; if a single
/// day's exports exhaust the range, the user supplies an
/// explicit filename.
const EXPORT_SEQUENCE_LIMIT: u32 = 99;
#[derive(Debug)]
pub enum ArchiveError {
Io {
path: PathBuf,
source: io::Error,
},
Zip {
path: PathBuf,
message: String,
},
ExportSequenceExhausted {
project: String,
target_dir: PathBuf,
limit: u32,
},
ImportCollisionExhausted {
path: PathBuf,
limit: u32,
},
InvalidZip(String),
NotAProjectArchive,
MultipleTopFolders,
UnsafeEntry(String),
}
impl std::fmt::Display for ArchiveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => f.write_str(&crate::t!(
"archive.io",
path = path.display(),
source = source,
)),
Self::Zip { path, message } => f.write_str(&crate::t!(
"archive.zip",
path = path.display(),
message = message,
)),
Self::ExportSequenceExhausted {
project,
target_dir,
limit,
} => f.write_str(&crate::t!(
"archive.export_sequence_exhausted",
project = project,
target_dir = target_dir.display(),
limit = limit,
)),
Self::ImportCollisionExhausted { path, limit } => {
// {limit:02} is a format specifier — the catalog
// helper rejects those (ADR-0019 §8.4). Pre-format
// the limit before substitution.
f.write_str(&crate::t!(
"archive.import_collision_exhausted",
path = path.display(),
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
impl std::error::Error for ArchiveError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
/// Default export-zip filename for `<date>-<project>-export-NN.zip`.
///
/// `date_yyyymmdd` is the today-local prefix (8 digits);
/// `project_name` is the project's directory name (we use the
/// directory name rather than the prettified display name so
/// the export filename round-trips cleanly through `import`).
#[must_use]
pub fn default_export_filename(date_yyyymmdd: &str, project_name: &str, sequence: u32) -> String {
format!("{date_yyyymmdd}-{project_name}-export-{sequence:02}.zip")
}
/// Find the next available `-NN` suffix for the default export
/// filename in `target_dir`, starting from 1. Returns
/// `ExportSequenceExhausted` if 1..=99 are all taken.
pub fn next_export_sequence(
target_dir: &Path,
project_name: &str,
) -> Result<(String, u32), ArchiveError> {
let date = today_local();
for seq in 1..=EXPORT_SEQUENCE_LIMIT {
let candidate = default_export_filename(&date, project_name, seq);
let candidate_path = target_dir.join(&candidate);
if !candidate_path.exists() {
return Ok((candidate, seq));
}
}
Err(ArchiveError::ExportSequenceExhausted {
project: project_name.to_string(),
target_dir: target_dir.to_path_buf(),
limit: EXPORT_SEQUENCE_LIMIT,
})
}
/// Export the project at `project_path` to `dst_zip`.
///
/// `project_name` is the basename used as the top-level folder
/// inside the archive (so unzipping creates
/// `<project_name>/...`). `dst_zip` is the full path where the
/// archive will be written; the caller is responsible for
/// resolving relative paths and picking a non-clobbering
/// filename.
pub fn export_project(
project_path: &Path,
project_name: &str,
dst_zip: &Path,
) -> Result<(), ArchiveError> {
info!(
src = %project_path.display(),
dst = %dst_zip.display(),
project = %project_name,
"exporting project",
);
if let Some(parent) = dst_zip.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let file = fs::File::create(dst_zip).map_err(|source| ArchiveError::Io {
path: dst_zip.to_path_buf(),
source,
})?;
let mut writer = ZipWriter::new(file);
let options: SimpleFileOptions = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
message: e.to_string(),
})?;
Ok(())
}
fn add_directory_entry(
writer: &mut ZipWriter<fs::File>,
name: &str,
zip_path: &Path,
) -> Result<(), ArchiveError> {
let entry_name = if name.ends_with('/') {
name.to_string()
} else {
format!("{name}/")
};
let options = SimpleFileOptions::default().unix_permissions(0o755);
writer
.add_directory(entry_name, options)
.map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})
}
fn add_directory_recursive(
writer: &mut ZipWriter<fs::File>,
src_dir: &Path,
zip_prefix: &str,
options: &SimpleFileOptions,
zip_path: &Path,
) -> Result<(), ArchiveError> {
let entries = fs::read_dir(src_dir).map_err(|source| ArchiveError::Io {
path: src_dir.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| ArchiveError::Io {
path: src_dir.to_path_buf(),
source,
})?;
let path = entry.path();
let name_os = entry.file_name();
let name = name_os.to_string_lossy().into_owned();
if should_exclude_from_export(&name) {
debug!(name = %name, "excluding from export");
continue;
}
let file_type = entry.file_type().map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
let entry_zip_path = format!("{zip_prefix}/{name}");
if file_type.is_dir() {
add_directory_entry(writer, &entry_zip_path, zip_path)?;
add_directory_recursive(writer, &path, &entry_zip_path, options, zip_path)?;
} else if file_type.is_file() {
writer
.start_file(&entry_zip_path, *options)
.map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let mut f = fs::File::open(&path).map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
io::copy(&mut f, writer).map_err(|source| ArchiveError::Io {
path: path.clone(),
source,
})?;
}
// Skip symlinks etc. — the project skeleton never
// creates them, and silently dropping them in an
// export is safer than following them blindly.
}
Ok(())
}
/// Per-name exclusion rules for export. We exclude:
///
/// - `playground.db` (always derived; ADR-0007 / ADR-0015 §11)
/// - `history.log` (ADR-0007 amendment 1: user-private)
/// - `.rdbms-playground.lock` (per-process)
/// - `.snapshots/` (undo ring; local working state, ADR-0006 Am. 1)
/// - `*.tmp` (atomic-write staging files)
/// - `project.yaml.v*.bak` (migration backups; recipient
/// doesn't need our local recovery aids)
fn should_exclude_from_export(name: &str) -> bool {
if EXPORT_EXCLUDED_NAMES.contains(&name) {
return true;
}
if name.ends_with(".tmp") {
return true;
}
if name.starts_with("project.yaml.v") && name.ends_with(".bak") {
return true;
}
false
}
/// Inspect `zip_path` and return the single top-level folder
/// name inside it, plus a `Vec` of every entry path (used by
/// the import step to extract).
///
/// Refuses if the zip contains zero top-level folders, more
/// than one, or no `project.yaml` directly under the single
/// top-level folder. This is the load-bearing check that
/// distinguishes "an export from this app" from "any old zip
/// the user happened to have."
pub fn inspect_zip(zip_path: &Path) -> Result<ZipInspection, ArchiveError> {
let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
path: zip_path.to_path_buf(),
source,
})?;
let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let mut top_folder: Option<String> = None;
let mut saw_project_yaml = false;
for i in 0..archive.len() {
let entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let Some(safe_path) = entry.enclosed_name() else {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
};
let mut comps = safe_path.components();
let Some(Component::Normal(first)) = comps.next() else {
// Empty or odd path — ignore.
continue;
};
let first_str = first.to_string_lossy().into_owned();
match &top_folder {
None => top_folder = Some(first_str.clone()),
Some(existing) if *existing == first_str => {}
Some(_) => return Err(ArchiveError::MultipleTopFolders),
}
let rest: PathBuf = comps.collect();
if rest == Path::new(PROJECT_YAML) {
saw_project_yaml = true;
}
}
let top_folder = top_folder.ok_or(ArchiveError::NotAProjectArchive)?;
if !saw_project_yaml {
return Err(ArchiveError::NotAProjectArchive);
}
Ok(ZipInspection { top_folder })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ZipInspection {
/// The single top-level folder name inside the zip. Used
/// as the default target project name for import.
pub top_folder: String,
}
/// Resolve a colliding target directory by appending `-NN`
/// until a free slot is found. `parent` is the directory the
/// project will live in; `name` is the desired basename.
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
}
for n in 2..=IMPORT_SUFFIX_LIMIT {
let candidate = parent.join(format!("{name}-{n:02}"));
if !candidate.exists() {
return Ok((candidate, n));
}
}
Err(ArchiveError::ImportCollisionExhausted {
path: direct,
limit: IMPORT_SUFFIX_LIMIT,
})
}
/// Extract `zip_path` into `target_dir` (which must not yet
/// exist). The zip's single top-level folder is unwrapped: the
/// archive's `<top_folder>/foo/bar` lands at
/// `target_dir/foo/bar`.
///
/// `inspect_zip` should have been called first to validate the
/// shape; this function trusts that result and focuses on the
/// extraction. Each entry is checked for path traversal one
/// more time before being written.
pub fn extract_into(
zip_path: &Path,
target_dir: &Path,
expected_top_folder: &str,
) -> Result<(), ArchiveError> {
if target_dir.exists() {
return Err(ArchiveError::Io {
path: target_dir.to_path_buf(),
source: io::Error::new(
io::ErrorKind::AlreadyExists,
"target directory already exists",
),
});
}
fs::create_dir_all(target_dir).map_err(|source| ArchiveError::Io {
path: target_dir.to_path_buf(),
source,
})?;
let file = fs::File::open(zip_path).map_err(|source| ArchiveError::Io {
path: zip_path.to_path_buf(),
source,
})?;
let mut archive = ZipArchive::new(file).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| ArchiveError::Zip {
path: zip_path.to_path_buf(),
message: e.to_string(),
})?;
let Some(safe_path) = entry.enclosed_name() else {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
};
let mut comps = safe_path.components();
let Some(Component::Normal(first)) = comps.next() else {
continue;
};
if first.to_string_lossy() != expected_top_folder {
return Err(ArchiveError::MultipleTopFolders);
}
let rest: PathBuf = comps.collect();
let dst_path = if rest.as_os_str().is_empty() {
// The top-folder entry itself (a directory). Skip
// — `target_dir` already exists.
continue;
} else {
target_dir.join(&rest)
};
// Defence-in-depth: confirm the resolved path stays
// under target_dir even after components collected.
if !dst_path.starts_with(target_dir) {
return Err(ArchiveError::UnsafeEntry(entry.name().to_string()));
}
if entry.is_dir() {
fs::create_dir_all(&dst_path).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
} else {
if let Some(parent) = dst_path.parent() {
fs::create_dir_all(parent).map_err(|source| ArchiveError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let mut out = fs::File::create(&dst_path).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::GITIGNORE;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
fn make_project(root: &Path, name: &str) -> PathBuf {
let p = root.join(name);
fs::create_dir_all(&p).unwrap();
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
fs::write(p.join("project.yaml.tmp"), "stale").unwrap();
// Undo snapshot ring — local working state, must be excluded.
fs::create_dir_all(p.join(crate::undo::SNAPSHOTS_DIR).join("0")).unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("index.yaml"),
"next_id: 1\nundo: []\nredo: []\n",
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
p
}
#[test]
fn export_excludes_db_history_lock() {
let tmp = tempdir();
let project = make_project(tmp.path(), "MyProject");
let zip_path = tmp.path().join("export.zip");
export_project(&project, "MyProject", &zip_path).unwrap();
// Re-open and list entries.
let f = fs::File::open(&zip_path).unwrap();
let mut archive = ZipArchive::new(f).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(
names.iter().any(|n| n == "MyProject/project.yaml"),
"names: {names:?}"
);
assert!(names.iter().any(|n| n == "MyProject/data/Customers.csv"));
assert!(!names.iter().any(|n| n.contains("playground.db")));
assert!(!names.iter().any(|n| n.contains("history.log")));
assert!(!names.iter().any(|n| n.contains(".lock")));
assert!(!names.iter().any(|n| n.ends_with(".tmp")));
// Undo snapshot ring is local working state — never shared.
assert!(
!names.iter().any(|n| n.contains(".snapshots")),
"snapshots leaked into export: {names:?}"
);
// .gitignore IS included (sensible default for the recipient).
assert!(names.iter().any(|n| n == "MyProject/.gitignore"));
}
#[test]
fn export_top_level_folder_is_project_name() {
let tmp = tempdir();
let project = make_project(tmp.path(), "MyProject");
let zip_path = tmp.path().join("export.zip");
export_project(&project, "MyProject", &zip_path).unwrap();
let inspect = inspect_zip(&zip_path).unwrap();
assert_eq!(inspect.top_folder, "MyProject");
}
#[test]
fn export_carries_the_stored_input_mode() {
// ADR-0015 mode-restore amendment (issue #14): the input
// mode is part of project.yaml, so it travels in the export
// verbatim — this is the teacher-prepares-an-advanced-mode
// project, hands it to students workflow.
use std::io::Read as _;
let tmp = tempdir();
let project = make_project(tmp.path(), "Advanced");
// Re-write project.yaml with an explicit advanced mode.
fs::write(
project.join(PROJECT_YAML),
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\n mode: advanced\ntables: []\nrelationships: []\n",
)
.unwrap();
let zip_path = tmp.path().join("export.zip");
export_project(&project, "Advanced", &zip_path).unwrap();
let f = fs::File::open(&zip_path).unwrap();
let mut archive = ZipArchive::new(f).unwrap();
let mut entry = archive.by_name("Advanced/project.yaml").unwrap();
let mut body = String::new();
entry.read_to_string(&mut body).unwrap();
assert!(
body.contains("mode: advanced"),
"exported project.yaml must carry the stored mode: {body}"
);
}
#[test]
fn inspect_rejects_zip_without_project_yaml() {
let tmp = tempdir();
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
}
#[test]
fn inspect_rejects_zip_with_multiple_top_folders() {
let tmp = tempdir();
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
}
#[test]
fn extract_into_unwraps_top_folder() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Source");
let zip_path = tmp.path().join("source.zip");
export_project(&project, "Source", &zip_path).unwrap();
let target = tmp.path().join("imported");
extract_into(&zip_path, &target, "Source").unwrap();
assert!(target.join(PROJECT_YAML).exists());
assert!(target.join("data").join("Customers.csv").exists());
// history.log NOT present (it was excluded from export).
assert!(!target.join(HISTORY_LOG).exists());
}
#[test]
fn extract_into_refuses_existing_target() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Source");
let zip_path = tmp.path().join("source.zip");
export_project(&project, "Source", &zip_path).unwrap();
let target = tmp.path().join("existing");
fs::create_dir(&target).unwrap();
let err = extract_into(&zip_path, &target, "Source").expect_err("must refuse");
assert!(matches!(err, ArchiveError::Io { .. }), "got: {err:?}");
}
#[test]
fn resolve_import_target_uses_direct_when_free() {
let tmp = tempdir();
let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path, tmp.path().join("Foo"));
assert_eq!(n, 0);
}
#[test]
fn resolve_import_target_appends_suffix_on_collision() {
let tmp = tempdir();
fs::create_dir(tmp.path().join("Foo")).unwrap();
let (path, n) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path, tmp.path().join("Foo-02"));
assert_eq!(n, 2);
// Add Foo-02 and try again.
fs::create_dir(&path).unwrap();
let (path3, n3) = resolve_import_target(tmp.path(), "Foo").unwrap();
assert_eq!(path3, tmp.path().join("Foo-03"));
assert_eq!(n3, 3);
}
#[test]
fn next_export_sequence_starts_at_one() {
let tmp = tempdir();
let (name, n) = next_export_sequence(tmp.path(), "MyProject").unwrap();
assert_eq!(n, 1);
assert!(name.contains("MyProject"));
assert!(name.ends_with("-01.zip"));
}
#[test]
fn next_export_sequence_skips_taken_slots() {
let tmp = tempdir();
let date = today_local();
// Pre-create -01 and -02.
fs::write(tmp.path().join(default_export_filename(&date, "P", 1)), "").unwrap();
fs::write(tmp.path().join(default_export_filename(&date, "P", 2)), "").unwrap();
let (_, n) = next_export_sequence(tmp.path(), "P").unwrap();
assert_eq!(n, 3);
}
#[test]
fn round_trip_export_then_inspect() {
let tmp = tempdir();
let project = make_project(tmp.path(), "Customers");
let zip = tmp.path().join("export.zip");
export_project(&project, "Customers", &zip).unwrap();
let inspect = inspect_zip(&zip).unwrap();
assert_eq!(inspect.top_folder, "Customers");
}
}