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() {
291 return ManifestPin {
292 status: "version-pinned".to_string(),
293 version: Some(version.to_string()),
294 ..ManifestPin::default()
295 };
296 }
297
298 let Some(spec_table) = value.as_table() else {
299 return ManifestPin {
300 status: "invalid-spec".to_string(),
301 ..ManifestPin::default()
302 };
303 };
304
305 let git = spec_table
306 .get("git")
307 .and_then(toml::Value::as_str)
308 .map(str::to_string);
309 let rev = spec_table
310 .get("rev")
311 .and_then(toml::Value::as_str)
312 .map(str::to_string);
313 let version = spec_table
314 .get("version")
315 .and_then(toml::Value::as_str)
316 .map(str::to_string);
317 let package = spec_table
318 .get("package")
319 .and_then(toml::Value::as_str)
320 .map(str::to_string);
321 let status = if spec.source_kind == "git" {
322 match (&git, &rev) {
323 (Some(_), Some(_)) => "pinned",
324 (Some(_), None) => "missing-rev",
325 (None, Some(_)) => "missing-git",
326 (None, None) => "missing-git-rev",
327 }
328 } else if version.is_some() {
329 "version-pinned"
330 } else {
331 "missing-version"
332 };
333
334 ManifestPin {
335 status: status.to_string(),
336 git,
337 rev,
338 version,
339 package,
340 }
341}
342
343fn sibling_state(repo_path: &Path) -> (String, Option<String>, bool) {
344 if !repo_path.is_dir() {
345 return ("missing".to_string(), None, false);
346 }
347
348 let head = git_output(repo_path, &["rev-parse", "HEAD"]);
349 let status = git_output(
350 repo_path,
351 &["status", "--porcelain=v1", "--untracked-files=no"],
352 );
353 match (head, status) {
354 (Some(head), Some(status)) => {
355 let dirty = !status.trim().is_empty();
356 let state = if dirty { "dirty" } else { "clean" };
357 (state.to_string(), Some(head), dirty)
358 }
359 (Some(head), None) => ("unavailable".to_string(), Some(head), false),
360 _ => ("unavailable".to_string(), None, false),
361 }
362}
363
364fn git_output(repo_path: &Path, args: &[&str]) -> Option<String> {
365 let output = Command::new("git")
366 .arg("-C")
367 .arg(repo_path)
368 .args(args)
369 .output()
370 .ok()?;
371 if !output.status.success() {
372 return None;
373 }
374
375 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
376}
377
378fn fixture_observation(value: &Value, inherited_upstream_status: &str) -> DependencyObservation {
379 let name = string_field(value, &["name", "dependency", "manifest_key"], "unknown");
380 let manifest_key = string_field(value, &["manifest_key", "name", "dependency"], &name);
381 let source_kind = string_field(value, &["source_kind"], "git");
382 let pinned_rev = nested_string_field(value, &[&["pinned", "rev"], &["pinned", "revision"]])
383 .or_else(|| string_field_optional(value, &["pinned_rev", "manifest_rev", "rev"]));
384 let version = nested_string_field(value, &[&["pinned", "version"]])
385 .or_else(|| string_field_optional(value, &["version", "manifest_version"]));
386 let git = nested_string_field(value, &[&["pinned", "git"]])
387 .or_else(|| string_field_optional(value, &["git", "manifest_git"]));
388 let local_head = string_field_optional(value, &["local_head", "sibling_head"]);
389 let dirty = bool_field(value, &["dirty", "sibling_dirty"], false);
390 let sibling_status = string_field(
391 value,
392 &["sibling_status"],
393 if dirty { "dirty" } else { "clean" },
394 );
395 let upstream_status = nested_string_field(value, &[&["upstream", "status"]])
396 .or_else(|| string_field_optional(value, &["upstream_status"]))
397 .unwrap_or_else(|| inherited_upstream_status.to_string());
398 let manifest_status = string_field(
399 value,
400 &["manifest_status"],
401 if source_kind == "git" && pinned_rev.is_none() {
402 "missing-rev"
403 } else if source_kind == "registry" && version.is_none() {
404 "missing-version"
405 } else if source_kind == "registry" {
406 "version-pinned"
407 } else {
408 "pinned"
409 },
410 );
411 let package = string_field(value, &["package"], &manifest_key);
412 let manifest_table = string_field(value, &["manifest_table"], "dependencies");
413 let sibling_path = string_field_optional(value, &["sibling_path", "path"]);
414 let required_tests = value
415 .get("required_downstream_tests")
416 .or_else(|| value.get("required_tests"))
417 .and_then(Value::as_array)
418 .map(|tests| {
419 tests
420 .iter()
421 .filter_map(Value::as_str)
422 .map(str::to_string)
423 .collect::<Vec<_>>()
424 })
425 .filter(|tests| !tests.is_empty())
426 .unwrap_or_else(|| {
427 vec![
428 STRICT_CHECK_COMMAND.to_string(),
429 FULL_CHECK_COMMAND.to_string(),
430 ]
431 });
432
433 DependencyObservation {
434 name,
435 package,
436 manifest_table,
437 manifest_key,
438 source_kind,
439 git,
440 version,
441 pinned_rev,
442 manifest_status,
443 sibling_path,
444 sibling_status,
445 local_head,
446 dirty,
447 upstream_status,
448 required_tests,
449 }
450}
451
452fn render_payload(
453 fixture_id: &str,
454 source_kind: &str,
455 dependencies: Vec<DependencyObservation>,
456 upstream_status: &str,
457 fixture_problem: Option<&str>,
458) -> Value {
459 let rendered_dependencies = dependencies
460 .iter()
461 .map(render_dependency)
462 .collect::<Vec<_>>();
463 let summary = summarize(&dependencies, upstream_status, fixture_problem);
464 let status = summary
465 .get("status")
466 .and_then(Value::as_str)
467 .unwrap_or("partial")
468 .to_string();
469 let recommendations = recommendations(&dependencies, fixture_problem);
470
471 json!({
472 "schema_version": SCHEMA_VERSION,
473 "status": status,
474 "_meta": {
475 "generated_at": Utc::now().to_rfc3339(),
476 "source": source_kind,
477 "fixture_id": fixture_id,
478 "contract": "read-only sibling dependency drift sentinel"
479 },
480 "summary": summary,
481 "dependencies": rendered_dependencies,
482 "recommendations": recommendations,
483 "mutation_contract": {
484 "read_only": true,
485 "mutates_git": false,
486 "mutates_files": false,
487 "runs_builds": false,
488 "touches_network": false,
489 "network_policy": "remote upstreams are not queried by default"
490 },
491 "privacy": {
492 "contains_session_content": false,
493 "contains_secrets": false,
494 "redaction_applied": false
495 }
496 })
497}
498
499fn render_dependency(observation: &DependencyObservation) -> Value {
500 let risk = classify(observation);
501 let revision_matches_pin = revision_matches_pin(observation);
502 json!({
503 "name": &observation.name,
504 "package": &observation.package,
505 "manifest": {
506 "table": &observation.manifest_table,
507 "key": &observation.manifest_key,
508 "status": &observation.manifest_status
509 },
510 "source": {
511 "kind": &observation.source_kind,
512 "git": &observation.git,
513 "version": &observation.version,
514 "rev": &observation.pinned_rev
515 },
516 "sibling": {
517 "path": &observation.sibling_path,
518 "status": &observation.sibling_status,
519 "local_head": &observation.local_head,
520 "dirty": observation.dirty,
521 "revision_matches_pin": revision_matches_pin
522 },
523 "upstream": {
524 "status": &observation.upstream_status
525 },
526 "risk": {
527 "level": risk.level,
528 "kind": risk.kind,
529 "release_readiness": risk.release_readiness,
530 "summary": risk.summary
531 },
532 "required_downstream_tests": &observation.required_tests
533 })
534}
535
536fn summarize(
537 dependencies: &[DependencyObservation],
538 upstream_status: &str,
539 fixture_problem: Option<&str>,
540) -> Value {
541 let risks = dependencies.iter().map(classify).collect::<Vec<_>>();
542 let warning_count = risks.iter().filter(|risk| risk.level == "warning").count();
543 let blocked_count = risks
544 .iter()
545 .filter(|risk| risk.release_readiness == "blocked")
546 .count();
547 let partial_count = risks.iter().filter(|risk| risk.partial).count()
548 + usize::from(upstream_status == "unavailable")
549 + usize::from(fixture_problem.is_some());
550 let dirty_count = dependencies.iter().filter(|dep| dep.dirty).count();
551 let local_rev_mismatch_count = dependencies
552 .iter()
553 .filter(|dep| revision_matches_pin(dep) == Some(false))
554 .count();
555 let missing_sibling_count = dependencies
556 .iter()
557 .filter(|dep| dep.sibling_status == "missing")
558 .count();
559 let missing_manifest_count = dependencies
560 .iter()
561 .filter(|dep| dep.manifest_status.starts_with("missing"))
562 .count();
563 let clean_count = risks.iter().filter(|risk| risk.level == "clean").count();
564 let status = if warning_count > 0 || blocked_count > 0 {
565 "warning"
566 } else if partial_count > 0 {
567 "partial"
568 } else {
569 "ok"
570 };
571 let release_readiness = if blocked_count > 0 {
572 "blocked"
573 } else if warning_count > 0 {
574 "review-required"
575 } else {
576 "ready"
577 };
578 let recommended_action = if blocked_count > 0 {
579 "restore-manifest-pin"
580 } else if warning_count > 0 {
581 "review-sibling-drift"
582 } else if partial_count > 0 {
583 "optional-sibling-context-missing"
584 } else {
585 "dependencies-clean"
586 };
587
588 json!({
589 "status": status,
590 "dependency_count": dependencies.len(),
591 "clean_count": clean_count,
592 "warning_count": warning_count,
593 "partial_count": partial_count,
594 "dirty_count": dirty_count,
595 "local_rev_mismatch_count": local_rev_mismatch_count,
596 "missing_sibling_count": missing_sibling_count,
597 "missing_manifest_count": missing_manifest_count,
598 "network_status": upstream_status,
599 "release_readiness": release_readiness,
600 "recommended_action": recommended_action,
601 "fixture_problem": fixture_problem
602 })
603}
604
605fn classify(observation: &DependencyObservation) -> DependencyRisk {
606 if observation.manifest_status.starts_with("missing")
607 || observation.manifest_status == "invalid-spec"
608 || observation.manifest_status == "manifest-unavailable"
609 {
610 return DependencyRisk {
611 level: "warning",
612 kind: "manifest-pin-missing",
613 release_readiness: "blocked",
614 summary: format!(
615 "{} does not have a complete manifest pin in [{}].{}",
616 observation.name, observation.manifest_table, observation.manifest_key
617 ),
618 partial: false,
619 };
620 }
621
622 if observation.dirty || observation.sibling_status == "dirty" {
623 return DependencyRisk {
624 level: "warning",
625 kind: "dirty-sibling",
626 release_readiness: "review-required",
627 summary: format!(
628 "{} sibling checkout is dirty; verify the committed pin before depending on local behavior.",
629 observation.name
630 ),
631 partial: false,
632 };
633 }
634
635 if revision_matches_pin(observation) == Some(false) {
636 return DependencyRisk {
637 level: "warning",
638 kind: "local-head-differs-from-pin",
639 release_readiness: "review-required",
640 summary: format!(
641 "{} sibling checkout HEAD differs from the Cargo.toml pin.",
642 observation.name
643 ),
644 partial: false,
645 };
646 }
647
648 if observation.sibling_status == "missing" {
649 return DependencyRisk {
650 level: "info",
651 kind: "missing-sibling-checkout",
652 release_readiness: "ready",
653 summary: format!(
654 "{} sibling checkout is absent; Cargo.toml remains authoritative.",
655 observation.name
656 ),
657 partial: true,
658 };
659 }
660
661 if observation.sibling_status == "unavailable" {
662 return DependencyRisk {
663 level: "info",
664 kind: "sibling-state-unavailable",
665 release_readiness: "ready",
666 summary: format!(
667 "{} sibling checkout exists but git state could not be read.",
668 observation.name
669 ),
670 partial: true,
671 };
672 }
673
674 if observation.upstream_status == "unavailable" {
675 return DependencyRisk {
676 level: "info",
677 kind: "upstream-unavailable",
678 release_readiness: "ready",
679 summary: format!(
680 "{} upstream was not reachable in the fixture; no network check is run by cass.",
681 observation.name
682 ),
683 partial: true,
684 };
685 }
686
687 DependencyRisk {
688 level: "clean",
689 kind: "matches-pin",
690 release_readiness: "ready",
691 summary: format!(
692 "{} manifest pin and local sibling state are aligned.",
693 observation.name
694 ),
695 partial: false,
696 }
697}
698
699fn revision_matches_pin(observation: &DependencyObservation) -> Option<bool> {
700 if observation.source_kind != "git" {
701 return None;
702 }
703 let local_head = observation.local_head.as_deref()?;
704 let pinned_rev = observation.pinned_rev.as_deref()?;
705 Some(
706 local_head == pinned_rev
707 || local_head.starts_with(pinned_rev)
708 || pinned_rev.starts_with(local_head),
709 )
710}
711
712fn recommendations(
713 dependencies: &[DependencyObservation],
714 fixture_problem: Option<&str>,
715) -> Vec<Value> {
716 let risks = dependencies.iter().map(classify).collect::<Vec<_>>();
717 let warning_count = risks.iter().filter(|risk| risk.level == "warning").count();
718 let has_fsqlite_warning = dependencies
719 .iter()
720 .zip(risks.iter())
721 .any(|(dep, risk)| dep.package == "fsqlite" && risk.level == "warning");
722 let mut output = vec![json!({
723 "kind": "strict-path-dep-validation",
724 "summary": "Run the strict dependency contract check before enabling local sibling overrides or updating pins.",
725 "commands": [STRICT_CHECK_COMMAND],
726 "requires_network": false,
727 "requires_human_confirmation": false
728 })];
729
730 if fixture_problem.is_some() {
731 output.push(json!({
732 "kind": "fixture-repair",
733 "summary": "Provide a sources.dependency_drift.dependencies array in the fixture before treating this projection as complete.",
734 "commands": ["cass swarm dependency-drift --json --fixture <fixture>"],
735 "requires_network": false,
736 "requires_human_confirmation": false
737 }));
738 }
739
740 if warning_count > 0 {
741 output.push(json!({
742 "kind": "review-drift-before-release",
743 "summary": "Do not treat local sibling behavior as release proof until Cargo.toml pins, build.rs contracts, and downstream checks agree.",
744 "commands": [STRICT_CHECK_COMMAND, FULL_CHECK_COMMAND],
745 "requires_network": false,
746 "requires_human_confirmation": false
747 }));
748 }
749
750 if has_fsqlite_warning {
751 output.push(json!({
752 "kind": "frankensqlite-first",
753 "summary": "If SQLite behavior is missing, fix /data/projects/frankensqlite and bump the fsqlite pin; do not add new rusqlite workarounds.",
754 "commands": [FSQLITE_REGRESSION_COMMAND, STRICT_CHECK_COMMAND],
755 "requires_network": false,
756 "requires_human_confirmation": false
757 }));
758 }
759
760 output
761}
762
763fn string_field(value: &Value, keys: &[&str], fallback: &str) -> String {
764 string_field_optional(value, keys).unwrap_or_else(|| fallback.to_string())
765}
766
767fn string_field_optional(value: &Value, keys: &[&str]) -> Option<String> {
768 keys.iter()
769 .find_map(|key| value.get(*key).and_then(Value::as_str))
770 .map(str::to_string)
771}
772
773fn nested_string_field(value: &Value, paths: &[&[&str]]) -> Option<String> {
774 paths
775 .iter()
776 .find_map(|path| {
777 let mut current = value;
778 for key in *path {
779 current = current.get(*key)?;
780 }
781 current.as_str()
782 })
783 .map(str::to_string)
784}
785
786fn bool_field(value: &Value, keys: &[&str], fallback: bool) -> bool {
787 keys.iter()
788 .find_map(|key| value.get(*key).and_then(Value::as_bool))
789 .unwrap_or(fallback)
790}
791
792fn display_path(path: &Path) -> String {
793 path.canonicalize()
794 .unwrap_or_else(|_| path.to_path_buf())
795 .display()
796 .to_string()
797}
798
799#[cfg(test)]
800mod tests {
801 use super::{DEPENDENCY_SPECS, DependencySpec, manifest_pin, read_manifest};
802 use std::error::Error;
803 use std::path::Path;
804
805 fn test_error(message: impl Into<String>) -> Box<dyn Error> {
806 std::io::Error::other(message.into()).into()
807 }
808
809 fn ensure(condition: bool, message: impl Into<String>) -> Result<(), Box<dyn Error>> {
810 if condition {
811 Ok(())
812 } else {
813 Err(test_error(message))
814 }
815 }
816
817 fn checked_in_manifest() -> Result<toml::Value, Box<dyn Error>> {
818 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
819 read_manifest(&path).ok_or_else(|| {
820 test_error(format!(
821 "checked-in Cargo.toml should parse: {}",
822 path.display()
823 ))
824 })
825 }
826
827 fn dependency_spec(name: &str) -> Result<&'static DependencySpec, Box<dyn Error>> {
828 DEPENDENCY_SPECS
829 .iter()
830 .find(|spec| spec.name == name)
831 .ok_or_else(|| test_error(format!("dependency spec missing: {name}")))
832 }
833
834 #[test]
835 fn read_manifest_parses_checked_in_cargo_toml() -> Result<(), Box<dyn Error>> {
836 let manifest = checked_in_manifest()?;
837 let dependencies = match manifest.get("dependencies").and_then(toml::Value::as_table) {
838 Some(dependencies) => dependencies,
839 None => return Err(test_error("dependencies table should exist")),
840 };
841 ensure(
842 dependencies.contains_key("frankensqlite"),
843 "dependency drift live mode must see Cargo.toml dependency pins",
844 )?;
845 ensure(
846 dependencies.contains_key("frankensearch"),
847 "dependency drift live mode must see git dependency pins",
848 )
849 }
850
851 #[test]
852 fn manifest_pin_reads_git_and_registry_dependency_specs() -> Result<(), Box<dyn Error>> {
853 let manifest = checked_in_manifest()?;
854
855 let frankensqlite_spec = dependency_spec("frankensqlite")?;
856 let frankensqlite = manifest_pin(&manifest, frankensqlite_spec);
857 ensure(
858 frankensqlite.status == "version-pinned",
859 format!(
860 "expected frankensqlite version-pinned, got {}",
861 frankensqlite.status
862 ),
863 )?;
864 ensure(
865 frankensqlite.package.as_deref() == Some(frankensqlite_spec.package),
866 "frankensqlite package should match the dependency spec",
867 )?;
868 ensure(
869 frankensqlite.version.as_deref() == Some("0.1.5"),
870 "frankensqlite registry version pin should match Cargo.toml",
871 )?;
872
873 let asupersync = manifest_pin(&manifest, dependency_spec("asupersync")?);
874 ensure(
875 asupersync.status == "version-pinned",
876 format!(
877 "expected asupersync version-pinned, got {}",
878 asupersync.status
879 ),
880 )?;
881 ensure(
882 asupersync.version.as_deref() == Some("0.3.2"),
883 "asupersync version pin should match Cargo.toml",
884 )
885 }
886}