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