Files
rdbms-playground/src/project/mod.rs
T
claude@clouddev1 6b4c4dcea4
ci / gate (push) Failing after 35s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s
feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
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).
2026-06-22 21:25:38 +00:00

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());
}
}