Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI

Replaces the in-memory database with an on-disk project. Startup either
opens a project at the positional CLI path (L1) or creates an auto-named
temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard
data directory or a --data-dir override. The new project::Project type
owns the directory skeleton and a PID+hostname lock file with
stale-lock takeover via sysinfo. The status bar now shows
"Project: <Display Name>", derived by a small kebab/snake/camel
prettifier. Per-command persistence to YAML/CSV/history.log is NOT
yet wired -- that's Iteration 2; for now playground.db carries the
state across quits.

Tests: 257 passing (231 lib + 9 new integration + 17 existing),
0 failing, 0 skipped. Clippy clean with nursery lints.
This commit is contained in:
claude@clouddev1
2026-05-07 20:21:52 +00:00
parent 4fca862c6c
commit 601d3b6c51
20 changed files with 1883 additions and 18 deletions
+476
View File
@@ -0,0 +1,476 @@
//! 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). Iteration 1 doesn't
/// read or write it yet; defining the constant now keeps
/// related code colocated.
pub const LAST_PROJECT_FILE: &str = "last_project";
/// 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)
}
/// 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,
/// Held for the project's lifetime; released on drop.
_lock: Lock,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
DataRootUnavailable,
#[error("project path `{0}` does not exist")]
PathNotFound(PathBuf),
#[error(
"path `{0}` does not look like a project directory \
(no `project.yaml` and no `playground.db`)"
)]
NotAProject(PathBuf),
#[error("path `{0}` already exists; pick a different name or remove it first")]
AlreadyExists(PathBuf),
#[error("filesystem error at `{path}`: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error(transparent)]
Naming(#[from] NamingError),
#[error(transparent)]
Lock(#[from] LockError),
}
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,
_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,
_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 lock = Lock::acquire(path)?;
info!(path = %path.display(), name = %display_name, "opened project");
Ok(Self {
path: path.to_path_buf(),
display_name,
_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
}
/// 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)
}
}
/// 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));
}
}