Skip to main content

oxibrain_cli/cmd/
sync.rs

1//! `oxibrain sync <DIR> [--space s]` — vault sync with occurrence identity.
2//!
3//! Scans DIR recursively for `.md`/`.html` files (oxibrain-connectors),
4//! classifies each against the ledger's event-path state for the vault source
5//! (`oxibrain_core::classify_event`), and ingests new/modified files via the
6//! event path with derived occurrence IDs (§4.2). The orchestration lives in
7//! `oxibrain::vault` — this command is arg parsing, printing, and transport
8//! selection.
9//!
10//! Two transports: with the store free, one embedded pass runs in-process.
11//! When the daemon holds the P8 advisory lock, the command attaches to it
12//! over the default socket and runs the same pass via the `sync/run` RPC —
13//! the daemon is the sole writer, so recurring ingestion is always
14//! daemon-hosted (ADR-010).
15//!
16//! Occurrence chain: `occurrence_id = H(source_id, locator, predecessor, content_hash)`.
17//! A → B → A creates three events because the predecessor differs.
18//! Unchanged files are skipped — re-syncing an unchanged tree is a no-op.
19//! Legacy episodes (pre-event-identity) participate in Unchanged classification
20//! but are never re-ingested.
21
22use anyhow::Context;
23use oxibrain::{vault, vault::SyncReport};
24#[cfg(unix)]
25use oxibrain_client::default_socket_path;
26use std::path::Path;
27
28pub async fn run(dir: &Path, root: &Path, space: &str) -> anyhow::Result<()> {
29    let report = match oxibrain::Brain::open(oxibrain::BrainConfig::at(dir)).await {
30        Ok(brain) => vault::sync_vault(&brain, root, space).await?,
31        Err(oxibrain::BrainError::Locked { holder }) => {
32            eprintln!("note: store locked ({holder}); attaching to the daemon socket");
33            run_via_daemon(root, space).await?
34        }
35        Err(e) => return Err(e.into()),
36    };
37    print_report(&report);
38    Ok(())
39}
40
41/// Run one sync pass through the daemon's `sync/run` RPC (trusted local
42/// socket). Registers the vault as a pull source; the daemon adopts it into
43/// a debounced watcher.
44async fn run_via_daemon(root: &Path, space: &str) -> anyhow::Result<SyncReport> {
45    let socket = socket_path()?;
46    let mut client = oxibrain_client::BrainClient::connect(&socket)
47        .await
48        .with_context(|| format!("attach to daemon at {}", socket.display()))?;
49    let out = client
50        .sync_run(&root.to_string_lossy(), space)
51        .await
52        .context("sync/run on daemon")?;
53    Ok(SyncReport {
54        new: out.new,
55        modified: out.modified,
56        unchanged: out.unchanged,
57    })
58}
59
60/// Resolve the daemon socket by the Oxi Foundation convention: explicit
61/// `$OXIBRAIN_SOCKET`, else `~/.oxi/brain/oxibrain.sock`.
62#[cfg(unix)]
63fn socket_path() -> anyhow::Result<std::path::PathBuf> {
64    default_socket_path().ok_or_else(|| {
65        anyhow::anyhow!("no daemon socket: $OXIBRAIN_SOCKET unset and $HOME unavailable")
66    })
67}
68
69#[cfg(not(unix))]
70fn socket_path() -> anyhow::Result<std::path::PathBuf> {
71    anyhow::bail!("daemon attach is only supported on Unix")
72}
73
74fn print_report(report: &SyncReport) {
75    if !report.new.is_empty() {
76        for p in &report.new {
77            println!("  new: {p}");
78        }
79    }
80    if !report.modified.is_empty() {
81        for p in &report.modified {
82            println!("  modified: {p}");
83        }
84    }
85    println!(
86        "sync complete: {} new, {} unchanged, {} modified",
87        report.new.len(),
88        report.unchanged.len(),
89        report.modified.len()
90    );
91}