blob was a dead-end: declarable but never fillable (no literal in either mode, seed-unsupported), so a blob column could only ever hold NULL. Remove Type::Blob + Value/CellValue::Blob + the base64 CSV path + the grammar slot + completion/render/type-change/seed handling + the binder refusal (whose message also carried a user-facing "DSL" copy-rule bug). The vocabulary is now nine types; base64 stays (clipboard OSC-52). Backward compat is the ADR-0015 migration framework's first real use: a v1->v2 format bump (CURRENT_SCHEMA_VERSION across serializer/parser/skeleton) with a migrator that rewrites `type: blob` -> `type: text`, and a forced .db rebuild from the migrated text when a blob column was actually converted (the stale .db keeps a STRICT BLOB engine column). Conversion to text is non-destructive and CSV-free. Covered by a full-stack integration test and a Tier-4 PTY test that opens a real legacy v1-blob project. Also sweeps the nine-type vocabulary through CLAUDE.md, requirements.md, the website (type reference, seed doc, highlight grammar), and the ADR-0030/0033/ 0035 cross-references; CHANGELOG Removed entry; handoff-79. BREAKING CHANGE: the `blob` column type is removed. Existing projects that declared a blob column are migrated on first open (the column becomes text; the original project.yaml is kept as a .v1.bak).
975 lines
34 KiB
Rust
975 lines
34 KiB
Rust
//! Project lifecycle: data dir resolution, project creation,
|
|
//! project opening, lock-file ownership.
|
|
//!
|
|
//! This module is the home of the in-memory representation of
|
|
//! a project on disk. ADR-0015 is the spec; the iteration that
|
|
//! introduced this module (Iteration 1) builds the directory
|
|
//! skeleton, the file-backed SQLite database, the lock file,
|
|
//! and the display-name plumbing. Per-command persistence to
|
|
//! YAML / CSV / `history.log` lands in Iteration 2.
|
|
//!
|
|
//! Nothing here touches Tokio. Project creation and opening
|
|
//! are sync filesystem operations; the runtime calls them once
|
|
//! at startup and once per `load`/`new`/`save as`.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use directories::ProjectDirs;
|
|
use tracing::{debug, info};
|
|
|
|
pub mod lock;
|
|
pub mod naming;
|
|
pub mod prettifier;
|
|
|
|
use lock::{Lock, LockError};
|
|
use naming::NamingError;
|
|
|
|
/// File and directory names inside a project. Public so other
|
|
/// modules (db, runtime, future iterations) can reference them
|
|
/// without re-deriving paths.
|
|
pub const PROJECT_YAML: &str = "project.yaml";
|
|
pub const DATA_DIR: &str = "data";
|
|
pub const HISTORY_LOG: &str = "history.log";
|
|
pub const PLAYGROUND_DB: &str = "playground.db";
|
|
pub const GITIGNORE: &str = ".gitignore";
|
|
|
|
/// Sub-directory of the data root that holds projects.
|
|
pub const PROJECTS_SUBDIR: &str = "projects";
|
|
|
|
/// State file under the data root used by `--resume`.
|
|
///
|
|
/// Records the absolute path of the most-recently-opened
|
|
/// project (Iteration 6, ADR-0015 §7). The runtime writes
|
|
/// it on every successful project open and reads it when
|
|
/// `--resume` is passed; a clean exit deliberately leaves
|
|
/// it intact (the whole point is to reopen "what I had").
|
|
pub const LAST_PROJECT_FILE: &str = "last_project";
|
|
|
|
/// Read the recorded last-project path under `data_root`,
|
|
/// stripping trailing whitespace/newlines.
|
|
///
|
|
/// Returns `Ok(None)` when the file is absent (a fresh data
|
|
/// root), `Err(_)` for IO errors that aren't `NotFound`. The
|
|
/// runtime treats `None` as "no resume target" and surfaces
|
|
/// the absent path explicitly when `--resume` was requested.
|
|
pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
|
|
let path = data_root.join(LAST_PROJECT_FILE);
|
|
match fs::read_to_string(&path) {
|
|
Ok(body) => {
|
|
let trimmed = body.trim();
|
|
if trimmed.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(PathBuf::from(trimmed)))
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Atomically write `project_path` as the recorded
|
|
/// last-project for `data_root` (uses temp-write + rename so
|
|
/// a crash mid-write never leaves a half-line behind).
|
|
///
|
|
/// The path is written verbatim, with a single trailing
|
|
/// newline. We don't canonicalize: a stale entry pointing at
|
|
/// a moved/deleted directory is the kind of error `--resume`
|
|
/// is supposed to surface clearly, not paper over by
|
|
/// resolving symlinks at write time.
|
|
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
|
|
fs::create_dir_all(data_root)?;
|
|
let final_path = data_root.join(LAST_PROJECT_FILE);
|
|
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
|
|
{
|
|
use std::io::Write as _;
|
|
let mut f = fs::File::create(&tmp_path)?;
|
|
writeln!(f, "{}", project_path.display())?;
|
|
f.sync_all()?;
|
|
}
|
|
fs::rename(&tmp_path, &final_path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the data root for this run.
|
|
///
|
|
/// - If `override_dir` is `Some`, that path is used verbatim
|
|
/// (CLI `--data-dir`, ADR-0015 §1).
|
|
/// - Otherwise the OS-standard application data directory is
|
|
/// used (Linux: `$XDG_DATA_HOME/rdbms-playground` or
|
|
/// `~/.local/share/rdbms-playground`; macOS:
|
|
/// `~/Library/Application Support/rdbms-playground`;
|
|
/// Windows: `%APPDATA%\rdbms-playground`).
|
|
pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, ProjectError> {
|
|
if let Some(p) = override_dir {
|
|
return Ok(p.to_path_buf());
|
|
}
|
|
let dirs =
|
|
ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
|
|
Ok(dirs.data_dir().to_path_buf())
|
|
}
|
|
|
|
/// `<data-root>/projects`. Created on demand.
|
|
#[must_use]
|
|
pub fn projects_dir(data_root: &Path) -> PathBuf {
|
|
data_root.join(PROJECTS_SUBDIR)
|
|
}
|
|
|
|
/// One entry the load picker shows. Contains the full path
|
|
/// (so the runtime can switch to it) plus the cached display
|
|
/// metadata (so the picker doesn't have to re-prettify on
|
|
/// every redraw).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ProjectListing {
|
|
pub path: PathBuf,
|
|
pub display_name: String,
|
|
/// The `mtime` of `project.yaml`, formatted as
|
|
/// `YYYY-MM-DD HH:MM` for display in the picker. Falls
|
|
/// back to "" if the metadata can't be read.
|
|
pub modified: String,
|
|
pub kind: ProjectKind,
|
|
}
|
|
|
|
/// List the projects available to the load picker.
|
|
///
|
|
/// Walks `<data_root>/projects/` and returns one
|
|
/// `ProjectListing` per child directory that looks like a
|
|
/// project (has a `project.yaml`). Sorted newest-first by
|
|
/// `project.yaml` mtime.
|
|
pub fn list_projects(data_root: &Path) -> Vec<ProjectListing> {
|
|
let dir = projects_dir(data_root);
|
|
let Ok(entries) = fs::read_dir(&dir) else {
|
|
return Vec::new();
|
|
};
|
|
let mut listings: Vec<(std::time::SystemTime, ProjectListing)> = Vec::new();
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let yaml = path.join(PROJECT_YAML);
|
|
if !yaml.exists() {
|
|
continue;
|
|
}
|
|
let mtime = yaml
|
|
.metadata()
|
|
.and_then(|m| m.modified())
|
|
.unwrap_or(std::time::UNIX_EPOCH);
|
|
let dirname = directory_name(&path);
|
|
let display_name = prettifier::prettify(&dirname);
|
|
let modified = format_modified(mtime);
|
|
let kind = if naming::is_temp_dirname(&dirname) {
|
|
ProjectKind::Temp
|
|
} else {
|
|
ProjectKind::Named
|
|
};
|
|
listings.push((
|
|
mtime,
|
|
ProjectListing {
|
|
path,
|
|
display_name,
|
|
modified,
|
|
kind,
|
|
},
|
|
));
|
|
}
|
|
// Sort newest mtime first.
|
|
listings.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
|
|
listings.into_iter().map(|(_, l)| l).collect()
|
|
}
|
|
|
|
fn format_modified(t: std::time::SystemTime) -> String {
|
|
let secs = t
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let day_secs = secs.rem_euclid(86_400);
|
|
let h = day_secs / 3600;
|
|
let m = (day_secs % 3600) / 60;
|
|
let (y, mo, d) = naming_ymd(secs);
|
|
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
|
}
|
|
|
|
/// Iteration-1 startup logic (ADR-0015 §1):
|
|
///
|
|
/// - If `project_path` is `Some`, open that project (refused if
|
|
/// it doesn't exist or doesn't look like one).
|
|
/// - Otherwise create a fresh auto-named temp project under the
|
|
/// active data root, resolved from `data_dir_override` plus
|
|
/// the OS-standard fallback.
|
|
///
|
|
/// Splits cleanly out of `runtime::run` so the same logic is
|
|
/// reachable from integration tests without booting a Tokio
|
|
/// runtime or a terminal.
|
|
pub fn open_or_create(
|
|
project_path: Option<&Path>,
|
|
data_dir_override: Option<&Path>,
|
|
) -> Result<Project, ProjectError> {
|
|
if let Some(path) = project_path {
|
|
Project::open(path)
|
|
} else {
|
|
let data_root = resolve_data_root(data_dir_override)?;
|
|
Project::create_temp(&data_root)
|
|
}
|
|
}
|
|
|
|
/// An opened project. Holds the lock for its lifetime.
|
|
#[derive(Debug)]
|
|
pub struct Project {
|
|
path: PathBuf,
|
|
display_name: String,
|
|
kind: ProjectKind,
|
|
/// Held for the project's lifetime; released on drop.
|
|
_lock: Lock,
|
|
}
|
|
|
|
/// Whether this project is auto-named temporary or
|
|
/// user-named permanent (ADR-0015 §1, §11).
|
|
///
|
|
/// The distinction drives the `save` command's behaviour:
|
|
/// for temp projects it elevates to a named project (== `save
|
|
/// as`); for named projects it reports "already auto-saved"
|
|
/// since no work has been done that wasn't already persisted.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ProjectKind {
|
|
Temp,
|
|
Named,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ProjectError {
|
|
DataRootUnavailable,
|
|
PathNotFound(PathBuf),
|
|
NotAProject(PathBuf),
|
|
AlreadyExists(PathBuf),
|
|
Io {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
Naming(NamingError),
|
|
Lock(LockError),
|
|
}
|
|
|
|
impl std::fmt::Display for ProjectError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
|
|
Self::PathNotFound(p) => {
|
|
f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
|
|
}
|
|
Self::NotAProject(p) => {
|
|
f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
|
|
}
|
|
Self::AlreadyExists(p) => {
|
|
f.write_str(&crate::t!("project.already_exists", path = p.display(),))
|
|
}
|
|
Self::Io { path, source } => f.write_str(&crate::t!(
|
|
"project.io",
|
|
path = path.display(),
|
|
source = source,
|
|
)),
|
|
// Naming and Lock are transparent — their own Display
|
|
// impls already route through the catalog.
|
|
Self::Naming(inner) => std::fmt::Display::fmt(inner, f),
|
|
Self::Lock(inner) => std::fmt::Display::fmt(inner, f),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ProjectError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Io { source, .. } => Some(source),
|
|
Self::Naming(inner) => Some(inner),
|
|
Self::Lock(inner) => Some(inner),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NamingError> for ProjectError {
|
|
fn from(e: NamingError) -> Self {
|
|
Self::Naming(e)
|
|
}
|
|
}
|
|
|
|
impl From<LockError> for ProjectError {
|
|
fn from(e: LockError) -> Self {
|
|
Self::Lock(e)
|
|
}
|
|
}
|
|
|
|
impl Project {
|
|
/// Create a new auto-named temp project under
|
|
/// `<data-root>/projects/` and acquire its lock.
|
|
///
|
|
/// The data root is created on demand (parent dirs included).
|
|
/// The slug is checked for collisions; the project directory
|
|
/// has its skeleton populated (an empty `project.yaml` with
|
|
/// just `version` + `created_at`, an empty `data/`, an empty
|
|
/// `history.log`, and a `.gitignore` template).
|
|
pub fn create_temp(data_root: &Path) -> Result<Self, ProjectError> {
|
|
let parent = projects_dir(data_root);
|
|
ensure_dir(&parent)?;
|
|
|
|
let mut rng = rand::rng();
|
|
let slug = naming::generate_temp_name(&mut rng, &parent, naming::today_local)?;
|
|
let path = parent.join(&slug);
|
|
|
|
Self::initialize_skeleton(&path)?;
|
|
let display_name = prettifier::prettify(&slug);
|
|
let lock = Lock::acquire(&path)?;
|
|
info!(path = %path.display(), name = %display_name, "created temp project");
|
|
Ok(Self {
|
|
path,
|
|
display_name,
|
|
kind: ProjectKind::Temp,
|
|
_lock: lock,
|
|
})
|
|
}
|
|
|
|
/// Create a *named* project at the chosen path. Refuses if
|
|
/// the path already exists (any kind of entry — directory,
|
|
/// file, symlink). The user should pick a different name or
|
|
/// remove the existing entry first (ADR-0015 §2).
|
|
///
|
|
/// The skeleton is initialized exactly like
|
|
/// `create_temp`. The display name is the prettified
|
|
/// directory name.
|
|
pub fn create_named(path: &Path) -> Result<Self, ProjectError> {
|
|
if path.exists() {
|
|
return Err(ProjectError::AlreadyExists(path.to_path_buf()));
|
|
}
|
|
Self::initialize_skeleton(path)?;
|
|
let dirname = directory_name(path);
|
|
let display_name = prettifier::prettify(&dirname);
|
|
let lock = Lock::acquire(path)?;
|
|
info!(path = %path.display(), name = %display_name, "created named project");
|
|
Ok(Self {
|
|
path: path.to_path_buf(),
|
|
display_name,
|
|
kind: ProjectKind::Named,
|
|
_lock: lock,
|
|
})
|
|
}
|
|
|
|
/// Open an existing project at `path`. Refuses if the path
|
|
/// does not exist or does not look like a project (no
|
|
/// `project.yaml` and no `playground.db` present).
|
|
///
|
|
/// Acquires the lock. The display name is the prettified
|
|
/// directory name.
|
|
pub fn open(path: &Path) -> Result<Self, ProjectError> {
|
|
if !path.exists() {
|
|
return Err(ProjectError::PathNotFound(path.to_path_buf()));
|
|
}
|
|
if !looks_like_project(path) {
|
|
return Err(ProjectError::NotAProject(path.to_path_buf()));
|
|
}
|
|
let dirname = directory_name(path);
|
|
let display_name = prettifier::prettify(&dirname);
|
|
let kind = if naming::is_temp_dirname(&dirname) {
|
|
ProjectKind::Temp
|
|
} else {
|
|
ProjectKind::Named
|
|
};
|
|
let lock = Lock::acquire(path)?;
|
|
info!(path = %path.display(), name = %display_name, "opened project");
|
|
Ok(Self {
|
|
path: path.to_path_buf(),
|
|
display_name,
|
|
kind,
|
|
_lock: lock,
|
|
})
|
|
}
|
|
|
|
/// Build the on-disk skeleton for a fresh project: the
|
|
/// directory itself, an empty `data/`, an empty
|
|
/// `history.log`, a placeholder `project.yaml` with just
|
|
/// the current schema `version` and `created_at`, and a `.gitignore`.
|
|
///
|
|
/// `playground.db` is not created here; it's created the
|
|
/// first time `Database::open` runs against the path
|
|
/// (sqlite creates the file on connect).
|
|
fn initialize_skeleton(path: &Path) -> Result<(), ProjectError> {
|
|
ensure_dir(path)?;
|
|
ensure_dir(&path.join(DATA_DIR))?;
|
|
|
|
// History log: empty file is fine.
|
|
write_if_missing(&path.join(HISTORY_LOG), "")?;
|
|
|
|
// project.yaml: minimal placeholder. Iteration 2 will
|
|
// actually populate `tables` / `relationships` on every
|
|
// schema mutation; for now we just ensure the file
|
|
// exists and carries the version + creation timestamp.
|
|
let yaml = format!(
|
|
"version: {}\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
|
|
crate::persistence::migrations::CURRENT_SCHEMA_VERSION,
|
|
iso8601_now(),
|
|
);
|
|
write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
|
|
|
|
// .gitignore template (ADR-0015 §11). Excludes the
|
|
// derived `.db`, the per-process lock, migration backups,
|
|
// and the undo snapshot ring (ADR-0006 Amendment 1 — local
|
|
// working state). `history.log` is intentionally NOT ignored
|
|
// (ADR-0007 amendment 1: per-user choice).
|
|
let gitignore =
|
|
"/playground.db\n/.rdbms-playground.lock\n/project.yaml.v*.bak\n/.snapshots/\n";
|
|
write_if_missing(&path.join(GITIGNORE), gitignore)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn display_name(&self) -> &str {
|
|
&self.display_name
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn kind(&self) -> ProjectKind {
|
|
self.kind
|
|
}
|
|
|
|
/// Is this an auto-named temp project that the user has
|
|
/// not modified?
|
|
///
|
|
/// Used by `safely_delete_temp_project` to clean up the
|
|
/// inevitable accumulation of auto-named temp directories
|
|
/// left behind when the user launches the app, immediately
|
|
/// loads another project (or quits without doing
|
|
/// anything), and never returns to the temp.
|
|
///
|
|
/// "Unmodified" requires *all* of:
|
|
///
|
|
/// - `kind` is `Temp`.
|
|
/// - `project.yaml` parses successfully and lists no
|
|
/// tables and no relationships.
|
|
/// - `data/` is empty (so we don't delete CSVs the user
|
|
/// might still want even if the YAML schema list is
|
|
/// empty for any reason).
|
|
///
|
|
/// Anything that fails defaults to "not unmodified"
|
|
/// (false), so a corrupted project is never auto-deleted.
|
|
#[must_use]
|
|
pub fn is_unmodified_temp(&self) -> bool {
|
|
if !matches!(self.kind, ProjectKind::Temp) {
|
|
return false;
|
|
}
|
|
let yaml_path = self.path.join(PROJECT_YAML);
|
|
let Ok(body) = fs::read_to_string(&yaml_path) else {
|
|
return false;
|
|
};
|
|
let Ok(snapshot) = crate::persistence::parse_schema(&body) else {
|
|
return false;
|
|
};
|
|
if !snapshot.tables.is_empty() || !snapshot.relationships.is_empty() {
|
|
return false;
|
|
}
|
|
// Defensive belt-and-braces: data/ must also be empty.
|
|
// This catches edge cases like a hand-edited YAML that
|
|
// lists no tables but the data dir still contains a
|
|
// user's CSV.
|
|
let data_dir = self.path.join(DATA_DIR);
|
|
// Missing data dir → also "no data" → empty.
|
|
fs::read_dir(&data_dir).map_or(true, |mut iter| iter.next().is_none())
|
|
}
|
|
|
|
/// Path to the SQLite database for this project. Always
|
|
/// `<project>/playground.db`.
|
|
#[must_use]
|
|
pub fn db_path(&self) -> PathBuf {
|
|
self.path.join(PLAYGROUND_DB)
|
|
}
|
|
}
|
|
|
|
/// Whitelisted file/directory names allowed inside a temp
|
|
/// project we're about to auto-delete. Anything else makes
|
|
/// `safely_delete_temp_project` refuse.
|
|
const ALLOWED_PROJECT_ENTRIES: &[&str] = &[
|
|
PROJECT_YAML,
|
|
DATA_DIR,
|
|
HISTORY_LOG,
|
|
PLAYGROUND_DB,
|
|
GITIGNORE,
|
|
".rdbms-playground.lock",
|
|
// Undo snapshot ring (ADR-0006 Amendment 1): a temp that was
|
|
// modified then undone back to empty can still carry this, and
|
|
// must remain auto-deletable.
|
|
crate::undo::SNAPSHOTS_DIR,
|
|
];
|
|
|
|
/// Reasons `safely_delete_temp_project` refuses to delete a
|
|
/// path. Each variant carries enough detail to surface in a
|
|
/// log warning.
|
|
#[derive(Debug)]
|
|
pub enum SafeDeleteError {
|
|
Refused {
|
|
path: PathBuf,
|
|
reason: &'static str,
|
|
},
|
|
Io {
|
|
path: PathBuf,
|
|
source: std::io::Error,
|
|
},
|
|
}
|
|
|
|
impl std::fmt::Display for SafeDeleteError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Refused { path, reason } => f.write_str(&crate::t!(
|
|
"project.safe_delete.refused",
|
|
path = path.display(),
|
|
reason = reason,
|
|
)),
|
|
Self::Io { path, source } => f.write_str(&crate::t!(
|
|
"project.safe_delete.io",
|
|
path = path.display(),
|
|
source = source,
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SafeDeleteError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Io { source, .. } => Some(source),
|
|
Self::Refused { .. } => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Conservatively delete a temp project's directory.
|
|
///
|
|
/// Stacks every guard we can think of so a bug elsewhere (or
|
|
/// a maliciously crafted CLI argument) can never escalate
|
|
/// into deleting the wrong directory:
|
|
///
|
|
/// 1. **Canonicalize** the path — resolves symlinks and `..`,
|
|
/// normalises against the actual filesystem.
|
|
/// 2. **Symlink rejection** at the top level — if the path
|
|
/// *is* a symlink, refuse.
|
|
/// 3. **Must be a directory** — `is_dir()` after the symlink
|
|
/// check.
|
|
/// 4. **Containment** — the canonical path must start with
|
|
/// the canonical `<data_root>/projects/`. Anything outside
|
|
/// that prefix (a user's home, /tmp, the system root)
|
|
/// cannot pass.
|
|
/// 5. **Marker-segment match** — the directory's basename
|
|
/// must contain the literal `[temp]` segment, the same
|
|
/// marker `validate_user_name` reserves so user-named
|
|
/// projects can never collide.
|
|
/// 6. **Allowlisted contents** — every direct child must be
|
|
/// one of the well-known project artefacts (or a migration
|
|
/// backup or atomic-write `.tmp` file). Refuse if anything
|
|
/// foreign is present (a user's `notes.md`, a stray
|
|
/// `.git/`, etc.).
|
|
///
|
|
/// Any single guard failing produces a `SafeDeleteError` and
|
|
/// no `remove_dir_all` runs. The caller is expected to log
|
|
/// the refusal and move on — leaving an unexpected directory
|
|
/// alone is always preferable to a wrong delete.
|
|
pub fn safely_delete_temp_project(
|
|
project_path: &Path,
|
|
data_root: &Path,
|
|
) -> Result<(), SafeDeleteError> {
|
|
// 1. Reject symlinks at the top level (before we
|
|
// canonicalize). canonicalize() would silently follow
|
|
// the link and pass the rest of the checks against
|
|
// the *target*, which is not what we want.
|
|
let top_meta = fs::symlink_metadata(project_path).map_err(|source| SafeDeleteError::Io {
|
|
path: project_path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
if top_meta.file_type().is_symlink() {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_path.to_path_buf(),
|
|
reason: "path is a symbolic link",
|
|
});
|
|
}
|
|
if !top_meta.is_dir() {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_path.to_path_buf(),
|
|
reason: "path is not a directory",
|
|
});
|
|
}
|
|
|
|
// 2. Canonicalize for the containment check. We do this
|
|
// only after the symlink-at-top check so we can't be
|
|
// tricked by a top-level symlink.
|
|
let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
|
|
path: project_path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
|
|
// 3. Containment: canonical path must be inside the
|
|
// canonical data-root projects dir.
|
|
let projects_root = projects_dir(data_root);
|
|
let projects_root_canon = fs::canonicalize(&projects_root).unwrap_or(projects_root);
|
|
if !project_canon.starts_with(&projects_root_canon) {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_canon,
|
|
reason: "path is not inside the active data dir's projects folder",
|
|
});
|
|
}
|
|
|
|
// 4. Marker segment: basename must contain `[temp]` per
|
|
// naming::is_temp_dirname.
|
|
let dirname = match project_canon.file_name().and_then(|n| n.to_str()) {
|
|
Some(s) => s,
|
|
None => {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_canon,
|
|
reason: "directory has no usable basename",
|
|
});
|
|
}
|
|
};
|
|
if !naming::is_temp_dirname(dirname) {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_canon,
|
|
reason: "directory name does not contain the [temp] marker",
|
|
});
|
|
}
|
|
|
|
// 5. Contents allowlist. Any direct child not in
|
|
// ALLOWED_PROJECT_ENTRIES (and not a migration backup
|
|
// or staged-write tmp) makes us refuse.
|
|
let entries = fs::read_dir(&project_canon).map_err(|source| SafeDeleteError::Io {
|
|
path: project_canon.clone(),
|
|
source,
|
|
})?;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|source| SafeDeleteError::Io {
|
|
path: project_canon.clone(),
|
|
source,
|
|
})?;
|
|
let name = entry.file_name();
|
|
let name_str = name.to_string_lossy().into_owned();
|
|
let is_allowed = ALLOWED_PROJECT_ENTRIES.iter().any(|a| *a == name_str)
|
|
|| name_str.starts_with("project.yaml.v") && name_str.ends_with(".bak")
|
|
|| name_str.ends_with(".tmp");
|
|
if !is_allowed {
|
|
return Err(SafeDeleteError::Refused {
|
|
path: project_canon.clone(),
|
|
reason: "directory contains an unexpected file",
|
|
});
|
|
}
|
|
}
|
|
|
|
// All guards passed. Remove the directory.
|
|
fs::remove_dir_all(&project_canon).map_err(|source| SafeDeleteError::Io {
|
|
path: project_canon,
|
|
source,
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy a project directory to a new location.
|
|
///
|
|
/// Used by `save` / `save as` (ADR-0015 §11). Excludes the
|
|
/// per-process lock file (a fresh one is acquired when the
|
|
/// destination project is opened); copies everything else
|
|
/// including `playground.db`. The target path must not
|
|
/// already exist (per the §2 collision rule); the caller is
|
|
/// expected to validate that before invoking this helper.
|
|
pub fn copy_project(src: &Path, dst: &Path) -> Result<(), ProjectError> {
|
|
if dst.exists() {
|
|
return Err(ProjectError::AlreadyExists(dst.to_path_buf()));
|
|
}
|
|
copy_dir_recursive(src, dst).map_err(|source| ProjectError::Io {
|
|
path: dst.to_path_buf(),
|
|
source,
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|
fs::create_dir_all(dst)?;
|
|
for entry in fs::read_dir(src)? {
|
|
let entry = entry?;
|
|
let name = entry.file_name();
|
|
if name == ".rdbms-playground.lock" {
|
|
// Per-process artifact; the destination project
|
|
// will write a fresh one when opened.
|
|
continue;
|
|
}
|
|
let src_path = entry.path();
|
|
let dst_path = dst.join(&name);
|
|
let file_type = entry.file_type()?;
|
|
if file_type.is_dir() {
|
|
copy_dir_recursive(&src_path, &dst_path)?;
|
|
} else {
|
|
fs::copy(&src_path, &dst_path)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Heuristic for "does this directory look like an
|
|
/// rdbms-playground project?" — used by `Project::open` to
|
|
/// reject obviously-wrong CLI arguments before we try to
|
|
/// acquire a lock or touch SQLite.
|
|
fn looks_like_project(path: &Path) -> bool {
|
|
path.join(PROJECT_YAML).exists() || path.join(PLAYGROUND_DB).exists()
|
|
}
|
|
|
|
fn ensure_dir(path: &Path) -> Result<(), ProjectError> {
|
|
fs::create_dir_all(path).map_err(|e| ProjectError::Io {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
})
|
|
}
|
|
|
|
fn write_if_missing(path: &Path, body: &str) -> Result<(), ProjectError> {
|
|
if path.exists() {
|
|
debug!(path = %path.display(), "skeleton file already present, leaving as-is");
|
|
return Ok(());
|
|
}
|
|
fs::write(path, body).map_err(|e| ProjectError::Io {
|
|
path: path.to_path_buf(),
|
|
source: e,
|
|
})
|
|
}
|
|
|
|
fn directory_name(path: &Path) -> String {
|
|
path.file_name()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.display().to_string())
|
|
}
|
|
|
|
/// Current UTC time as an ISO-8601 string with second
|
|
/// precision and a `Z` suffix. Mirrors the `history.log`
|
|
/// timestamp format (ADR-0015 §5).
|
|
fn iso8601_now() -> String {
|
|
let now = std::time::SystemTime::now();
|
|
let secs = now
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
iso8601_from_unix_secs(secs)
|
|
}
|
|
|
|
fn iso8601_from_unix_secs(secs: i64) -> String {
|
|
let day_secs = secs.rem_euclid(86_400);
|
|
let h = day_secs / 3600;
|
|
let m = (day_secs % 3600) / 60;
|
|
let s = day_secs % 60;
|
|
let (y, mo, d) = naming_ymd(secs);
|
|
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
|
|
}
|
|
|
|
/// Wrapper that delegates to the same conversion the naming
|
|
/// module uses, kept private so we don't expose the helper
|
|
/// twice.
|
|
const fn naming_ymd(secs: i64) -> (u32, u32, u32) {
|
|
// Re-implement the same Howard-Hinnant conversion locally
|
|
// so we don't reach into another module's private fn.
|
|
let days = secs.div_euclid(86_400);
|
|
let z = days + 719_468;
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
let doe = (z - era * 146_097) as u64;
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let y = yoe as i64 + era * 400;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let y = if m <= 2 { y + 1 } else { y };
|
|
(y as u32, m as u32, d as u32)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn tempdir() -> tempfile::TempDir {
|
|
tempfile::tempdir().expect("create tempdir")
|
|
}
|
|
|
|
#[test]
|
|
fn data_root_override_is_used_verbatim() {
|
|
let tmp = tempdir();
|
|
let resolved = resolve_data_root(Some(tmp.path())).unwrap();
|
|
assert_eq!(resolved, tmp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn data_root_default_returns_some_path() {
|
|
// Can't assert the exact path (it depends on the host
|
|
// OS and env), but we can confirm we get *something*
|
|
// sensible-looking.
|
|
let resolved = resolve_data_root(None).unwrap();
|
|
let s = resolved.display().to_string();
|
|
assert!(
|
|
s.contains("rdbms-playground"),
|
|
"expected resolved path to mention the app name; got: {s}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_temp_builds_skeleton() {
|
|
let tmp = tempdir();
|
|
let project = Project::create_temp(tmp.path()).expect("create temp");
|
|
|
|
let path = project.path();
|
|
assert!(path.exists());
|
|
assert!(path.join(PROJECT_YAML).exists());
|
|
assert!(path.join(DATA_DIR).is_dir());
|
|
assert!(path.join(HISTORY_LOG).exists());
|
|
assert!(path.join(GITIGNORE).exists());
|
|
|
|
// playground.db is created lazily by SQLite, not by us.
|
|
assert!(!path.join(PLAYGROUND_DB).exists());
|
|
|
|
// Lock file must exist while we hold the project.
|
|
assert!(path.join(".rdbms-playground.lock").exists());
|
|
|
|
// YAML carries version + created_at.
|
|
let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
|
|
assert!(yaml.contains("version: 2"));
|
|
assert!(yaml.contains("created_at:"));
|
|
|
|
// .gitignore matches ADR-0015.
|
|
let gi = fs::read_to_string(path.join(GITIGNORE)).unwrap();
|
|
assert!(gi.contains("/playground.db"));
|
|
assert!(gi.contains("/.rdbms-playground.lock"));
|
|
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
|
|
assert!(
|
|
!gi.contains("history.log"),
|
|
"history.log should NOT be ignored"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn temp_project_lives_under_projects_subdir() {
|
|
let tmp = tempdir();
|
|
let project = Project::create_temp(tmp.path()).expect("create temp");
|
|
let parent = project.path().parent().unwrap();
|
|
assert_eq!(parent.file_name().unwrap(), PROJECTS_SUBDIR);
|
|
}
|
|
|
|
#[test]
|
|
fn create_temp_display_name_is_prettified() {
|
|
let tmp = tempdir();
|
|
let project = Project::create_temp(tmp.path()).expect("create temp");
|
|
// Name should not start with a digit (date prefix
|
|
// stripped) and each word capitalized.
|
|
let dn = project.display_name();
|
|
assert!(
|
|
dn.chars().next().map(char::is_uppercase).unwrap_or(false),
|
|
"expected title-cased display name, got: {dn}"
|
|
);
|
|
assert!(!dn.contains('-'));
|
|
assert!(!dn.starts_with(char::is_numeric));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_releases_lock() {
|
|
let tmp = tempdir();
|
|
let path = {
|
|
let project = Project::create_temp(tmp.path()).expect("create temp");
|
|
project.path().to_path_buf()
|
|
};
|
|
assert!(!path.join(".rdbms-playground.lock").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn create_named_refuses_existing_path() {
|
|
let tmp = tempdir();
|
|
let target = tmp.path().join("MyProject");
|
|
fs::create_dir(&target).unwrap();
|
|
let err = Project::create_named(&target).expect_err("must refuse");
|
|
assert!(
|
|
matches!(err, ProjectError::AlreadyExists(_)),
|
|
"got: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn create_named_builds_skeleton_at_arbitrary_path() {
|
|
let tmp = tempdir();
|
|
let target = tmp.path().join("TermPlanner");
|
|
let project = Project::create_named(&target).expect("create named");
|
|
assert_eq!(project.display_name(), "Term Planner");
|
|
assert!(target.join(PROJECT_YAML).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn open_refuses_nonexistent_path() {
|
|
let tmp = tempdir();
|
|
let err = Project::open(&tmp.path().join("does-not-exist")).expect_err("must refuse");
|
|
assert!(matches!(err, ProjectError::PathNotFound(_)), "got: {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn open_refuses_non_project_directory() {
|
|
let tmp = tempdir();
|
|
let dir = tmp.path().join("random");
|
|
fs::create_dir(&dir).unwrap();
|
|
fs::write(dir.join("README.txt"), "hello").unwrap();
|
|
let err = Project::open(&dir).expect_err("must refuse");
|
|
assert!(matches!(err, ProjectError::NotAProject(_)), "got: {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn open_succeeds_after_create() {
|
|
let tmp = tempdir();
|
|
let path = {
|
|
let project = Project::create_temp(tmp.path()).expect("create");
|
|
project.path().to_path_buf()
|
|
};
|
|
// Re-open after the original Project was dropped.
|
|
let reopened = Project::open(&path).expect("reopen");
|
|
assert_eq!(reopened.path(), path);
|
|
}
|
|
|
|
#[test]
|
|
fn db_path_points_inside_project() {
|
|
let tmp = tempdir();
|
|
let project = Project::create_temp(tmp.path()).expect("create");
|
|
assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
|
|
}
|
|
|
|
#[test]
|
|
fn read_last_project_returns_none_when_missing() {
|
|
let tmp = tempdir();
|
|
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn write_then_read_last_project_round_trips() {
|
|
let tmp = tempdir();
|
|
let target = std::path::PathBuf::from("/tmp/some/project");
|
|
write_last_project(tmp.path(), &target).unwrap();
|
|
let read_back = read_last_project(tmp.path()).unwrap();
|
|
assert_eq!(read_back, Some(target));
|
|
}
|
|
|
|
#[test]
|
|
fn last_project_strips_trailing_whitespace() {
|
|
let tmp = tempdir();
|
|
fs::write(
|
|
tmp.path().join(LAST_PROJECT_FILE),
|
|
"/tmp/some/project\n\n ",
|
|
)
|
|
.unwrap();
|
|
let read_back = read_last_project(tmp.path()).unwrap();
|
|
assert_eq!(
|
|
read_back,
|
|
Some(std::path::PathBuf::from("/tmp/some/project"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_last_project_file_is_treated_as_none() {
|
|
let tmp = tempdir();
|
|
fs::write(tmp.path().join(LAST_PROJECT_FILE), " \n").unwrap();
|
|
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
|
}
|
|
}
|