Files
rdbms-playground/src/project/mod.rs
T
claude@clouddev1 6ca297579e round-5 follow-up r2: migrate all thiserror Display attributes to catalog
Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.

Twelve error types migrated:

- dsl::action::UnknownAction         → parse.custom.unknown_action
- dsl::parser::ParseError            → parse.error_wrapper + parse.empty
- dsl::value::ValueError             → value.{type_mismatch,format}
- persistence::csv_io::CsvError      → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError       → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError           → project.lock.*
- project::naming::NamingError       → project.naming.*
- project::naming::UserNameError     → project.user_name.*
- project::mod::ProjectError         → project.{path_not_found,...}
- project::mod::SafeDeleteError      → project.safe_delete.*
- archive::ArchiveError              → archive.*
- cli::ArgsError                     → cli.*
- db::DbError                        → db.error.*

Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.

Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.

Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
2026-05-13 21:24:51 +00:00

968 lines
33 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
/// `version: 1` 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: 1\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
iso8601_now(),
);
write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
// .gitignore template (ADR-0015 §11). Excludes the
// derived `.db`, the per-process lock, and migration
// backups. `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";
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",
];
/// 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: 1"));
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("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());
}
}