1use chrono::Utc;
2use serde_json::{Value, json};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const SCHEMA_VERSION: &str = "cass.swarm.dependency_drift.v1";
8const STRICT_CHECK_COMMAND: &str = "rch exec -- env CARGO_TARGET_DIR=/tmp/cass-strict-target cargo check --features strict-path-dep-validation";
9const FULL_CHECK_COMMAND: &str =
10 "rch exec -- env CARGO_TARGET_DIR=/tmp/cass-check-target cargo check --all-targets";
11const FSQLITE_REGRESSION_COMMAND: &str = "rch exec -- env CARGO_TARGET_DIR=/tmp/cass-fsqlite-target cargo test --lib cleanup_orphan_fk_rows -- --nocapture";
12
13#[derive(Clone, Copy)]
14struct DependencySpec {
15 name: &'static str,
16 package: &'static str,
17 manifest_table: &'static str,
18 manifest_key: &'static str,
19 source_kind: &'static str,
20 repo_rel: &'static str,
21 required_tests: &'static [&'static str],
22}
23
24#[derive(Clone, Default)]
25struct ManifestPin {
26 status: String,
27 git: Option<String>,
28 rev: Option<String>,
29 version: Option<String>,
30 package: Option<String>,
31}
32
33#[derive(Clone)]
34struct DependencyObservation {
35 name: String,
36 package: String,
37 manifest_table: String,
38 manifest_key: String,
39 source_kind: String,
40 git: Option<String>,
41 version: Option<String>,
42 pinned_rev: Option<String>,
43 manifest_status: String,
44 sibling_path: Option<String>,
45 sibling_status: String,
46 local_head: Option<String>,
47 dirty: bool,
48 upstream_status: String,
49 required_tests: Vec<String>,
50}
51
52#[derive(Clone)]
53struct DependencyRisk {
54 level: &'static str,
55 kind: &'static str,
56 release_readiness: &'static str,
57 summary: String,
58 partial: bool,
59}
60
61const DEPENDENCY_SPECS: &[DependencySpec] = &[
62 DependencySpec {
63 name: "frankensqlite",
64 package: "fsqlite",
65 manifest_table: "dependencies",
66 manifest_key: "frankensqlite",
67 source_kind: "registry",
68 repo_rel: "../frankensqlite",
69 required_tests: &[
70 STRICT_CHECK_COMMAND,
71 FULL_CHECK_COMMAND,
72 FSQLITE_REGRESSION_COMMAND,
73 ],
74 },
75 DependencySpec {
76 name: "fsqlite-types",
77 package: "fsqlite-types",
78 manifest_table: "dev-dependencies",
79 manifest_key: "fsqlite-types",
80 source_kind: "registry",
81 repo_rel: "../frankensqlite",
82 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
83 },
84 DependencySpec {
85 name: "franken-agent-detection",
86 package: "franken-agent-detection",
87 manifest_table: "dependencies",
88 manifest_key: "franken-agent-detection",
89 source_kind: "git",
90 repo_rel: "../franken_agent_detection",
91 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
92 },
93 DependencySpec {
94 name: "asupersync",
95 package: "asupersync",
96 manifest_table: "dependencies",
97 manifest_key: "asupersync",
98 source_kind: "registry",
99 repo_rel: "../asupersync",
100 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
101 },
102 DependencySpec {
103 name: "frankensearch",
104 package: "frankensearch",
105 manifest_table: "dependencies",
106 manifest_key: "frankensearch",
107 source_kind: "git",
108 repo_rel: "../frankensearch",
109 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
110 },
111 DependencySpec {
112 name: "ftui",
113 package: "ftui",
114 manifest_table: "dependencies",
115 manifest_key: "ftui",
116 source_kind: "git",
117 repo_rel: "../frankentui",
118 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
119 },
120 DependencySpec {
121 name: "ftui-runtime",
122 package: "ftui-runtime",
123 manifest_table: "dependencies",
124 manifest_key: "ftui-runtime",
125 source_kind: "git",
126 repo_rel: "../frankentui",
127 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
128 },
129 DependencySpec {
130 name: "ftui-tty",
131 package: "ftui-tty",
132 manifest_table: "dependencies",
133 manifest_key: "ftui-tty",
134 source_kind: "git",
135 repo_rel: "../frankentui",
136 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
137 },
138 DependencySpec {
139 name: "ftui-extras",
140 package: "ftui-extras",
141 manifest_table: "dependencies",
142 manifest_key: "ftui-extras",
143 source_kind: "git",
144 repo_rel: "../frankentui",
145 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
146 },
147 DependencySpec {
148 name: "toon",
149 package: "tru",
150 manifest_table: "dependencies",
151 manifest_key: "toon",
152 source_kind: "git",
153 repo_rel: "../toon_rust",
154 required_tests: &[STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
155 },
156];
157
158#[must_use]
159pub fn render_dependency_drift_live() -> Value {
160 let manifest_dir = runtime_manifest_dir();
161 let manifest = read_manifest(&manifest_dir.join("Cargo.toml"));
162 let dependencies = DEPENDENCY_SPECS
163 .iter()
164 .map(|spec| live_observation(spec, &manifest_dir, manifest.as_ref()))
165 .collect::<Vec<_>>();
166
167 render_payload("live", "live", dependencies, "not_checked", None)
168}
169
170#[must_use]
171pub fn render_dependency_drift_fixture(fixture_id: &str, source: Option<&Value>) -> Value {
172 let upstream_status = source
173 .and_then(|value| value.get("network"))
174 .and_then(|network| {
175 network
176 .get("upstream_status")
177 .or_else(|| network.get("status"))
178 })
179 .and_then(Value::as_str)
180 .unwrap_or("not_checked");
181
182 let Some(source) = source else {
183 return render_payload(
184 fixture_id,
185 "fixture",
186 Vec::new(),
187 upstream_status,
188 Some("dependency_drift fixture source is missing"),
189 );
190 };
191
192 let dependencies = source
193 .get("dependencies")
194 .and_then(Value::as_array)
195 .map(|values| {
196 values
197 .iter()
198 .map(|value| fixture_observation(value, upstream_status))
199 .collect::<Vec<_>>()
200 })
201 .unwrap_or_default();
202
203 let fixture_problem = if dependencies.is_empty() {
204 Some("dependency_drift fixture did not include dependencies")
205 } else {
206 None
207 };
208
209 render_payload(
210 fixture_id,
211 "fixture",
212 dependencies,
213 upstream_status,
214 fixture_problem,
215 )
216}
217
218fn runtime_manifest_dir() -> PathBuf {
219 match std::env::current_dir() {
220 Ok(path) if path.join("Cargo.toml").is_file() => path,
221 _ => PathBuf::from(env!("CARGO_MANIFEST_DIR")),
222 }
223}
224
225fn read_manifest(path: &Path) -> Option<toml::Value> {
226 fs::read_to_string(path)
227 .ok()
228 .and_then(|text| text.parse::<toml::Table>().ok())
229 .map(toml::Value::Table)
230}
231
232fn live_observation(
233 spec: &DependencySpec,
234 manifest_dir: &Path,
235 manifest: Option<&toml::Value>,
236) -> DependencyObservation {
237 let pin = manifest
238 .map(|manifest| manifest_pin(manifest, spec))
239 .unwrap_or_else(|| ManifestPin {
240 status: "manifest-unavailable".to_string(),
241 ..ManifestPin::default()
242 });
243 let repo_path = manifest_dir.join(spec.repo_rel);
244 let sibling_path = Some(display_path(&repo_path));
245 let sibling_state = sibling_state(&repo_path);
246
247 DependencyObservation {
248 name: spec.name.to_string(),
249 package: pin
250 .package
251 .clone()
252 .unwrap_or_else(|| spec.package.to_string()),
253 manifest_table: spec.manifest_table.to_string(),
254 manifest_key: spec.manifest_key.to_string(),
255 source_kind: spec.source_kind.to_string(),
256 git: pin.git,
257 version: pin.version,
258 pinned_rev: pin.rev,
259 manifest_status: pin.status,
260 sibling_path,
261 sibling_status: sibling_state.0,
262 local_head: sibling_state.1,
263 dirty: sibling_state.2,
264 upstream_status: "not_checked".to_string(),
265 required_tests: spec
266 .required_tests
267 .iter()
268 .map(|command| (*command).to_string())
269 .collect(),
270 }
271}
272
273fn manifest_pin(manifest: &toml::Value, spec: &DependencySpec) -> ManifestPin {
274 let Some(table) = manifest
275 .get(spec.manifest_table)
276 .and_then(toml::Value::as_table)
277 else {
278 return ManifestPin {
279 status: "missing-table".to_string(),
280 ..ManifestPin::default()
281 };
282 };
283 let Some(value) = table.get(spec.manifest_key) else {
284 return ManifestPin {
285 status: "missing-dependency".to_string(),
286 ..ManifestPin::default()
287 };
288 };
289
290 if let Some(version) = value.as_str().map(str::trim) {
291 if spec.source_kind == "git" {
292 return ManifestPin {
293 status: "invalid-spec".to_string(),
294 ..ManifestPin::default()
295 };
296 }
297
298 return ManifestPin {
299 status: if version.is_empty() {
300 "missing-version"
301 } else {
302 "version-pinned"
303 }
304 .to_string(),
305 version: if version.is_empty() {
306 None
307 } else {
308 Some(version.to_string())
309 },
310 ..ManifestPin::default()
311 };
312 }
313
314 let Some(spec_table) = value.as_table() else {
315 return ManifestPin {
316 status: "invalid-spec".to_string(),
317 ..ManifestPin::default()
318 };
319 };
320
321 let git = non_empty_toml_string(spec_table, "git");
322 let rev = non_empty_toml_string(spec_table, "rev");
323 let version = non_empty_toml_string(spec_table, "version");
324 let package = non_empty_toml_string(spec_table, "package");
325 let status = if spec.source_kind == "git" {
326 git_pin_status(git.is_some(), rev.is_some())
327 } else if version.is_some() {
328 "version-pinned"
329 } else {
330 "missing-version"
331 };
332
333 ManifestPin {
334 status: status.to_string(),
335 git,
336 rev,
337 version,
338 package,
339 }
340}
341
342fn git_pin_status(has_git: bool, has_rev: bool) -> &'static str {
343 match (has_git, has_rev) {
344 (true, true) => "pinned",
345 (true, false) => "missing-rev",
346 (false, true) => "missing-git",
347 (false, false) => "missing-git-rev",
348 }
349}
350
351fn non_empty_toml_string(table: &toml::Table, key: &str) -> Option<String> {
352 table
353 .get(key)
354 .and_then(toml::Value::as_str)
355 .map(str::trim)
356 .filter(|value| !value.is_empty())
357 .map(str::to_string)
358}
359
360fn sibling_state(repo_path: &Path) -> (String, Option<String>, bool) {
361 if !repo_path.is_dir() {
362 return ("missing".to_string(), None, false);
363 }
364
365 let head = git_output(repo_path, &["rev-parse", "HEAD"]);
366 let status = git_output(
367 repo_path,
368 &["status", "--porcelain=v1", "--untracked-files=no"],
369 );
370 match (head, status) {
371 (Some(head), Some(status)) => {
372 let dirty = !status.trim().is_empty();
373 let state = if dirty { "dirty" } else { "clean" };
374 (state.to_string(), Some(head), dirty)
375 }
376 (Some(head), None) => ("unavailable".to_string(), Some(head), false),
377 _ => ("unavailable".to_string(), None, false),
378 }
379}
380
381fn git_output(repo_path: &Path, args: &[&str]) -> Option<String> {
382 let output = Command::new("git")
383 .arg("-C")
384 .arg(repo_path)
385 .args(args)
386 .output()
387 .ok()?;
388 if !output.status.success() {
389 return None;
390 }
391
392 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
393}
394
395fn fixture_observation(value: &Value, inherited_upstream_status: &str) -> DependencyObservation {
396 let name = string_field(value, &["name", "dependency", "manifest_key"], "unknown");
397 let manifest_key = string_field(value, &["manifest_key", "name", "dependency"], &name);
398 let source_kind = string_field(value, &["source_kind"], "git");
399 let pinned_rev = nested_string_field(value, &[&["pinned", "rev"], &["pinned", "revision"]])
400 .or_else(|| string_field_optional(value, &["pinned_rev", "manifest_rev", "rev"]));
401 let version = nested_string_field(value, &[&["pinned", "version"]])
402 .or_else(|| string_field_optional(value, &["version", "manifest_version"]));
403 let git = nested_string_field(value, &[&["pinned", "git"]])
404 .or_else(|| string_field_optional(value, &["git", "manifest_git"]));
405 let local_head = string_field_optional(value, &["local_head", "sibling_head"]);
406 let dirty = bool_field(value, &["dirty", "sibling_dirty"], false);
407 let sibling_status = string_field(
408 value,
409 &["sibling_status"],
410 if dirty { "dirty" } else { "clean" },
411 );
412 let upstream_status = nested_string_field(value, &[&["upstream", "status"]])
413 .or_else(|| string_field_optional(value, &["upstream_status"]))
414 .unwrap_or_else(|| inherited_upstream_status.to_string());
415 let inferred_manifest_status = inferred_fixture_manifest_status(
416 &source_kind,
417 git.as_deref(),
418 pinned_rev.as_deref(),
419 version.as_deref(),
420 );
421 let supplied_manifest_status = string_field_optional(value, &["manifest_status"]);
422 let manifest_status = match supplied_manifest_status.as_deref() {
423 Some(status @ ("pinned" | "version-pinned")) if status != inferred_manifest_status => {
424 inferred_manifest_status.to_string()
425 }
426 Some(status) => status.to_string(),
427 None => inferred_manifest_status.to_string(),
428 };
429 let package = string_field(value, &["package"], &manifest_key);
430 let manifest_table = string_field(value, &["manifest_table"], "dependencies");
431 let sibling_path = string_field_optional(value, &["sibling_path", "path"]);
432 let required_tests = value
433 .get("required_downstream_tests")
434 .or_else(|| value.get("required_tests"))
435 .and_then(Value::as_array)
436 .map(|tests| {
437 tests
438 .iter()
439 .filter_map(Value::as_str)
440 .map(str::to_string)
441 .collect::<Vec<_>>()
442 })
443 .filter(|tests| !tests.is_empty())
444 .unwrap_or_else(|| {
445 vec![
446 STRICT_CHECK_COMMAND.to_string(),
447 FULL_CHECK_COMMAND.to_string(),
448 ]
449 });
450
451 DependencyObservation {
452 name,
453 package,
454 manifest_table,
455 manifest_key,
456 source_kind,
457 git,
458 version,
459 pinned_rev,
460 manifest_status,
461 sibling_path,
462 sibling_status,
463 local_head,
464 dirty,
465 upstream_status,
466 required_tests,
467 }
468}
469
470fn inferred_fixture_manifest_status(
471 source_kind: &str,
472 git: Option<&str>,
473 pinned_rev: Option<&str>,
474 version: Option<&str>,
475) -> &'static str {
476 match source_kind {
477 "git" => git_pin_status(git.is_some(), pinned_rev.is_some()),
478 "registry" if version.is_some() => "version-pinned",
479 "registry" => "missing-version",
480 _ => "invalid-spec",
481 }
482}
483
484fn render_payload(
485 fixture_id: &str,
486 source_kind: &str,
487 dependencies: Vec<DependencyObservation>,
488 upstream_status: &str,
489 fixture_problem: Option<&str>,
490) -> Value {
491 let rendered_dependencies = dependencies
492 .iter()
493 .map(render_dependency)
494 .collect::<Vec<_>>();
495 let summary = summarize(&dependencies, upstream_status, fixture_problem);
496 let status = summary
497 .get("status")
498 .and_then(Value::as_str)
499 .unwrap_or("partial")
500 .to_string();
501 let recommendations = recommendations(&dependencies, fixture_problem);
502
503 json!({
504 "schema_version": SCHEMA_VERSION,
505 "status": status,
506 "_meta": {
507 "generated_at": Utc::now().to_rfc3339(),
508 "source": source_kind,
509 "fixture_id": fixture_id,
510 "contract": "read-only sibling dependency drift sentinel"
511 },
512 "summary": summary,
513 "dependencies": rendered_dependencies,
514 "recommendations": recommendations,
515 "mutation_contract": {
516 "read_only": true,
517 "mutates_git": false,
518 "mutates_files": false,
519 "runs_builds": false,
520 "touches_network": false,
521 "network_policy": "remote upstreams are not queried by default"
522 },
523 "privacy": {
524 "contains_session_content": false,
525 "contains_secrets": false,
526 "redaction_applied": false
527 }
528 })
529}
530
531fn render_dependency(observation: &DependencyObservation) -> Value {
532 let risk = classify(observation);
533 let revision_matches_pin = revision_matches_pin(observation);
534 json!({
535 "name": &observation.name,
536 "package": &observation.package,
537 "manifest": {
538 "table": &observation.manifest_table,
539 "key": &observation.manifest_key,
540 "status": &observation.manifest_status
541 },
542 "source": {
543 "kind": &observation.source_kind,
544 "git": &observation.git,
545 "version": &observation.version,
546 "rev": &observation.pinned_rev
547 },
548 "sibling": {
549 "path": &observation.sibling_path,
550 "status": &observation.sibling_status,
551 "local_head": &observation.local_head,
552 "dirty": observation.dirty,
553 "revision_matches_pin": revision_matches_pin
554 },
555 "upstream": {
556 "status": &observation.upstream_status
557 },
558 "risk": {
559 "level": risk.level,
560 "kind": risk.kind,
561 "release_readiness": risk.release_readiness,
562 "summary": risk.summary
563 },
564 "required_downstream_tests": &observation.required_tests
565 })
566}
567
568fn summarize(
569 dependencies: &[DependencyObservation],
570 upstream_status: &str,
571 fixture_problem: Option<&str>,
572) -> Value {
573 let risks = dependencies.iter().map(classify).collect::<Vec<_>>();
574 let warning_count = risks.iter().filter(|risk| risk.level == "warning").count();
575 let blocked_count = risks
576 .iter()
577 .filter(|risk| risk.release_readiness == "blocked")
578 .count();
579 let partial_count = risks.iter().filter(|risk| risk.partial).count()
580 + usize::from(upstream_status == "unavailable")
581 + usize::from(fixture_problem.is_some());
582 let dirty_count = dependencies.iter().filter(|dep| dep.dirty).count();
583 let local_rev_mismatch_count = dependencies
584 .iter()
585 .filter(|dep| revision_matches_pin(dep) == Some(false))
586 .count();
587 let missing_sibling_count = dependencies
588 .iter()
589 .filter(|dep| dep.sibling_status == "missing")
590 .count();
591 let missing_manifest_count = dependencies
592 .iter()
593 .filter(|dep| dep.manifest_status.starts_with("missing"))
594 .count();
595 let clean_count = risks.iter().filter(|risk| risk.level == "clean").count();
596 let status = if warning_count > 0 || blocked_count > 0 {
597 "warning"
598 } else if partial_count > 0 {
599 "partial"
600 } else {
601 "ok"
602 };
603 let release_readiness = if blocked_count > 0 {
604 "blocked"
605 } else if warning_count > 0 {
606 "review-required"
607 } else {
608 "ready"
609 };
610 let recommended_action = if blocked_count > 0 {
611 "restore-manifest-pin"
612 } else if warning_count > 0 {
613 "review-sibling-drift"
614 } else if partial_count > 0 {
615 "optional-sibling-context-missing"
616 } else {
617 "dependencies-clean"
618 };
619
620 json!({
621 "status": status,
622 "dependency_count": dependencies.len(),
623 "clean_count": clean_count,
624 "warning_count": warning_count,
625 "partial_count": partial_count,
626 "dirty_count": dirty_count,
627 "local_rev_mismatch_count": local_rev_mismatch_count,
628 "missing_sibling_count": missing_sibling_count,
629 "missing_manifest_count": missing_manifest_count,
630 "network_status": upstream_status,
631 "release_readiness": release_readiness,
632 "recommended_action": recommended_action,
633 "fixture_problem": fixture_problem
634 })
635}
636
637fn classify(observation: &DependencyObservation) -> DependencyRisk {
638 if observation.manifest_status.starts_with("missing")
639 || observation.manifest_status == "invalid-spec"
640 || observation.manifest_status == "manifest-unavailable"
641 {
642 return DependencyRisk {
643 level: "warning",
644 kind: "manifest-pin-missing",
645 release_readiness: "blocked",
646 summary: format!(
647 "{} does not have a complete manifest pin in [{}].{}",
648 observation.name, observation.manifest_table, observation.manifest_key
649 ),
650 partial: false,
651 };
652 }
653
654 if observation.dirty || observation.sibling_status == "dirty" {
655 return DependencyRisk {
656 level: "warning",
657 kind: "dirty-sibling",
658 release_readiness: "review-required",
659 summary: format!(
660 "{} sibling checkout is dirty; verify the committed pin before depending on local behavior.",
661 observation.name
662 ),
663 partial: false,
664 };
665 }
666
667 if revision_matches_pin(observation) == Some(false) {
668 return DependencyRisk {
669 level: "warning",
670 kind: "local-head-differs-from-pin",
671 release_readiness: "review-required",
672 summary: format!(
673 "{} sibling checkout HEAD differs from the Cargo.toml pin.",
674 observation.name
675 ),
676 partial: false,
677 };
678 }
679
680 if observation.sibling_status == "missing" {
681 return DependencyRisk {
682 level: "info",
683 kind: "missing-sibling-checkout",
684 release_readiness: "ready",
685 summary: format!(
686 "{} sibling checkout is absent; Cargo.toml remains authoritative.",
687 observation.name
688 ),
689 partial: true,
690 };
691 }
692
693 if observation.sibling_status == "unavailable" {
694 return DependencyRisk {
695 level: "info",
696 kind: "sibling-state-unavailable",
697 release_readiness: "ready",
698 summary: format!(
699 "{} sibling checkout exists but git state could not be read.",
700 observation.name
701 ),
702 partial: true,
703 };
704 }
705
706 if observation.upstream_status == "unavailable" {
707 return DependencyRisk {
708 level: "info",
709 kind: "upstream-unavailable",
710 release_readiness: "ready",
711 summary: format!(
712 "{} upstream was not reachable in the fixture; no network check is run by cass.",
713 observation.name
714 ),
715 partial: true,
716 };
717 }
718
719 DependencyRisk {
720 level: "clean",
721 kind: "matches-pin",
722 release_readiness: "ready",
723 summary: format!(
724 "{} manifest pin and local sibling state are aligned.",
725 observation.name
726 ),
727 partial: false,
728 }
729}
730
731fn revision_matches_pin(observation: &DependencyObservation) -> Option<bool> {
732 if observation.source_kind != "git" {
733 return None;
734 }
735 let local_head = observation.local_head.as_deref()?.trim();
736 let pinned_rev = observation.pinned_rev.as_deref()?.trim();
737 if local_head.is_empty() || pinned_rev.is_empty() {
738 return Some(false);
739 }
740
741 Some(local_head == pinned_rev || local_head.starts_with(pinned_rev))
742}
743
744fn recommendations(
745 dependencies: &[DependencyObservation],
746 fixture_problem: Option<&str>,
747) -> Vec<Value> {
748 let risks = dependencies.iter().map(classify).collect::<Vec<_>>();
749 let warning_count = risks.iter().filter(|risk| risk.level == "warning").count();
750 let has_fsqlite_warning = dependencies
751 .iter()
752 .zip(risks.iter())
753 .any(|(dep, risk)| dep.package == "fsqlite" && risk.level == "warning");
754 let mut output = vec![json!({
755 "kind": "strict-path-dep-validation",
756 "summary": "Run the strict dependency contract check before enabling local sibling overrides or updating pins.",
757 "commands": [STRICT_CHECK_COMMAND],
758 "requires_network": false,
759 "requires_human_confirmation": false
760 })];
761
762 if fixture_problem.is_some() {
763 output.push(json!({
764 "kind": "fixture-repair",
765 "summary": "Provide a sources.dependency_drift.dependencies array in the fixture before treating this projection as complete.",
766 "commands": ["cass swarm dependency-drift --json --fixture <fixture>"],
767 "requires_network": false,
768 "requires_human_confirmation": false
769 }));
770 }
771
772 if warning_count > 0 {
773 output.push(json!({
774 "kind": "review-drift-before-release",
775 "summary": "Do not treat local sibling behavior as release proof until Cargo.toml pins, build.rs contracts, and downstream checks agree.",
776 "commands": [STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
777 "requires_network": false,
778 "requires_human_confirmation": false
779 }));
780 }
781
782 if has_fsqlite_warning {
783 output.push(json!({
784 "kind": "frankensqlite-first",
785 "summary": "If SQLite behavior is missing, fix /data/projects/frankensqlite and bump the fsqlite pin; do not add new rusqlite workarounds.",
786 "commands": [FSQLITE_REGRESSION_COMMAND, STRICT_CHECK_COMMAND],
787 "requires_network": false,
788 "requires_human_confirmation": false
789 }));
790 }
791
792 output
793}
794
795fn string_field(value: &Value, keys: &[&str], fallback: &str) -> String {
796 string_field_optional(value, keys).unwrap_or_else(|| fallback.to_string())
797}
798
799fn string_field_optional(value: &Value, keys: &[&str]) -> Option<String> {
800 keys.iter()
801 .find_map(|key| value.get(*key).and_then(Value::as_str))
802 .and_then(clean_string)
803}
804
805fn nested_string_field(value: &Value, paths: &[&[&str]]) -> Option<String> {
806 paths
807 .iter()
808 .find_map(|path| {
809 let mut current = value;
810 for key in *path {
811 current = current.get(*key)?;
812 }
813 current.as_str()
814 })
815 .and_then(clean_string)
816}
817
818fn clean_string(value: &str) -> Option<String> {
819 let value = value.trim();
820 if value.is_empty() {
821 None
822 } else {
823 Some(value.to_string())
824 }
825}
826
827fn bool_field(value: &Value, keys: &[&str], fallback: bool) -> bool {
828 keys.iter()
829 .find_map(|key| value.get(*key).and_then(Value::as_bool))
830 .unwrap_or(fallback)
831}
832
833fn display_path(path: &Path) -> String {
834 path.canonicalize()
835 .unwrap_or_else(|_| path.to_path_buf())
836 .display()
837 .to_string()
838}
839
840#[cfg(test)]
841mod tests {
842 use super::{
843 DEPENDENCY_SPECS, DependencyObservation, DependencySpec, classify, fixture_observation,
844 manifest_pin, read_manifest, revision_matches_pin,
845 };
846 use serde_json::json;
847 use std::error::Error;
848 use std::path::Path;
849
850 fn test_error(message: impl Into<String>) -> Box<dyn Error> {
851 std::io::Error::other(message.into()).into()
852 }
853
854 fn ensure(condition: bool, message: impl Into<String>) -> Result<(), Box<dyn Error>> {
855 if condition {
856 Ok(())
857 } else {
858 Err(test_error(message))
859 }
860 }
861
862 fn checked_in_manifest() -> Result<toml::Value, Box<dyn Error>> {
863 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
864 read_manifest(&path).ok_or_else(|| {
865 test_error(format!(
866 "checked-in Cargo.toml should parse: {}",
867 path.display()
868 ))
869 })
870 }
871
872 fn minimal_git_observation(
873 local_head: Option<&str>,
874 pinned_rev: Option<&str>,
875 ) -> DependencyObservation {
876 DependencyObservation {
877 name: "fixture".to_string(),
878 package: "fixture".to_string(),
879 manifest_table: "dependencies".to_string(),
880 manifest_key: "fixture".to_string(),
881 source_kind: "git".to_string(),
882 git: Some("https://example.invalid/fixture".to_string()),
883 version: None,
884 pinned_rev: pinned_rev.map(str::to_string),
885 manifest_status: "pinned".to_string(),
886 sibling_path: None,
887 sibling_status: "clean".to_string(),
888 local_head: local_head.map(str::to_string),
889 dirty: false,
890 upstream_status: "not_checked".to_string(),
891 required_tests: Vec::new(),
892 }
893 }
894
895 fn dependency_spec(name: &str) -> Result<&'static DependencySpec, Box<dyn Error>> {
896 DEPENDENCY_SPECS
897 .iter()
898 .find(|spec| spec.name == name)
899 .ok_or_else(|| test_error(format!("dependency spec missing: {name}")))
900 }
901
902 #[test]
903 fn read_manifest_parses_checked_in_cargo_toml() -> Result<(), Box<dyn Error>> {
904 let manifest = checked_in_manifest()?;
905 let dependencies = match manifest.get("dependencies").and_then(toml::Value::as_table) {
906 Some(dependencies) => dependencies,
907 None => return Err(test_error("dependencies table should exist")),
908 };
909 ensure(
910 dependencies.contains_key("frankensqlite"),
911 "dependency drift live mode must see Cargo.toml dependency pins",
912 )?;
913 ensure(
914 dependencies.contains_key("frankensearch"),
915 "dependency drift live mode must see git dependency pins",
916 )
917 }
918
919 #[test]
920 fn manifest_pin_reads_git_and_registry_dependency_specs() -> Result<(), Box<dyn Error>> {
921 let manifest = checked_in_manifest()?;
922
923 let frankensqlite_spec = dependency_spec("frankensqlite")?;
924 let frankensqlite = manifest_pin(&manifest, frankensqlite_spec);
925 ensure(
926 frankensqlite.status == "version-pinned",
927 format!(
928 "expected frankensqlite version-pinned, got {}",
929 frankensqlite.status
930 ),
931 )?;
932 ensure(
933 frankensqlite.package.as_deref() == Some(frankensqlite_spec.package),
934 "frankensqlite package should match the dependency spec",
935 )?;
936 ensure(
937 frankensqlite.version.as_deref() == Some("=0.1.9"),
938 "frankensqlite registry version pin should match Cargo.toml",
939 )?;
940
941 let asupersync = manifest_pin(&manifest, dependency_spec("asupersync")?);
942 ensure(
943 asupersync.status == "version-pinned",
944 format!(
945 "expected asupersync version-pinned, got {}",
946 asupersync.status
947 ),
948 )?;
949 ensure(
950 asupersync.version.as_deref() == Some("=0.3.4"),
951 "asupersync version pin should match Cargo.toml",
952 )
953 }
954
955 #[test]
956 fn manifest_pin_treats_blank_pin_fields_as_missing() -> Result<(), Box<dyn Error>> {
957 let manifest = r#"
958 [dependencies]
959 fixture = { git = "https://example.invalid/fixture", rev = "" }
960 missing-git-fixture = { git = " ", rev = "abc123" }
961 registry-fixture = { version = " " }
962 string-fixture = ""
963 "#
964 .parse::<toml::Table>()
965 .map(toml::Value::Table)?;
966
967 let git_spec = DependencySpec {
968 name: "fixture",
969 package: "fixture",
970 manifest_table: "dependencies",
971 manifest_key: "fixture",
972 source_kind: "git",
973 repo_rel: "../fixture",
974 required_tests: &[],
975 };
976 let registry_spec = DependencySpec {
977 name: "registry-fixture",
978 package: "registry-fixture",
979 manifest_table: "dependencies",
980 manifest_key: "registry-fixture",
981 source_kind: "registry",
982 repo_rel: "../registry-fixture",
983 required_tests: &[],
984 };
985 let missing_git_spec = DependencySpec {
986 name: "missing-git-fixture",
987 package: "missing-git-fixture",
988 manifest_table: "dependencies",
989 manifest_key: "missing-git-fixture",
990 source_kind: "git",
991 repo_rel: "../missing-git-fixture",
992 required_tests: &[],
993 };
994 let string_spec = DependencySpec {
995 name: "string-fixture",
996 package: "string-fixture",
997 manifest_table: "dependencies",
998 manifest_key: "string-fixture",
999 source_kind: "registry",
1000 repo_rel: "../string-fixture",
1001 required_tests: &[],
1002 };
1003
1004 let git_pin = manifest_pin(&manifest, &git_spec);
1005 ensure(
1006 git_pin.status == "missing-rev",
1007 format!(
1008 "blank git rev should be missing-rev, got {}",
1009 git_pin.status
1010 ),
1011 )?;
1012 let missing_git_pin = manifest_pin(&manifest, &missing_git_spec);
1013 ensure(
1014 missing_git_pin.status == "missing-git",
1015 format!(
1016 "blank git URL should be missing-git, got {}",
1017 missing_git_pin.status
1018 ),
1019 )?;
1020 let registry_pin = manifest_pin(&manifest, ®istry_spec);
1021 ensure(
1022 registry_pin.status == "missing-version",
1023 format!(
1024 "blank registry version should be missing-version, got {}",
1025 registry_pin.status
1026 ),
1027 )?;
1028 let string_pin = manifest_pin(&manifest, &string_spec);
1029 ensure(
1030 string_pin.status == "missing-version",
1031 format!(
1032 "blank string dependency version should be missing-version, got {}",
1033 string_pin.status
1034 ),
1035 )
1036 }
1037
1038 #[test]
1039 fn manifest_pin_rejects_bare_string_specs_for_git_dependencies() -> Result<(), Box<dyn Error>> {
1040 let manifest = r#"
1041 [dependencies]
1042 git-fixture = "1.2.3"
1043 "#
1044 .parse::<toml::Table>()
1045 .map(toml::Value::Table)?;
1046 let git_spec = DependencySpec {
1047 name: "git-fixture",
1048 package: "git-fixture",
1049 manifest_table: "dependencies",
1050 manifest_key: "git-fixture",
1051 source_kind: "git",
1052 repo_rel: "../git-fixture",
1053 required_tests: &[],
1054 };
1055
1056 let pin = manifest_pin(&manifest, &git_spec);
1057 ensure(
1058 pin.status == "invalid-spec",
1059 format!(
1060 "git dependencies must use table specs with git+rev pins, got {}",
1061 pin.status
1062 ),
1063 )
1064 }
1065
1066 #[test]
1067 fn fixture_observation_requires_git_url_for_git_pin() -> Result<(), Box<dyn Error>> {
1068 let source = json!({
1069 "name": "fixture",
1070 "source_kind": "git",
1071 "git": " ",
1072 "pinned_rev": "abc123",
1073 "manifest_status": "pinned",
1074 "sibling_status": "clean",
1075 "local_head": "abc123456789"
1076 });
1077
1078 let observation = fixture_observation(&source, "not_checked");
1079
1080 ensure(
1081 observation.git.is_none(),
1082 "blank fixture git URL should not be treated as a manifest git source",
1083 )?;
1084 ensure(
1085 observation.manifest_status == "missing-git",
1086 format!(
1087 "blank fixture git URL should override stale pinned status, got {}",
1088 observation.manifest_status
1089 ),
1090 )?;
1091 ensure(
1092 classify(&observation).kind == "manifest-pin-missing",
1093 "missing fixture git URLs should block release readiness",
1094 )
1095 }
1096
1097 #[test]
1098 fn fixture_observation_treats_blank_revision_as_missing_pin() -> Result<(), Box<dyn Error>> {
1099 let source = json!({
1100 "name": "fixture",
1101 "source_kind": "git",
1102 "git": "https://example.invalid/fixture",
1103 "pinned_rev": " ",
1104 "sibling_status": "clean",
1105 "local_head": "abc123456789"
1106 });
1107
1108 let observation = fixture_observation(&source, "not_checked");
1109 ensure(
1110 observation.pinned_rev.is_none(),
1111 "blank fixture pinned_rev should not be treated as a revision",
1112 )?;
1113 ensure(
1114 observation.manifest_status == "missing-rev",
1115 format!(
1116 "blank fixture revisions should force missing-rev, got {}",
1117 observation.manifest_status
1118 ),
1119 )?;
1120 ensure(
1121 classify(&observation).kind == "manifest-pin-missing",
1122 "blank fixture revisions should block release readiness",
1123 )
1124 }
1125
1126 #[test]
1127 fn fixture_observation_does_not_trust_stale_pinned_status() -> Result<(), Box<dyn Error>> {
1128 let source = json!({
1129 "name": "fixture",
1130 "source_kind": "git",
1131 "git": "https://example.invalid/fixture",
1132 "pinned_rev": " ",
1133 "manifest_status": "pinned",
1134 "sibling_status": "clean",
1135 "local_head": "abc123456789"
1136 });
1137
1138 let observation = fixture_observation(&source, "not_checked");
1139
1140 ensure(
1141 observation.manifest_status == "missing-rev",
1142 format!(
1143 "blank fixture revisions should override stale pinned status, got {}",
1144 observation.manifest_status
1145 ),
1146 )?;
1147 ensure(
1148 classify(&observation).kind == "manifest-pin-missing",
1149 "stale fixture status must not make blank revisions release-ready",
1150 )
1151 }
1152
1153 #[test]
1154 fn fixture_observation_does_not_trust_stale_version_pinned_status() -> Result<(), Box<dyn Error>>
1155 {
1156 let source = json!({
1157 "name": "registry-fixture",
1158 "source_kind": "registry",
1159 "version": " ",
1160 "manifest_status": "version-pinned",
1161 "sibling_status": "clean"
1162 });
1163
1164 let observation = fixture_observation(&source, "not_checked");
1165
1166 ensure(
1167 observation.manifest_status == "missing-version",
1168 format!(
1169 "blank fixture versions should override stale version-pinned status, got {}",
1170 observation.manifest_status
1171 ),
1172 )?;
1173 ensure(
1174 classify(&observation).kind == "manifest-pin-missing",
1175 "stale fixture status must not make blank registry versions release-ready",
1176 )
1177 }
1178
1179 #[test]
1180 fn revision_match_requires_local_head_to_extend_pinned_rev() -> Result<(), Box<dyn Error>> {
1181 ensure(
1182 revision_matches_pin(&minimal_git_observation(
1183 Some("abc123456789"),
1184 Some("abc123"),
1185 )) == Some(true),
1186 "a checked-out full HEAD should match a shorter pinned rev prefix",
1187 )?;
1188 ensure(
1189 revision_matches_pin(&minimal_git_observation(
1190 Some("abc123"),
1191 Some("abc123456789"),
1192 )) == Some(false),
1193 "a truncated local HEAD must not satisfy a longer pinned rev",
1194 )?;
1195 ensure(
1196 revision_matches_pin(&minimal_git_observation(Some("abc123"), Some(""))) == Some(false),
1197 "empty pinned revs are invalid pins, not wildcard matches",
1198 )?;
1199 ensure(
1200 revision_matches_pin(&minimal_git_observation(Some(""), Some("abc123"))) == Some(false),
1201 "empty local HEADs cannot prove a pin match",
1202 )
1203 }
1204}