1use 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#[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
36pub 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 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}