gobby-wiki 0.8.0

Gobby wiki CLI shell
//! Obsidian-vault integration for `gwiki init`.
//!
//! Two pieces of hardening run at init time:
//!
//! 1. [`seed_app_json`] writes `<vault>/.obsidian/app.json` with a
//!    `userIgnoreFilters` entry for the control dir ([`crate::vault::STATE_ROOT`])
//!    so it is de-noised in Obsidian's search/graph.
//! 2. [`ensure_gitignore_obsidian`] adds the machine-local Obsidian workspace
//!    files under the resolved vault directory to the enclosing `.gitignore` —
//!    but only inside a git work tree. Stable vault config such as `app.json`
//!    stays commit-eligible so clones open with the intended vault behavior.

use std::path::{Path, PathBuf};

use serde_json::{Map, Value};

use crate::WikiError;
use crate::vault::STATE_ROOT;

const WORKSPACE_STATE_FILES: &[&str] =
    &["workspace.json", "workspaces.json", "workspace-mobile.json"];

/// The `userIgnoreFilters` pattern that hides the control dir in Obsidian.
fn state_filter() -> String {
    format!("{STATE_ROOT}/")
}

/// Seed `<vault_root>/.obsidian/app.json` with a `userIgnoreFilters` entry for the
/// control dir.
///
/// Idempotent and non-destructive: merges into any existing `app.json`, preserving
/// unrelated keys and existing filters. Obsidian owns this file and rewrites it on
/// every settings change, so we never overwrite it wholesale. If the existing file
/// is shaped unexpectedly (not an object, or `userIgnoreFilters` is not an array),
/// we leave it untouched rather than fight Obsidian.
pub(crate) fn seed_app_json(vault_root: &Path) -> Result<(), WikiError> {
    let dir = vault_root.join(".obsidian");
    let path = dir.join("app.json");
    let filter = state_filter();

    let mut root = match std::fs::read(&path) {
        Ok(bytes) => serde_json::from_slice::<Value>(&bytes).map_err(|source| WikiError::Json {
            action: "parse obsidian app.json",
            path: Some(path.clone()),
            source,
        })?,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
        Err(source) => {
            return Err(WikiError::Io {
                action: "read obsidian app.json",
                path: Some(path.clone()),
                source,
            });
        }
    };

    let Some(object) = root.as_object_mut() else {
        return Ok(());
    };
    let filters = object
        .entry("userIgnoreFilters")
        .or_insert_with(|| Value::Array(Vec::new()));
    let Some(filters) = filters.as_array_mut() else {
        return Ok(());
    };
    if filters.iter().any(|e| e.as_str() == Some(filter.as_str())) {
        return Ok(());
    }
    filters.push(Value::String(filter));

    let serialized = serde_json::to_vec_pretty(&root).map_err(|source| WikiError::Json {
        action: "serialize obsidian app.json",
        path: Some(path.clone()),
        source,
    })?;

    std::fs::create_dir_all(&dir).map_err(|source| WikiError::Io {
        action: "create obsidian config directory",
        path: Some(dir.clone()),
        source,
    })?;
    std::fs::write(&path, serialized).map_err(|source| WikiError::Io {
        action: "write obsidian app.json",
        path: Some(path),
        source,
    })
}

/// Ensure machine-local Obsidian workspace state is git-ignored, but only when
/// `vault_root` lives inside a git work tree (not every wiki lives in a code
/// project). Rules are anchored to the resolved vault directory (`wiki/`, or a
/// `gobby-wiki` collision fallback) relative to the git root.
///
/// The rules are appended at end-of-file: git uses last-match-wins, so a
/// `.gitignore` that re-includes the vault would otherwise keep tracking local
/// workspace churn. Idempotent: a no-op when all workspace rules already exist.
pub(crate) fn ensure_gitignore_obsidian(vault_root: &Path) -> Result<(), WikiError> {
    let Some(git_root) = find_git_root(vault_root) else {
        return Ok(());
    };
    // `find_git_root` walks up from `vault_root`, so the git root is always an
    // ancestor and `strip_prefix` cannot fail.
    let vault_rel = vault_root
        .strip_prefix(&git_root)
        .unwrap_or(vault_root)
        .to_string_lossy()
        .replace('\\', "/");
    let workspace_rules = WORKSPACE_STATE_FILES
        .iter()
        .map(|file| {
            if vault_rel.is_empty() {
                format!(".obsidian/{file}")
            } else {
                format!("{vault_rel}/.obsidian/{file}")
            }
        })
        .collect::<Vec<_>>();
    let gitignore = git_root.join(".gitignore");

    let existing = match std::fs::read_to_string(&gitignore) {
        Ok(text) => Some(text),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
        Err(source) => {
            return Err(WikiError::Io {
                action: "read .gitignore",
                path: Some(gitignore.clone()),
                source,
            });
        }
    };

    if let Some(text) = &existing
        && workspace_rules
            .iter()
            .all(|rule| text.lines().any(|line| line.trim() == rule.as_str()))
    {
        return Ok(());
    }

    let mut next = existing.unwrap_or_default();
    if !next.is_empty() && !next.ends_with('\n') {
        next.push('\n');
    }
    for rule in &workspace_rules {
        if !next.lines().any(|line| line.trim() == rule.as_str()) {
            next.push_str(rule);
            next.push('\n');
        }
    }

    std::fs::write(&gitignore, next).map_err(|source| WikiError::Io {
        action: "write .gitignore",
        path: Some(gitignore),
        source,
    })
}

