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().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, &registry_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}