Skip to main content

shipshape_core/facts/
mod.rs

1//! Deterministic repo-fact detector (port of `infer-repo-facts.py` —
2//! ADR-0001 §3).
3//!
4//! [`gather`] is a pure function of `(repo tree, git HEAD)`: it sniffs
5//! ecosystems and manifests, counts committers, reads tags, detects CI/bot/
6//! issues signals, extracts a README self-label and description, and applies the
7//! SCHEMA.md §4 maturity truth table. All I/O goes through the [`Fs`] and
8//! [`GitRepo`] ports (`crate::ports`), so the detector is exercised entirely
9//! against in-memory fakes and never touches the real filesystem or git — the
10//! whole point of the injected-port seam (ADR-0001 §2).
11//!
12//! The report shape lives in [`crate::protocol::facts`] (the versioned wire
13//! DTO); this module owns only the detection logic. It is **reproducible**: for
14//! a fixed repository state *and* wall-clock day it produces byte-identical
15//! facts, so `/shipshape-init` and the readiness `audit` reading the same `shipshape
16//! facts` output agree on maturity and the gated core. It is not a pure function
17//! of `HEAD` alone: the recent-committer count uses git's `--since=1 year ago`
18//! (evaluated against the current clock) and reads all refs/tags, so a run that
19//! crosses the one-year boundary, or after refs change, can shift — the same
20//! time-relative behavior `infer-repo-facts.py` has.
21//!
22//! ## Fidelity to the Python detector
23//!
24//! Field names, the manifest set, the SemVer/monorepo-prefix handling, the CI
25//! globs, the spike-label list, and the maturity truth table all mirror
26//! `infer-repo-facts.py`. TOML manifests (`Cargo.toml`, `pyproject.toml`) are
27//! parsed by scanning the relevant `[section]` for `key = "value"` (single or
28//! double quotes) rather than via a full TOML library — the contract normalizer
29//! avoids new parser deps the same way. This matches the Python `tomllib` path
30//! for the common manifest shapes; two edge cases are **not** reproduced and are
31//! deliberately out of scope: escaped/multiline TOML strings, and Python's
32//! whole-file regex *fallback* that fires only when `tomllib` itself fails on
33//! malformed TOML (there, Python may surface a `name` from an unrelated table;
34//! this port returns none). Both are exotic in a real `pyproject.toml`.
35
36use std::path::Path;
37
38use crate::contract::schema::{Ecosystem, Maturity};
39use crate::ports::{Fs, GitRepo};
40pub use crate::protocol::facts::{CargoPublishFlag, CargoPublishPolicy};
41use crate::protocol::facts::{
42    DistributionSurface, Facts, FactsReport, MaturitySignals, Package, RustWorkspace,
43    WorkspaceMember,
44};
45
46/// Root-level manifests, in the canonical probe order. The ecosystem order here
47/// also fixes the order `ecosystems` and `packages` are emitted in.
48const MANIFESTS: &[(&str, Ecosystem)] = &[
49    ("Cargo.toml", Ecosystem::Rust),
50    ("package.json", Ecosystem::Node),
51    ("pyproject.toml", Ecosystem::Python),
52    ("setup.py", Ecosystem::Python),
53    ("go.mod", Ecosystem::Go),
54];
55
56/// README tokens that self-label a project as pre-release (a spike signal).
57/// Matched case-insensitively as whole substrings; kept small + explicit for
58/// reproducibility (mirrors the Python `SPIKE_LABELS`).
59const SPIKE_LABELS: &[&str] = &[
60    "work in progress",
61    "work-in-progress",
62    "wip",
63    "experimental",
64    "prototype",
65    "pre-alpha",
66    "proof of concept",
67    "proof-of-concept",
68    "status: private, early",
69    "not production ready",
70    "not production-ready",
71    "early prototype",
72    "spike",
73];
74
75/// CI configuration paths. `.github/workflows` is a directory that counts only
76/// when non-empty; the rest are single-file configs that count on existence.
77const CI_GLOBS: &[&str] = &[
78    ".github/workflows",
79    ".gitlab-ci.yml",
80    ".circleci",
81    "azure-pipelines.yml",
82    ".drone.yml",
83    "Jenkinsfile",
84];
85
86/// Character cap for a manifest read (matches the Python `_read` default, which
87/// reads decoded characters, not bytes).
88const MANIFEST_LIMIT: usize = 200_000;
89/// Character cap for a README read (matches the Python text-mode README read).
90const README_LIMIT: usize = 4_000;
91/// Character cap for the emitted `description`.
92const DESCRIPTION_CHARS: usize = 120;
93
94/// Detect the deterministic repo facts under `repo_root`.
95///
96/// `repo_root` is used verbatim for the emitted `repo_root` field and as the
97/// join base for filesystem probes; the caller canonicalizes it (mirroring the
98/// Python `os.path.realpath`) before passing it in. Never mutates anything.
99#[must_use]
100pub fn gather(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> Facts {
101    let is_git = git.is_work_tree();
102    // `has_commits` needs both a work tree and a resolvable HEAD (an unborn repo
103    // is a work tree with no HEAD).
104    let has_commits = is_git && git.head_commit().is_ok();
105
106    let (ecosystems, packages) = detect_manifests(repo_root, fs);
107    // The Rust workspace's publishable member graph — off-wire plumbing the release
108    // planner derives a dependency-ordered publish set from (`None` for a repo with
109    // no multi-crate Cargo workspace). Derived from the same manifests as `packages`
110    // but carrying the publish flags + intra-workspace dependency edges `packages`
111    // omits, so a downstream repo that declares only its bin crate still gets its lib
112    // crate planned, lib-before-bin (`release-rust-workspace-multicrate`).
113    let rust_workspace = detect_rust_workspace(repo_root, fs);
114    let distribution_surface = detect_distribution_surface(repo_root, fs);
115
116    // ── committers (mailmap-aware, whole `--all` history) ──
117    let (committers_total, committers_recent_year) = if has_commits {
118        let total = git.shortlog(None).map_or(0, count_lines);
119        let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
120        (total, recent)
121    } else {
122        (0, 0)
123    };
124
125    // ── tags / releases ──
126    let tags = if has_commits {
127        git.tags().unwrap_or_default()
128    } else {
129        Vec::new()
130    };
131    let semver_tags: Vec<(u64, u64, u64, bool)> =
132        tags.iter().filter_map(|t| semver_parse(t)).collect();
133    let has_semver_tag = !semver_tags.is_empty();
134    // Count *shipped* releases: non-prerelease SemVer tags at `>=0.1.0`. A `0.0.x`
135    // tag is SemVer's initial-scratch space ("anything MAY change at any time"),
136    // and a `-rc`/`-alpha` prerelease is not shipped — neither is counted. A
137    // consumer can recompute this from the emitted `tags` with the same parse.
138    let shipped_release_tags = semver_tags
139        .iter()
140        .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
141        .count();
142    let ge_1_0_tag = semver_tags
143        .iter()
144        .any(|&(major, _, _, pre)| major >= 1 && !pre);
145    let manifest_ge_1_0 = packages
146        .iter()
147        .any(|p| version_ge_1_0(p.version.as_deref()));
148    let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
149
150    // ── CI presence ──
151    let has_ci = CI_GLOBS.iter().any(|glob| {
152        let path = repo_root.join(glob);
153        if !fs.exists(&path) {
154            return false;
155        }
156        if glob.ends_with("workflows") {
157            // A workflows *directory* counts only when it holds an entry.
158            fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
159        } else {
160            true
161        }
162    });
163
164    // ── dependency bot ──
165    let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
166        Some("dependabot".to_string())
167    } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
168        .iter()
169        .any(|f| fs.is_file(&repo_root.join(f)))
170    {
171        Some("renovate".to_string())
172    } else {
173        None
174    };
175
176    let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
177
178    // ── README self-label + description ──
179    let readme_text = ["README.md", "README.rst", "README.txt", "README"]
180        .iter()
181        .find_map(|name| {
182            let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
183            (!text.is_empty()).then_some(text)
184        });
185    let readme_self_label = readme_text.as_deref().and_then(|text| {
186        let low = text.to_lowercase();
187        SPIKE_LABELS
188            .iter()
189            .any(|label| low.contains(label))
190            .then(|| "spike".to_string())
191    });
192    let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
193
194    // ── maturity inference (SCHEMA.md §4, production first, tie → mvp) ──
195    //
196    // A deliberately-pre-1.0 (ZeroVer) project can still be production-grade: the
197    // version number is not release maturity. The `>=1.0` gate is really a
198    // stability *declaration*; below 1.0 that declaration is absent, so the
199    // `zerover_release_evidence` path requires compensating evidence of a
200    // maintained release process — a dependency-update bot configured **and** a
201    // release *cadence* (>=2 shipped `>=0.1.0` releases). A single tag is a
202    // moment; two prove the project has actually iterated a release more than
203    // once, which a lone `git tag` cannot fake. Combined with the always-required
204    // CI and >=2 recent committers, this is materially harder to inflate than the
205    // old "only a tag" concern.
206    //
207    // The asymmetry (a `>=1.0` project reaches `production` without a bot, a 0.x
208    // one does not) is intentional: `>=1.0` already carries the stability signal
209    // this path has to reconstruct. These remain presence/name heuristics over a
210    // cooperative repo (CI/bot detected by path, tags by name) — not adversarial
211    // proofs — and `/shipshape-init` surfaces every signal to a human before it lands
212    // in the contract. Each input is already in the report (`has_ci`,
213    // `dependency_bot`, `tags` — from which `shipped_release_tags` recomputes via
214    // the same parse — `committers_recent_year`, `has_ge_1_0_release`), so the
215    // decision is re-derivable without a new wire field.
216    let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
217    let release_gate = has_ge_1_0_release || zerover_release_evidence;
218    let production = committers_recent_year >= 2 && has_ci && release_gate;
219    let spike =
220        !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
221    let inferred_maturity = if production {
222        Maturity::Production
223    } else if spike {
224        Maturity::Spike
225    } else {
226        Maturity::Mvp
227    };
228
229    Facts {
230        repo_root: repo_root.display().to_string(),
231        is_git,
232        has_commits,
233        ecosystems,
234        packages,
235        committers_total,
236        committers_recent_year,
237        tags,
238        has_semver_tag,
239        has_ge_1_0_release,
240        has_ci,
241        dependency_bot,
242        has_issues_dir,
243        readme_self_label,
244        description,
245        maturity_signals: MaturitySignals { production, spike },
246        inferred_maturity,
247        distribution_surface,
248        rust_workspace,
249    }
250}
251
252/// Gather the complete public facts report, including the Cargo publish evidence
253/// used by the contract normalizer's hard floor.
254///
255/// Keeping the evidence collection here as a direct call to
256/// [`cargo_publish_evidence`] makes the CLI report and the normalizer consume one
257/// implementation rather than two parsers that could drift.
258#[must_use]
259pub fn gather_report(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> FactsReport {
260    FactsReport {
261        facts: gather(repo_root, fs, git),
262        cargo_publish: cargo_publish_evidence(repo_root, fs),
263    }
264}
265
266/// Detect cargo-dist configuration and tag-triggered GitHub workflows through the
267/// injected filesystem port. The trigger scanner intentionally recognizes only the
268/// ordinary nested `on: push: tags:` shape: missing an exotic YAML spelling is safe,
269/// while treating an unrelated `tags:` key as release infrastructure is not.
270///
271/// Cargo publication evidence follows repository-local reusable workflow calls, so a
272/// conventional tag workflow delegating to `on: workflow_call` is not misreported as
273/// missing. This remains evidence for a plan-time warning, not a hard validator: shell
274/// scripts and remote reusable workflows cannot be proved by a bounded tree scan.
275pub fn detect_distribution_surface(repo_root: &Path, fs: &dyn Fs) -> DistributionSurface {
276    let mut cargo_dist_evidence = Vec::new();
277    if fs.is_file(&repo_root.join("dist-workspace.toml")) {
278        cargo_dist_evidence.push("dist-workspace.toml".to_string());
279    }
280    if read_text(fs, &repo_root.join("Cargo.toml"), MANIFEST_LIMIT).is_some_and(|text| {
281        text.lines()
282            .any(|line| line.trim() == "[workspace.metadata.dist]")
283    }) {
284        cargo_dist_evidence.push("Cargo.toml ([workspace.metadata.dist])".to_string());
285    }
286
287    let workflows = repo_root.join(".github/workflows");
288    let mut workflow_texts = fs
289        .read_dir(&workflows)
290        .unwrap_or_default()
291        .into_iter()
292        .filter(|name| {
293            Path::new(name).extension().is_some_and(|extension| {
294                extension.eq_ignore_ascii_case("yml") || extension.eq_ignore_ascii_case("yaml")
295            })
296        })
297        .filter_map(|name| {
298            read_text(fs, &workflows.join(&name), MANIFEST_LIMIT).map(|text| (name, text))
299        })
300        .collect::<Vec<_>>();
301    workflow_texts.sort_by(|a, b| a.0.cmp(&b.0));
302
303    let tag_triggered_workflows = workflow_texts
304        .iter()
305        .filter(|(_, text)| workflow_pushes_tags(text))
306        .map(|(name, _)| name.clone())
307        .collect::<Vec<_>>();
308    let tag_triggered_cargo_publish_workflows = workflow_texts
309        .iter()
310        .filter(|(_, text)| workflow_pushes_tags(text))
311        .filter(|(name, _)| workflow_reaches_cargo_publish(name, &workflow_texts, &mut Vec::new()))
312        .map(|(name, _)| name.clone())
313        .collect::<Vec<_>>();
314
315    DistributionSurface {
316        has_cargo_dist: !cargo_dist_evidence.is_empty(),
317        cargo_dist_evidence,
318        tag_triggered_workflows,
319        tag_triggered_cargo_publish_workflows,
320    }
321}
322
323fn workflow_reaches_cargo_publish(
324    name: &str,
325    workflows: &[(String, String)],
326    visiting: &mut Vec<String>,
327) -> bool {
328    if visiting.iter().any(|seen| seen == name) {
329        return false;
330    }
331    let Some((_, text)) = workflows.iter().find(|(candidate, _)| candidate == name) else {
332        return false;
333    };
334    if workflow_runs_cargo_publish(text) {
335        return true;
336    }
337
338    visiting.push(name.to_string());
339    let reaches_publish = local_workflow_calls(text).iter().any(|called| {
340        workflows
341            .iter()
342            .find(|(candidate, _)| candidate == called)
343            .is_some_and(|(_, callee)| {
344                workflow_accepts_calls(callee)
345                    && workflow_reaches_cargo_publish(called, workflows, visiting)
346            })
347    });
348    visiting.pop();
349    reaches_publish
350}
351
352fn workflow_runs_cargo_publish(text: &str) -> bool {
353    workflow_jobs(text).is_some_and(|jobs| {
354        jobs.values().any(|job| {
355            yaml_mapping(job)
356                .and_then(|job| job.get("steps"))
357                .and_then(serde_yaml::Value::as_sequence)
358                .is_some_and(|steps| {
359                    steps.iter().any(|step| {
360                        yaml_mapping(step)
361                            .and_then(|step| step.get("run"))
362                            .and_then(serde_yaml::Value::as_str)
363                            .is_some_and(command_runs_cargo_publish)
364                    })
365                })
366        })
367    })
368}
369
370fn command_runs_cargo_publish(command: &str) -> bool {
371    command.lines().any(|line| {
372        let uncommented = line.split('#').next().unwrap_or("");
373        uncommented
374            .split([';', '\n'])
375            .flat_map(|part| part.split("&&"))
376            .flat_map(|part| part.split("||"))
377            .any(command_segment_runs_cargo_publish)
378    })
379}
380
381fn command_segment_runs_cargo_publish(segment: &str) -> bool {
382    let tokens = segment.split_ascii_whitespace().collect::<Vec<_>>();
383    let mut index = tokens
384        .iter()
385        .position(|token| *token == "cargo")
386        .unwrap_or(tokens.len());
387    if index == tokens.len()
388        || tokens[..index]
389            .iter()
390            .any(|token| !token.contains('=') || token.starts_with('-'))
391    {
392        return false;
393    }
394    index += 1;
395    if tokens
396        .get(index)
397        .is_some_and(|token| token.starts_with('+'))
398    {
399        index += 1;
400    }
401    if tokens.get(index) != Some(&"publish") {
402        return false;
403    }
404
405    let options = &tokens[index + 1..];
406    if options.iter().any(|option| {
407        matches!(*option, "--dry-run" | "--help" | "-h" | "--index")
408            || option.starts_with("--index=")
409    }) {
410        return false;
411    }
412    for (option_index, option) in options.iter().enumerate() {
413        if let Some(registry) = option.strip_prefix("--registry=") {
414            if registry != "crates-io" {
415                return false;
416            }
417        } else if *option == "--registry"
418            && options.get(option_index + 1).copied() != Some("crates-io")
419        {
420            return false;
421        }
422    }
423    true
424}
425
426fn workflow_accepts_calls(text: &str) -> bool {
427    let mut on_indent = None;
428    for line in text.lines() {
429        let uncommented = line.split('#').next().unwrap_or("");
430        if uncommented.trim().is_empty() {
431            continue;
432        }
433        let indent = uncommented.len() - uncommented.trim_start().len();
434        let key = uncommented.trim();
435        if key == "on:" {
436            on_indent = Some(indent);
437            continue;
438        }
439        if let Some(on) = on_indent {
440            if indent <= on {
441                return false;
442            }
443            if key == "workflow_call:" {
444                return true;
445            }
446        }
447    }
448    false
449}
450
451fn local_workflow_calls(text: &str) -> Vec<String> {
452    let Some(jobs) = workflow_jobs(text) else {
453        return Vec::new();
454    };
455    jobs.into_values()
456        .filter_map(|job| {
457            job.as_mapping()
458                .and_then(|job| job.get("uses"))
459                .and_then(serde_yaml::Value::as_str)
460                .and_then(|uses| uses.strip_prefix("./.github/workflows/"))
461                .map(str::to_string)
462        })
463        .collect()
464}
465
466fn workflow_jobs(text: &str) -> Option<serde_yaml::Mapping> {
467    let document = serde_yaml::from_str::<serde_yaml::Value>(text).ok()?;
468    yaml_mapping(&document)
469        .and_then(|root| root.get("jobs"))
470        .and_then(serde_yaml::Value::as_mapping)
471        .cloned()
472}
473
474fn yaml_mapping(value: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> {
475    value.as_mapping()
476}
477
478fn workflow_pushes_tags(text: &str) -> bool {
479    let mut on_indent = None;
480    let mut push_indent = None;
481    for line in text.lines() {
482        let uncommented = line.split('#').next().unwrap_or("");
483        if uncommented.trim().is_empty() {
484            continue;
485        }
486        let indent = uncommented.len() - uncommented.trim_start().len();
487        let key = uncommented.trim();
488        if key == "on:" {
489            on_indent = Some(indent);
490            push_indent = None;
491            continue;
492        }
493        if let Some(on) = on_indent {
494            if indent <= on {
495                on_indent = None;
496                push_indent = None;
497                continue;
498            }
499            if key == "push:" {
500                push_indent = Some(indent);
501                continue;
502            }
503        }
504        if let Some(push) = push_indent {
505            if indent <= push {
506                push_indent = None;
507            } else if key == "tags:" {
508                return true;
509            }
510        }
511    }
512    false
513}
514
515/// Read every Cargo manifest's `publish` disposition under `repo_root` — the
516/// **supporting evidence** for (or contradiction of) the contract's crates.io
517/// publish targets.
518///
519/// Covers the root `Cargo.toml` when it declares a `[package]` **and** every
520/// resolved `[workspace].members` manifest — a hybrid root (`[package]` *and*
521/// `[workspace] members`) is a common Cargo layout and yields both, so a member's
522/// `publish = false` is never invisible behind a publishable root. Member
523/// enumeration is the shared one (escape-proofed, glob-expanded, exclusion-aware,
524/// deduped). Empty when the repo has no readable `Cargo.toml`, which callers must
525/// treat as *no evidence either way*, never as "nothing is publishable".
526///
527/// Workspace inheritance is resolved, not guessed: a member writing
528/// `publish.workspace = true` takes the root `[workspace.package]` verdict, and
529/// [`CargoPublishPolicy::Unknown`] when there is none to inherit. Anything else this
530/// textual reader cannot model is `Unknown` too — so neither the error nor the
531/// warning direction ever fires on a shape that was not actually read.
532#[must_use]
533pub fn cargo_publish_evidence(repo_root: &Path, fs: &dyn Fs) -> Vec<CargoPublishFlag> {
534    let root_path = repo_root.join("Cargo.toml");
535    let Some(root_text) = read_text(fs, &root_path, MANIFEST_LIMIT) else {
536        return Vec::new();
537    };
538    let ws_pkg = toml_section(&root_text, "workspace.package");
539    // The workspace-wide default a member inherits with `publish.workspace = true`.
540    // Absent `[workspace.package]` ⇒ nothing to inherit ⇒ `Unknown` for such a member.
541    let inherited = ws_pkg
542        .as_deref()
543        .map_or(CargoPublishPolicy::Unknown, |block| {
544            block_publish_policy(block)
545        });
546    let mut out = Vec::new();
547    if toml_section(&root_text, "package").is_some() {
548        out.push(CargoPublishFlag {
549            manifest: "Cargo.toml".to_string(),
550            package: parse_manifest("Cargo.toml", &root_text)
551                .unwrap_or_default()
552                .package,
553            policy: manifest_publish_policy(&root_text, inherited),
554        });
555    }
556    for rel in workspace_member_dirs(repo_root, fs, &root_text) {
557        let Some(text) = read_text(fs, &repo_root.join(&rel).join("Cargo.toml"), MANIFEST_LIMIT)
558        else {
559            continue;
560        };
561        let (package, _) = resolve_member_name_version(&text, ws_pkg.as_deref());
562        out.push(CargoPublishFlag {
563            manifest: format!("{rel}/Cargo.toml"),
564            package,
565            policy: manifest_publish_policy(&text, inherited),
566        });
567    }
568    out
569}
570
571/// The crates.io publish policy a whole manifest states, resolving a
572/// `publish.workspace = true` member against `inherited`.
573fn manifest_publish_policy(text: &str, inherited: CargoPublishPolicy) -> CargoPublishPolicy {
574    let Some(block) = toml_section(text, "package") else {
575        // No `[package]` — nothing to publish from this manifest at all.
576        return CargoPublishPolicy::Forbidden;
577    };
578    if inherits_workspace_publish(&block) {
579        return inherited;
580    }
581    block_publish_policy(&block)
582}
583
584/// Read a `[package]`/`[workspace.package]` block's `publish` key.
585///
586/// `publish` absent ⇒ [`Allowed`](CargoPublishPolicy::Allowed) (Cargo's default);
587/// `false` ⇒ `Forbidden`; `true` ⇒ `Allowed`; an allow-list ⇒ `Allowed` only when it
588/// names `crates-io` (so `publish = []` is `Forbidden`, matching `cargo metadata`'s
589/// `Some([])`). A `publish` key present in a shape neither reader recognizes — an
590/// inline/multi-line table — is [`Unknown`](CargoPublishPolicy::Unknown) rather than
591/// a guessed default.
592fn block_publish_policy(block: &str) -> CargoPublishPolicy {
593    if let Some(registries) = toml_str_array(block, "publish") {
594        return if registries.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS) {
595            CargoPublishPolicy::Allowed
596        } else {
597            CargoPublishPolicy::Forbidden
598        };
599    }
600    match toml_bool_value(block, "publish") {
601        Some(true) => CargoPublishPolicy::Allowed,
602        Some(false) => CargoPublishPolicy::Forbidden,
603        // Absent is Cargo's permissive default — but only when the key is genuinely
604        // absent, not when it is present in an unread shape.
605        None => {
606            if block_declares_key(block, "publish") {
607                CargoPublishPolicy::Unknown
608            } else {
609                CargoPublishPolicy::Allowed
610            }
611        }
612    }
613}
614
615/// Whether a member's `publish` is the inheritance form (`publish.workspace = true`
616/// dotted, or `publish = { workspace = true }` inline).
617fn inherits_workspace_publish(block: &str) -> bool {
618    block.lines().any(|line| {
619        let line = strip_toml_comment(line).trim();
620        line.starts_with("publish.workspace")
621            || (line.starts_with("publish")
622                && line.contains('{')
623                && line.contains("workspace")
624                && line.contains("true"))
625    })
626}
627
628/// Whether a block declares `key` at all (in ANY value shape), used to separate a
629/// genuinely absent key from one whose value this reader could not parse.
630fn block_declares_key(block: &str, key: &str) -> bool {
631    block.lines().any(|line| {
632        let line = strip_toml_comment(line).trim();
633        line.strip_prefix(key).is_some_and(|rest| {
634            rest.starts_with(|c: char| c.is_whitespace() || c == '=' || c == '.')
635        })
636    })
637}
638
639/// Sniff root-level manifests into the ordered `ecosystems` + `packages` lists.
640fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
641    let mut ecosystems: Vec<Ecosystem> = Vec::new();
642    let mut packages: Vec<Package> = Vec::new();
643    for &(fname, eco) in MANIFESTS {
644        let path = repo_root.join(fname);
645        if !fs.is_file(&path) {
646            continue;
647        }
648        let text = read_text(fs, &path, MANIFEST_LIMIT);
649        let parsed = text
650            .as_deref()
651            .and_then(|t| parse_manifest(fname, t))
652            .unwrap_or_default();
653        // A Cargo virtual workspace (no `[package]`) still marks the repo rust.
654        if !ecosystems.contains(&eco) {
655            ecosystems.push(eco);
656        }
657        // A Cargo *virtual workspace* (no `[package]`) declares its real crates in
658        // `[workspace].members`: descend into each member manifest and emit one
659        // entry per member with its resolved name + version, rather than a single
660        // null-named root entry.
661        if fname == "Cargo.toml" && parsed.package.is_none() {
662            if let Some(text) = text.as_deref() {
663                if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
664                    continue;
665                }
666            }
667            // Not a members-bearing virtual workspace (or no member manifest
668            // resolved): fall through to the null root entry — the repo is still
669            // rust, and the null-named entry preserves today's signal.
670        }
671        // `Cargo.toml`/`go.mod` always yield a package entry (even without a
672        // declared name); the others only when they name a package.
673        if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
674            packages.push(Package {
675                ecosystem: eco,
676                manifest: fname.to_string(),
677                package: parsed.package,
678                version: parsed.version,
679            });
680        }
681    }
682    // `binary` only when NO package ecosystem is detected — never additive.
683    if ecosystems.is_empty() {
684        ecosystems.push(Ecosystem::Binary);
685    }
686    (ecosystems, packages)
687}
688
689/// Enumerate a Cargo virtual workspace's members into `packages`, one entry per
690/// member manifest that resolves through `fs`. Explicit members are read in
691/// declaration order; a trailing single-level glob (`crates/*`) is expanded by
692/// listing its directory through the `Fs` port and sorting the entries, so the
693/// emitted order is deterministic regardless of the underlying read-dir order.
694/// `[workspace].exclude` entries are dropped, and duplicate member paths (e.g.
695/// an explicit member also matched by a glob) are emitted once.
696///
697/// Each member reports its own `[package].name`; the version is its literal
698/// `[package].version`, or — when the member declares `version.workspace = true`
699/// (dotted) or `version = { workspace = true }` (inline) — the version inherited
700/// from the root `[workspace.package]` table.
701///
702/// Returns `true` when at least one member manifest was emitted (the caller then
703/// skips the null root entry); `false` when the root has no `members` array or no
704/// listed member manifest resolved (the caller keeps today's null-entry behavior).
705fn push_workspace_members(
706    repo_root: &Path,
707    fs: &dyn Fs,
708    root_text: &str,
709    eco: Ecosystem,
710    packages: &mut Vec<Package>,
711) -> bool {
712    let ws_pkg = toml_section(root_text, "workspace.package");
713    let dirs = workspace_member_dirs(repo_root, fs, root_text);
714    let before = packages.len();
715    for rel in &dirs {
716        let manifest_path = repo_root.join(rel).join("Cargo.toml");
717        let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
718            continue;
719        };
720        let (package, version) = resolve_member_name_version(&member_text, ws_pkg.as_deref());
721        packages.push(Package {
722            ecosystem: eco,
723            manifest: format!("{rel}/Cargo.toml"),
724            package,
725            version,
726        });
727    }
728    packages.len() > before
729}
730
731/// Resolve a Cargo virtual-workspace root's `[workspace].members` to the ordered,
732/// de-duplicated, **existing** member directories (manifest-relative, no trailing
733/// slash) — the single member-enumeration used by both the `packages` emission and
734/// the release-planner workspace graph, so the two never drift.
735///
736/// Applies the rules the fact detector has always used: escape-proof (an absolute
737/// or `..`-bearing member is rejected — with a real `Fs` those would read manifests
738/// outside `repo_root` and taint the facts), trailing single-level glob (`crates/*`,
739/// bare `*`) expansion via the `Fs` port with sorted entries (deterministic
740/// regardless of read-dir order), `[workspace].exclude` removal, first-seen dedup,
741/// and dropping any member whose `Cargo.toml` does not resolve. Empty when the root
742/// declares no `[workspace].members` array (not a members-bearing virtual workspace)
743/// or none resolve.
744fn workspace_member_dirs(repo_root: &Path, fs: &dyn Fs, root_text: &str) -> Vec<String> {
745    let Some(ws_block) = toml_section(root_text, "workspace") else {
746        return Vec::new();
747    };
748    let members = match toml_str_array(&ws_block, "members") {
749        Some(members) if !members.is_empty() => members,
750        _ => return Vec::new(),
751    };
752    let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
753        .unwrap_or_default()
754        .iter()
755        .map(|e| e.trim_end_matches('/').to_string())
756        .collect();
757    // Manifest-relative dirs already collected — dedup preserving first-seen order.
758    let mut out: Vec<String> = Vec::new();
759    for member in members {
760        let member = member.trim_end_matches('/');
761        if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
762            continue;
763        }
764        if let Some(prefix) = glob_parent(member) {
765            // Trailing single-level glob (`crates/*`, bare `*`): expand one level.
766            let dir = if prefix.is_empty() {
767                repo_root.to_path_buf()
768            } else {
769                repo_root.join(prefix)
770            };
771            let mut names = fs.read_dir(&dir).unwrap_or_default();
772            names.sort();
773            for name in names {
774                let rel = if prefix.is_empty() {
775                    name
776                } else {
777                    format!("{prefix}/{name}")
778                };
779                push_member_dir(repo_root, fs, &rel, &exclude, &mut out);
780            }
781            continue;
782        }
783        // A glob shape we do not expand (`?`, character classes, a non-trailing
784        // `*`): skip rather than probe a literal metacharacter path.
785        if member.contains(['*', '?']) {
786            continue;
787        }
788        push_member_dir(repo_root, fs, member, &exclude, &mut out);
789    }
790    out
791}
792
793/// Add member dir `rel` to `out` unless it is `exclude`d, already collected, or its
794/// `Cargo.toml` does not resolve through `fs`.
795fn push_member_dir(
796    repo_root: &Path,
797    fs: &dyn Fs,
798    rel: &str,
799    exclude: &[String],
800    out: &mut Vec<String>,
801) {
802    let rel = rel.trim_end_matches('/');
803    if exclude.iter().any(|e| e == rel) || out.iter().any(|s| s == rel) {
804        return;
805    }
806    if !fs.is_file(&repo_root.join(rel).join("Cargo.toml")) {
807        return;
808    }
809    out.push(rel.to_string());
810}
811
812/// Detect the Rust workspace's crates.io-**publishable** member graph — the
813/// off-wire plumbing [`Facts::rust_workspace`] carries for the release planner.
814///
815/// `None` unless the repo root is a members-bearing Cargo *virtual workspace* (the
816/// shape that expresses a lib+bin split): reads each member manifest for its
817/// `[package].name`, version (honoring `version.workspace = true` inheritance),
818/// `publish` allow-list, and intra-workspace dependency edges, then keeps only the
819/// crates.io-publishable members (matching the cargo adapter's cut-time `cargo
820/// metadata` filter — a `publish = false` member, or one restricted to a
821/// non-crates.io registry, is dropped) with their edges restricted to other
822/// publishable members. Members are returned in workspace declaration order; the
823/// planner applies the topological publish ordering. `None` when no publishable
824/// named member resolves (nothing for the planner to expand).
825///
826/// **Edges gate publish ORDER, and a missed edge fails a cut CLOSED — it is not a
827/// free "hint".** The coordinator walks the plan's target order; the cargo adapter
828/// re-derives the graph from `cargo metadata` and index-waits on each crate's real
829/// deps, but it does **not** re-order the plan. So a missed ordering edge that puts a
830/// dependent before its dependency makes the dependent's `cargo publish` fail on the
831/// not-yet-indexed sibling — a safe, no-mis-publish failure, but a *failed release for
832/// a valid workspace*. The edge parse is therefore precise where it counts:
833/// [`member_dependency_edges`] treats only `path`/`workspace` dependencies as edges
834/// (a registry dep sharing a member's name is not an edge, so no false constraint /
835/// false cycle), and reads the plain, target-specific, sub-table, and dotted-key
836/// dependency forms plus inline `package = "…"` renames. Release-pin discovery is
837/// parser-backed separately, so a declaration shape this ordering scanner misses can
838/// no longer leave an exact internal pin stale during an engine-owned bump.
839///
840/// One parsed workspace member before the publishability filter — the intermediate
841/// [`detect_rust_workspace`] reduces to the published [`WorkspaceMember`] set.
842struct RawMember {
843    package: String,
844    version: Option<String>,
845    publishable: bool,
846    /// Publish-order dependency edges (normal + build): crate name + literal version.
847    deps: Vec<(String, Option<String>)>,
848    /// All local dependency declarations, including dev and target-specific tables.
849    /// Duplicates are preserved for plan-time pin-equivalence validation.
850    pins: Vec<(String, Option<String>)>,
851}
852
853fn detect_rust_workspace(repo_root: &Path, fs: &dyn Fs) -> Option<RustWorkspace> {
854    let root_path = repo_root.join("Cargo.toml");
855    if !fs.is_file(&root_path) {
856        return None;
857    }
858    let root_text = read_text_full(fs, &root_path)?;
859    let dirs = workspace_member_dirs(repo_root, fs, &root_text);
860    if dirs.is_empty() {
861        return None;
862    }
863    let ws_pkg = toml_section(&root_text, "workspace.package");
864    let mut workspace_pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>> =
865        std::collections::BTreeMap::new();
866    let mut pin_parse_error = None;
867    match crate::release::bump::cargo_workspace_pin_declarations(&root_text) {
868        Ok(declarations) => {
869            for declaration in declarations {
870                workspace_pin_reqs
871                    .entry(declaration.package)
872                    .or_default()
873                    .push(declaration.requirement);
874            }
875        }
876        Err(error) => pin_parse_error = Some(format!("Cargo.toml: {error}")),
877    }
878
879    // First pass: parse every named member (name, version, publishability, edges).
880    let mut raw: Vec<RawMember> = Vec::new();
881    for rel in &dirs {
882        let Some(text) = read_text_full(fs, &repo_root.join(rel).join("Cargo.toml")) else {
883            continue;
884        };
885        let (package, version) = resolve_member_name_version(&text, ws_pkg.as_deref());
886        // A member with no `[package].name` cannot be a publish target — skip it (a
887        // nested virtual workspace, or a malformed manifest).
888        let Some(package) = package else { continue };
889        raw.push(RawMember {
890            package,
891            version,
892            publishable: member_publishable_to_crates_io(&text),
893            deps: member_dependency_edges(&text),
894            pins: match crate::release::bump::cargo_pin_declarations(&text) {
895                Ok(declarations) => declarations
896                    .into_iter()
897                    .map(|d| (d.package, d.requirement))
898                    .collect(),
899                Err(error) => {
900                    pin_parse_error.get_or_insert_with(|| format!("{rel}/Cargo.toml: {error}"));
901                    Vec::new()
902                }
903            },
904        });
905    }
906
907    // Keep only crates.io-publishable members; restrict each member's edges to the
908    // OTHER publishable members (a dep on a non-publishable member does not gate the
909    // publish order, and a self-edge is meaningless).
910    let publishable_names: std::collections::BTreeSet<&str> = raw
911        .iter()
912        .filter(|m| m.publishable)
913        .map(|m| m.package.as_str())
914        .collect();
915    let members: Vec<WorkspaceMember> = raw
916        .iter()
917        .filter(|m| m.publishable)
918        .map(|m| {
919            // Restrict to edges to OTHER publishable members; carry each edge's literal
920            // requirement string (when the manifest declared one) so the pin-rewrite
921            // derivation can key precisely on the `=<ver>` lockstep convention.
922            let mut dep_reqs: std::collections::BTreeMap<String, String> =
923                std::collections::BTreeMap::new();
924            for (name, req) in &m.deps {
925                if name.as_str() == m.package || !publishable_names.contains(name.as_str()) {
926                    continue;
927                }
928                if let Some(req) = req {
929                    // First declaration wins (a crate appearing in both `[dependencies]`
930                    // and `[build-dependencies]` shares one requirement in practice).
931                    dep_reqs.entry(name.clone()).or_insert_with(|| req.clone());
932                }
933            }
934            let mut workspace_deps: Vec<String> = m
935                .deps
936                .iter()
937                .map(|(name, _req)| name.clone())
938                .filter(|d| d.as_str() != m.package && publishable_names.contains(d.as_str()))
939                .collect();
940            workspace_deps.sort();
941            workspace_deps.dedup();
942            let mut pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>> =
943                std::collections::BTreeMap::new();
944            for (name, req) in &m.pins {
945                if name.as_str() != m.package && publishable_names.contains(name.as_str()) {
946                    pin_reqs.entry(name.clone()).or_default().push(req.clone());
947                }
948            }
949            WorkspaceMember {
950                package: m.package.clone(),
951                version: m.version.clone(),
952                workspace_deps,
953                dep_reqs,
954                pin_reqs,
955            }
956        })
957        .collect();
958    if members.is_empty() {
959        return None;
960    }
961    Some(RustWorkspace {
962        members,
963        workspace_pin_reqs,
964        pin_parse_error,
965    })
966}
967
968/// Whether a Cargo member manifest permits publishing to crates.io — the same
969/// predicate the cargo adapter applies at cut time (`publishable_to_crates_io`), but
970/// read from raw manifest text rather than `cargo metadata`.
971///
972/// `publish` absent ⇒ any registry (yes). `publish = false` ⇒ no. `publish = true`
973/// ⇒ yes. `publish = ["crates-io", …]` ⇒ only if the list names `crates-io`
974/// (`publish = []` therefore reads as no, matching `cargo metadata`'s `Some([])`).
975fn member_publishable_to_crates_io(text: &str) -> bool {
976    let Some(block) = toml_section(text, "package") else {
977        // No `[package]` at all — not a publishable crate. (`detect_rust_workspace`
978        // already drops an unnamed member before this runs; failing closed here is a
979        // defensive guard so a future name-fabrication path can never leak a
980        // package-less manifest into the crates.io publish set.)
981        return false;
982    };
983    // Array form first: `publish = ["crates-io"]`.
984    if let Some(regs) = toml_str_array(&block, "publish") {
985        return regs.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS);
986    }
987    // Bool form: `publish = false` / `publish = true`; absent ⇒ publishable.
988    !matches!(toml_bool_value(&block, "publish"), Some(false))
989}
990
991/// Cargo's registry alias for crates.io — the token a member manifest's `publish`
992/// allow-list must contain to be crates.io-publishable (mirrors the cargo adapter's
993/// `CRATES_IO_ALIAS`).
994const CRATES_IO_REGISTRY_ALIAS: &str = "crates-io";
995
996/// Read a boolean TOML value (`key = true` / `key = false`) from a section body,
997/// stripping a trailing `#` comment. `None` when the key is absent or its value is
998/// not a bare bool literal (an array or table form is the caller's concern).
999fn toml_bool_value(block: &str, key: &str) -> Option<bool> {
1000    for line in block.lines() {
1001        let rest = line.trim_start();
1002        let Some(rest) = rest.strip_prefix(key) else {
1003            continue;
1004        };
1005        // Whole-token match (else `publisher` would match `publish`).
1006        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1007            continue;
1008        }
1009        let Some(rest) = rest.trim_start().strip_prefix('=') else {
1010            continue;
1011        };
1012        match strip_toml_comment(rest).trim() {
1013            "true" => return Some(true),
1014            "false" => return Some(false),
1015            _ => return None,
1016        }
1017    }
1018    None
1019}
1020
1021/// One table the dependency-edge scanner is inside while walking a member manifest.
1022enum DepTable {
1023    /// A `[dependencies]` / `[build-dependencies]` body — including a target-specific
1024    /// `[target.<cfg>.dependencies]` / `[target.<cfg>.build-dependencies]`, which
1025    /// gate publish order exactly like the unconditional tables (crates.io validates
1026    /// them at `cargo publish`).
1027    Table,
1028    /// A `[dependencies.<name>]` sub-table body (plain or target-specific),
1029    /// accumulating whether it is a **local** dep (a `path`/`workspace` key) and its
1030    /// `package = "…"` rename, so the edge is emitted with the real crate name on flush.
1031    Sub {
1032        /// The sub-table key (the crate name, unless overridden by `package`).
1033        key: String,
1034        /// The `package = "…"` rename value, if the body declares one.
1035        package: Option<String>,
1036        /// Whether the body marks this an intra-workspace dep (`path`/`workspace`).
1037        local: bool,
1038        /// The literal `version = "…"` requirement the body declares, if any — carried
1039        /// so the pin-rewrite derivation can key on the exact `=<ver>` lockstep string.
1040        version: Option<String>,
1041    },
1042    /// A non-order-gating table (`[dev-dependencies]`, `[features]`, `[package]`, …).
1043    Other,
1044}
1045
1046/// The intra-workspace dependency edges a Cargo member manifest declares, each paired
1047/// with the **literal version requirement** the manifest states for it (or `None` for a
1048/// path-only / `workspace = true` edge whose requirement is not in this manifest). The
1049/// raw edge set [`detect_rust_workspace`] intersects with the publishable member names.
1050///
1051/// **Only `path` / `workspace` dependencies are edges.** A registry dependency
1052/// (`serde = "1"`, or an inline table with only `version`) is NOT an intra-workspace
1053/// edge even when a workspace member happens to share its name — Cargo resolves
1054/// `serde = "1"` to crates.io, never to the local member, so treating a name
1055/// collision as an edge would invent a false ordering constraint (and a false cycle).
1056/// This mirrors Cargo's own model: a member edge exists iff the dependency resolves
1057/// to a `path`/`workspace` source.
1058///
1059/// The paired requirement is the precise-pin-rewrite source
1060/// (`release-rust-workspace-multicrate` facet 3): the planner keeps only the edge whose
1061/// requirement literally equals `=<from_version>`, so a caret/range/`workspace = true`
1062/// edge is never clobbered.
1063///
1064/// Line-oriented (no TOML dependency — matching this module's parsing style). It reads:
1065/// `[dependencies]` / `[build-dependencies]` and their target-specific
1066/// (`[target.<cfg>.dependencies]`) and sub-table (`[dependencies.<name>]`) forms;
1067/// inline tables (`dep = { path = "…", package = "real", version = "=X" }`), dotted keys
1068/// (`dep.path = "…"`, `dep.workspace = true`), and the `package = "…"` rename in each.
1069/// Dev-dependencies are excluded (they never gate publish order and can legitimately
1070/// cycle).
1071///
1072/// **Known ordering blind spots (each fails a cut CLOSED, never mis-publishes — see
1073/// `detect_rust_workspace`):** a dependency inheriting a *rename* through root
1074/// `[workspace.dependencies]` can miss the edge, and a multi-line inline table is read
1075/// only by its first physical line. Exact-pin ownership does not share these blind
1076/// spots: the parser-backed bump scanner resolves root inheritance, dotted keys, and
1077/// multiline inline tables independently and seals their rewrites before cut.
1078fn member_dependency_edges(text: &str) -> Vec<(String, Option<String>)> {
1079    let mut state = DepTable::Other;
1080    let mut out: Vec<(String, Option<String>)> = Vec::new();
1081
1082    for line in text.lines() {
1083        let t = strip_toml_comment(line).trim();
1084        if let Some(header) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
1085            // Leaving a table: flush a completed sub-table's edge before switching.
1086            flush_dep_subtable(&mut state, &mut out);
1087            state = classify_dep_table(header.trim());
1088            continue;
1089        }
1090        match &mut state {
1091            DepTable::Table => {
1092                if let Some(edge) = dep_edge_from_line(t) {
1093                    out.push(edge);
1094                }
1095            }
1096            DepTable::Sub {
1097                package,
1098                local,
1099                version,
1100                ..
1101            } => {
1102                // Accumulate the sub-table body: a `package` rename, the
1103                // `path`/`workspace` markers that make it a local edge, and the
1104                // `version` requirement.
1105                if let Some((k, v)) = split_key_value(t) {
1106                    match k {
1107                        "package" if package.is_none() => *package = extract_quoted(v),
1108                        "path" => *local = true,
1109                        "workspace" if v.trim_start().starts_with("true") => *local = true,
1110                        "version" if version.is_none() => *version = extract_quoted(v),
1111                        _ => {}
1112                    }
1113                }
1114            }
1115            DepTable::Other => {}
1116        }
1117    }
1118    flush_dep_subtable(&mut state, &mut out);
1119    out
1120}
1121
1122/// Classify a table header into the dependency-edge scanner's [`DepTable`] state.
1123///
1124/// Recognizes the sub-table forms (`[dependencies.<name>]`,
1125/// `[target.<cfg>.dependencies.<name>]`) and the plain/target-specific table forms
1126/// (`[dependencies]`, `[target.<cfg>.build-dependencies]`), while excluding every
1127/// `dev-dependencies` variant. The `<cfg>` in a target header may contain dots and
1128/// quotes; classification keys on the unquoted `.dependencies` / `.build-dependencies`
1129/// suffix (and the `.dependencies.` / `.build-dependencies.` infix for sub-tables),
1130/// which is robust regardless of the cfg contents.
1131fn classify_dep_table(header: &str) -> DepTable {
1132    if let Some(name) = dep_subtable_name(header) {
1133        return DepTable::Sub {
1134            key: name.trim().trim_matches(['"', '\'']).to_string(),
1135            package: None,
1136            local: false,
1137            version: None,
1138        };
1139    }
1140    // dev-dependencies (plain or target-specific) never gate publish order.
1141    if !header.contains("dev-dependencies")
1142        && (header == "dependencies"
1143            || header == "build-dependencies"
1144            || header.ends_with(".dependencies")
1145            || header.ends_with(".build-dependencies"))
1146    {
1147        return DepTable::Table;
1148    }
1149    DepTable::Other
1150}
1151
1152/// The `<name>` of a dependency sub-table header (`[dependencies.<name>]`,
1153/// `[build-dependencies.<name>]`, or their target-specific
1154/// `[target.<cfg>.dependencies.<name>]` forms), or `None` when the header is not a
1155/// dependency sub-table. Every `dev-dependencies` form yields `None`.
1156fn dep_subtable_name(header: &str) -> Option<&str> {
1157    if header.contains("dev-dependencies") {
1158        return None;
1159    }
1160    if let Some(name) = header
1161        .strip_prefix("dependencies.")
1162        .or_else(|| header.strip_prefix("build-dependencies."))
1163    {
1164        return Some(name);
1165    }
1166    // Target-specific: split on the LAST `.dependencies.` / `.build-dependencies.`
1167    // (the cfg expression precedes it and may itself contain dots).
1168    for infix in [".dependencies.", ".build-dependencies."] {
1169        if let Some(idx) = header.rfind(infix) {
1170            return Some(&header[idx + infix.len()..]);
1171        }
1172    }
1173    None
1174}
1175
1176/// The intra-workspace edge crate name a `key = value` line under a `[dependencies]`
1177/// table declares, or `None` when the line is not a local (`path`/`workspace`) dep.
1178///
1179/// A registry dep (`foo = "1"`, or `foo = { version = "1" }` with no `path`) yields
1180/// `None` — it is not a workspace edge. A local dep yields its crate name: an inline
1181/// `package = "…"` rename when present, else the bare key. Dotted forms
1182/// (`foo.path = "…"`, `foo.workspace = true`) are recognized; a lone `foo.version` /
1183/// `foo.package` is not a local marker.
1184fn dep_edge_from_line(line: &str) -> Option<(String, Option<String>)> {
1185    let (key_full, val) = split_key_value(line)?;
1186    let mut segs = key_full.split('.');
1187    let crate_key = segs.next().unwrap_or("").trim().trim_matches(['"', '\'']);
1188    if crate_key.is_empty() {
1189        return None;
1190    }
1191    match segs.next().map(str::trim) {
1192        // Dotted local markers: `foo.path = "…"` / `foo.workspace = true`. A dotted
1193        // `foo.version = "…"` on a separate physical line is not carried here (each
1194        // line is read independently) — a documented blind spot that fails a cut
1195        // closed (a missing pin rewrite leaves a stale `=<from>` pin the publish
1196        // rejects), never mis-rewrites.
1197        Some("path") => Some((crate_key.to_string(), None)),
1198        Some("workspace") if val.trim_start().starts_with("true") => {
1199            Some((crate_key.to_string(), None))
1200        }
1201        // `foo.version`, `foo.package`, `foo.features`, `foo.optional`, … alone do not
1202        // mark a local dep.
1203        Some(_) => None,
1204        // Simple key: only an inline table with a `path`/`workspace = true` is local.
1205        None => {
1206            if !val.starts_with('{') || !inline_table_is_local(val) {
1207                return None;
1208            }
1209            let name = inline_table_package(val).unwrap_or_else(|| crate_key.to_string());
1210            Some((name, inline_table_version(val)))
1211        }
1212    }
1213}
1214
1215/// Emit a completed dependency sub-table's edge into `out` when the body marked it a
1216/// local dep — its `package` rename if any, else the sub-table key. A registry
1217/// sub-table (no `path`/`workspace`) emits nothing. Resets `state` to [`DepTable::Other`].
1218fn flush_dep_subtable(state: &mut DepTable, out: &mut Vec<(String, Option<String>)>) {
1219    if let DepTable::Sub {
1220        key,
1221        package,
1222        local: true,
1223        version,
1224    } = state
1225    {
1226        out.push((
1227            package.clone().unwrap_or_else(|| key.clone()),
1228            version.clone(),
1229        ));
1230    }
1231    *state = DepTable::Other;
1232}
1233
1234/// Split a TOML `key = value` line into its trimmed, unquoted key and its trimmed
1235/// value text, or `None` when the line has no `=` or an empty key (a blank or
1236/// continuation line).
1237fn split_key_value(line: &str) -> Option<(&str, &str)> {
1238    let eq = line.find('=')?;
1239    let key = line[..eq].trim().trim_matches(['"', '\'']);
1240    if key.is_empty() {
1241        return None;
1242    }
1243    Some((key, line[eq + 1..].trim()))
1244}
1245
1246/// Whether an inline dependency table (`{ … }`) resolves to an intra-workspace member
1247/// — it declares a whole-token `path` key or `workspace = true`. A table with only a
1248/// `version` (a plain crates.io dep) is not local.
1249fn inline_table_is_local(inline: &str) -> bool {
1250    contains_toml_key(inline, "path") || toml_key_is_true(inline, "workspace")
1251}
1252
1253/// Whether `s` contains `key` as a whole TOML key immediately followed (past spaces)
1254/// by `=` — so `path = "…"` matches but a `path` substring inside another value, or a
1255/// longer key like `no-default-features`, does not.
1256fn contains_toml_key(s: &str, key: &str) -> bool {
1257    scan_toml_key(s, key, |_after| true)
1258}
1259
1260/// Whether `s` assigns `true` to a whole TOML key `key` (`workspace = true`).
1261fn toml_key_is_true(s: &str, key: &str) -> bool {
1262    scan_toml_key(s, key, |after| after.trim_start().starts_with("true"))
1263}
1264
1265/// Scan `s` for a whole-token TOML `key` immediately followed (past spaces) by `=`,
1266/// and test the post-`=` remainder with `accept`. "Whole token" = the char before
1267/// `key` is not an identifier char (alphanumeric / `_` / `-`), so a substring inside a
1268/// value or a longer key never matches. Returns `true` on the first accepted match.
1269fn scan_toml_key(s: &str, key: &str, accept: impl Fn(&str) -> bool) -> bool {
1270    let mut rest = s;
1271    while let Some(pos) = rest.find(key) {
1272        let prev_is_ident = rest[..pos]
1273            .chars()
1274            .next_back()
1275            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1276        let after = rest[pos + key.len()..].trim_start();
1277        if !prev_is_ident {
1278            if let Some(value) = after.strip_prefix('=') {
1279                if accept(value) {
1280                    return true;
1281                }
1282            }
1283        }
1284        rest = &rest[pos + key.len()..];
1285    }
1286    false
1287}
1288
1289/// The `package = "…"` value inside an inline dependency table (`{ package = "bar",
1290/// version = "1" }`), or `None` when the table declares no rename. A best-effort
1291/// single-line scan (inline tables are single-line in practice).
1292///
1293/// Matches `package` as a **whole key** (the preceding char is not an identifier
1294/// char and the next non-space char is `=`), so a substring inside another value —
1295/// e.g. a `path = "../my-package-dir"` — is never misread as a rename key.
1296fn inline_table_package(inline: &str) -> Option<String> {
1297    let mut rest = inline;
1298    while let Some(pos) = rest.find("package") {
1299        let prev_is_ident = rest[..pos]
1300            .chars()
1301            .next_back()
1302            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1303        let after = rest[pos + "package".len()..].trim_start();
1304        if !prev_is_ident {
1305            if let Some(value) = after.strip_prefix('=') {
1306                return extract_quoted(value.trim_start());
1307            }
1308        }
1309        // Not the rename key (a substring, or `package` not followed by `=`): keep
1310        // scanning past this occurrence.
1311        rest = &rest[pos + "package".len()..];
1312    }
1313    None
1314}
1315
1316/// The `version = "…"` value inside an inline dependency table (`{ path = "…",
1317/// version = "=0.4.0" }`), or `None` when the table declares no explicit version. A
1318/// best-effort single-line scan matching `version` as a **whole key** — the same
1319/// whole-token discipline as [`inline_table_package`], so a `version` substring inside
1320/// another value is never misread.
1321fn inline_table_version(inline: &str) -> Option<String> {
1322    let mut rest = inline;
1323    while let Some(pos) = rest.find("version") {
1324        let prev_is_ident = rest[..pos]
1325            .chars()
1326            .next_back()
1327            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1328        let after = rest[pos + "version".len()..].trim_start();
1329        if !prev_is_ident {
1330            if let Some(value) = after.strip_prefix('=') {
1331                return extract_quoted(value.trim_start());
1332            }
1333        }
1334        rest = &rest[pos + "version".len()..];
1335    }
1336    None
1337}
1338
1339/// The literal parent directory of a trailing single-level glob member — `crates/*`
1340/// → `Some("crates")`, bare `*` → `Some("")` — or `None` when `member` is not such
1341/// a glob. A prefix that itself contains a glob metacharacter is not expandable.
1342fn glob_parent(member: &str) -> Option<&str> {
1343    if member == "*" {
1344        return Some("");
1345    }
1346    member
1347        .strip_suffix("/*")
1348        .filter(|prefix| !prefix.contains(['*', '?']))
1349}
1350
1351/// Resolve a workspace member's `(name, version)` from its manifest text, honoring
1352/// `version.workspace = true` inheritance from the root `[workspace.package]`
1353/// block. Crate names are never workspace-inherited, so `name` is taken verbatim.
1354fn resolve_member_name_version(
1355    member_text: &str,
1356    ws_pkg: Option<&str>,
1357) -> (Option<String>, Option<String>) {
1358    let parsed = parse_cargo(member_text).unwrap_or_default();
1359    let version = parsed.version.or_else(|| {
1360        // Scope the inheritance probe to the member's own `[package]` block: a
1361        // `version.workspace = true` in an unrelated table (`[package.metadata.*]`,
1362        // a tool config) must not be read as `[package].version` inheritance.
1363        let inherits = toml_section(member_text, "package")
1364            .is_some_and(|block| field_inherits_workspace(&block, "version"));
1365        if inherits {
1366            ws_pkg.and_then(|block| toml_str_value(block, "version", false))
1367        } else {
1368            None
1369        }
1370    });
1371    (parsed.package, version)
1372}
1373
1374/// The description: first non-empty manifest `description`, else the first
1375/// non-heading README line — both trimmed and truncated to 120 characters.
1376fn detect_description(
1377    repo_root: &Path,
1378    fs: &dyn Fs,
1379    packages: &[Package],
1380    readme_text: Option<&str>,
1381) -> Option<String> {
1382    let manifest_desc = packages.iter().find_map(|p| {
1383        let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
1384        let desc = parse_manifest(&p.manifest, &text)?.description?;
1385        (!desc.is_empty()).then_some(desc)
1386    });
1387    if let Some(desc) = manifest_desc {
1388        return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
1389    }
1390    readme_text?.lines().find_map(|line| {
1391        let s = line.trim();
1392        let is_prose =
1393            !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
1394        is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
1395    })
1396}
1397
1398// ── Manifest parsing (name + version + description) ──────────────────────────
1399
1400/// The name/version/description parsed from one manifest.
1401#[derive(Debug, Default)]
1402struct ParsedManifest {
1403    package: Option<String>,
1404    version: Option<String>,
1405    description: Option<String>,
1406}
1407
1408/// Dispatch to the per-manifest parser. `setup.py` yields nothing (its metadata
1409/// is executable, not declarative — the Python detector skips it too).
1410///
1411/// Dispatches on the manifest's *basename* so a member path
1412/// (`crates/shipshape-core/Cargo.toml`) parses like a root `Cargo.toml` — the
1413/// description pass re-reads member manifests by their stored relative path.
1414fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
1415    let base = Path::new(fname)
1416        .file_name()
1417        .and_then(|n| n.to_str())
1418        .unwrap_or(fname);
1419    match base {
1420        "Cargo.toml" => parse_cargo(text),
1421        "package.json" => parse_package_json(text),
1422        "pyproject.toml" => Some(parse_pyproject(text)),
1423        "go.mod" => Some(parse_gomod(text)),
1424        _ => None, // setup.py
1425    }
1426}
1427
1428/// Parse a Cargo manifest's `[package]` block. Returns `None` for a virtual
1429/// workspace (no `[package]`), which still marks the repo rust upstream.
1430fn parse_cargo(text: &str) -> Option<ParsedManifest> {
1431    let block = toml_section(text, "package")?;
1432    Some(ParsedManifest {
1433        package: toml_str_value(&block, "name", false),
1434        version: toml_str_value(&block, "version", false),
1435        description: toml_str_value(&block, "description", true),
1436    })
1437}
1438
1439fn parse_package_json(text: &str) -> Option<ParsedManifest> {
1440    let value: serde_json::Value = serde_json::from_str(text).ok()?;
1441    let field = |key: &str| {
1442        value
1443            .get(key)
1444            .and_then(serde_json::Value::as_str)
1445            .map(str::to_string)
1446    };
1447    Some(ParsedManifest {
1448        package: field("name"),
1449        version: field("version"),
1450        description: field("description"),
1451    })
1452}
1453
1454/// Parse a `pyproject.toml`: the standard `[project]` table first, then a legacy
1455/// `[tool.poetry]` fallback when `[project]` names no package.
1456fn parse_pyproject(text: &str) -> ParsedManifest {
1457    let mut parsed = ParsedManifest::default();
1458    if let Some(block) = toml_section(text, "project") {
1459        parsed.package = toml_str_value(&block, "name", false);
1460        parsed.version = toml_str_value(&block, "version", false);
1461        parsed.description = toml_str_value(&block, "description", true);
1462    }
1463    if parsed.package.is_none() {
1464        if let Some(block) = toml_section(text, "tool.poetry") {
1465            parsed.package = toml_str_value(&block, "name", false);
1466            parsed.version = toml_str_value(&block, "version", false);
1467            parsed.description = toml_str_value(&block, "description", true);
1468        }
1469    }
1470    parsed
1471}
1472
1473/// Parse a `go.mod`'s `module <path>` line. Always yields a (possibly empty)
1474/// result — a `go.mod` marks the repo go regardless of a `module` line.
1475fn parse_gomod(text: &str) -> ParsedManifest {
1476    let module = text.lines().find_map(|line| {
1477        line.strip_prefix("module")
1478            .filter(|rest| rest.starts_with(char::is_whitespace))
1479            .and_then(|rest| rest.split_whitespace().next())
1480            .map(str::to_string)
1481    });
1482    ParsedManifest {
1483        package: module,
1484        version: None,
1485        description: None,
1486    }
1487}
1488
1489/// Extract a TOML `[header]` section body: every line after the header line up
1490/// to the next `[...]` line or end of file. `None` when the header is absent.
1491/// The header must begin the line (no indentation), matching the Python `^\[`.
1492fn toml_section(text: &str, header: &str) -> Option<String> {
1493    let needle = format!("[{header}]");
1494    let mut in_section = false;
1495    let mut out = String::new();
1496    for line in text.lines() {
1497        if in_section {
1498            if line.starts_with('[') {
1499                break;
1500            }
1501            out.push_str(line);
1502            out.push('\n');
1503        } else if line.starts_with(&needle) {
1504            in_section = true;
1505        }
1506    }
1507    in_section.then_some(out)
1508}
1509
1510/// Find `key = "value"` within a TOML section body and return the quoted value.
1511/// `allow_empty` controls whether an empty `""` counts (the Python `name`/
1512/// `version` patterns require non-empty; `description` allows empty).
1513fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
1514    for line in block.lines() {
1515        let rest = line.trim_start();
1516        let Some(rest) = rest.strip_prefix(key) else {
1517            continue;
1518        };
1519        // The key must be a whole token: the char after it is whitespace or `=`
1520        // (else `name` would spuriously match `nameservers`). Mirrors the Python
1521        // `^\s*<key>\s*=` anchor.
1522        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1523            continue;
1524        }
1525        let Some(rest) = rest.trim_start().strip_prefix('=') else {
1526            continue;
1527        };
1528        let Some(value) = extract_quoted(rest.trim_start()) else {
1529            continue;
1530        };
1531        if value.is_empty() && !allow_empty {
1532            return None;
1533        }
1534        return Some(value);
1535    }
1536    None
1537}
1538
1539/// Find `key = [ "a", "b", … ]` within a TOML section body and return the quoted
1540/// elements, in order. Handles both the single-line array and a multi-line array
1541/// that spans several lines (Cargo `members`/`exclude` lists are commonly
1542/// formatted either way), stripping `#` comments so a commented-out element is not
1543/// returned. Elements are returned as their raw quoted text — glob expansion and
1544/// path validation are the caller's job. `None` when the key is absent.
1545fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
1546    // Accumulate from the `key = [` line through the line holding the closing `]`.
1547    let mut acc = String::new();
1548    let mut collecting = false;
1549    for line in block.lines() {
1550        // Drop a trailing `#` comment first, so a commented-out element
1551        // (`# "old-member"`) or a `]` inside a comment does not corrupt the scan.
1552        let line = strip_toml_comment(line);
1553        if collecting {
1554            acc.push_str(line);
1555            acc.push('\n');
1556            if line.contains(']') {
1557                break;
1558            }
1559            continue;
1560        }
1561        let rest = line.trim_start();
1562        let Some(rest) = rest.strip_prefix(key) else {
1563            continue;
1564        };
1565        // The key must be a whole token (else `members-extra` would match).
1566        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1567            continue;
1568        }
1569        let Some(rest) = rest.trim_start().strip_prefix('=') else {
1570            continue;
1571        };
1572        acc.push_str(rest);
1573        acc.push('\n');
1574        collecting = true;
1575        if rest.contains(']') {
1576            break;
1577        }
1578    }
1579    if !collecting {
1580        return None;
1581    }
1582    // Slice between the first `[` and the first `]`, then pull quoted strings.
1583    let start = acc.find('[')?;
1584    let end = acc[start..].find(']')? + start;
1585    let mut inner = &acc[start + 1..end];
1586    let mut out = Vec::new();
1587    while let Some(pos) = inner.find(['"', '\'']) {
1588        let quote = inner.as_bytes()[pos] as char;
1589        let after = &inner[pos + 1..];
1590        let Some(close) = after.find(quote) else {
1591            break;
1592        };
1593        out.push(after[..close].to_string());
1594        inner = &after[close + 1..];
1595    }
1596    Some(out)
1597}
1598
1599/// Whether a member manifest block declares `<key>.workspace = true` (dotted) or
1600/// `<key> = { workspace = true }` (inline) — the two forms of Cargo workspace
1601/// field inheritance. Used to decide whether to inherit from `[workspace.package]`.
1602/// Callers pass the member's `[package]` block, not the whole file, so an unrelated
1603/// table cannot trip the match.
1604fn field_inherits_workspace(block: &str, key: &str) -> bool {
1605    let dotted = format!("{key}.workspace");
1606    for line in block.lines() {
1607        let t = strip_toml_comment(line).trim_start();
1608        // Dotted: `version.workspace = true`.
1609        if let Some(rest) = t.strip_prefix(&dotted) {
1610            let rest = rest.trim_start();
1611            if let Some(rest) = rest.strip_prefix('=') {
1612                if is_true_literal(rest.trim_start()) {
1613                    return true;
1614                }
1615            }
1616            continue;
1617        }
1618        // Inline table: `version = { workspace = true }`.
1619        if let Some(rest) = t.strip_prefix(key) {
1620            if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1621                continue;
1622            }
1623            let Some(rest) = rest.trim_start().strip_prefix('=') else {
1624                continue;
1625            };
1626            let v = rest.trim_start();
1627            // Require the exact `workspace = true` entry inside the inline table —
1628            // a substring test would accept `{ workspace = false, x = true }`.
1629            if v.starts_with('{') && inline_table_has_workspace_true(v) {
1630                return true;
1631            }
1632        }
1633    }
1634    false
1635}
1636
1637/// Whether `s` begins with the TOML boolean `true` as a whole token (not
1638/// `trueish`, not the string `"true"`), allowing a trailing comment or `}`.
1639fn is_true_literal(s: &str) -> bool {
1640    match s.strip_prefix("true") {
1641        Some(rest) => {
1642            let rest = rest.trim_start();
1643            rest.is_empty() || rest.starts_with(['#', '}', ','])
1644        }
1645        None => false,
1646    }
1647}
1648
1649/// Whether an inline table (`{ … }`) contains an exact `workspace = true` entry.
1650/// Whitespace-insensitive so `{workspace=true}` and `{ workspace = true }` both
1651/// match, while `{ workspace = false, other = true }` does not.
1652fn inline_table_has_workspace_true(inline: &str) -> bool {
1653    let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
1654    compact.trim_start_matches('{').split(',').any(|entry| {
1655        entry
1656            .strip_prefix("workspace=true")
1657            .is_some_and(|r| r.is_empty() || r == "}")
1658    })
1659}
1660
1661/// Return `line` with any trailing `#` comment removed, respecting `#` characters
1662/// that fall inside a `"`/`'` quoted string (which are literal, not comments).
1663fn strip_toml_comment(line: &str) -> &str {
1664    let mut quote: Option<u8> = None;
1665    for (i, &b) in line.as_bytes().iter().enumerate() {
1666        match quote {
1667            Some(q) => {
1668                if b == q {
1669                    quote = None;
1670                }
1671            }
1672            None => match b {
1673                b'"' | b'\'' => quote = Some(b),
1674                b'#' => return &line[..i],
1675                _ => {}
1676            },
1677        }
1678    }
1679    line
1680}
1681
1682/// Read a leading quoted string — `"..."` or `'...'`. TOML allows both basic
1683/// (double) and literal (single) strings, and `tomllib` accepts either, so both
1684/// are honored here for parity. No escape handling: neither this nor the Python
1685/// regex `"([^"]+)"` unescapes, and manifest name/version/description do not need
1686/// it in practice.
1687fn extract_quoted(s: &str) -> Option<String> {
1688    let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
1689    let s = &s[1..];
1690    let end = s.find(quote)?;
1691    Some(s[..end].to_string())
1692}
1693
1694// ── SemVer helpers ───────────────────────────────────────────────────────────
1695
1696/// Parse a possibly package-prefixed `SemVer` tag into
1697/// `(major, minor, patch, is_prerelease)`, or `None` if it is not `SemVer`.
1698///
1699/// Strips a monorepo `pkg-`/`pkg@`/`pkg/` prefix (e.g. `core-v1.2.3`,
1700/// `@acme/cli@2.0.0`) before parsing, mirroring the Python `_semver_parse`.
1701fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
1702    parse_semver_core(strip_pkg_prefix(tag))
1703}
1704
1705/// Strip everything up to and including the rightmost `@`/`/`/`-` that is
1706/// immediately followed by an optional `v` and a `X.Y.Z` version.
1707fn strip_pkg_prefix(tag: &str) -> &str {
1708    let bytes = tag.as_bytes();
1709    for i in (0..bytes.len()).rev() {
1710        if matches!(bytes[i], b'@' | b'/' | b'-') {
1711            let rest = &tag[i + 1..];
1712            if starts_with_version(rest) {
1713                return rest;
1714            }
1715        }
1716    }
1717    tag
1718}
1719
1720/// Whether `s` begins with `v?\d+\.\d+\.\d+` (the version-start lookahead).
1721fn starts_with_version(s: &str) -> bool {
1722    let mut rest = s.strip_prefix('v').unwrap_or(s);
1723    for i in 0..3 {
1724        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1725        if digits == 0 {
1726            return false;
1727        }
1728        rest = &rest[digits..];
1729        if i < 2 {
1730            match rest.strip_prefix('.') {
1731                Some(r) => rest = r,
1732                None => return false,
1733            }
1734        }
1735    }
1736    true
1737}
1738
1739/// Fully parse a version core `v?\d+\.\d+\.\d+(?:[-+].*)?`. The prerelease flag
1740/// is set when a `-` (not `+`) immediately follows `X.Y.Z`.
1741fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
1742    let mut rest = core.strip_prefix('v').unwrap_or(core);
1743    let mut nums = [0u64; 3];
1744    for (i, slot) in nums.iter_mut().enumerate() {
1745        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1746        if digits == 0 {
1747            return None;
1748        }
1749        *slot = rest[..digits].parse().ok()?;
1750        rest = &rest[digits..];
1751        if i < 2 {
1752            rest = rest.strip_prefix('.')?;
1753        }
1754    }
1755    let pre = match rest.chars().next() {
1756        None | Some('+') => false,
1757        Some('-') => true,
1758        Some(_) => return None, // trailing junk after X.Y.Z → not SemVer
1759    };
1760    Some((nums[0], nums[1], nums[2], pre))
1761}
1762
1763/// Whether a manifest version string is `>=1.0` (`v?\d+\.` with major `>=1`).
1764fn version_ge_1_0(version: Option<&str>) -> bool {
1765    let Some(v) = version else {
1766        return false;
1767    };
1768    let s = v.strip_prefix('v').unwrap_or(v);
1769    let digits = s.bytes().take_while(u8::is_ascii_digit).count();
1770    // A leading number followed by a `.` — bare `1` (no dot) does not qualify.
1771    if digits == 0 || !s[digits..].starts_with('.') {
1772        return false;
1773    }
1774    s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
1775}
1776
1777// ── Small helpers ────────────────────────────────────────────────────────────
1778
1779/// Read a file through the [`Fs`] port as lossy UTF-8, capped at `limit`
1780/// *characters* (not bytes) — the Python `_read` opens in text mode, so
1781/// `fh.read(limit)` counts decoded characters. Slicing bytes instead would
1782/// under-read a multibyte README (a 4000-byte cap holds only ~1333 CJK chars)
1783/// and could split a codepoint into a `U+FFFD`. `None` when the read fails.
1784fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
1785    let bytes = fs.read(path).ok()?;
1786    Some(
1787        String::from_utf8_lossy(&bytes)
1788            .chars()
1789            .take(limit)
1790            .collect(),
1791    )
1792}
1793
1794/// Read a complete UTF-8-lossy file. Release-plan workspace discovery must not use the
1795/// general facts cap: cut execution reads the complete manifest, so truncating here
1796/// would let plan and cut classify different pin declaration sets.
1797fn read_text_full(fs: &dyn Fs, path: &Path) -> Option<String> {
1798    Some(String::from_utf8_lossy(&fs.read(path).ok()?).into_owned())
1799}
1800
1801/// Count non-blank lines (git shortlog emits one per committer).
1802fn count_lines(text: String) -> usize {
1803    text.lines().filter(|l| !l.trim().is_empty()).count()
1804}
1805
1806/// Truncate to at most `n` characters (not bytes) — the Python `[:n]` slice.
1807fn truncate_chars(s: &str, n: usize) -> String {
1808    s.chars().take(n).collect()
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813    use super::*;
1814    use std::collections::{HashMap, HashSet};
1815    use std::path::PathBuf;
1816
1817    // ── In-memory fakes for the ports ──────────────────────────────────────
1818
1819    #[derive(Default)]
1820    struct FakeFs {
1821        files: HashMap<PathBuf, Vec<u8>>,
1822        dirs: HashSet<PathBuf>,
1823    }
1824
1825    impl FakeFs {
1826        fn file(mut self, path: &str, contents: &str) -> Self {
1827            let p = PathBuf::from(path);
1828            // Register ancestor directories so `read_dir`/`is_dir` see them.
1829            let mut cur = p.parent();
1830            while let Some(dir) = cur {
1831                if dir.as_os_str().is_empty() {
1832                    break;
1833                }
1834                self.dirs.insert(dir.to_path_buf());
1835                cur = dir.parent();
1836            }
1837            self.files.insert(p, contents.as_bytes().to_vec());
1838            self
1839        }
1840
1841        fn dir(mut self, path: &str) -> Self {
1842            self.dirs.insert(PathBuf::from(path));
1843            self
1844        }
1845    }
1846
1847    impl Fs for FakeFs {
1848        fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
1849            self.files
1850                .get(path)
1851                .cloned()
1852                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1853        }
1854        fn exists(&self, path: &Path) -> bool {
1855            self.files.contains_key(path) || self.dirs.contains(path)
1856        }
1857        fn is_dir(&self, path: &Path) -> bool {
1858            self.dirs.contains(path)
1859        }
1860        fn is_file(&self, path: &Path) -> bool {
1861            self.files.contains_key(path)
1862        }
1863        fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
1864            if !self.dirs.contains(dir) {
1865                return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
1866            }
1867            let mut names: Vec<String> = self
1868                .files
1869                .keys()
1870                .chain(self.dirs.iter())
1871                .filter(|p| p.parent() == Some(dir))
1872                .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1873                .collect();
1874            names.sort();
1875            Ok(names)
1876        }
1877    }
1878
1879    #[derive(Default)]
1880    struct FakeGit {
1881        work_tree: bool,
1882        head: Option<String>,
1883        shortlog_all: String,
1884        shortlog_recent: String,
1885        tags: Vec<String>,
1886    }
1887
1888    impl GitRepo for FakeGit {
1889        fn head_commit(&self) -> std::io::Result<String> {
1890            self.head
1891                .clone()
1892                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1893        }
1894        fn is_work_tree(&self) -> bool {
1895            self.work_tree
1896        }
1897        fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
1898            if !self.work_tree {
1899                return Err(std::io::Error::from(std::io::ErrorKind::Other));
1900            }
1901            Ok(if since.is_some() {
1902                self.shortlog_recent.clone()
1903            } else {
1904                self.shortlog_all.clone()
1905            })
1906        }
1907        fn tags(&self) -> std::io::Result<Vec<String>> {
1908            if self.work_tree {
1909                Ok(self.tags.clone())
1910            } else {
1911                Err(std::io::Error::from(std::io::ErrorKind::Other))
1912            }
1913        }
1914        fn git_common_dir(&self) -> std::io::Result<PathBuf> {
1915            Ok(PathBuf::from("/repo/.git"))
1916        }
1917    }
1918
1919    /// A git repo with `n` distinct committers total / recent and the given tags.
1920    fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
1921        let lines = |n: usize| {
1922            (0..n)
1923                .map(|i| format!("     3\tDev {i} <dev{i}@example.com>"))
1924                .collect::<Vec<_>>()
1925                .join("\n")
1926        };
1927        FakeGit {
1928            work_tree: true,
1929            head: Some("deadbeef".to_string()),
1930            shortlog_all: lines(total),
1931            shortlog_recent: lines(recent),
1932            tags: tags.iter().map(|t| (*t).to_string()).collect(),
1933        }
1934    }
1935
1936    fn repo() -> &'static Path {
1937        Path::new("/repo")
1938    }
1939
1940    // ── Empty / unborn repo ────────────────────────────────────────────────
1941
1942    #[test]
1943    fn empty_repo_is_spike_binary() {
1944        let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1945        assert!(!facts.is_git);
1946        assert!(!facts.has_commits);
1947        assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1948        assert!(facts.packages.is_empty());
1949        assert_eq!(facts.committers_total, 0);
1950        assert_eq!(facts.committers_recent_year, 0);
1951        assert!(facts.tags.is_empty());
1952        assert!(!facts.has_ci);
1953        assert_eq!(facts.dependency_bot, None);
1954        assert_eq!(facts.description, None);
1955        // No CI, no SemVer tag, <=1 committer → spike.
1956        assert!(facts.maturity_signals.spike);
1957        assert_eq!(facts.inferred_maturity, Maturity::Spike);
1958    }
1959
1960    #[test]
1961    fn unborn_repo_has_no_commits() {
1962        // A work tree whose HEAD does not resolve (no commits yet): is_git true,
1963        // has_commits false, so no committers/tags are read.
1964        let git = FakeGit {
1965            work_tree: true,
1966            head: None,
1967            ..FakeGit::default()
1968        };
1969        let facts = gather(repo(), &FakeFs::default(), &git);
1970        assert!(facts.is_git);
1971        assert!(!facts.has_commits);
1972        assert_eq!(facts.committers_total, 0);
1973        assert!(facts.tags.is_empty());
1974    }
1975
1976    // ── Ecosystem + manifest detection ─────────────────────────────────────
1977
1978    #[test]
1979    fn cargo_package_name_version_description() {
1980        let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1981                     description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1982        let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1983        let facts = gather(repo(), &fs, &FakeGit::default());
1984        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1985        assert_eq!(facts.packages.len(), 1);
1986        let p = &facts.packages[0];
1987        assert_eq!(p.ecosystem, Ecosystem::Rust);
1988        assert_eq!(p.manifest, "Cargo.toml");
1989        assert_eq!(p.package.as_deref(), Some("rg"));
1990        assert_eq!(p.version.as_deref(), Some("0.3.0"));
1991        assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1992    }
1993
1994    #[test]
1995    fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1996        // A virtual workspace whose only member manifest is absent falls back to
1997        // the null root entry: the repo is still rust, and the null-named entry
1998        // preserves today's signal rather than emitting nothing.
1999        let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
2000        let facts = gather(repo(), &fs, &FakeGit::default());
2001        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
2002        assert_eq!(facts.packages.len(), 1);
2003        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2004        assert_eq!(facts.packages[0].package, None);
2005        assert_eq!(facts.packages[0].version, None);
2006    }
2007
2008    #[test]
2009    fn cargo_virtual_workspace_enumerates_members() {
2010        // A virtual-workspace root + two members: one inherits the workspace
2011        // version (`version.workspace = true`), one pins its own literal version.
2012        let root = "[workspace]\nresolver = \"2\"\n\
2013                    members = [\"crates/core\", \"crates/cli\"]\n\n\
2014                    [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
2015        let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
2016                    edition.workspace = true\ndescription = \"the core lib\"\n";
2017        let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
2018                   description = \"the cli\"\n";
2019        let fs = FakeFs::default()
2020            .file("/repo/Cargo.toml", root)
2021            .file("/repo/crates/core/Cargo.toml", core)
2022            .file("/repo/crates/cli/Cargo.toml", cli);
2023        let facts = gather(repo(), &fs, &FakeGit::default());
2024        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
2025        assert_eq!(facts.packages.len(), 2);
2026        // Declaration order is preserved.
2027        let core_pkg = &facts.packages[0];
2028        assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
2029        assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
2030        assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
2031        // `version.workspace = true` inherits 0.1.0 from [workspace.package].
2032        assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
2033        let cli_pkg = &facts.packages[1];
2034        assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
2035        assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
2036        assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
2037        // The description pass re-reads the first member manifest by its path.
2038        assert_eq!(facts.description.as_deref(), Some("the core lib"));
2039    }
2040
2041    // ── rust_workspace graph (release-planner plumbing) ────────────────────────
2042
2043    #[test]
2044    fn rust_workspace_graph_captures_lib_bin_edge() {
2045        // The canonical lib+bin shape: the bin pins the lib by exact version, the
2046        // exact `dep = { path, version = "=X" }` form `/shipshape-init` emits.
2047        let root = "[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n\n\
2048                    [workspace.package]\nversion = \"0.1.6\"\n";
2049        let core = "[package]\nname = \"octl-core\"\nversion.workspace = true\n\
2050                    publish = true\n";
2051        let cli = "[package]\nname = \"orchestratectl\"\nversion.workspace = true\n\
2052                   publish = true\n\n[dependencies]\n\
2053                   octl-core = { path = \"../core\", version = \"=0.1.6\" }\n\
2054                   serde = \"1\"\n";
2055        let fs = FakeFs::default()
2056            .file("/repo/Cargo.toml", root)
2057            .file("/repo/crates/core/Cargo.toml", core)
2058            .file("/repo/crates/cli/Cargo.toml", cli);
2059        let ws = detect_rust_workspace(repo(), &fs).expect("a members-bearing workspace");
2060        // Both members, in declaration order (planner applies the topo order).
2061        let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
2062        assert_eq!(names, vec!["octl-core", "orchestratectl"]);
2063        // The lib has no intra-workspace deps; the bin depends on the lib only
2064        // (`serde` is an external crate, not a member, so it is not an edge).
2065        assert!(ws.members[0].workspace_deps.is_empty());
2066        assert_eq!(ws.members[1].workspace_deps, vec!["octl-core".to_string()]);
2067        assert_eq!(ws.members[0].version.as_deref(), Some("0.1.6"));
2068    }
2069
2070    #[test]
2071    fn rust_workspace_graph_drops_unpublishable_members() {
2072        // `publish = false` and a non-crates.io registry restriction both exclude a
2073        // member from the publish set (matching the cargo adapter's metadata filter);
2074        // an edge to an excluded member is not an ordering edge.
2075        let root = "[workspace]\nmembers = [\"a\", \"b\", \"c\"]\n";
2076        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n"; // publishable (absent)
2077        let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n";
2078        let c = "[package]\nname = \"c\"\nversion = \"1.0.0\"\n\
2079                 publish = [\"my-registry\"]\n\n[dependencies]\n\
2080                 b = { path = \"../b\" }\n";
2081        let fs = FakeFs::default()
2082            .file("/repo/Cargo.toml", root)
2083            .file("/repo/a/Cargo.toml", a)
2084            .file("/repo/b/Cargo.toml", b)
2085            .file("/repo/c/Cargo.toml", c);
2086        let ws = detect_rust_workspace(repo(), &fs).expect("at least `a` is publishable");
2087        let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
2088        assert_eq!(names, vec!["a"], "only the publishable member survives");
2089    }
2090
2091    #[test]
2092    fn rust_workspace_graph_honors_crates_io_allow_list() {
2093        // `publish = ["crates-io"]` is publishable; the array form is not `publish = false`.
2094        let root = "[workspace]\nmembers = [\"a\"]\n";
2095        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\
2096                 publish = [\"crates-io\"]\n";
2097        let fs = FakeFs::default()
2098            .file("/repo/Cargo.toml", root)
2099            .file("/repo/a/Cargo.toml", a);
2100        let ws = detect_rust_workspace(repo(), &fs).expect("crates-io allow-listed");
2101        assert_eq!(ws.members.len(), 1);
2102    }
2103
2104    #[test]
2105    fn cargo_publish_evidence_reads_the_root_package() {
2106        let fs = FakeFs::default().file(
2107            "/repo/Cargo.toml",
2108            "[package]\nname = \"solo\"\nversion = \"0.1.0\"\npublish = false\n",
2109        );
2110        assert_eq!(
2111            cargo_publish_evidence(repo(), &fs),
2112            vec![CargoPublishFlag {
2113                manifest: "Cargo.toml".to_string(),
2114                package: Some("solo".to_string()),
2115                policy: CargoPublishPolicy::Forbidden,
2116            }]
2117        );
2118    }
2119
2120    #[test]
2121    fn cargo_publish_evidence_reads_every_workspace_member() {
2122        let fs = FakeFs::default()
2123            .file(
2124                "/repo/Cargo.toml",
2125                "[workspace]\nmembers = [\"a\", \"b\"]\n",
2126            )
2127            .file(
2128                "/repo/a/Cargo.toml",
2129                "[package]\nname = \"a\"\nversion = \"1.0.0\"\n",
2130            )
2131            .file(
2132                "/repo/b/Cargo.toml",
2133                "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n",
2134            );
2135        assert_eq!(
2136            policies(&fs),
2137            vec![
2138                ("a/Cargo.toml".to_string(), CargoPublishPolicy::Allowed),
2139                ("b/Cargo.toml".to_string(), CargoPublishPolicy::Forbidden),
2140            ]
2141        );
2142    }
2143
2144    /// A HYBRID root — `[package]` *and* `[workspace] members` — is a common Cargo
2145    /// layout, and a member's `publish = false` must not be invisible behind a
2146    /// publishable root. Both are read.
2147    #[test]
2148    fn cargo_publish_evidence_reads_a_hybrid_root_and_its_members() {
2149        let fs = FakeFs::default()
2150            .file(
2151                "/repo/Cargo.toml",
2152                "[package]\nname = \"root\"\nversion = \"1.0.0\"\n\n\
2153                 [workspace]\nmembers = [\"crates/private\"]\n",
2154            )
2155            .file(
2156                "/repo/crates/private/Cargo.toml",
2157                "[package]\nname = \"private\"\nversion = \"1.0.0\"\npublish = false\n",
2158            );
2159        assert_eq!(
2160            policies(&fs),
2161            vec![
2162                ("Cargo.toml".to_string(), CargoPublishPolicy::Allowed),
2163                (
2164                    "crates/private/Cargo.toml".to_string(),
2165                    CargoPublishPolicy::Forbidden
2166                ),
2167            ]
2168        );
2169    }
2170
2171    /// Every `publish` value shape Cargo accepts, read from the `[package]` block.
2172    #[test]
2173    fn cargo_publish_evidence_covers_every_publish_value_shape() {
2174        let cases = [
2175            ("", CargoPublishPolicy::Allowed), // absent
2176            ("publish = true\n", CargoPublishPolicy::Allowed),
2177            ("publish = false\n", CargoPublishPolicy::Forbidden),
2178            ("publish=false\n", CargoPublishPolicy::Forbidden), // no spaces
2179            ("publish = false # private\n", CargoPublishPolicy::Forbidden), // trailing comment
2180            ("publish = []\n", CargoPublishPolicy::Forbidden),  // empty allow-list
2181            ("publish = [\"crates-io\"]\n", CargoPublishPolicy::Allowed),
2182            (
2183                "publish = [\"my-registry\"]\n",
2184                CargoPublishPolicy::Forbidden,
2185            ),
2186            // A shape this textual reader does not model is Unknown, never a guess.
2187            (
2188                "publish = { workspace = false }\n",
2189                CargoPublishPolicy::Unknown,
2190            ),
2191        ];
2192        for (line, expected) in cases {
2193            let fs = FakeFs::default().file(
2194                "/repo/Cargo.toml",
2195                &format!("[package]\nname = \"solo\"\nversion = \"0.1.0\"\n{line}"),
2196            );
2197            assert_eq!(
2198                cargo_publish_evidence(repo(), &fs)[0].policy,
2199                expected,
2200                "publish line {line:?}"
2201            );
2202        }
2203    }
2204
2205    /// A `publish` key in ANOTHER table must not be read as the package's — the read
2206    /// is `[package]`-scoped, so a decoy cannot forge a `Forbidden` verdict (which
2207    /// would be a spurious hard error in the contract normalizer).
2208    #[test]
2209    fn cargo_publish_evidence_ignores_a_publish_key_outside_the_package_table() {
2210        let fs = FakeFs::default().file(
2211            "/repo/Cargo.toml",
2212            "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n\n\
2213             [package.metadata.release]\npublish = false\n\n\
2214             [dependencies]\npublish = \"1.0\"\n",
2215        );
2216        assert_eq!(
2217            cargo_publish_evidence(repo(), &fs)[0].policy,
2218            CargoPublishPolicy::Allowed
2219        );
2220    }
2221
2222    /// `publish.workspace = true` is RESOLVED against `[workspace.package]`, in both
2223    /// directions — the modern single-place layout must not be misread as unguarded.
2224    #[test]
2225    fn cargo_publish_evidence_resolves_workspace_inheritance() {
2226        let member = "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n";
2227        for (ws_publish, expected) in [
2228            ("publish = false\n", CargoPublishPolicy::Forbidden),
2229            ("publish = true\n", CargoPublishPolicy::Allowed),
2230            ("", CargoPublishPolicy::Allowed), // inherits the permissive default
2231        ] {
2232            let fs = FakeFs::default()
2233                .file(
2234                    "/repo/Cargo.toml",
2235                    &format!(
2236                        "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n{ws_publish}"
2237                    ),
2238                )
2239                .file("/repo/a/Cargo.toml", member);
2240            assert_eq!(
2241                cargo_publish_evidence(repo(), &fs)[0].policy,
2242                expected,
2243                "[workspace.package] {ws_publish:?}"
2244            );
2245        }
2246
2247        // Inheriting with NO `[workspace.package]` to inherit from resolves to
2248        // Unknown — never a verdict either way.
2249        let fs = FakeFs::default()
2250            .file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n")
2251            .file("/repo/a/Cargo.toml", member);
2252        assert_eq!(
2253            cargo_publish_evidence(repo(), &fs)[0].policy,
2254            CargoPublishPolicy::Unknown
2255        );
2256    }
2257
2258    #[test]
2259    fn facts_report_exposes_the_normalizers_exact_tri_state_evidence() {
2260        let fs = FakeFs::default()
2261            .file(
2262                "/repo/Cargo.toml",
2263                "[workspace]\nmembers = [\"allowed\", \"inherited\", \"unknown\"]\n\
2264                 \n[workspace.package]\npublish = false\n",
2265            )
2266            .file(
2267                "/repo/allowed/Cargo.toml",
2268                "[package]\nname = \"allowed\"\npublish = true\n",
2269            )
2270            .file(
2271                "/repo/inherited/Cargo.toml",
2272                "[package]\nname = \"inherited\"\npublish.workspace = true\n",
2273            )
2274            .file(
2275                "/repo/unknown/Cargo.toml",
2276                "[package]\nname = \"unknown\"\npublish = { workspace = false }\n",
2277            );
2278
2279        let report = gather_report(repo(), &fs, &FakeGit::default());
2280        assert_eq!(report.cargo_publish, cargo_publish_evidence(repo(), &fs));
2281        assert_eq!(
2282            report
2283                .cargo_publish
2284                .iter()
2285                .map(|e| e.policy)
2286                .collect::<Vec<_>>(),
2287            vec![
2288                CargoPublishPolicy::Allowed,
2289                CargoPublishPolicy::Forbidden,
2290                CargoPublishPolicy::Unknown,
2291            ]
2292        );
2293
2294        let json = serde_json::to_value(&report).expect("facts report serializes");
2295        assert_eq!(json["cargo_publish"][0]["manifest"], "allowed/Cargo.toml");
2296        assert_eq!(json["cargo_publish"][0]["policy"], "allowed");
2297        assert_eq!(json["cargo_publish"][1]["policy"], "forbidden");
2298        assert_eq!(json["cargo_publish"][2]["policy"], "unknown");
2299    }
2300
2301    #[test]
2302    fn cargo_publish_evidence_is_empty_without_a_manifest() {
2303        // No `Cargo.toml` ⇒ no evidence at all (never "nothing is publishable").
2304        assert!(cargo_publish_evidence(repo(), &FakeFs::default()).is_empty());
2305    }
2306
2307    /// `(manifest, policy)` rows of the evidence read, for readable assertions.
2308    fn policies(fs: &FakeFs) -> Vec<(String, CargoPublishPolicy)> {
2309        cargo_publish_evidence(repo(), fs)
2310            .into_iter()
2311            .map(|m| (m.manifest, m.policy))
2312            .collect()
2313    }
2314
2315    #[test]
2316    fn rust_workspace_graph_none_for_single_crate_repo() {
2317        // A single root crate (no `[workspace].members`) is not a multi-crate
2318        // workspace — the planner has nothing to expand.
2319        let cargo = "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n";
2320        let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
2321        assert!(detect_rust_workspace(repo(), &fs).is_none());
2322    }
2323
2324    #[test]
2325    fn rust_workspace_graph_dep_rename_via_inline_package_key() {
2326        // A renamed dependency (`alias = { package = "real-core" }`) resolves to the
2327        // real crate name, so the edge to the workspace member is still detected.
2328        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2329        let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
2330        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2331                   alias = { package = \"real-core\", path = \"../core\" }\n";
2332        let fs = FakeFs::default()
2333            .file("/repo/Cargo.toml", root)
2334            .file("/repo/core/Cargo.toml", core)
2335            .file("/repo/cli/Cargo.toml", cli);
2336        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2337        let cli = ws
2338            .members
2339            .iter()
2340            .find(|m| m.package == "cli")
2341            .expect("cli member");
2342        assert_eq!(cli.workspace_deps, vec!["real-core".to_string()]);
2343    }
2344
2345    #[test]
2346    fn rust_workspace_graph_ignores_registry_dep_sharing_a_member_name() {
2347        // A registry dependency (`shared = "1"`) is NEVER an intra-workspace edge, even
2348        // when a workspace member happens to be named `shared` — Cargo resolves it to
2349        // crates.io. Treating the name collision as an edge would invent a false
2350        // ordering constraint (and here a false cycle: cli↔shared).
2351        let root = "[workspace]\nmembers = [\"shared\", \"cli\"]\n";
2352        let shared = "[package]\nname = \"shared\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2353                      cli = { path = \"../cli\" }\n"; // shared really depends on cli
2354        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2355                   shared = \"1\"\n"; // a crates.io `shared`, NOT the workspace member
2356        let fs = FakeFs::default()
2357            .file("/repo/Cargo.toml", root)
2358            .file("/repo/shared/Cargo.toml", shared)
2359            .file("/repo/cli/Cargo.toml", cli);
2360        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2361        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2362        let shared = ws.members.iter().find(|m| m.package == "shared").unwrap();
2363        assert!(
2364            cli.workspace_deps.is_empty(),
2365            "a registry dep sharing a member name is not an edge"
2366        );
2367        assert_eq!(
2368            shared.workspace_deps,
2369            vec!["cli".to_string()],
2370            "the real path edge is still detected"
2371        );
2372    }
2373
2374    #[test]
2375    fn rust_workspace_graph_ignores_registry_inline_table_without_path() {
2376        // An inline table with only `version` is a crates.io dep, not a workspace edge.
2377        let root = "[workspace]\nmembers = [\"a\", \"b\"]\n";
2378        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n";
2379        let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2380                 a = { version = \"1\", features = [\"x\"] }\n";
2381        let fs = FakeFs::default()
2382            .file("/repo/Cargo.toml", root)
2383            .file("/repo/a/Cargo.toml", a)
2384            .file("/repo/b/Cargo.toml", b);
2385        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2386        let b = ws.members.iter().find(|m| m.package == "b").unwrap();
2387        assert!(
2388            b.workspace_deps.is_empty(),
2389            "an inline registry dep (no path/workspace) is not an edge"
2390        );
2391    }
2392
2393    #[test]
2394    fn rust_workspace_graph_records_the_lockstep_pin_requirement() {
2395        // facet 3: an inline `= "=X"` pin's requirement is carried in dep_reqs so the
2396        // planner can rewrite only the exact lockstep edge.
2397        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2398        let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
2399        let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
2400                   octl-core = { path = \"../core\", version = \"=0.4.0\" }\n";
2401        let fs = FakeFs::default()
2402            .file("/repo/Cargo.toml", root)
2403            .file("/repo/core/Cargo.toml", core)
2404            .file("/repo/cli/Cargo.toml", cli);
2405        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2406        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2407        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2408        assert_eq!(
2409            cli.dep_reqs.get("octl-core").map(String::as_str),
2410            Some("=0.4.0")
2411        );
2412        assert_eq!(
2413            cli.pin_reqs.get("octl-core"),
2414            Some(&vec![Some("=0.4.0".to_string())])
2415        );
2416    }
2417
2418    #[test]
2419    fn rust_workspace_records_inherited_root_exact_pin() {
2420        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n\
2421                    [workspace.package]\nversion = \"0.4.0\"\n\
2422                    [workspace.dependencies]\ncore = {\npath = \"core\",\nversion = \"=0.4.0\"\n}\n";
2423        let core = "[package]\nname = \"core\"\nversion.workspace = true\n";
2424        let cli = "[package]\nname = \"cli\"\nversion.workspace = true\n\n[dependencies]\ncore.workspace = true\n";
2425        let fs = FakeFs::default()
2426            .file("/repo/Cargo.toml", root)
2427            .file("/repo/core/Cargo.toml", core)
2428            .file("/repo/cli/Cargo.toml", cli);
2429        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2430
2431        assert_eq!(
2432            ws.workspace_pin_reqs.get("core"),
2433            Some(&vec![Some("=0.4.0".to_string())])
2434        );
2435        let cli = ws
2436            .members
2437            .iter()
2438            .find(|member| member.package == "cli")
2439            .unwrap();
2440        assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2441    }
2442
2443    #[test]
2444    fn rust_workspace_pin_requirements_preserve_all_dependency_tables() {
2445        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2446        let core = "[package]\nname = \"core\"\nversion = \"0.4.0\"\n";
2447        let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n";
2448        let fs = FakeFs::default()
2449            .file("/repo/Cargo.toml", root)
2450            .file("/repo/core/Cargo.toml", core)
2451            .file("/repo/cli/Cargo.toml", cli);
2452        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2453        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2454
2455        assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2456        assert_eq!(
2457            cli.pin_reqs.get("core"),
2458            Some(&vec![Some("=0.4.0".to_string()); 4])
2459        );
2460    }
2461
2462    #[test]
2463    fn rust_workspace_pin_discovery_reads_the_complete_manifest() {
2464        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2465        let core = "[package]\nname = \"core\"\nversion = \"0.4.0\"\n";
2466        let mut cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n".to_string();
2467        cli.push('#');
2468        cli.push_str(&"x".repeat(MANIFEST_LIMIT));
2469        cli.push_str("\n[dev-dependencies]\ncore = { path = \"../core\", version = \"^0.4\" }\n");
2470        let fs = FakeFs::default()
2471            .file("/repo/Cargo.toml", root)
2472            .file("/repo/core/Cargo.toml", core)
2473            .file("/repo/cli/Cargo.toml", &cli);
2474        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2475        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2476
2477        assert_eq!(
2478            cli.pin_reqs.get("core"),
2479            Some(&vec![Some("=0.4.0".to_string()), Some("^0.4".to_string())])
2480        );
2481    }
2482
2483    #[test]
2484    fn rust_workspace_graph_omits_req_for_a_pathonly_edge() {
2485        // A path-only edge (no version) records the edge but no requirement, so the
2486        // planner emits no pin rewrite for it (fail closed — the publish would surface
2487        // a real error if a pin were needed).
2488        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2489        let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
2490        let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
2491                   octl-core = { path = \"../core\" }\n";
2492        let fs = FakeFs::default()
2493            .file("/repo/Cargo.toml", root)
2494            .file("/repo/core/Cargo.toml", core)
2495            .file("/repo/cli/Cargo.toml", cli);
2496        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2497        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2498        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2499        assert!(!cli.dep_reqs.contains_key("octl-core"));
2500    }
2501
2502    #[test]
2503    fn rust_workspace_graph_reads_target_specific_dependency_edges() {
2504        // A `[target.'cfg(...)'.dependencies]` normal dep gates publish order too —
2505        // crates.io validates it at `cargo publish`.
2506        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2507        let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
2508        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
2509                   [target.'cfg(unix)'.dependencies]\n\
2510                   octl-core = { path = \"../core\", version = \"=1.0.0\" }\n";
2511        let fs = FakeFs::default()
2512            .file("/repo/Cargo.toml", root)
2513            .file("/repo/core/Cargo.toml", core)
2514            .file("/repo/cli/Cargo.toml", cli);
2515        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2516        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2517        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2518    }
2519
2520    #[test]
2521    fn rust_workspace_graph_reads_subtable_path_edge_and_rename() {
2522        // `[dependencies.<name>]` sub-table with a `path` is a local edge; a `package`
2523        // key inside renames it. A version-only sub-table is NOT an edge.
2524        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2525        let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
2526        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
2527                   [dependencies.alias]\npackage = \"real-core\"\npath = \"../core\"\n\
2528                   version = \"=1.0.0\"\n\n\
2529                   [dependencies.serde]\nversion = \"1\"\n"; // registry sub-table, not an edge
2530        let fs = FakeFs::default()
2531            .file("/repo/Cargo.toml", root)
2532            .file("/repo/core/Cargo.toml", core)
2533            .file("/repo/cli/Cargo.toml", cli);
2534        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2535        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2536        assert_eq!(
2537            cli.workspace_deps,
2538            vec!["real-core".to_string()],
2539            "the renamed path sub-table is the only edge; the version-only one is not"
2540        );
2541    }
2542
2543    #[test]
2544    fn rust_workspace_graph_reads_dotted_key_path_edge() {
2545        // Dotted-key form: `dep.path = "..."` (and `dep.workspace = true`) are local
2546        // edges; a lone `dep.version` is not.
2547        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2548        let core = "[package]\nname = \"core\"\nversion = \"1.0.0\"\n";
2549        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2550                   core.path = \"../core\"\ncore.version = \"=1.0.0\"\n";
2551        let fs = FakeFs::default()
2552            .file("/repo/Cargo.toml", root)
2553            .file("/repo/core/Cargo.toml", core)
2554            .file("/repo/cli/Cargo.toml", cli);
2555        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2556        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2557        assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2558    }
2559
2560    #[test]
2561    fn rust_workspace_graph_dep_rename_ignores_package_substring_in_a_path() {
2562        // A path value that literally contains "package" must NOT be misread as a
2563        // `package = ` rename key: the edge stays on the bare key `octl-core`.
2564        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2565        let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
2566        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2567                   octl-core = { path = \"../my-package-core\", version = \"1\" }\n";
2568        let fs = FakeFs::default()
2569            .file("/repo/Cargo.toml", root)
2570            .file("/repo/core/Cargo.toml", core)
2571            .file("/repo/cli/Cargo.toml", cli);
2572        // `octl-core` is not a member name here (the lib is named `octl-core`, the dep
2573        // key is `octl-core`) — assert the key resolves, not a spurious path substring.
2574        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2575        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2576        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2577    }
2578
2579    #[test]
2580    fn rust_workspace_graph_reads_build_dependency_edges() {
2581        // A build-dependency on a workspace member gates publish order too.
2582        let root = "[workspace]\nmembers = [\"gen\", \"app\"]\n";
2583        let gen = "[package]\nname = \"gen\"\nversion = \"1.0.0\"\n";
2584        let app = "[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n\
2585                   [build-dependencies]\ngen = { path = \"../gen\" }\n";
2586        let fs = FakeFs::default()
2587            .file("/repo/Cargo.toml", root)
2588            .file("/repo/gen/Cargo.toml", gen)
2589            .file("/repo/app/Cargo.toml", app);
2590        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2591        let app = ws.members.iter().find(|m| m.package == "app").unwrap();
2592        assert_eq!(app.workspace_deps, vec!["gen".to_string()]);
2593    }
2594
2595    #[test]
2596    fn rust_workspace_graph_excludes_dev_dependency_edges() {
2597        // A dev-dependency never gates publish order (and can legitimately cycle:
2598        // a lib that dev-depends on the CLI for integration tests).
2599        let root = "[workspace]\nmembers = [\"lib\", \"cli\"]\n";
2600        let lib = "[package]\nname = \"lib\"\nversion = \"1.0.0\"\n\n\
2601                   [dev-dependencies]\ncli = { path = \"../cli\" }\n";
2602        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2603                   lib = { path = \"../lib\" }\n";
2604        let fs = FakeFs::default()
2605            .file("/repo/Cargo.toml", root)
2606            .file("/repo/lib/Cargo.toml", lib)
2607            .file("/repo/cli/Cargo.toml", cli);
2608        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2609        let lib = ws.members.iter().find(|m| m.package == "lib").unwrap();
2610        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2611        assert!(
2612            lib.workspace_deps.is_empty(),
2613            "dev-dependency edge is not an ordering edge"
2614        );
2615        assert_eq!(cli.workspace_deps, vec!["lib".to_string()]);
2616    }
2617
2618    #[test]
2619    fn cargo_workspace_inline_version_inheritance() {
2620        // The inline-table inheritance form `version = { workspace = true }`.
2621        let root = "[workspace]\nmembers = [\"m\"]\n\n\
2622                    [workspace.package]\nversion = \"1.5.0\"\n";
2623        let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
2624        let fs = FakeFs::default()
2625            .file("/repo/Cargo.toml", root)
2626            .file("/repo/m/Cargo.toml", member);
2627        let facts = gather(repo(), &fs, &FakeGit::default());
2628        assert_eq!(facts.packages.len(), 1);
2629        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2630        assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
2631        // A member at >=1.0 (inherited) drives has_ge_1_0_release.
2632        assert!(facts.has_ge_1_0_release);
2633    }
2634
2635    #[test]
2636    fn cargo_workspace_multiline_members_array() {
2637        // Members formatted across several lines (the common rustfmt layout).
2638        let root = "[workspace]\nmembers = [\n    \"a\",\n    \"b\",\n]\n\n\
2639                    [workspace.package]\nversion = \"0.2.0\"\n";
2640        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2641        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2642        let fs = FakeFs::default()
2643            .file("/repo/Cargo.toml", root)
2644            .file("/repo/a/Cargo.toml", a)
2645            .file("/repo/b/Cargo.toml", b);
2646        let facts = gather(repo(), &fs, &FakeGit::default());
2647        let names: Vec<_> = facts
2648            .packages
2649            .iter()
2650            .map(|p| p.package.as_deref())
2651            .collect();
2652        assert_eq!(names, vec![Some("a"), Some("b")]);
2653        assert!(facts
2654            .packages
2655            .iter()
2656            .all(|p| p.version.as_deref() == Some("0.2.0")));
2657    }
2658
2659    #[test]
2660    fn cargo_workspace_member_without_workspace_package_table() {
2661        // `version.workspace = true` but no `[workspace.package]` to inherit from:
2662        // the version resolves to null (nothing to inherit), name still reported.
2663        let root = "[workspace]\nmembers = [\"m\"]\n";
2664        let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
2665        let fs = FakeFs::default()
2666            .file("/repo/Cargo.toml", root)
2667            .file("/repo/m/Cargo.toml", member);
2668        let facts = gather(repo(), &fs, &FakeGit::default());
2669        assert_eq!(facts.packages.len(), 1);
2670        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2671        assert_eq!(facts.packages[0].version, None);
2672    }
2673
2674    #[test]
2675    fn cargo_workspace_glob_members_are_expanded() {
2676        // `members = ["crates/*"]` expands one directory level, sorted, through the
2677        // Fs port. A non-crate entry (no Cargo.toml) is skipped.
2678        let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
2679                    [workspace.package]\nversion = \"0.4.0\"\n";
2680        let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
2681        let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
2682        let fs = FakeFs::default()
2683            .file("/repo/Cargo.toml", root)
2684            .file("/repo/crates/za/Cargo.toml", a)
2685            .file("/repo/crates/mb/Cargo.toml", b)
2686            .file("/repo/crates/README.md", "not a crate\n");
2687        let facts = gather(repo(), &fs, &FakeGit::default());
2688        // Directory order is sorted (mb before za), not declaration order.
2689        let names: Vec<_> = facts
2690            .packages
2691            .iter()
2692            .map(|p| p.package.as_deref())
2693            .collect();
2694        assert_eq!(names, vec![Some("mb"), Some("za")]);
2695        assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
2696        assert!(facts
2697            .packages
2698            .iter()
2699            .all(|p| p.version.as_deref() == Some("0.4.0")));
2700    }
2701
2702    #[test]
2703    fn cargo_workspace_exclude_and_dedup() {
2704        // A glob and an explicit member overlap (dedup to one entry); `exclude`
2705        // drops a matched member.
2706        let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
2707                    exclude = [\"crates/b\"]\n\n\
2708                    [workspace.package]\nversion = \"0.1.0\"\n";
2709        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2710        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2711        let fs = FakeFs::default()
2712            .file("/repo/Cargo.toml", root)
2713            .file("/repo/crates/a/Cargo.toml", a)
2714            .file("/repo/crates/b/Cargo.toml", b);
2715        let facts = gather(repo(), &fs, &FakeGit::default());
2716        // `b` excluded; `a` matched by both the glob and the explicit entry → once.
2717        let names: Vec<_> = facts
2718            .packages
2719            .iter()
2720            .map(|p| p.package.as_deref())
2721            .collect();
2722        assert_eq!(names, vec![Some("a")]);
2723    }
2724
2725    #[test]
2726    fn cargo_workspace_commented_out_member_is_ignored() {
2727        // A commented-out member line must not be emitted, even if the path exists.
2728        let root = "[workspace]\nmembers = [\n    \"a\",\n    # \"b\",\n]\n\n\
2729                    [workspace.package]\nversion = \"0.1.0\"\n";
2730        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2731        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2732        let fs = FakeFs::default()
2733            .file("/repo/Cargo.toml", root)
2734            .file("/repo/a/Cargo.toml", a)
2735            .file("/repo/b/Cargo.toml", b);
2736        let facts = gather(repo(), &fs, &FakeGit::default());
2737        let names: Vec<_> = facts
2738            .packages
2739            .iter()
2740            .map(|p| p.package.as_deref())
2741            .collect();
2742        assert_eq!(names, vec![Some("a")]);
2743    }
2744
2745    #[test]
2746    fn cargo_workspace_rejects_escaping_member_paths() {
2747        // Absolute and `..` members are rejected (a fact detector reports only the
2748        // repo's own packages) → no member resolves → null root entry fallback.
2749        let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
2750        let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
2751        let fs = FakeFs::default()
2752            .file("/repo/Cargo.toml", root)
2753            .file("/outside/Cargo.toml", outside)
2754            .file("/abs/Cargo.toml", outside);
2755        let facts = gather(repo(), &fs, &FakeGit::default());
2756        assert_eq!(facts.packages.len(), 1);
2757        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2758        assert_eq!(facts.packages[0].package, None);
2759    }
2760
2761    #[test]
2762    fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
2763        // `version.workspace = true` in `[package.metadata.*]` must NOT be read as
2764        // `[package].version` inheritance; and `= trueish` is not the bool `true`.
2765        let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
2766                    [workspace.package]\nversion = \"7.7.7\"\n";
2767        let m = "[package]\nname = \"m\"\n\n\
2768                 [package.metadata.tool]\nversion.workspace = true\n";
2769        let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
2770        let fs = FakeFs::default()
2771            .file("/repo/Cargo.toml", root)
2772            .file("/repo/m/Cargo.toml", m)
2773            .file("/repo/n/Cargo.toml", n);
2774        let facts = gather(repo(), &fs, &FakeGit::default());
2775        // Neither inherits the workspace version.
2776        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2777        assert_eq!(facts.packages[0].version, None);
2778        assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
2779        assert_eq!(facts.packages[1].version, None);
2780    }
2781
2782    #[test]
2783    fn cargo_workspace_inline_inheritance_rejects_false_positive() {
2784        // `version = { workspace = false, … }` must not inherit; a genuine
2785        // `{ workspace = true }` must.
2786        let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
2787                    [workspace.package]\nversion = \"3.0.0\"\n";
2788        let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
2789        let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
2790        let fs = FakeFs::default()
2791            .file("/repo/Cargo.toml", root)
2792            .file("/repo/yes/Cargo.toml", yes)
2793            .file("/repo/no/Cargo.toml", no);
2794        let facts = gather(repo(), &fs, &FakeGit::default());
2795        assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
2796        assert_eq!(facts.packages[1].version, None);
2797    }
2798
2799    #[test]
2800    fn package_json_parsed_and_go_mod_module() {
2801        let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
2802        let fs = FakeFs::default()
2803            .file("/repo/package.json", pkg)
2804            .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
2805        let facts = gather(repo(), &fs, &FakeGit::default());
2806        assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
2807        let node = facts
2808            .packages
2809            .iter()
2810            .find(|p| p.ecosystem == Ecosystem::Node)
2811            .unwrap();
2812        assert_eq!(node.package.as_deref(), Some("@acme/cli"));
2813        assert_eq!(node.version.as_deref(), Some("2.0.0"));
2814        let go = facts
2815            .packages
2816            .iter()
2817            .find(|p| p.ecosystem == Ecosystem::Go)
2818            .unwrap();
2819        assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
2820        // package.json's description wins (first package with a description).
2821        assert_eq!(facts.description.as_deref(), Some("cli"));
2822    }
2823
2824    #[test]
2825    fn pyproject_project_then_poetry_fallback() {
2826        let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
2827                       description = \"a widget\"\n";
2828        let facts = gather(
2829            repo(),
2830            &FakeFs::default().file("/repo/pyproject.toml", project),
2831            &FakeGit::default(),
2832        );
2833        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2834        assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
2835
2836        let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
2837        let facts = gather(
2838            repo(),
2839            &FakeFs::default().file("/repo/pyproject.toml", poetry),
2840            &FakeGit::default(),
2841        );
2842        assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
2843    }
2844
2845    #[test]
2846    fn pyproject_single_quoted_strings_parse() {
2847        // TOML literal (single-quoted) strings are valid and `tomllib` accepts
2848        // them; the scanner must too, or a >=1.0 release would be missed.
2849        let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
2850                       description = 'a widget'\n";
2851        let facts = gather(
2852            repo(),
2853            &FakeFs::default().file("/repo/pyproject.toml", project),
2854            &FakeGit::default(),
2855        );
2856        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2857        assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
2858        assert!(facts.has_ge_1_0_release);
2859        assert_eq!(facts.description.as_deref(), Some("a widget"));
2860    }
2861
2862    #[test]
2863    fn toml_key_matches_whole_token_not_prefix() {
2864        // `version-code` / `namespace` must not satisfy the `version` / `name`
2865        // key match.
2866        let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
2867                     name = \"real\"\nversion = \"0.2.0\"\n";
2868        let facts = gather(
2869            repo(),
2870            &FakeFs::default().file("/repo/Cargo.toml", cargo),
2871            &FakeGit::default(),
2872        );
2873        assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
2874        assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
2875    }
2876
2877    #[test]
2878    fn setup_py_marks_python_but_adds_no_package() {
2879        let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
2880        let facts = gather(repo(), &fs, &FakeGit::default());
2881        assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
2882        assert!(facts.packages.is_empty());
2883    }
2884
2885    #[test]
2886    fn ecosystems_emit_in_canonical_order() {
2887        // Files added out of order; output follows the MANIFESTS order.
2888        let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
2889            "/repo/Cargo.toml",
2890            "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
2891        );
2892        let facts = gather(repo(), &fs, &FakeGit::default());
2893        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
2894    }
2895
2896    // ── CI / bot / issues signals ──────────────────────────────────────────
2897
2898    #[test]
2899    fn workflows_dir_counts_only_when_non_empty() {
2900        // Empty workflows dir → no CI.
2901        let empty = FakeFs::default().dir("/repo/.github/workflows").file(
2902            "/repo/Cargo.toml",
2903            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2904        );
2905        assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
2906
2907        // A file inside it → CI present.
2908        let with_wf = FakeFs::default()
2909            .file("/repo/.github/workflows/ci.yml", "on: push\n")
2910            .file(
2911                "/repo/Cargo.toml",
2912                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2913            );
2914        assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
2915    }
2916
2917    #[test]
2918    fn single_file_ci_configs_count_on_existence() {
2919        for name in [
2920            ".gitlab-ci.yml",
2921            "azure-pipelines.yml",
2922            ".drone.yml",
2923            "Jenkinsfile",
2924        ] {
2925            let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
2926            assert!(
2927                gather(repo(), &fs, &FakeGit::default()).has_ci,
2928                "{name} should count as CI"
2929            );
2930        }
2931    }
2932
2933    #[test]
2934    fn distribution_surface_detects_cargo_dist_and_only_tag_push_workflows() {
2935        let fs = FakeFs::default()
2936            .file("/repo/dist-workspace.toml", "[dist]\nci = \"github\"\n")
2937            .file("/repo/Cargo.toml", "[workspace.metadata.dist]\ndist = true\n")
2938            .file(
2939                "/repo/.github/workflows/release.yml",
2940                "on:\n  push:\n    tags:\n      - 'v*'\n",
2941            )
2942            .file(
2943                "/repo/.github/workflows/ci.yml",
2944                "on:\n  push:\n    branches: [main]\n  pull_request:\n    tags: [never-a-trigger]\n",
2945            );
2946        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2947        assert!(surface.has_cargo_dist);
2948        assert_eq!(
2949            surface.cargo_dist_evidence,
2950            vec![
2951                "dist-workspace.toml".to_string(),
2952                "Cargo.toml ([workspace.metadata.dist])".to_string()
2953            ]
2954        );
2955        assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
2956        assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2957    }
2958
2959    #[test]
2960    fn cargo_publish_workflow_detection_is_empty_when_workflow_is_missing() {
2961        let fs = FakeFs::default().file(
2962            "/repo/Cargo.toml",
2963            "[package]\nname = \"missing-workflow\"\n",
2964        );
2965        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2966        assert!(surface.tag_triggered_workflows.is_empty());
2967        assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2968    }
2969
2970    #[test]
2971    fn dispatch_only_cargo_publish_workflow_is_not_tag_triggered_evidence() {
2972        let fs = FakeFs::default().file(
2973            "/repo/.github/workflows/publish.yml",
2974            "on:\n  workflow_dispatch:\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo publish\n",
2975        );
2976        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2977        assert!(surface.tag_triggered_workflows.is_empty());
2978        assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2979    }
2980
2981    #[test]
2982    fn direct_tag_triggered_cargo_publish_workflow_is_detected() {
2983        let fs = FakeFs::default().file(
2984            "/repo/.github/workflows/publish.yaml",
2985            "on:\n  push:\n    tags:\n      - 'v*'\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo publish --locked\n",
2986        );
2987        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2988        assert_eq!(surface.tag_triggered_workflows, vec!["publish.yaml"]);
2989        assert_eq!(
2990            surface.tag_triggered_cargo_publish_workflows,
2991            vec!["publish.yaml"]
2992        );
2993    }
2994
2995    #[test]
2996    fn cargo_publish_command_requires_a_real_crates_io_publish() {
2997        for command in [
2998            "cargo publish --dry-run",
2999            "cargo publish --help",
3000            "cargo publish --registry internal",
3001            "cargo publish --index https://example.invalid",
3002            "echo cargo publish",
3003            "# cargo publish",
3004        ] {
3005            assert!(
3006                !command_runs_cargo_publish(command),
3007                "{command:?} is not credible crates.io publish evidence"
3008            );
3009        }
3010        for command in [
3011            "cargo publish --locked",
3012            "cargo +stable publish --locked",
3013            "cd crates/demo && cargo publish --registry crates-io",
3014            "CARGO_TERM_COLOR=always cargo publish; echo done",
3015        ] {
3016            assert!(
3017                command_runs_cargo_publish(command),
3018                "{command:?} should be credible crates.io publish evidence"
3019            );
3020        }
3021    }
3022
3023    #[test]
3024    fn tag_triggered_local_reusable_cargo_publish_workflow_is_detected() {
3025        let fs = FakeFs::default()
3026            .file(
3027                "/repo/.github/workflows/release.yml",
3028                "on:\n  push:\n    tags:\n      - 'v*'\njobs:\n  crates:\n    uses: ./.github/workflows/publish.yml\n",
3029            )
3030            .file(
3031                "/repo/.github/workflows/publish.yml",
3032                "on:\n  workflow_call:\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo publish --locked\n",
3033            );
3034        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3035        assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
3036        assert_eq!(
3037            surface.tag_triggered_cargo_publish_workflows,
3038            vec!["release.yml"]
3039        );
3040    }
3041
3042    #[test]
3043    fn local_workflow_without_workflow_call_is_not_credible_publish_evidence() {
3044        let fs = FakeFs::default()
3045            .file(
3046                "/repo/.github/workflows/release.yml",
3047                "on:\n  push:\n    tags:\n      - 'v*'\njobs:\n  crates:\n    uses: ./.github/workflows/publish.yml\n",
3048            )
3049            .file(
3050                "/repo/.github/workflows/publish.yml",
3051                "on:\n  workflow_dispatch:\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - run: cargo publish --locked\n",
3052            );
3053        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3054        assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
3055        assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
3056    }
3057
3058    #[test]
3059    fn distribution_surface_is_empty_without_dist_or_tag_push() {
3060        let fs = FakeFs::default()
3061            .file("/repo/Cargo.toml", "[package]\nname = \"plain\"\n")
3062            .file(
3063                "/repo/.github/workflows/ci.yaml",
3064                "on:\n  push:\n    branches: [main]\n",
3065            );
3066        let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3067        assert!(!surface.has_cargo_dist);
3068        assert!(surface.cargo_dist_evidence.is_empty());
3069        assert!(surface.tag_triggered_workflows.is_empty());
3070        assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
3071    }
3072
3073    #[test]
3074    fn dependency_bot_and_issues_dir() {
3075        let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
3076        assert_eq!(
3077            gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
3078            Some("dependabot".to_string())
3079        );
3080        let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
3081        assert_eq!(
3082            gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
3083            Some("renovate".to_string())
3084        );
3085        // dependabot takes precedence when both are present.
3086        let both = FakeFs::default()
3087            .file("/repo/.github/dependabot.yml", "version: 2\n")
3088            .file("/repo/renovate.json", "{}\n");
3089        assert_eq!(
3090            gather(repo(), &both, &FakeGit::default()).dependency_bot,
3091            Some("dependabot".to_string())
3092        );
3093        let issues = FakeFs::default().dir("/repo/issues");
3094        assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
3095    }
3096
3097    // ── README self-label + description fallback ───────────────────────────
3098
3099    #[test]
3100    fn readme_self_label_and_prose_description() {
3101        let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
3102        let fs = FakeFs::default().file("/repo/README.md", readme);
3103        let facts = gather(repo(), &fs, &FakeGit::default());
3104        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
3105        // First non-heading, non-`!`, non-`>` line.
3106        assert_eq!(
3107            facts.description.as_deref(),
3108            Some("Status: private, early. Not much yet.")
3109        );
3110    }
3111
3112    #[test]
3113    fn description_truncates_to_120_chars() {
3114        let long = "x".repeat(200);
3115        let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
3116        let facts = gather(repo(), &fs, &FakeGit::default());
3117        // "intro" is the first prose line; assert truncation on a long manifest
3118        // description instead to exercise the cap.
3119        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
3120        let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
3121        let facts2 = gather(repo(), &fs2, &FakeGit::default());
3122        assert_eq!(facts.description.as_deref(), Some("intro"));
3123        // Count characters, not bytes — the cap is a char cap.
3124        assert_eq!(
3125            facts2.description.as_deref().map(|d| d.chars().count()),
3126            Some(120)
3127        );
3128    }
3129
3130    #[test]
3131    fn read_limit_counts_chars_not_bytes() {
3132        // A multibyte description right at the boundary: a byte-slice cap would
3133        // truncate/corrupt it; the char cap keeps it whole. `く` is 3 bytes.
3134        let desc = "く".repeat(60); // 60 chars, 180 bytes — under the 120 char cap
3135        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
3136        let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
3137        let facts = gather(repo(), &fs, &FakeGit::default());
3138        assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
3139        // No replacement character crept in from a mid-codepoint byte slice.
3140        assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
3141    }
3142
3143    // ── SemVer tag handling ────────────────────────────────────────────────
3144
3145    #[test]
3146    fn semver_parse_plain_prefixed_and_prerelease() {
3147        assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
3148        assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
3149        assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
3150        assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
3151        assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
3152        assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
3153        assert_eq!(semver_parse("nightly"), None);
3154        assert_eq!(semver_parse("1.2"), None);
3155        assert_eq!(semver_parse("1.2.3.4"), None);
3156    }
3157
3158    #[test]
3159    fn ge_1_0_release_from_tag_but_not_from_prerelease() {
3160        let fs = FakeFs::default().file(
3161            "/repo/Cargo.toml",
3162            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
3163        );
3164        // A 1.0.0 tag → has_ge_1_0_release.
3165        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
3166        assert!(facts.has_semver_tag);
3167        assert!(facts.has_ge_1_0_release);
3168        // Only a 1.0.0-rc prerelease tag → not a >=1.0 release.
3169        let fs2 = FakeFs::default().file(
3170            "/repo/Cargo.toml",
3171            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
3172        );
3173        let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
3174        assert!(facts2.has_semver_tag);
3175        assert!(!facts2.has_ge_1_0_release);
3176    }
3177
3178    #[test]
3179    fn ge_1_0_release_from_manifest_version() {
3180        let fs = FakeFs::default().file(
3181            "/repo/Cargo.toml",
3182            "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
3183        );
3184        let facts = gather(repo(), &fs, &FakeGit::default());
3185        assert!(facts.has_ge_1_0_release);
3186    }
3187
3188    #[test]
3189    fn version_ge_1_0_requires_dot_after_major() {
3190        assert!(version_ge_1_0(Some("1.0.0")));
3191        assert!(version_ge_1_0(Some("v2.3")));
3192        assert!(version_ge_1_0(Some("2024.1")));
3193        assert!(!version_ge_1_0(Some("0.9.9")));
3194        assert!(!version_ge_1_0(Some("1"))); // no dot
3195        assert!(!version_ge_1_0(None));
3196    }
3197
3198    // ── Maturity truth table ───────────────────────────────────────────────
3199
3200    #[test]
3201    fn production_needs_two_recent_committers_ge_1_0_and_ci() {
3202        let fs = FakeFs::default()
3203            .file(
3204                "/repo/Cargo.toml",
3205                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
3206            )
3207            .file("/repo/.github/workflows/ci.yml", "on: push\n");
3208        let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
3209        assert!(facts.has_ci);
3210        assert!(facts.has_ge_1_0_release);
3211        assert!(facts.maturity_signals.production);
3212        assert_eq!(facts.inferred_maturity, Maturity::Production);
3213    }
3214
3215    #[test]
3216    fn mvp_when_ci_present_but_not_production_grade() {
3217        // Has CI (so not spike) but only one recent committer and no >=1.0.
3218        let fs = FakeFs::default()
3219            .file(
3220                "/repo/Cargo.toml",
3221                "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
3222            )
3223            .file("/repo/.github/workflows/ci.yml", "on: push\n");
3224        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
3225        assert!(!facts.maturity_signals.production);
3226        assert!(!facts.maturity_signals.spike);
3227        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3228    }
3229
3230    /// A `ZeroVer` repo with the full release process: CI + a dependency bot +
3231    /// `n` shipped `>=0.1.0` release tags. `bot` chooses the dependency-bot file.
3232    fn zerover_fs(bot: &str) -> FakeFs {
3233        FakeFs::default()
3234            .file(
3235                "/repo/Cargo.toml",
3236                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3237            )
3238            .file("/repo/.github/workflows/ci.yml", "on: push\n")
3239            .file(&format!("/repo/{bot}"), "version: 2\n")
3240    }
3241
3242    #[test]
3243    fn pre_1_0_with_full_release_infra_is_production() {
3244        // A deliberately-0.x (ZeroVer) repo with a maintained release process: CI,
3245        // a dependency-update bot, ≥2 recent committers, and a release cadence of
3246        // two shipped ≥0.1.0 releases — but NO ≥1.0 release. It reaches
3247        // `production` via the ZeroVer path even though `has_ge_1_0_release` is
3248        // false.
3249        let fs = zerover_fs(".github/dependabot.yml");
3250        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
3251        assert!(facts.has_ci);
3252        assert!(!facts.has_ge_1_0_release);
3253        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
3254        assert!(facts.maturity_signals.production);
3255        assert_eq!(facts.inferred_maturity, Maturity::Production);
3256    }
3257
3258    #[test]
3259    fn renovate_unlocks_the_zerover_release_path() {
3260        // The ZeroVer path is bot-agnostic: a `renovate.json` unlocks it exactly
3261        // as `dependabot.yml` does.
3262        let fs = zerover_fs("renovate.json");
3263        let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
3264        assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
3265        assert!(facts.maturity_signals.production);
3266        assert_eq!(facts.inferred_maturity, Maturity::Production);
3267    }
3268
3269    #[test]
3270    fn bare_0x_with_only_a_tag_is_not_production() {
3271        // The guard: a 0.x repo with ONLY a SemVer tag — no CI, no dependency
3272        // bot — must NOT inflate to `production`. It has a shipped tag and ≥2
3273        // recent committers, but the substantive signals (CI + a dep bot +
3274        // cadence) are absent.
3275        let fs = FakeFs::default().file(
3276            "/repo/Cargo.toml",
3277            "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3278        );
3279        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
3280        assert!(!facts.has_ci);
3281        assert!(!facts.has_ge_1_0_release);
3282        assert_eq!(facts.dependency_bot, None);
3283        assert!(!facts.maturity_signals.production);
3284        // Has a SemVer tag → not spike; the tie resolves to mvp.
3285        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3286    }
3287
3288    #[test]
3289    fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
3290        // The gaming guard: CI + a dep bot + ≥2 recent committers, but the only
3291        // tags are `0.0.x` — SemVer's initial-scratch space. Those are not
3292        // shipped releases, so the ZeroVer path stays closed → mvp. This blocks
3293        // the "empty workflow + empty dependabot.yml + `v0.0.1`" inflation.
3294        let fs = zerover_fs(".github/dependabot.yml");
3295        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
3296        assert!(facts.has_ci);
3297        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
3298        assert!(!facts.maturity_signals.production);
3299        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3300    }
3301
3302    #[test]
3303    fn pre_1_0_release_infra_requires_release_cadence() {
3304        // CI + a dep bot + ≥2 recent committers + a single shipped ≥0.1.0 tag →
3305        // one release is a moment, not a cadence → not production. Two shipped
3306        // releases are required, so a lone `git tag` can't unlock the path.
3307        let fs = zerover_fs(".github/dependabot.yml");
3308        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
3309        assert!(!facts.maturity_signals.production);
3310        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3311    }
3312
3313    #[test]
3314    fn pre_1_0_release_infra_requires_dependency_bot() {
3315        // CI + a release cadence (two shipped tags) + ≥2 recent committers but NO
3316        // dependency bot → the ZeroVer path is incomplete → mvp. The dep bot is
3317        // the sole missing signal here, isolating its requirement.
3318        let fs = FakeFs::default()
3319            .file(
3320                "/repo/Cargo.toml",
3321                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3322            )
3323            .file("/repo/.github/workflows/ci.yml", "on: push\n");
3324        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
3325        assert_eq!(facts.dependency_bot, None);
3326        assert!(!facts.maturity_signals.production);
3327        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3328    }
3329
3330    #[test]
3331    fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
3332        // CI + a dep bot + ≥2 recent committers, but the tags are one shipped
3333        // release plus prereleases (`v0.6.0-rc1`, `v0.7.0-rc1`) → only one
3334        // non-prerelease ≥0.1.0 tag → no cadence → not production. Confirms
3335        // prereleases don't pad the shipped-release count.
3336        let fs = zerover_fs(".github/dependabot.yml");
3337        let facts = gather(
3338            repo(),
3339            &fs,
3340            &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
3341        );
3342        assert!(facts.has_semver_tag);
3343        assert!(!facts.maturity_signals.production);
3344        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3345    }
3346
3347    #[test]
3348    fn pre_1_0_release_infra_requires_two_recent_committers() {
3349        // Full ZeroVer release evidence (CI + dep bot + cadence) but a single
3350        // recent committer → not production (solo maintenance).
3351        let fs = zerover_fs(".github/dependabot.yml");
3352        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
3353        assert!(!facts.maturity_signals.production);
3354        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3355    }
3356
3357    #[test]
3358    fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
3359        // Regression: the ≥1.0 path is unchanged — a ≥1.0 release + CI + ≥2
3360        // recent committers reaches production with NO dependency bot and no
3361        // cadence requirement. The bot asymmetry applies only below 1.0.
3362        let fs = FakeFs::default()
3363            .file(
3364                "/repo/Cargo.toml",
3365                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
3366            )
3367            .file("/repo/.github/workflows/ci.yml", "on: push\n");
3368        let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
3369        assert!(facts.has_ge_1_0_release);
3370        assert_eq!(facts.dependency_bot, None);
3371        assert!(facts.maturity_signals.production);
3372        assert_eq!(facts.inferred_maturity, Maturity::Production);
3373    }
3374
3375    #[test]
3376    fn spike_forced_by_readme_label_even_with_multiple_committers() {
3377        // No CI, no SemVer tag, but 3 committers — the README label flips spike.
3378        let fs = FakeFs::default()
3379            .file(
3380                "/repo/Cargo.toml",
3381                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
3382            )
3383            .file(
3384                "/repo/README.md",
3385                "# X\n\nThis is an experimental prototype.\n",
3386            );
3387        let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
3388        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
3389        assert!(facts.maturity_signals.spike);
3390        assert_eq!(facts.inferred_maturity, Maturity::Spike);
3391    }
3392
3393    #[test]
3394    fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
3395        // No CI, no tag, >1 committer, no label → spike's committer clause fails
3396        // → mvp (the tie-breaker).
3397        let fs = FakeFs::default().file(
3398            "/repo/Cargo.toml",
3399            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
3400        );
3401        let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
3402        assert!(!facts.maturity_signals.spike);
3403        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3404    }
3405
3406    // ── Determinism ────────────────────────────────────────────────────────
3407
3408    #[test]
3409    fn same_repo_same_facts() {
3410        let build = || {
3411            FakeFs::default()
3412                .file(
3413                    "/repo/Cargo.toml",
3414                    "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
3415                )
3416                .file("/repo/.github/workflows/ci.yml", "on: push\n")
3417        };
3418        let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
3419        let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
3420        assert_eq!(
3421            serde_json::to_string(&a).unwrap(),
3422            serde_json::to_string(&b).unwrap()
3423        );
3424    }
3425}