/// Walk up from `start` for a `.git` entry — a directory for normal repos, a file
/// for worktrees and submodules. Returns the directory that contains it, or `None`.
fn find_git_root(start: &Path) -> Option<PathBuf> {
    let mut current = Some(start);
    while let Some(dir) = current {
        if dir.join(".git").exists() {
            return Some(dir.to_path_buf());
        }
        current = dir.parent();
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn seed_app_json_creates_filter_when_absent() {
        let temp = tempfile::tempdir().expect("tempdir");
        let vault = temp.path();
        seed_app_json(vault).expect("seed");
        let text =
            std::fs::read_to_string(vault.join(".obsidian/app.json")).expect("read app.json");
        assert!(text.contains("userIgnoreFilters"));
        assert!(text.contains("_gwiki/"));
    }

    #[test]
    fn seed_app_json_is_idempotent_and_preserves_keys() {
        let temp = tempfile::tempdir().expect("tempdir");
        let vault = temp.path();
        let obsidian = vault.join(".obsidian");
        std::fs::create_dir_all(&obsidian).expect("mkdir");
        std::fs::write(
            obsidian.join("app.json"),
            r#"{"promptDelete":false,"userIgnoreFilters":["existing/"]}"#,
        )
        .expect("seed existing");

        seed_app_json(vault).expect("seed once");
        seed_app_json(vault).expect("seed twice");

        let text = std::fs::read_to_string(obsidian.join("app.json")).expect("read app.json");
        let value: Value = serde_json::from_str(&text).expect("parse");
        let filters = value["userIgnoreFilters"].as_array().expect("array");
        assert_eq!(
            filters.iter().filter(|e| *e == "_gwiki/").count(),
            1,
            "filter added exactly once"
        );
        assert!(
            filters.iter().any(|e| *e == "existing/"),
            "existing filter kept"
        );
        assert_eq!(
            value["promptDelete"],
            Value::Bool(false),
            "unrelated key kept"
        );
    }

    #[test]
    fn gitignore_noop_outside_git_work_tree() {
        let temp = tempfile::tempdir().expect("tempdir");
        ensure_gitignore_obsidian(temp.path()).expect("noop");
        assert!(!temp.path().join(".gitignore").exists());
    }

    #[test]
    fn gitignore_appends_workspace_state_once_and_preserves_existing() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        std::fs::create_dir_all(root.join(".git")).expect("fake git");
        std::fs::write(root.join(".gitignore"), "/target\n.claude/\n").expect("seed gitignore");
        let vault = root.join("wiki");
        std::fs::create_dir_all(&vault).expect("create vault dir");

        ensure_gitignore_obsidian(&vault).expect("first");
        ensure_gitignore_obsidian(&vault).expect("second");

        let text = std::fs::read_to_string(root.join(".gitignore")).expect("read");
        for rule in [
            "wiki/.obsidian/workspace.json",
            "wiki/.obsidian/workspaces.json",
            "wiki/.obsidian/workspace-mobile.json",
        ] {
            assert_eq!(
                text.lines().filter(|l| l.trim() == rule).count(),
                1,
                "{rule} appended exactly once"
            );
        }
        assert!(text.contains("/target"), "existing content preserved");
        assert!(
            text.trim_end()
                .ends_with("wiki/.obsidian/workspace-mobile.json"),
            "workspace rules land at end of file"
        );
        assert!(
            !text.lines().any(|l| l.trim() == ".obsidian/"),
            "stable Obsidian config remains commit-eligible"
        );
    }

    #[test]
    fn gitignore_created_when_absent_in_git_tree() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        std::fs::create_dir_all(root.join(".git")).expect("fake git");
        let vault = root.join("wiki");
        std::fs::create_dir_all(&vault).expect("create vault dir");
        ensure_gitignore_obsidian(&vault).expect("create");
        let text = std::fs::read_to_string(root.join(".gitignore")).expect("read");
        assert_eq!(
            text,
            concat!(
                "wiki/.obsidian/workspace.json\n",
                "wiki/.obsidian/workspaces.json\n",
                "wiki/.obsidian/workspace-mobile.json\n"
            )
        );
    }

    #[test]
    fn gitignore_rules_track_fallback_vault_directory() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path();
        std::fs::create_dir_all(root.join(".git")).expect("fake git");
        let vault = root.join("gobby-wiki");
        std::fs::create_dir_all(&vault).expect("create fallback vault dir");
        ensure_gitignore_obsidian(&vault).expect("create");
        let text = std::fs::read_to_string(root.join(".gitignore")).expect("read");
        assert_eq!(
            text,
            concat!(
                "gobby-wiki/.obsidian/workspace.json\n",
                "gobby-wiki/.obsidian/workspaces.json\n",
                "gobby-wiki/.obsidian/workspace-mobile.json\n"
            )
        );
    }
}