obj-cli 1.0.0

Command-line tools (dump, check, stat, backup) for the obj embedded document database.
Documentation
//! `obj check` acceptance tests (issue #98).
//!
//! Drives the `obj` binary via `assert_cmd` across the three
//! exit-code paths the AC specifies:
//!
//! - clean DB → exit 0, stdout begins with `ok: `.
//! - hand-corrupted DB → exit 1, output mentions a failure variant.
//! - missing path → exit 2, stderr mentions the missing file.
//!
//! Cross-platform: `tempfile::TempDir` for the DB host directory,
//! `assert_cmd::Command::cargo_bin("obj")` to invoke the binary
//! through cargo's test harness so Linux / macOS / Windows all
//! follow the same path.

use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};

use assert_cmd::Command;
use obj::{Db, Document, IndexSpec};
use obj_core::pager::page::PAGE_SIZE;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use serde::{Deserialize, Serialize};
use tempfile::TempDir;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct User {
    email: String,
    handle: String,
}

impl Document for User {
    const COLLECTION: &'static str = "users";
    const VERSION: u32 = 1;

    fn indexes() -> Vec<IndexSpec> {
        vec![IndexSpec::unique("by_email", "email").expect("static spec")]
    }
}

fn populate(path: &std::path::Path, n: u32) {
    let db = Db::open(path).expect("open");
    for i in 0..n {
        db.insert(User {
            email: format!("user{i}@example.com"),
            handle: format!("u{i}"),
        })
        .expect("insert");
    }
}

#[test]
fn check_clean_db_exits_zero() {
    let dir = TempDir::new().expect("tmp");
    let path = dir.path().join("clean.obj");
    populate(&path, 16);
    Command::cargo_bin("obj")
        .expect("binary")
        .arg("check")
        .arg(&path)
        .assert()
        .success()
        .stdout(contains("ok:"));
}

#[test]
fn check_corrupted_db_exits_one() {
    let dir = TempDir::new().expect("tmp");
    let path = dir.path().join("corrupted.obj");
    populate(&path, 32);
    // Mirror the M11 integrity test's recipe: checkpoint so the
    // WAL no longer shadows the bytes we are about to flip; find
    // the primary B-tree root via the catalog; flip a byte inside
    // its slot directory.
    checkpoint_and_close(&path).expect("checkpoint");
    let target_pid = locate_primary_page(&path).expect("primary");
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .open(&path)
        .expect("open for corruption");
    let target_off = (target_pid * PAGE_SIZE as u64) + 64;
    file.seek(SeekFrom::Start(target_off)).expect("seek");
    file.write_all(&[0xFFu8]).expect("flip");
    drop(file);

    Command::cargo_bin("obj")
        .expect("binary")
        .arg("check")
        .arg(&path)
        .assert()
        .code(1)
        .stderr(
            contains("ChecksumMismatch")
                .or(contains("BTreeSortViolation"))
                .or(contains("Corruption")),
        );
}

#[test]
fn check_missing_path_exits_two() {
    // `Db::open` creates the file on first open, so a path inside
    // an *existing* temp dir would succeed (open creates a fresh
    // DB). To exercise the "I/O / couldn't even ask" branch, point
    // at a path under a non-existent parent directory — every host
    // OS the workspace targets refuses to create that.
    let dir = TempDir::new().expect("tmp");
    let missing = dir.path().join("no-such-dir").join("does-not-exist.obj");
    Command::cargo_bin("obj")
        .expect("binary")
        .arg("check")
        .arg(&missing)
        .assert()
        .code(2)
        .stderr(contains("error:"));
}

/// Open the file at the pager layer and call `close()` so the WAL
/// is checkpointed into the main file and the sidecar removed.
fn checkpoint_and_close(path: &std::path::Path) -> obj::Result<()> {
    use obj_core::pager::{Config, Pager};
    use obj_core::platform::FileHandle;
    let pager = Pager::<FileHandle>::open(path, Config::default())?;
    pager.close()
}

/// Look up the primary B-tree root page-id of the `users` collection.
fn locate_primary_page(path: &std::path::Path) -> obj::Result<u64> {
    use obj_core::pager::{Config, Pager};
    use obj_core::platform::FileHandle;
    use obj_core::Catalog;

    let mut pager = Pager::<FileHandle>::open(path, Config::default())?;
    pager.begin_txn();
    let catalog = Catalog::<FileHandle>::open_or_init(&mut pager)?;
    let descriptor = catalog.get(&mut pager, "users")?.expect("users present");
    pager.end_txn();
    Ok(descriptor.primary_root)
}