Skip to main content

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}