gobby-wiki 0.3.0

Gobby wiki CLI shell
#![allow(dead_code)]

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

use tempfile::TempDir;

pub const PROJECT_ID: &str = "project-123";
pub const GCODE_JSON: &str = r#"{
  "id": "project-123",
  "name": "gcode-fixture"
}
"#;

const GWIKI_SCOPE_TABLES: &[&str] = &[
    "gwiki_ingestions",
    "gwiki_links",
    "gwiki_chunks",
    "gwiki_sources",
    "gwiki_documents",
];

fn validated_gwiki_scope_table_name(table: &str) -> Option<&'static str> {
    match table {
        "gwiki_ingestions" => Some("gwiki_ingestions"),
        "gwiki_links" => Some("gwiki_links"),
        "gwiki_chunks" => Some("gwiki_chunks"),
        "gwiki_sources" => Some("gwiki_sources"),
        "gwiki_documents" => Some("gwiki_documents"),
        _ => None,
    }
}

pub struct GwikiFixture {
    _tempdir: TempDir,
    root: PathBuf,
    hub: PathBuf,
    home: PathBuf,
    project: PathBuf,
}

pub struct InitializedTopic {
    pub name: String,
    pub vault: PathBuf,
}

impl GwikiFixture {
    pub fn new() -> Self {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let root = tempdir.path().to_path_buf();
        let hub = root.join("hub");
        let home = root.join("home");
        let project = root.join("project");
        fs::create_dir_all(&home).expect("create isolated home");
        fs::create_dir_all(&project).expect("create isolated project");

        Self {
            _tempdir: tempdir,
            root,
            hub,
            home,
            project,
        }
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn hub(&self) -> &Path {
        &self.hub
    }

    pub fn home(&self) -> &Path {
        &self.home
    }

    pub fn project(&self) -> &Path {
        &self.project
    }

    pub fn topic_vault(&self, topic: &str) -> PathBuf {
        self.hub.join("topics").join(topic)
    }

    pub fn command(&self) -> Command {
        self.command_in(self.root())
    }

    pub fn command_in(&self, cwd: &Path) -> Command {
        let mut command = gwiki_command();
        self.apply_isolated_env(&mut command).current_dir(cwd);
        command
    }

    pub fn command_in_project(&self) -> Command {
        self.command_in(self.project())
    }

    pub fn command_with_database_url_in(&self, cwd: &Path, database_url: &str) -> Command {
        let mut command = self.command_in(cwd);
        command.env("GWIKI_DATABASE_URL", database_url);
        command
    }

    pub fn output(&self, args: &[&str]) -> Output {
        self.output_in(self.root(), args)
    }

    pub fn output_in(&self, cwd: &Path, args: &[&str]) -> Output {
        self.command_in(cwd)
            .args(args)
            .output()
            .expect("gwiki binary runs")
    }

    pub fn output_in_project(&self, args: &[&str]) -> Output {
        self.output_in(self.project(), args)
    }

    pub fn output_with_database_url_in(
        &self,
        cwd: &Path,
        database_url: &str,
        args: &[&str],
    ) -> Output {
        self.command_with_database_url_in(cwd, database_url)
            .args(args)
            .output()
            .expect("gwiki binary runs")
    }

    pub fn init_topic(&self, label: &str) -> InitializedTopic {
        let name = unique_topic(label);
        let output = self.output(&["init", "--topic", &name]);
        assert_success(&output, "topic init");
        InitializedTopic {
            vault: self.topic_vault(&name),
            name,
        }
    }

    fn apply_isolated_env<'a>(&self, command: &'a mut Command) -> &'a mut Command {
        command
            .env("GOBBY_WIKI_HUB", &self.hub)
            .env("HOME", &self.home)
            .env("XDG_CONFIG_HOME", self.root.join("xdg-config"))
            .env("XDG_DATA_HOME", self.root.join("xdg-data"))
            .env("XDG_CACHE_HOME", self.root.join("xdg-cache"))
            .env("XDG_STATE_HOME", self.root.join("xdg-state"))
    }
}

pub fn write_gcode_json(project: &Path) -> PathBuf {
    write_gobby_fixture(project, "gcode.json", GCODE_JSON)
}

fn write_gobby_fixture(project: &Path, file_name: &str, contents: &str) -> PathBuf {
    let gobby_dir = project.join(".gobby");
    fs::create_dir_all(&gobby_dir).expect("create .gobby");
    let path = gobby_dir.join(file_name);
    fs::write(&path, contents).expect("write .gobby fixture");
    path
}

pub fn assert_gcode_json_unchanged(path: &Path) {
    assert_eq!(
        fs::read_to_string(path).expect("read gcode json"),
        GCODE_JSON
    );
}

pub fn gwiki_command() -> Command {
    let mut command = Command::new(env!("CARGO_BIN_EXE_gwiki"));
    strip_service_env(&mut command);
    command
}

pub fn assert_success(output: &Output, label: &str) {
    assert!(
        output.status.success(),
        "{label} failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

pub fn json_stdout(output: &Output) -> serde_json::Value {
    serde_json::from_slice(&output.stdout).expect("stdout is JSON")
}

pub fn json_stderr(output: &Output) -> serde_json::Value {
    serde_json::from_slice(&output.stderr).expect("stderr is JSON")
}

pub fn unique_topic(label: &str) -> String {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time after epoch")
        .as_nanos();
    format!(
        "{label}-{}-{nanos}-{}",
        std::process::id(),
        uuid::Uuid::new_v4().simple()
    )
}

pub fn postgres_test_database_url() -> Option<String> {
    std::env::var("GWIKI_POSTGRES_TEST_DATABASE_URL")
        .ok()
        .or_else(|| std::env::var("GCODE_POSTGRES_TEST_DATABASE_URL").ok())
        .filter(|value| !value.trim().is_empty())
}

pub struct GwikiScopeCleanup {
    database_url: String,
    scope_kind: &'static str,
    scope_id: String,
}

impl GwikiScopeCleanup {
    pub fn new(database_url: String, scope_kind: &'static str, scope_id: String) -> Self {
        Self {
            database_url,
            scope_kind,
            scope_id,
        }
    }
}

impl Drop for GwikiScopeCleanup {
    fn drop(&mut self) {
        cleanup_gwiki_scope(&self.database_url, self.scope_kind, &self.scope_id);
    }
}

pub fn strip_service_env(command: &mut Command) -> &mut Command {
    for key in [
        "GWIKI_DATABASE_URL",
        "GWIKI_POSTGRES_TEST_DATABASE_URL",
        "GOBBY_POSTGRES_DSN",
        "GCODE_DATABASE_URL",
        "GCODE_POSTGRES_TEST_DATABASE_URL",
        "GOBBY_FALKORDB_HOST",
        "GOBBY_FALKORDB_PORT",
        "GOBBY_FALKORDB_PASSWORD",
        "GOBBY_QDRANT_URL",
        "GOBBY_QDRANT_API_KEY",
        "GOBBY_HOME",
    ] {
        command.env_remove(key);
    }
    command
}

fn cleanup_gwiki_scope(database_url: &str, scope_kind: &str, scope_id: &str) {
    let Ok(mut client) = gobby_core::postgres::connect_readwrite(database_url) else {
        return;
    };
    for table in GWIKI_SCOPE_TABLES {
        let Some(table) = validated_gwiki_scope_table_name(table) else {
            continue;
        };
        let sql = format!("DELETE FROM {table} WHERE scope_kind = $1 AND scope_id = $2");
        let _ = client.execute(&sql, &[&scope_kind, &scope_id]);
    }
}