1use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29use serde::{Deserialize, Serialize};
30
31pub const LOCK_FILE: &str = "bynk.contracts.lock";
33
34const LOCK_VERSION: u32 = 1;
35
36#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
39pub struct ContractsLock {
40 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 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 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 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 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
110pub 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
143pub fn serialize(lock: &ContractsLock) -> String {
146 toml::to_string_pretty(lock).expect("ContractsLock always serializes")
147}
148
149pub 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
160pub 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
170pub 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
183pub 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}