1use crate::error::{EngineError, Result};
29use crate::git_ops::GitRepo;
30use serde::Deserialize;
31use std::path::Path;
32
33pub const WORKSPACE_CONTRACT_PATH: &str = ".kranz/workspace.json";
34pub const SCHEMA_VERSION: u32 = 1;
35
36#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38pub struct WorkspaceContract {
39 #[serde(default)]
42 pub schema_version: u32,
43 #[serde(default)]
45 pub bootstrap: Vec<String>,
46 #[serde(default)]
47 pub services: Vec<ServiceSpec>,
48 #[serde(default)]
50 pub readiness: Vec<String>,
51 #[serde(default)]
52 pub data: Option<DataHooks>,
53 #[serde(default)]
54 pub previews: Vec<PreviewSpec>,
55 #[serde(default)]
57 pub secrets: Vec<String>,
58 #[serde(default)]
59 pub disk: Option<DiskHints>,
60 #[serde(default)]
63 pub mounts: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
67#[serde(rename_all = "camelCase", deny_unknown_fields)]
68pub struct ServiceSpec {
69 pub name: String,
70 pub start: String,
71 #[serde(default)]
72 pub health_check: Option<String>,
73 pub port: ServicePort,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct ServicePort {
79 pub policy: PortPolicy,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub enum PortPolicy {
88 Dynamic,
89 Fixed(u16),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct PreviewSpec {
95 pub name: String,
96 pub url_template: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109#[serde(rename_all = "camelCase", deny_unknown_fields)]
110pub struct DataHooks {
111 #[serde(default)]
112 pub clone: Option<String>,
113 #[serde(default)]
114 pub migrate: Option<String>,
115 #[serde(default)]
116 pub reset: Option<String>,
117 #[serde(default)]
118 pub skew_check: Option<String>,
119 #[serde(default)]
123 pub reset_between_rounds: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct DiskHints {
130 #[serde(default)]
131 pub prune: Option<String>,
132 #[serde(default)]
133 pub retain: Option<String>,
134}
135
136pub fn load_workspace_contract(repo_root: &Path) -> Result<Option<WorkspaceContract>> {
141 let path = repo_root.join(WORKSPACE_CONTRACT_PATH);
142 let bytes = match std::fs::read(&path) {
143 Ok(bytes) => bytes,
144 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
145 Err(e) => return Err(EngineError::Io(e)),
146 };
147 parse_workspace_contract(&bytes)
148 .map(Some)
149 .map_err(|violation| {
150 EngineError::Config(format!(
151 "workspace contract {WORKSPACE_CONTRACT_PATH} is invalid (owner: repo-setup): {violation}"
152 ))
153 })
154}
155
156pub fn load_workspace_contract_at_ref(
166 repo: &GitRepo,
167 ref_name: &str,
168) -> Result<Option<WorkspaceContract>> {
169 match repo.show_file(ref_name, WORKSPACE_CONTRACT_PATH)? {
170 None => Ok(None),
171 Some(bytes) => parse_workspace_contract(&bytes)
172 .map(Some)
173 .map_err(|violation| {
174 EngineError::Config(format!(
175 "workspace contract {WORKSPACE_CONTRACT_PATH} at {ref_name} is invalid (owner: repo-setup): {violation}"
176 ))
177 }),
178 }
179}
180
181pub fn parse_workspace_contract(bytes: &[u8]) -> std::result::Result<WorkspaceContract, String> {
184 let contract: WorkspaceContract = serde_json::from_slice(bytes)
185 .map_err(|e| format!("invalid JSON in {WORKSPACE_CONTRACT_PATH}: {e}"))?;
186 validate_workspace_contract(&contract)?;
187 Ok(contract)
188}
189
190fn validate_workspace_contract(contract: &WorkspaceContract) -> std::result::Result<(), String> {
191 if contract.schema_version != SCHEMA_VERSION {
192 return Err(format!(
193 "unsupported schemaVersion {} (expected {SCHEMA_VERSION})",
194 contract.schema_version
195 ));
196 }
197
198 for (i, command) in contract.bootstrap.iter().enumerate() {
199 if command.trim().is_empty() {
200 return Err(format!("bootstrap[{i}] has an empty command string"));
201 }
202 }
203 for (i, command) in contract.readiness.iter().enumerate() {
204 if command.trim().is_empty() {
205 return Err(format!("readiness[{i}] has an empty command string"));
206 }
207 }
208
209 if let Some(data) = &contract.data {
210 for (field, hook) in [
211 ("clone", &data.clone),
212 ("migrate", &data.migrate),
213 ("reset", &data.reset),
214 ("skewCheck", &data.skew_check),
215 ] {
216 if let Some(command) = hook {
217 if command.trim().is_empty() {
218 return Err(format!("data.{field} has an empty command string"));
219 }
220 }
221 }
222 if data.reset_between_rounds && data.reset.is_none() {
225 return Err("data.resetBetweenRounds requires a declared data.reset hook".to_string());
226 }
227 if data.skew_check.is_some() && data.migrate.is_none() && data.reset.is_none() {
231 return Err(
232 "data.skewCheck requires a declared data.migrate or data.reset hook \
233 (the skew Block action names it)"
234 .to_string(),
235 );
236 }
237 }
238
239 for (i, name) in contract.secrets.iter().enumerate() {
240 if !is_secret_name(name) {
241 return Err(format!(
242 "secrets[{i}] {name:?} is not a secret NAME (expected ^[A-Z][A-Z0-9_]*$); \
243 secret values never belong in the tracked contract"
244 ));
245 }
246 }
247
248 for (i, mount) in contract.mounts.iter().enumerate() {
249 if !is_contract_absolute(mount) || has_parent_components(mount) {
255 return Err(format!(
256 "mounts[{i}] {mount:?} must be an absolute path without '..' components"
257 ));
258 }
259 }
260
261 for (i, service) in contract.services.iter().enumerate() {
262 if contract.services[..i]
263 .iter()
264 .any(|s| s.name == service.name)
265 {
266 return Err(format!("duplicate service name {:?}", service.name));
267 }
268 if let PortPolicy::Fixed(fixed) = &service.port.policy {
269 if let Some(other) = contract.services[..i]
270 .iter()
271 .find(|s| matches!(&s.port.policy, PortPolicy::Fixed(f) if f == fixed))
272 {
273 return Err(format!(
274 "fixed port {fixed} collides between services {:?} and {:?}",
275 other.name, service.name
276 ));
277 }
278 }
279 }
280
281 for (i, preview) in contract.previews.iter().enumerate() {
282 if contract.previews[..i]
283 .iter()
284 .any(|p| p.name == preview.name)
285 {
286 return Err(format!("duplicate preview name {:?}", preview.name));
287 }
288 }
289
290 Ok(())
291}
292
293fn is_secret_name(name: &str) -> bool {
295 let mut chars = name.chars();
296 match chars.next() {
297 Some(first) if first.is_ascii_uppercase() => {}
298 _ => return false,
299 }
300 chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
301}
302
303fn is_contract_absolute(mount: &str) -> bool {
308 if mount.starts_with('/') {
309 return true;
310 }
311 if mount.starts_with("\\\\") {
312 return true;
313 }
314 let bytes = mount.as_bytes();
315 bytes.len() >= 3
316 && bytes[0].is_ascii_alphabetic()
317 && bytes[1] == b':'
318 && (bytes[2] == b'\\' || bytes[2] == b'/')
319}
320
321fn has_parent_components(mount: &str) -> bool {
323 mount.split(['/', '\\']).any(|component| component == "..")
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn full_contract_json() -> &'static [u8] {
331 br#"{
332 "schemaVersion": 1,
333 "bootstrap": ["cargo fetch", "npm ci"],
334 "services": [
335 {
336 "name": "api",
337 "start": "cargo run -p api",
338 "healthCheck": "curl -sf localhost:8080/health",
339 "port": { "policy": "dynamic" }
340 },
341 {
342 "name": "db",
343 "start": "docker compose up db",
344 "port": { "policy": { "fixed": 5432 } }
345 }
346 ],
347 "readiness": ["curl -sf localhost:8080/health", "pg_isready"],
348 "data": {
349 "clone": "pg_dump golden | psql workspace",
350 "migrate": "sqlx migrate run",
351 "reset": "dropdb workspace && createdb workspace",
352 "skewCheck": "sqlx migrate info --check",
353 "resetBetweenRounds": true
354 },
355 "previews": [
356 { "name": "app", "urlTemplate": "http://localhost:{port}/" }
357 ],
358 "secrets": ["DATABASE_URL", "STRIPE_API_KEY"],
359 "disk": { "prune": "docker system prune -f", "retain": "7d" },
360 "mounts": ["/var/cache/cargo", "/var/lib/postgres"]
361 }"#
362 }
363
364 #[test]
365 fn workspace_contract_full_schema_round_trip() {
366 let contract = parse_workspace_contract(full_contract_json()).expect("valid contract");
367 assert_eq!(contract.schema_version, 1);
368 assert_eq!(contract.bootstrap, ["cargo fetch", "npm ci"]);
369 assert_eq!(contract.services.len(), 2);
370 assert_eq!(contract.services[0].name, "api");
371 assert_eq!(
372 contract.services[0].health_check.as_deref(),
373 Some("curl -sf localhost:8080/health")
374 );
375 assert_eq!(contract.services[0].port.policy, PortPolicy::Dynamic);
376 assert_eq!(contract.services[1].port.policy, PortPolicy::Fixed(5432));
377 assert_eq!(contract.readiness.len(), 2);
378 let data = contract.data.expect("data hooks");
379 assert_eq!(data.migrate.as_deref(), Some("sqlx migrate run"));
380 assert_eq!(
381 data.skew_check.as_deref(),
382 Some("sqlx migrate info --check")
383 );
384 assert!(data.reset_between_rounds);
385 assert_eq!(contract.previews.len(), 1);
386 assert_eq!(
387 contract.previews[0].url_template,
388 "http://localhost:{port}/"
389 );
390 assert_eq!(contract.secrets, ["DATABASE_URL", "STRIPE_API_KEY"]);
391 let disk = contract.disk.expect("disk hints");
392 assert_eq!(disk.prune.as_deref(), Some("docker system prune -f"));
393 assert_eq!(contract.mounts.len(), 2);
394 }
395
396 #[test]
397 fn workspace_contract_minimal_is_valid() {
398 let contract = parse_workspace_contract(br#"{"schemaVersion": 1}"#).expect("minimal");
399 assert!(contract.bootstrap.is_empty());
400 assert!(contract.services.is_empty());
401 assert!(contract.mounts.is_empty());
402 assert!(contract.data.is_none());
403 }
404
405 #[test]
406 fn workspace_contract_missing_file_is_none_not_error() {
407 let dir = tempfile::tempdir().expect("tempdir");
408 let loaded = load_workspace_contract(dir.path()).expect("load must not error");
409 assert!(loaded.is_none());
410 }
411
412 #[test]
418 fn composition_audit_workspace_contract_missing_is_none_and_invalid_fails_closed() {
419 let dir = tempfile::tempdir().expect("tempdir");
420 assert!(load_workspace_contract(dir.path())
421 .expect("a missing contract loads as None, never an error")
422 .is_none());
423
424 for (json, needle) in [
425 (r#"{"schemaVersion": 2}"#, "unsupported schemaVersion 2"),
427 (r#"{"bootstrap": ["true"]}"#, "unsupported schemaVersion 0"),
429 (
431 r#"{"schemaVersion": 1, "secrets": ["sk-live-abc"]}"#,
432 "not a secret NAME",
433 ),
434 (
436 r#"{"schemaVersion": 1, "mounts": ["/var/../etc"]}"#,
437 "absolute path without '..'",
438 ),
439 ] {
440 let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
441 assert!(err.contains(needle), "{json}: {err}");
442 }
443 }
444
445 #[test]
446 fn workspace_contract_loads_from_repo_root() {
447 let dir = tempfile::tempdir().expect("tempdir");
448 let kranz = dir.path().join(".kranz");
449 std::fs::create_dir_all(&kranz).unwrap();
450 std::fs::write(kranz.join("workspace.json"), full_contract_json()).unwrap();
451 let loaded = load_workspace_contract(dir.path())
452 .expect("load")
453 .expect("present");
454 assert_eq!(loaded.services.len(), 2);
455 }
456
457 #[test]
458 fn workspace_contract_invalid_json_refused() {
459 let err = parse_workspace_contract(b"{ not json").unwrap_err();
460 assert!(err.contains("invalid JSON"), "{err}");
461 }
462
463 #[test]
464 fn workspace_contract_wrong_schema_version_refused() {
465 let err = parse_workspace_contract(br#"{"schemaVersion": 2}"#).unwrap_err();
466 assert!(err.contains("unsupported schemaVersion 2"), "{err}");
467 let err = parse_workspace_contract(br#"{"bootstrap": ["true"]}"#).unwrap_err();
469 assert!(err.contains("unsupported schemaVersion 0"), "{err}");
470 }
471
472 #[test]
473 fn workspace_contract_duplicate_service_names_refused() {
474 let err = parse_workspace_contract(
475 br#"{"schemaVersion": 1, "services": [
476 {"name": "api", "start": "a", "port": {"policy": "dynamic"}},
477 {"name": "api", "start": "b", "port": {"policy": "dynamic"}}
478 ]}"#,
479 )
480 .unwrap_err();
481 assert!(err.contains("duplicate service name \"api\""), "{err}");
482 }
483
484 #[test]
485 fn workspace_contract_duplicate_preview_names_refused() {
486 let err = parse_workspace_contract(
487 br#"{"schemaVersion": 1, "previews": [
488 {"name": "app", "urlTemplate": "http://a/"},
489 {"name": "app", "urlTemplate": "http://b/"}
490 ]}"#,
491 )
492 .unwrap_err();
493 assert!(err.contains("duplicate preview name \"app\""), "{err}");
494 }
495
496 #[test]
497 fn workspace_contract_fixed_port_collision_refused() {
498 let err = parse_workspace_contract(
499 br#"{"schemaVersion": 1, "services": [
500 {"name": "db", "start": "a", "port": {"policy": {"fixed": 5432}}},
501 {"name": "db-replica", "start": "b", "port": {"policy": {"fixed": 5432}}}
502 ]}"#,
503 )
504 .unwrap_err();
505 assert!(
506 err.contains("fixed port 5432 collides between services \"db\" and \"db-replica\""),
507 "{err}"
508 );
509 parse_workspace_contract(
511 br#"{"schemaVersion": 1, "services": [
512 {"name": "db", "start": "a", "port": {"policy": {"fixed": 5432}}},
513 {"name": "api", "start": "b", "port": {"policy": "dynamic"}}
514 ]}"#,
515 )
516 .expect("dynamic + fixed mix is valid");
517 }
518
519 #[test]
520 fn workspace_contract_empty_bootstrap_command_refused() {
521 let err = parse_workspace_contract(
522 br#"{"schemaVersion": 1, "bootstrap": ["cargo fetch", " "]}"#,
523 )
524 .unwrap_err();
525 assert!(
526 err.contains("bootstrap[1] has an empty command string"),
527 "{err}"
528 );
529 }
530
531 #[test]
532 fn workspace_contract_empty_readiness_command_refused() {
533 let err =
534 parse_workspace_contract(br#"{"schemaVersion": 1, "readiness": [""]}"#).unwrap_err();
535 assert!(
536 err.contains("readiness[0] has an empty command string"),
537 "{err}"
538 );
539 }
540
541 #[test]
546 fn workspace_contract_data_hooks_validate_shape_and_cross_references() {
547 for (field, json) in [
548 ("clone", r#"{"schemaVersion": 1, "data": {"clone": " "}}"#),
549 (
550 "migrate",
551 r#"{"schemaVersion": 1, "data": {"migrate": " "}}"#,
552 ),
553 ("reset", r#"{"schemaVersion": 1, "data": {"reset": " "}}"#),
554 (
557 "skewCheck",
558 r#"{"schemaVersion": 1, "data": {"migrate": "m", "skewCheck": " "}}"#,
559 ),
560 ] {
561 let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
562 assert!(
563 err.contains(&format!("data.{field} has an empty command string")),
564 "{field}: {err}"
565 );
566 }
567
568 let err = parse_workspace_contract(
570 br#"{"schemaVersion": 1, "data": {"resetBetweenRounds": true}}"#,
571 )
572 .unwrap_err();
573 assert!(
574 err.contains("data.resetBetweenRounds requires a declared data.reset hook"),
575 "{err}"
576 );
577 parse_workspace_contract(
578 br#"{"schemaVersion": 1, "data": {"reset": "seed", "resetBetweenRounds": true}}"#,
579 )
580 .expect("resetBetweenRounds with a reset hook is valid");
581
582 let err =
585 parse_workspace_contract(br#"{"schemaVersion": 1, "data": {"skewCheck": "check"}}"#)
586 .unwrap_err();
587 assert!(
588 err.contains("data.skewCheck requires a declared data.migrate or data.reset hook"),
589 "{err}"
590 );
591 parse_workspace_contract(
592 br#"{"schemaVersion": 1, "data": {"skewCheck": "check", "migrate": "m"}}"#,
593 )
594 .expect("skewCheck with migrate is valid");
595 parse_workspace_contract(
596 br#"{"schemaVersion": 1, "data": {"skewCheck": "check", "reset": "r"}}"#,
597 )
598 .expect("skewCheck with reset is valid");
599
600 let contract =
603 parse_workspace_contract(br#"{"schemaVersion": 1, "data": {"clone": "seed"}}"#)
604 .expect("clone-only data block is valid");
605 let data = contract.data.expect("data hooks");
606 assert!(!data.reset_between_rounds);
607 }
608
609 #[test]
610 fn workspace_contract_secret_value_shaped_entries_refused() {
611 for bad in [
612 "database_url",
613 "DATABASE-URL",
614 "sk-live-abc123",
615 "9LIVES",
616 "",
617 ] {
618 let json = format!(r#"{{"schemaVersion": 1, "secrets": ["DATABASE_URL", "{bad}"]}}"#);
619 let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
620 assert!(
621 err.contains("secrets[1]") && err.contains("not a secret NAME"),
622 "entry {bad:?}: {err}"
623 );
624 }
625 parse_workspace_contract(
627 br#"{"schemaVersion": 1, "data": {"clone": "pg_dump $golden | psql -h db workspace"}}"#,
628 )
629 .expect("data hooks are free-form commands");
630 }
631
632 #[test]
633 fn workspace_contract_mount_must_be_absolute_without_parent_components() {
634 for (bad, why) in [
635 ("relative/cache", "non-absolute"),
636 ("~/.cargo", "tilde is not absolute to Path"),
637 ("/var/cache/../outside", "parent component"),
638 ("../outside", "relative parent component"),
639 ] {
640 let json = format!(r#"{{"schemaVersion": 1, "mounts": ["{bad}"]}}"#);
641 let err = parse_workspace_contract(json.as_bytes()).unwrap_err();
642 assert!(
643 err.contains("mounts[0]") && err.contains("absolute path without '..'"),
644 "{why}: {err}"
645 );
646 }
647 parse_workspace_contract(br#"{"schemaVersion": 1, "mounts": ["/var/cache/cargo"]}"#)
648 .expect("absolute mount without '..' is valid");
649 for good in ["/var/cache/cargo", "C:\\cache\\cargo", "C:/cache/cargo"] {
652 let json = serde_json::json!({ "schemaVersion": 1, "mounts": [good] }).to_string();
653 parse_workspace_contract(json.as_bytes())
654 .unwrap_or_else(|e| panic!("{good:?} must validate on every platform: {e}"));
655 }
656 }
657
658 #[test]
659 fn workspace_contract_load_error_names_owner_and_path() {
660 let dir = tempfile::tempdir().expect("tempdir");
661 let kranz = dir.path().join(".kranz");
662 std::fs::create_dir_all(&kranz).unwrap();
663 std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 9}"#).unwrap();
664 let err = load_workspace_contract(dir.path()).unwrap_err();
665 let msg = err.to_string();
666 assert!(msg.contains("workspace contract"), "{msg}");
667 assert!(msg.contains(WORKSPACE_CONTRACT_PATH), "{msg}");
668 assert!(msg.contains("repo-setup"), "{msg}");
669 assert!(msg.contains("unsupported schemaVersion 9"), "{msg}");
670 }
671
672 #[test]
676 fn workspace_contract_at_ref_reads_the_committed_ref_not_the_tree() {
677 let dir = tempfile::tempdir().expect("tempdir");
678 let root = dir.path();
679 let git = |args: &[&str]| {
680 let out = std::process::Command::new("git")
681 .args(args)
682 .current_dir(root)
683 .output()
684 .expect("spawn git");
685 assert!(
686 out.status.success(),
687 "git {args:?} failed: {}",
688 String::from_utf8_lossy(&out.stderr)
689 );
690 };
691 if std::process::Command::new("git")
692 .arg("--version")
693 .output()
694 .is_err()
695 {
696 crate::test_capability::skip(
697 crate::test_capability::capability::GIT,
698 "git is not on PATH",
699 );
700 return;
701 }
702 git(&["init", "-b", "main"]);
703 git(&["config", "user.name", "test"]);
704 git(&["config", "user.email", "test@example.com"]);
705 let kranz = root.join(".kranz");
706 std::fs::create_dir_all(&kranz).unwrap();
707 std::fs::write(
708 kranz.join("workspace.json"),
709 br#"{"schemaVersion": 1, "readiness": ["pg_isready"]}"#,
710 )
711 .unwrap();
712 git(&["add", "-A"]);
713 git(&["commit", "-m", "contract"]);
714
715 let repo = GitRepo::open(root).expect("open repo");
716 let loaded = load_workspace_contract_at_ref(&repo, "main")
717 .expect("load")
718 .expect("present on main");
719 assert_eq!(loaded.readiness, ["pg_isready"]);
720
721 std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 1}"#).unwrap();
723 let loaded = load_workspace_contract_at_ref(&repo, "main")
724 .expect("load")
725 .expect("still the committed contract");
726 assert_eq!(loaded.readiness, ["pg_isready"]);
727
728 git(&["checkout", "--orphan", "empty"]);
732 git(&["rm", "-rf", "."]);
733 git(&["commit", "--allow-empty", "-m", "empty"]);
734 assert!(load_workspace_contract_at_ref(&repo, "empty")
735 .expect("load")
736 .is_none());
737 }
738
739 #[test]
741 fn workspace_contract_at_ref_invalid_fails_closed() {
742 let dir = tempfile::tempdir().expect("tempdir");
743 let root = dir.path();
744 let git = |args: &[&str]| {
745 let out = std::process::Command::new("git")
746 .args(args)
747 .current_dir(root)
748 .output()
749 .expect("spawn git");
750 assert!(
751 out.status.success(),
752 "git {args:?} failed: {}",
753 String::from_utf8_lossy(&out.stderr)
754 );
755 };
756 if std::process::Command::new("git")
757 .arg("--version")
758 .output()
759 .is_err()
760 {
761 crate::test_capability::skip(
762 crate::test_capability::capability::GIT,
763 "git is not on PATH",
764 );
765 return;
766 }
767 git(&["init", "-b", "main"]);
768 git(&["config", "user.name", "test"]);
769 git(&["config", "user.email", "test@example.com"]);
770 let kranz = root.join(".kranz");
771 std::fs::create_dir_all(&kranz).unwrap();
772 std::fs::write(kranz.join("workspace.json"), br#"{"schemaVersion": 9}"#).unwrap();
773 git(&["add", "-A"]);
774 git(&["commit", "-m", "broken contract"]);
775
776 let repo = GitRepo::open(root).expect("open repo");
777 let err = load_workspace_contract_at_ref(&repo, "main").unwrap_err();
778 let msg = err.to_string();
779 assert!(msg.contains("workspace contract"), "{msg}");
780 assert!(msg.contains("repo-setup"), "{msg}");
781 assert!(msg.contains("unsupported schemaVersion 9"), "{msg}");
782 }
783}