cliban_sync/lib.rs
1//! `cliban-sync` — the bridge between a cliban board and an external issue
2//! tracker.
3//!
4//! Deliberately *not* a sync engine. There is no daemon, no polling, and no
5//! merge algorithm; there are two explicit verbs a human or agent invokes on
6//! one issue at a time — import (remote → cliban) and push (cliban → remote) —
7//! and a [`links`] table recording which local issue corresponds to which
8//! remote one.
9//!
10//! What makes that tractable is declared field ownership rather than
11//! reconciliation. The remote owns title, priority, labels, due date, workflow
12//! state, and the `## Spec` prose. cliban owns `## Plan`, `## Activity Log`,
13//! and `## Notes` — the parts that have no counterpart upstream and are the
14//! whole reason the board exists. Neither side ever merges the other's fields,
15//! so the only conflict left is "the remote moved since we last looked", which
16//! is a timestamp comparison (see `linear::push`).
17
18pub mod config;
19pub mod error;
20pub mod linear;
21pub mod links;
22
23pub use error::{Error, Result};
24
25/// Provider key stored in `remote_links.provider`.
26pub const PROVIDER_LINEAR: &str = "linear";
27
28/// Entity key stored in `remote_links.entity`. Only issues are linked today;
29/// the column exists so milestones or projects can be added without a
30/// migration.
31pub const ENTITY_ISSUE: &str = "issue";
32
33/// Stable hash of the remote-owned field values, stored as
34/// `remote_links.base_hash`.
35///
36/// What it is for: a refresh overwrites the fields the remote owns, and we would
37/// like to tell the user when that overwrite is about to discard something they
38/// typed locally. Storing the whole prior state would do it; a hash is enough,
39/// because the only question asked is "are these bytes the ones we wrote last
40/// time?".
41///
42/// Deliberately excludes status. Status is *expected* to diverge — an agent
43/// moving an issue through the board is the entire point — so including it would
44/// make the warning fire on every refresh and teach people to ignore it.
45pub fn fingerprint(parts: &[&str]) -> String {
46 use sha2::{Digest, Sha256};
47 let mut hasher = Sha256::new();
48 for part in parts {
49 // Length-prefixed so ("ab", "c") and ("a", "bc") do not collide.
50 hasher.update((part.len() as u64).to_le_bytes());
51 hasher.update(part.as_bytes());
52 }
53 format!("{:x}", hasher.finalize())
54}
55
56#[cfg(test)]
57mod tests {
58 use super::fingerprint;
59
60 #[test]
61 fn fingerprint_is_stable_and_order_sensitive() {
62 assert_eq!(fingerprint(&["a", "b"]), fingerprint(&["a", "b"]));
63 assert_ne!(fingerprint(&["a", "b"]), fingerprint(&["b", "a"]));
64 }
65
66 #[test]
67 fn fingerprint_does_not_collide_on_field_boundaries() {
68 // The classic concatenation bug: without length prefixes these match.
69 assert_ne!(fingerprint(&["ab", "c"]), fingerprint(&["a", "bc"]));
70 assert_ne!(fingerprint(&["", "ab"]), fingerprint(&["ab", ""]));
71 }
72}