Skip to main content

coding_agent_search/
dependency_drift.rs

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}