Skip to main content

oxibrain_cli/cmd/
sync.rs

1//! `oxibrain sync <DIR> [--space s]` — vault sync.
2//!
3//! Scans DIR recursively for `.md` files (oxibrain-connectors), classifies each
4//! against the ledger's live note episodes for the space
5//! (`oxibrain_core::classify_sync`), and ingests new/modified files with
6//! `occurred_at` = file mtime so episode ids are stable across re-syncs.
7//! Unchanged files are skipped — re-syncing an unchanged tree is a no-op.
8//!
9//! Modified paths append a new episode; the previous episode remains
10//! (append-only ledger, P1). Its assertions stay live until retracted — check
11//! `oxibrain contradictions` after syncing edits.
12
13use anyhow::{Context, bail};
14use oxibrain::{Brain, BrainConfig};
15use oxibrain_connectors::scan_directory;
16use oxibrain_core::{SyncAction, SyncFile, classify_sync, content_hash};
17use oxibrain_ports::Timestamp;
18use std::collections::HashMap;
19use std::path::Path;
20use std::time::UNIX_EPOCH;
21
22/// Per-run outcome, returned for programmatic use and printed by the CLI.
23#[derive(Debug, Default, PartialEq, Eq)]
24pub struct SyncReport {
25    pub new: Vec<String>,
26    pub unchanged: Vec<String>,
27    pub modified: Vec<String>,
28}
29
30pub async fn run(dir: &Path, root: &Path, space: &str) -> anyhow::Result<()> {
31    let report = sync(dir, root, space).await?;
32    print_report(&report);
33    Ok(())
34}
35
36/// Scan, classify, ingest. The store path convention is the file's path
37/// relative to the sync root (forward slashes), so syncs are stable across
38/// working directories and machines.
39pub async fn sync(dir: &Path, root: &Path, space: &str) -> anyhow::Result<SyncReport> {
40    if !root.is_dir() {
41        bail!("not a directory: {}", root.display());
42    }
43    let files = scan_directory(root);
44    let brain = Brain::open(BrainConfig::at(dir)).await?;
45    let space_id = brain.ensure_space(space).await?;
46    let known = brain.note_hashes(&space_id).await?;
47
48    // Content is dropped after hashing; keep it per path for the ingest pass.
49    let mut contents: HashMap<String, (String, Timestamp)> = HashMap::new();
50    let sync_files: Vec<SyncFile> = files
51        .into_iter()
52        .filter_map(|f| {
53            let path = f.path.to_str()?.to_string();
54            let modified = systemtime_to_timestamp(f.modified);
55            let hash = content_hash(&f.content);
56            contents.insert(path.clone(), (f.content, modified));
57            Some(SyncFile {
58                path,
59                content_hash: hash,
60                modified,
61            })
62        })
63        .collect();
64
65    let mut report = SyncReport::default();
66    for action in classify_sync(sync_files, &known) {
67        match action {
68            SyncAction::New(f) => {
69                ingest_one(&brain, &space_id, &contents, &f).await?;
70                report.new.push(f.path);
71            }
72            SyncAction::Modified(f) => {
73                ingest_one(&brain, &space_id, &contents, &f).await?;
74                report.modified.push(f.path);
75            }
76            SyncAction::Unchanged(p) => report.unchanged.push(p),
77        }
78    }
79    Ok(report)
80}
81
82async fn ingest_one(
83    brain: &Brain,
84    space_id: &str,
85    contents: &HashMap<String, (String, Timestamp)>,
86    f: &SyncFile,
87) -> anyhow::Result<()> {
88    let (content, occurred_at) = contents
89        .get(&f.path)
90        .with_context(|| format!("content missing for scanned path {}", f.path))?;
91    brain
92        .ingest_note(space_id, &f.path, content.clone(), *occurred_at)
93        .await?;
94    Ok(())
95}
96
97fn systemtime_to_timestamp(t: std::time::SystemTime) -> Timestamp {
98    let millis = t
99        .duration_since(UNIX_EPOCH)
100        .map(|d| d.as_millis() as i64)
101        .unwrap_or(0);
102    Timestamp(millis)
103}
104
105fn print_report(report: &SyncReport) {
106    println!(
107        "sync complete: {} new, {} unchanged, {} modified",
108        report.new.len(),
109        report.unchanged.len(),
110        report.modified.len()
111    );
112    for p in &report.new {
113        println!("  new:       {p}");
114    }
115    for p in &report.modified {
116        println!("  modified:  {p}");
117    }
118    if !report.modified.is_empty() {
119        println!(
120            "  note: modified paths append a new episode; previous versions remain — \
121             check `oxibrain contradictions` and `retract` stale claims"
122        );
123    }
124}