4cd574b909
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.
723 lines
26 KiB
Rust
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");
|
|
}
|
|
}
|