wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The workshop's one database, `$PRJ/.state/wsb.db`
//! ([`wsb_prj::database_path`]).
//!
//! Everything wsb keeps in a database is in this one file: the machine's agent
//! sessions, every worktree's test catalogue and workbench layout (each row
//! keyed by its worktree), the GitHub mirror's tables and the peers'. No
//! worktree, worktree or machine folder holds a database of its own, and
//! nothing in one is ever committed.
//!
//! Opened once per process: every command and every route of the daemon
//! shares one pool, and the schema is brought up to date once.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};

use anyhow::{Context, Result};

use crate::persist::global::{DatabaseConfig, DatabaseService};

fn opened() -> &'static Mutex<HashMap<PathBuf, Arc<DatabaseService>>> {
    static OPENED: OnceLock<Mutex<HashMap<PathBuf, Arc<DatabaseService>>>> = OnceLock::new();
    OPENED.get_or_init(Default::default)
}

/// Where the database is.
pub fn path() -> Result<PathBuf> {
    Ok(wsb_prj::database_path()?)
}

/// The databases an older wsb kept for the machine, while any is still
/// there: the machine's sessions in its own folder, its peers, and the
/// workshop's GitHub mirror.
pub fn older_databases() -> Result<Vec<PathBuf>> {
    let store = path()?;
    let machine = crate::daemon::machine_dir().map_err(anyhow::Error::msg)?;
    let mut old = vec![machine.join("machine.db"), machine.join("wsb.db")];
    if let Ok(workshop) = wsb_prj::Workshop::from_env() {
        old.push(workshop.state_dir().join("github.db"));
    }
    Ok(old.into_iter().filter(|db| *db != store && db.exists()).collect())
}

/// Refuse to go on while an older wsb's databases are there. Anything this
/// wsb wrote first would be a second database `wsb migrate` must then refuse
/// to choose between.
pub fn require_current_layout() -> Result<()> {
    let old = older_databases()?;
    if !old.is_empty() {
        anyhow::bail!(
            "an older wsb's databases are still here ({}): stop the daemon (`wsb daemon stop`) and run \
             `wsb migrate` to bring them into {}",
            old.iter().map(|db| db.display().to_string()).collect::<Vec<_>>().join(", "),
            path()?.display()
        );
    }
    Ok(())
}

/// The database, opened — once per process for each place it can be, so a
/// test pointing the machine somewhere of its own gets that machine's.
/// Refused while an older wsb's databases remain ([`require_current_layout`]).
pub fn open() -> Result<Arc<DatabaseService>> {
    require_current_layout()?;
    open_for_migration()
}

/// The database, opened whatever else is there: `wsb migrate`'s, which is
/// what brings the older databases into it.
pub fn open_for_migration() -> Result<Arc<DatabaseService>> {
    let path = path()?;
    let mut held = opened().lock().map_err(|_| anyhow::anyhow!("the database registry was poisoned"))?;
    if let Some(service) = held.get(&path) {
        return Ok(service.clone());
    }
    let service = Arc::new(
        DatabaseService::new(&DatabaseConfig::new(&path))
            .with_context(|| format!("opening the workshop's database at {}", path.display()))?,
    );
    held.insert(path, service.clone());
    Ok(service)
}

#[cfg(test)]
mod tests {
    use crate::test_environment::Environment;

    /// TEST20136: the workshop's database is the workshop's whoever names the
    /// machine's folder: unnamed, and named as this machine's own folder in
    /// the workshop — as a service definition names it — both are
    /// `$PRJ/.state/wsb.db`; a machine folder of its own carries its own
    /// database; with no workshop it is in the home folder.
    ///
    /// The service named the machine's folder, and the daemon it started kept
    /// a second database beside the one every command used: two sets of
    /// sessions and an empty mirror on the boards page.
    #[test]
    fn test20136_the_machines_own_folder_shares_the_workshops_database() {
        let temp = tempfile::tempdir().unwrap();
        let prj = crate::platform::canonicalize(temp.path()).unwrap().join("prj");
        let apart = prj.parent().unwrap().join("apart");
        let home = prj.parent().unwrap().join("home");
        for dir in [&prj, &apart, &home] {
            std::fs::create_dir_all(dir).unwrap();
        }
        let workshop = wsb_prj::Workshop::at(&prj).unwrap();
        let host = workshop.host_dir().unwrap();
        std::fs::create_dir_all(&host).unwrap();
        let ours = prj.join(".state/wsb.db");

        let mut env = Environment::set(&[("PRJ", &prj.to_string_lossy()), ("HOME", &home.to_string_lossy())]);
        env.unset(crate::daemon::MACHINE_DIR_ENV);
        assert_eq!(super::path().unwrap(), ours);

        env.change(&[(crate::daemon::MACHINE_DIR_ENV, &host.to_string_lossy())]);
        assert_eq!(super::path().unwrap(), ours, "the machine's own folder kept a database of its own");
        // However the folder is spelled.
        env.change(&[(crate::daemon::MACHINE_DIR_ENV, &format!("{}/../{}", host.display(), host.file_name().unwrap().to_string_lossy()))]);
        assert_eq!(super::path().unwrap(), ours);

        env.change(&[(crate::daemon::MACHINE_DIR_ENV, &apart.to_string_lossy())]);
        assert_eq!(super::path().unwrap(), apart.join("wsb.db"), "a machine set apart shares the workshop's database");

        env.unset(crate::daemon::MACHINE_DIR_ENV);
        env.unset("PRJ");
        assert_eq!(super::path().unwrap(), home.join(".wsb/wsb.db"));
    }
}