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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user