//! 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> { 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 { 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()) } /// `/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 `/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 { 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 { 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 for ProjectError { fn from(e: NamingError) -> Self { Self::Naming(e) } } impl From for ProjectError { fn from(e: LockError) -> Self { Self::Lock(e) } } impl Project { /// Create a new auto-named temp project under /// `/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 { 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 { 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 { 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 /// `/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 `/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()); } }