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).
7//!
8//! Occurrence chain: `occurrence_id = H(source_id, locator, predecessor, content_hash)`.
9//! A → B → A creates three events because the predecessor differs.
10//! Unchanged files are skipped — re-syncing an unchanged tree is a no-op.
11//! Legacy episodes (pre-event-identity) participate in Unchanged classification
12//! but are never re-ingested.
13
14use anyhow::{Context, bail};
15use oxibrain::{Brain, BrainConfig, IngestAttachment, SourceRef, TrustTier};
16use oxibrain_connectors::scan_directory;
17use oxibrain_core::{
18    SyncAction, SyncFile, classify_event, content_hash, occurrence_id, sync::LocatorState,
19};
20use oxibrain_ports::Timestamp;
21use std::collections::HashMap;
22use std::path::Path;
23use std::time::UNIX_EPOCH;
24
25/// Per-run outcome, returned for programmatic use and printed by the CLI.
26#[derive(Debug, Default, PartialEq, Eq)]
27pub struct SyncReport {
28    pub new: Vec<String>,
29    pub unchanged: Vec<String>,
30    pub modified: Vec<String>,
31}
32
33pub async fn run(dir: &Path, root: &Path, space: &str) -> anyhow::Result<()> {
34    let report = sync(dir, root, space).await?;
35    print_report(&report);
36    Ok(())
37}
38
39/// Scan, classify, ingest via event path. The locator convention is the file's
40/// path relative to the sync root (forward slashes).
41pub async fn sync(dir: &Path, root: &Path, space: &str) -> anyhow::Result<SyncReport> {
42    if !root.is_dir() {
43        bail!("not a directory: {}", root.display());
44    }
45    let files = scan_directory(root);
46    let brain = Brain::open(BrainConfig::at(dir)).await?;
47    let space_id = brain.ensure_space(space).await?;
48
49    // Register the vault as a pull source. Source name = canonical path.
50    let source_name = root
51        .canonicalize()
52        .unwrap_or_else(|_| root.to_path_buf())
53        .to_string_lossy()
54        .into_owned();
55    let source_id = brain
56        .ensure_source(&space_id, &source_name, "document_revision", "pull")
57        .await?;
58
59    // Fetch both classification inputs.
60    let legacy = brain.note_hashes(&space_id).await?;
61    let event_states = brain.locator_states(&space_id, &source_id).await?;
62
63    // Content is dropped after hashing; keep it per path for the ingest pass.
64    let mut contents: HashMap<String, (String, Timestamp)> = HashMap::new();
65    let sync_files: Vec<SyncFile> = files
66        .into_iter()
67        .filter_map(|f| {
68            let path = f.path.to_str()?.to_string();
69            let modified = systemtime_to_timestamp(f.modified);
70            let hash = content_hash(&f.content);
71            contents.insert(path.clone(), (f.content, modified));
72            Some(SyncFile {
73                path,
74                content_hash: hash,
75                modified,
76            })
77        })
78        .collect();
79
80    let mut report = SyncReport::default();
81    let now = brain.clock_now();
82    for action in classify_event(sync_files, &legacy, &event_states) {
83        match action {
84            SyncAction::New(f) => {
85                ingest_event_one(
86                    &brain,
87                    &space_id,
88                    &source_id,
89                    &contents,
90                    &event_states,
91                    &f,
92                    now,
93                )
94                .await?;
95                report.new.push(f.path);
96            }
97            SyncAction::Modified(f) => {
98                ingest_event_one(
99                    &brain,
100                    &space_id,
101                    &source_id,
102                    &contents,
103                    &event_states,
104                    &f,
105                    now,
106                )
107                .await?;
108                report.modified.push(f.path);
109            }
110            SyncAction::Unchanged(p) => report.unchanged.push(p),
111        }
112    }
113    Ok(report)
114}
115
116async fn ingest_event_one(
117    brain: &Brain,
118    space_id: &str,
119    source_id: &str,
120    contents: &HashMap<String, (String, Timestamp)>,
121    event_states: &HashMap<String, LocatorState>,
122    f: &SyncFile,
123    now: Timestamp,
124) -> anyhow::Result<()> {
125    let (content, _occurred_at) = contents
126        .get(&f.path)
127        .with_context(|| format!("content missing for scanned path {}", f.path))?;
128
129    // Derive occurrence: predecessor is the latest occurrence for this locator.
130    let predecessor = event_states
131        .get(&f.path)
132        .map(|s| s.latest_occurrence_id.as_str());
133    let occ = occurrence_id(source_id, &f.path, predecessor, &f.content_hash);
134
135    let attachment = IngestAttachment {
136        source_id: source_id.into(),
137        occurrence_id: occ,
138        accepted_at: now,
139        principal: "sync".into(),
140        claims_json: "{}".into(),
141    };
142
143    brain
144        .ingest_event(
145            space_id,
146            content.clone(),
147            SourceRef::Note {
148                path: f.path.clone(),
149            },
150            TrustTier::Trusted,
151            Some(&attachment),
152            "vault-sync",
153        )
154        .await?;
155    Ok(())
156}
157
158fn systemtime_to_timestamp(t: std::time::SystemTime) -> Timestamp {
159    let millis = t
160        .duration_since(UNIX_EPOCH)
161        .map(|d| d.as_millis() as i64)
162        .unwrap_or(0);
163    Timestamp(millis)
164}
165
166fn print_report(report: &SyncReport) {
167    if !report.new.is_empty() {
168        for p in &report.new {
169            println!("  new: {p}");
170        }
171    }
172    if !report.modified.is_empty() {
173        for p in &report.modified {
174            println!("  modified: {p}");
175        }
176    }
177    println!(
178        "sync complete: {} new, {} unchanged, {} modified",
179        report.new.len(),
180        report.unchanged.len(),
181        report.modified.len()
182    );
183}