Skip to main content

bynk_project/
contracts_lock.rs

1//! #2554: `bynk.contracts.lock`, the older service contracts a callee still
2//! accepts, as a document.
3//!
4//! A callee compares the `X-Bynk-Contract` hash a caller stamps with its own
5//! (ADR 0200). Once the classifier (`bynk_check::contract_compat`, #2485) can
6//! say a change is compatible, a callee can be deployed ahead of its callers
7//! if it also accepts the hash they were compiled against. That hash cannot be
8//! derived from the callee's source, which only knows the contract it has
9//! now, so the older contracts are recorded here, committed, and visible in
10//! review.
11//!
12//! The document holds the older contracts as the shape text
13//! (`bynk_check::contract_compat::ServiceShape::to_text`), not hashes: the
14//! build re-checks each against the contract the callee has now, and a hash
15//! cannot be classified. This module is pure, like `schema_registry`: it
16//! reads and writes no disk. `bynk-driver`'s `contracts_lock` module owns the
17//! file, and `bynk-check` owns what the shapes mean.
18//!
19//! ```toml
20//! version = 1
21//!
22//! [accepts."shop.payment"]
23//! charge = ['Svc("charge",P("order",B("Int")),Ret(B("Bool")))']
24//! ```
25
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31/// The file at the project root.
32pub const LOCK_FILE: &str = "bynk.contracts.lock";
33
34const LOCK_VERSION: u32 = 1;
35
36/// The older shapes each context's services accept: qualified context name
37/// (`shop.payment`) to service to shape texts, oldest first.
38#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
39pub struct ContractsLock {
40    // No serde `default`: a lock with no `version` is a truncated write, not
41    // an empty lock, and must fail the read rather than silently forget what
42    // a callee accepts (the argument `bynk.schema.lock` makes for the same
43    // field).
44    version: u32,
45    #[serde(default)]
46    accepts: BTreeMap<String, BTreeMap<String, Vec<String>>>,
47}
48
49impl ContractsLock {
50    pub fn new() -> Self {
51        ContractsLock {
52            version: LOCK_VERSION,
53            accepts: BTreeMap::new(),
54        }
55    }
56
57    /// The older shapes `context`'s `service` accepts, oldest first.
58    pub fn accepted(&self, context: &str, service: &str) -> &[String] {
59        self.accepts
60            .get(context)
61            .and_then(|s| s.get(service))
62            .map(Vec::as_slice)
63            .unwrap_or(&[])
64    }
65
66    /// Every context's accepted shapes.
67    pub fn contexts(&self) -> impl Iterator<Item = (&String, &BTreeMap<String, Vec<String>>)> {
68        self.accepts.iter()
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.accepts.is_empty()
73    }
74
75    /// Record `shape` as accepted by `context`'s `service`. Returns whether it
76    /// was new; an entry already there is left where it is.
77    pub fn accept(&mut self, context: &str, service: &str, shape: &str) -> bool {
78        let shapes = self
79            .accepts
80            .entry(context.to_string())
81            .or_default()
82            .entry(service.to_string())
83            .or_default();
84        if shapes.iter().any(|s| s == shape) {
85            return false;
86        }
87        shapes.push(shape.to_string());
88        true
89    }
90
91    /// Replace what `context`'s `service` accepts, dropping the service (and
92    /// the context, when it was the last) if `shapes` is empty.
93    pub fn set(&mut self, context: &str, service: &str, shapes: Vec<String>) {
94        if shapes.is_empty() {
95            if let Some(services) = self.accepts.get_mut(context) {
96                services.remove(service);
97                if services.is_empty() {
98                    self.accepts.remove(context);
99                }
100            }
101            return;
102        }
103        self.accepts
104            .entry(context.to_string())
105            .or_default()
106            .insert(service.to_string(), shapes);
107    }
108}
109
110/// Read the lock's pre-read content. `None` is a project that has none, which
111/// accepts nothing. `Some(text)` that is empty, unparseable or of another
112/// version is corruption and fails: a callee silently forgetting the callers
113/// it accepts would answer them `409` in production.
114///
115/// `project_root` only names the file in a message.
116pub fn parse(existing: Option<&str>, project_root: &Path) -> Result<ContractsLock, String> {
117    let Some(text) = existing else {
118        return Ok(ContractsLock::new());
119    };
120    let path = project_root.join(LOCK_FILE);
121    if text.trim().is_empty() {
122        return Err(format!(
123            "contracts lock `{}` is empty or truncated (corrupt); restore it from version control",
124            path.display()
125        ));
126    }
127    let lock: ContractsLock = toml::from_str(text).map_err(|e| {
128        format!(
129            "contracts lock `{}` is corrupt ({e}); restore it from version control",
130            path.display()
131        )
132    })?;
133    if lock.version != LOCK_VERSION {
134        return Err(format!(
135            "unsupported contracts lock version {} (`{}`)",
136            lock.version,
137            path.display()
138        ));
139    }
140    Ok(lock)
141}
142
143/// The lock's TOML form. Cannot fail for this data shape (string keys and
144/// string lists).
145pub fn serialize(lock: &ContractsLock) -> String {
146    toml::to_string_pretty(lock).expect("ContractsLock always serializes")
147}
148
149/// How many older contracts one service may accept before the build warns. A
150/// callee that accepts a long tail has not been pruned: callers have moved on.
151pub const ACCEPTED_CAP: usize = 8;
152
153fn attributed(category: &'static str, message: String, note: &str) -> crate::AttributedError {
154    let mut error =
155        bynk_syntax::CompileError::new(category, bynk_syntax::span::Span::default(), message);
156    error.notes.push(note.to_string());
157    crate::AttributedError::new(Some(PathBuf::from(LOCK_FILE)), error)
158}
159
160/// `bynk.project.contracts_lock_corrupt`: the lock cannot be read. Like the
161/// schema lock, it fails the build rather than reading as empty.
162pub fn corrupt_error(message: String) -> crate::AttributedError {
163    attributed(
164        "bynk.project.contracts_lock_corrupt",
165        message,
166        "restore it from version control rather than deleting it: a callee that forgets the contracts it accepts refuses its live callers",
167    )
168}
169
170/// `bynk.project.contracts_lock_stale` (warning): an entry the callee does not
171/// accept, because it is unreadable or no longer compatible with the contract
172/// the service has now. The build leaves it out; the lock is not rewritten.
173pub fn stale_warning(context: &str, service: &str, why: &str) -> crate::AttributedError {
174    attributed(
175        "bynk.project.contracts_lock_stale",
176        format!(
177            "`{LOCK_FILE}` lists an older contract of `{context}.{service}` that the service no longer accepts: {why}"
178        ),
179        "the entry is ignored; callers still on that contract are refused (409 ContractMismatch). Remove the entry once they have moved on",
180    )
181}
182
183/// `bynk.project.contracts_lock_oversized` (warning): a service accepts more
184/// than [`ACCEPTED_CAP`] older contracts.
185pub fn oversized_warning(context: &str, service: &str, count: usize) -> crate::AttributedError {
186    attributed(
187        "bynk.project.contracts_lock_oversized",
188        format!(
189            "`{LOCK_FILE}` has `{context}.{service}` accepting {count} older contracts (more than {ACCEPTED_CAP})"
190        ),
191        "remove the entries no live caller was compiled against",
192    )
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn root() -> &'static Path {
200        Path::new("/project")
201    }
202
203    #[test]
204    fn no_file_accepts_nothing() {
205        let lock = parse(None, root()).unwrap();
206        assert!(lock.is_empty());
207        assert!(lock.accepted("shop.payment", "charge").is_empty());
208    }
209
210    #[test]
211    fn a_lock_round_trips() {
212        let mut lock = ContractsLock::new();
213        assert!(lock.accept("shop.payment", "charge", "one"));
214        assert!(lock.accept("shop.payment", "charge", "two"));
215        assert!(
216            !lock.accept("shop.payment", "charge", "one"),
217            "no duplicate"
218        );
219        assert!(lock.accept("shop.orders", "place", "three"));
220        let text = serialize(&lock);
221        let back = parse(Some(&text), root()).unwrap();
222        assert_eq!(back, lock);
223        assert_eq!(back.accepted("shop.payment", "charge"), ["one", "two"]);
224    }
225
226    #[test]
227    fn a_shape_with_quotes_and_commas_survives() {
228        let shape = r#"Svc("charge",P("o",B("Int")),Ret(Rf("T","String","Matches(\"a,b\")")))"#;
229        let mut lock = ContractsLock::new();
230        lock.accept("shop.payment", "charge", shape);
231        let back = parse(Some(&serialize(&lock)), root()).unwrap();
232        assert_eq!(back.accepted("shop.payment", "charge"), [shape]);
233    }
234
235    #[test]
236    fn set_replaces_and_empties() {
237        let mut lock = ContractsLock::new();
238        lock.accept("shop.payment", "charge", "one");
239        lock.accept("shop.payment", "refund", "x");
240        lock.set("shop.payment", "charge", vec!["two".into()]);
241        assert_eq!(lock.accepted("shop.payment", "charge"), ["two"]);
242        lock.set("shop.payment", "charge", Vec::new());
243        assert!(lock.accepted("shop.payment", "charge").is_empty());
244        lock.set("shop.payment", "refund", Vec::new());
245        assert!(lock.is_empty(), "an emptied context disappears");
246    }
247
248    #[test]
249    fn a_corrupt_lock_fails_rather_than_forgetting() {
250        for bad in ["", "   \n", "not toml [", "accepts = {}"] {
251            assert!(parse(Some(bad), root()).is_err(), "{bad:?}");
252        }
253        assert!(
254            parse(Some("version = 2\n"), root())
255                .unwrap_err()
256                .contains("unsupported")
257        );
258    }
259}