opys_engine/commands/sync.rs
1//! The full auto-sync pass: reconcile cross-references, linkify body prose, and
2//! relocate documents to their canonical layout path. Invoked by `maybe_sync`
3//! after every mutating command and by the `opys sync` command.
4//!
5//! The pass runs over the in-memory store: reconstruct every doc, backfill
6//! timestamps, run the (unchanged) `links` reconcile/linkify engine on the
7//! reconstructed `Doc`s, write the changed ones back via `put_doc`, point every
8//! doc at its canonical path, and flush. Relation-map / linkify logic stays in
9//! Rust — the store is the data layer feeding it.
10
11use crate::doc::Doc;
12use crate::error::{usage, Result};
13use crate::links;
14use crate::project::Project;
15use crate::store::Store;
16use crate::Ctx;
17
18/// The `opys sync` command entry: open the project and run the full pass.
19pub fn run_command(ctx: &Ctx) -> Result<()> {
20 let prj = ctx.open()?;
21 let n = run(&prj, ctx.backend.as_ref())?;
22 println!("synced {n} document(s)");
23 Ok(())
24}
25
26/// Load the corpus, run the sync pass, and flush. Returns the document count.
27/// Errors (without writing) if any document fails to parse.
28pub fn run(prj: &Project, backend: &dyn crate::backend::Backend) -> Result<usize> {
29 let (mut store, errs) = backend.load(prj)?;
30 if !errs.is_empty() {
31 return Err(usage("fix parse errors first (run verify)"));
32 }
33 let n = pass(prj, &mut store)?;
34 backend.flush(prj, store)?;
35 Ok(n)
36}
37
38/// Run the reconcile/linkify/backfill/relocate pass against an already-open
39/// store (no flush). Returns the document count. Used both by [`run`] and, on
40/// the invocation's own store, after a mutating command.
41pub fn pass(prj: &Project, store: &mut Store) -> Result<usize> {
42 // Clean up the index older versions generated at the base; opys no longer
43 // writes one (slice the inventory live with `opys list`/`opys query`).
44 let _ = std::fs::remove_file(prj.base.join("INDEX.md"));
45
46 let rows = store.full_rows()?;
47 let count = rows.len();
48
49 // Reconstruct the working set, backfilling the auto-maintained timestamps on
50 // docs predating the fields from the file's mtime. Housekeeping, so it must
51 // not bump `updated` on docs that already have it — only fill genuine gaps.
52 let mut docs: Vec<Doc> = Vec::with_capacity(count);
53 for r in &rows {
54 let mut doc = r.doc.clone();
55 let need_created = !doc.frontmatter.contains_key("created");
56 let need_updated = !doc.frontmatter.contains_key("updated");
57 if (need_created || need_updated) && r.orig_mtime.is_some() {
58 let ts = r.orig_mtime.clone().unwrap();
59 if need_created {
60 doc.frontmatter.set_str("created", ts.clone());
61 }
62 if need_updated {
63 doc.frontmatter.set_str("updated", ts);
64 }
65 }
66 docs.push(doc);
67 }
68
69 // The (unchanged) relation/linkify engine operates on the reconstructed set.
70 links::reconcile(&mut docs);
71 links::reconcile_blockers(&mut docs);
72 let index = links::build_index(&docs);
73 let prefixes: Vec<String> = prj.pcfg.types.values().map(|t| t.prefix.clone()).collect();
74 let re = links::ref_re(&prefixes);
75 for d in docs.iter_mut() {
76 let dir = d.path.parent().unwrap_or(&prj.base).to_path_buf();
77 d.body = links::linkify(&d.body, &dir, &index, &re);
78 }
79
80 // Write back what the pass changed, and point every mislocated doc at its
81 // canonical path (flush relocates the file even with no content change).
82 // The canonical path is computed in Rust and only written when it differs,
83 // so the common no-op sync issues almost no SQL.
84 for (r, d) in rows.iter().zip(&docs) {
85 if d.to_text() != r.doc.to_text() {
86 store.put_doc(&prj.pcfg, Some(r.dkey), d)?;
87 }
88 if let (Some(id), Some(status)) = (d.id(), d.status()) {
89 let canonical = prj
90 .pcfg
91 .doc_relpath(id, status)
92 .to_string_lossy()
93 .into_owned();
94 if canonical != r.relpath {
95 store.set_path(r.dkey, &canonical)?;
96 }
97 }
98 }
99 Ok(count)
100}