Skip to main content

ossctl_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 `/oss-init` and the readiness `audit` reading the same `ossctl
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};
40use crate::protocol::facts::{Facts, MaturitySignals, Package, RustWorkspace, WorkspaceMember};
41
42/// Root-level manifests, in the canonical probe order. The ecosystem order here
43/// also fixes the order `ecosystems` and `packages` are emitted in.
44const MANIFESTS: &[(&str, Ecosystem)] = &[
45    ("Cargo.toml", Ecosystem::Rust),
46    ("package.json", Ecosystem::Node),
47    ("pyproject.toml", Ecosystem::Python),
48    ("setup.py", Ecosystem::Python),
49    ("go.mod", Ecosystem::Go),
50];
51
52/// README tokens that self-label a project as pre-release (a spike signal).
53/// Matched case-insensitively as whole substrings; kept small + explicit for
54/// reproducibility (mirrors the Python `SPIKE_LABELS`).
55const SPIKE_LABELS: &[&str] = &[
56    "work in progress",
57    "work-in-progress",
58    "wip",
59    "experimental",
60    "prototype",
61    "pre-alpha",
62    "proof of concept",
63    "proof-of-concept",
64    "status: private, early",
65    "not production ready",
66    "not production-ready",
67    "early prototype",
68    "spike",
69];
70
71/// CI configuration paths. `.github/workflows` is a directory that counts only
72/// when non-empty; the rest are single-file configs that count on existence.
73const CI_GLOBS: &[&str] = &[
74    ".github/workflows",
75    ".gitlab-ci.yml",
76    ".circleci",
77    "azure-pipelines.yml",
78    ".drone.yml",
79    "Jenkinsfile",
80];
81
82/// Character cap for a manifest read (matches the Python `_read` default, which
83/// reads decoded characters, not bytes).
84const MANIFEST_LIMIT: usize = 200_000;
85/// Character cap for a README read (matches the Python text-mode README read).
86const README_LIMIT: usize = 4_000;
87/// Character cap for the emitted `description`.
88const DESCRIPTION_CHARS: usize = 120;
89
90/// Detect the deterministic repo facts under `repo_root`.
91///
92/// `repo_root` is used verbatim for the emitted `repo_root` field and as the
93/// join base for filesystem probes; the caller canonicalizes it (mirroring the
94/// Python `os.path.realpath`) before passing it in. Never mutates anything.
95#[must_use]
96pub fn gather(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> Facts {
97    let is_git = git.is_work_tree();
98    // `has_commits` needs both a work tree and a resolvable HEAD (an unborn repo
99    // is a work tree with no HEAD).
100    let has_commits = is_git && git.head_commit().is_ok();
101
102    let (ecosystems, packages) = detect_manifests(repo_root, fs);
103    // The Rust workspace's publishable member graph — off-wire plumbing the release
104    // planner derives a dependency-ordered publish set from (`None` for a repo with
105    // no multi-crate Cargo workspace). Derived from the same manifests as `packages`
106    // but carrying the publish flags + intra-workspace dependency edges `packages`
107    // omits, so a downstream repo that declares only its bin crate still gets its lib
108    // crate planned, lib-before-bin (`release-rust-workspace-multicrate`).
109    let rust_workspace = detect_rust_workspace(repo_root, fs);
110
111    // ── committers (mailmap-aware, whole `--all` history) ──
112    let (committers_total, committers_recent_year) = if has_commits {
113        let total = git.shortlog(None).map_or(0, count_lines);
114        let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
115        (total, recent)
116    } else {
117        (0, 0)
118    };
119
120    // ── tags / releases ──
121    let tags = if has_commits {
122        git.tags().unwrap_or_default()
123    } else {
124        Vec::new()
125    };
126    let semver_tags: Vec<(u64, u64, u64, bool)> =
127        tags.iter().filter_map(|t| semver_parse(t)).collect();
128    let has_semver_tag = !semver_tags.is_empty();
129    // Count *shipped* releases: non-prerelease SemVer tags at `>=0.1.0`. A `0.0.x`
130    // tag is SemVer's initial-scratch space ("anything MAY change at any time"),
131    // and a `-rc`/`-alpha` prerelease is not shipped — neither is counted. A
132    // consumer can recompute this from the emitted `tags` with the same parse.
133    let shipped_release_tags = semver_tags
134        .iter()
135        .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
136        .count();
137    let ge_1_0_tag = semver_tags
138        .iter()
139        .any(|&(major, _, _, pre)| major >= 1 && !pre);
140    let manifest_ge_1_0 = packages
141        .iter()
142        .any(|p| version_ge_1_0(p.version.as_deref()));
143    let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
144
145    // ── CI presence ──
146    let has_ci = CI_GLOBS.iter().any(|glob| {
147        let path = repo_root.join(glob);
148        if !fs.exists(&path) {
149            return false;
150        }
151        if glob.ends_with("workflows") {
152            // A workflows *directory* counts only when it holds an entry.
153            fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
154        } else {
155            true
156        }
157    });
158
159    // ── dependency bot ──
160    let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
161        Some("dependabot".to_string())
162    } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
163        .iter()
164        .any(|f| fs.is_file(&repo_root.join(f)))
165    {
166        Some("renovate".to_string())
167    } else {
168        None
169    };
170
171    let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
172
173    // ── README self-label + description ──
174    let readme_text = ["README.md", "README.rst", "README.txt", "README"]
175        .iter()
176        .find_map(|name| {
177            let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
178            (!text.is_empty()).then_some(text)
179        });
180    let readme_self_label = readme_text.as_deref().and_then(|text| {
181        let low = text.to_lowercase();
182        SPIKE_LABELS
183            .iter()
184            .any(|label| low.contains(label))
185            .then(|| "spike".to_string())
186    });
187    let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
188
189    // ── maturity inference (SCHEMA.md §4, production first, tie → mvp) ──
190    //
191    // A deliberately-pre-1.0 (ZeroVer) project can still be production-grade: the
192    // version number is not release maturity. The `>=1.0` gate is really a
193    // stability *declaration*; below 1.0 that declaration is absent, so the
194    // `zerover_release_evidence` path requires compensating evidence of a
195    // maintained release process — a dependency-update bot configured **and** a
196    // release *cadence* (>=2 shipped `>=0.1.0` releases). A single tag is a
197    // moment; two prove the project has actually iterated a release more than
198    // once, which a lone `git tag` cannot fake. Combined with the always-required
199    // CI and >=2 recent committers, this is materially harder to inflate than the
200    // old "only a tag" concern.
201    //
202    // The asymmetry (a `>=1.0` project reaches `production` without a bot, a 0.x
203    // one does not) is intentional: `>=1.0` already carries the stability signal
204    // this path has to reconstruct. These remain presence/name heuristics over a
205    // cooperative repo (CI/bot detected by path, tags by name) — not adversarial
206    // proofs — and `/oss-init` surfaces every signal to a human before it lands
207    // in the contract. Each input is already in the report (`has_ci`,
208    // `dependency_bot`, `tags` — from which `shipped_release_tags` recomputes via
209    // the same parse — `committers_recent_year`, `has_ge_1_0_release`), so the
210    // decision is re-derivable without a new wire field.
211    let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
212    let release_gate = has_ge_1_0_release || zerover_release_evidence;
213    let production = committers_recent_year >= 2 && has_ci && release_gate;
214    let spike =
215        !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
216    let inferred_maturity = if production {
217        Maturity::Production
218    } else if spike {
219        Maturity::Spike
220    } else {
221        Maturity::Mvp
222    };
223
224    Facts {
225        repo_root: repo_root.display().to_string(),
226        is_git,
227        has_commits,
228        ecosystems,
229        packages,
230        committers_total,
231        committers_recent_year,
232        tags,
233        has_semver_tag,
234        has_ge_1_0_release,
235        has_ci,
236        dependency_bot,
237        has_issues_dir,
238        readme_self_label,
239        description,
240        maturity_signals: MaturitySignals { production, spike },
241        inferred_maturity,
242        rust_workspace,
243    }
244}
245
246/// Sniff root-level manifests into the ordered `ecosystems` + `packages` lists.
247fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
248    let mut ecosystems: Vec<Ecosystem> = Vec::new();
249    let mut packages: Vec<Package> = Vec::new();
250    for &(fname, eco) in MANIFESTS {
251        let path = repo_root.join(fname);
252        if !fs.is_file(&path) {
253            continue;
254        }
255        let text = read_text(fs, &path, MANIFEST_LIMIT);
256        let parsed = text
257            .as_deref()
258            .and_then(|t| parse_manifest(fname, t))
259            .unwrap_or_default();
260        // A Cargo virtual workspace (no `[package]`) still marks the repo rust.
261        if !ecosystems.contains(&eco) {
262            ecosystems.push(eco);
263        }
264        // A Cargo *virtual workspace* (no `[package]`) declares its real crates in
265        // `[workspace].members`: descend into each member manifest and emit one
266        // entry per member with its resolved name + version, rather than a single
267        // null-named root entry.
268        if fname == "Cargo.toml" && parsed.package.is_none() {
269            if let Some(text) = text.as_deref() {
270                if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
271                    continue;
272                }
273            }
274            // Not a members-bearing virtual workspace (or no member manifest
275            // resolved): fall through to the null root entry — the repo is still
276            // rust, and the null-named entry preserves today's signal.
277        }
278        // `Cargo.toml`/`go.mod` always yield a package entry (even without a
279        // declared name); the others only when they name a package.
280        if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
281            packages.push(Package {
282                ecosystem: eco,
283                manifest: fname.to_string(),
284                package: parsed.package,
285                version: parsed.version,
286            });
287        }
288    }
289    // `binary` only when NO package ecosystem is detected — never additive.
290    if ecosystems.is_empty() {
291        ecosystems.push(Ecosystem::Binary);
292    }
293    (ecosystems, packages)
294}
295
296/// Enumerate a Cargo virtual workspace's members into `packages`, one entry per
297/// member manifest that resolves through `fs`. Explicit members are read in
298/// declaration order; a trailing single-level glob (`crates/*`) is expanded by
299/// listing its directory through the `Fs` port and sorting the entries, so the
300/// emitted order is deterministic regardless of the underlying read-dir order.
301/// `[workspace].exclude` entries are dropped, and duplicate member paths (e.g.
302/// an explicit member also matched by a glob) are emitted once.
303///
304/// Each member reports its own `[package].name`; the version is its literal
305/// `[package].version`, or — when the member declares `version.workspace = true`
306/// (dotted) or `version = { workspace = true }` (inline) — the version inherited
307/// from the root `[workspace.package]` table.
308///
309/// Returns `true` when at least one member manifest was emitted (the caller then
310/// skips the null root entry); `false` when the root has no `members` array or no
311/// listed member manifest resolved (the caller keeps today's null-entry behavior).
312fn push_workspace_members(
313    repo_root: &Path,
314    fs: &dyn Fs,
315    root_text: &str,
316    eco: Ecosystem,
317    packages: &mut Vec<Package>,
318) -> bool {
319    let ws_pkg = toml_section(root_text, "workspace.package");
320    let dirs = workspace_member_dirs(repo_root, fs, root_text);
321    let before = packages.len();
322    for rel in &dirs {
323        let manifest_path = repo_root.join(rel).join("Cargo.toml");
324        let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
325            continue;
326        };
327        let (package, version) = resolve_member_name_version(&member_text, ws_pkg.as_deref());
328        packages.push(Package {
329            ecosystem: eco,
330            manifest: format!("{rel}/Cargo.toml"),
331            package,
332            version,
333        });
334    }
335    packages.len() > before
336}
337
338/// Resolve a Cargo virtual-workspace root's `[workspace].members` to the ordered,
339/// de-duplicated, **existing** member directories (manifest-relative, no trailing
340/// slash) — the single member-enumeration used by both the `packages` emission and
341/// the release-planner workspace graph, so the two never drift.
342///
343/// Applies the rules the fact detector has always used: escape-proof (an absolute
344/// or `..`-bearing member is rejected — with a real `Fs` those would read manifests
345/// outside `repo_root` and taint the facts), trailing single-level glob (`crates/*`,
346/// bare `*`) expansion via the `Fs` port with sorted entries (deterministic
347/// regardless of read-dir order), `[workspace].exclude` removal, first-seen dedup,
348/// and dropping any member whose `Cargo.toml` does not resolve. Empty when the root
349/// declares no `[workspace].members` array (not a members-bearing virtual workspace)
350/// or none resolve.
351fn workspace_member_dirs(repo_root: &Path, fs: &dyn Fs, root_text: &str) -> Vec<String> {
352    let Some(ws_block) = toml_section(root_text, "workspace") else {
353        return Vec::new();
354    };
355    let members = match toml_str_array(&ws_block, "members") {
356        Some(members) if !members.is_empty() => members,
357        _ => return Vec::new(),
358    };
359    let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
360        .unwrap_or_default()
361        .iter()
362        .map(|e| e.trim_end_matches('/').to_string())
363        .collect();
364    // Manifest-relative dirs already collected — dedup preserving first-seen order.
365    let mut out: Vec<String> = Vec::new();
366    for member in members {
367        let member = member.trim_end_matches('/');
368        if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
369            continue;
370        }
371        if let Some(prefix) = glob_parent(member) {
372            // Trailing single-level glob (`crates/*`, bare `*`): expand one level.
373            let dir = if prefix.is_empty() {
374                repo_root.to_path_buf()
375            } else {
376                repo_root.join(prefix)
377            };
378            let mut names = fs.read_dir(&dir).unwrap_or_default();
379            names.sort();
380            for name in names {
381                let rel = if prefix.is_empty() {
382                    name
383                } else {
384                    format!("{prefix}/{name}")
385                };
386                push_member_dir(repo_root, fs, &rel, &exclude, &mut out);
387            }
388            continue;
389        }
390        // A glob shape we do not expand (`?`, character classes, a non-trailing
391        // `*`): skip rather than probe a literal metacharacter path.
392        if member.contains(['*', '?']) {
393            continue;
394        }
395        push_member_dir(repo_root, fs, member, &exclude, &mut out);
396    }
397    out
398}
399
400/// Add member dir `rel` to `out` unless it is `exclude`d, already collected, or its
401/// `Cargo.toml` does not resolve through `fs`.
402fn push_member_dir(
403    repo_root: &Path,
404    fs: &dyn Fs,
405    rel: &str,
406    exclude: &[String],
407    out: &mut Vec<String>,
408) {
409    let rel = rel.trim_end_matches('/');
410    if exclude.iter().any(|e| e == rel) || out.iter().any(|s| s == rel) {
411        return;
412    }
413    if !fs.is_file(&repo_root.join(rel).join("Cargo.toml")) {
414        return;
415    }
416    out.push(rel.to_string());
417}
418
419/// Detect the Rust workspace's crates.io-**publishable** member graph — the
420/// off-wire plumbing [`Facts::rust_workspace`] carries for the release planner.
421///
422/// `None` unless the repo root is a members-bearing Cargo *virtual workspace* (the
423/// shape that expresses a lib+bin split): reads each member manifest for its
424/// `[package].name`, version (honoring `version.workspace = true` inheritance),
425/// `publish` allow-list, and intra-workspace dependency edges, then keeps only the
426/// crates.io-publishable members (matching the cargo adapter's cut-time `cargo
427/// metadata` filter — a `publish = false` member, or one restricted to a
428/// non-crates.io registry, is dropped) with their edges restricted to other
429/// publishable members. Members are returned in workspace declaration order; the
430/// planner applies the topological publish ordering. `None` when no publishable
431/// named member resolves (nothing for the planner to expand).
432///
433/// **Edges gate publish ORDER, and a missed edge fails a cut CLOSED — it is not a
434/// free "hint".** The coordinator walks the plan's target order; the cargo adapter
435/// re-derives the graph from `cargo metadata` and index-waits on each crate's real
436/// deps, but it does **not** re-order the plan. So a missed ordering edge that puts a
437/// dependent before its dependency makes the dependent's `cargo publish` fail on the
438/// not-yet-indexed sibling — a safe, no-mis-publish failure, but a *failed release for
439/// a valid workspace*. The edge parse is therefore precise where it counts:
440/// [`member_dependency_edges`] treats only `path`/`workspace` dependencies as edges
441/// (a registry dep sharing a member's name is not an edge, so no false constraint /
442/// false cycle), and reads the plain, target-specific, sub-table, and dotted-key
443/// dependency forms plus inline `package = "…"` renames. Its two documented blind
444/// spots (a rename inherited through the root `[workspace.dependencies]`, and a
445/// multi-line inline table) each fail closed the same way.
446///
447/// One parsed workspace member before the publishability filter — the intermediate
448/// [`detect_rust_workspace`] reduces to the published [`WorkspaceMember`] set.
449struct RawMember {
450    package: String,
451    version: Option<String>,
452    publishable: bool,
453    /// Intra-workspace dependency edges: crate name + the literal version
454    /// requirement the manifest declares for it (or `None` for a path-only edge).
455    deps: Vec<(String, Option<String>)>,
456}
457
458fn detect_rust_workspace(repo_root: &Path, fs: &dyn Fs) -> Option<RustWorkspace> {
459    let root_path = repo_root.join("Cargo.toml");
460    if !fs.is_file(&root_path) {
461        return None;
462    }
463    let root_text = read_text(fs, &root_path, MANIFEST_LIMIT)?;
464    let dirs = workspace_member_dirs(repo_root, fs, &root_text);
465    if dirs.is_empty() {
466        return None;
467    }
468    let ws_pkg = toml_section(&root_text, "workspace.package");
469
470    // First pass: parse every named member (name, version, publishability, edges).
471    let mut raw: Vec<RawMember> = Vec::new();
472    for rel in &dirs {
473        let Some(text) = read_text(fs, &repo_root.join(rel).join("Cargo.toml"), MANIFEST_LIMIT)
474        else {
475            continue;
476        };
477        let (package, version) = resolve_member_name_version(&text, ws_pkg.as_deref());
478        // A member with no `[package].name` cannot be a publish target — skip it (a
479        // nested virtual workspace, or a malformed manifest).
480        let Some(package) = package else { continue };
481        raw.push(RawMember {
482            package,
483            version,
484            publishable: member_publishable_to_crates_io(&text),
485            deps: member_dependency_edges(&text),
486        });
487    }
488
489    // Keep only crates.io-publishable members; restrict each member's edges to the
490    // OTHER publishable members (a dep on a non-publishable member does not gate the
491    // publish order, and a self-edge is meaningless).
492    let publishable_names: std::collections::BTreeSet<&str> = raw
493        .iter()
494        .filter(|m| m.publishable)
495        .map(|m| m.package.as_str())
496        .collect();
497    let members: Vec<WorkspaceMember> = raw
498        .iter()
499        .filter(|m| m.publishable)
500        .map(|m| {
501            // Restrict to edges to OTHER publishable members; carry each edge's literal
502            // requirement string (when the manifest declared one) so the pin-rewrite
503            // derivation can key precisely on the `=<ver>` lockstep convention.
504            let mut dep_reqs: std::collections::BTreeMap<String, String> =
505                std::collections::BTreeMap::new();
506            for (name, req) in &m.deps {
507                if name.as_str() == m.package || !publishable_names.contains(name.as_str()) {
508                    continue;
509                }
510                if let Some(req) = req {
511                    // First declaration wins (a crate appearing in both `[dependencies]`
512                    // and `[build-dependencies]` shares one requirement in practice).
513                    dep_reqs.entry(name.clone()).or_insert_with(|| req.clone());
514                }
515            }
516            let mut workspace_deps: Vec<String> = m
517                .deps
518                .iter()
519                .map(|(name, _req)| name.clone())
520                .filter(|d| d.as_str() != m.package && publishable_names.contains(d.as_str()))
521                .collect();
522            workspace_deps.sort();
523            workspace_deps.dedup();
524            WorkspaceMember {
525                package: m.package.clone(),
526                version: m.version.clone(),
527                workspace_deps,
528                dep_reqs,
529            }
530        })
531        .collect();
532    if members.is_empty() {
533        return None;
534    }
535    Some(RustWorkspace { members })
536}
537
538/// Whether a Cargo member manifest permits publishing to crates.io — the same
539/// predicate the cargo adapter applies at cut time (`publishable_to_crates_io`), but
540/// read from raw manifest text rather than `cargo metadata`.
541///
542/// `publish` absent ⇒ any registry (yes). `publish = false` ⇒ no. `publish = true`
543/// ⇒ yes. `publish = ["crates-io", …]` ⇒ only if the list names `crates-io`
544/// (`publish = []` therefore reads as no, matching `cargo metadata`'s `Some([])`).
545fn member_publishable_to_crates_io(text: &str) -> bool {
546    let Some(block) = toml_section(text, "package") else {
547        // No `[package]` at all — not a publishable crate. (`detect_rust_workspace`
548        // already drops an unnamed member before this runs; failing closed here is a
549        // defensive guard so a future name-fabrication path can never leak a
550        // package-less manifest into the crates.io publish set.)
551        return false;
552    };
553    // Array form first: `publish = ["crates-io"]`.
554    if let Some(regs) = toml_str_array(&block, "publish") {
555        return regs.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS);
556    }
557    // Bool form: `publish = false` / `publish = true`; absent ⇒ publishable.
558    !matches!(toml_bool_value(&block, "publish"), Some(false))
559}
560
561/// Cargo's registry alias for crates.io — the token a member manifest's `publish`
562/// allow-list must contain to be crates.io-publishable (mirrors the cargo adapter's
563/// `CRATES_IO_ALIAS`).
564const CRATES_IO_REGISTRY_ALIAS: &str = "crates-io";
565
566/// Read a boolean TOML value (`key = true` / `key = false`) from a section body,
567/// stripping a trailing `#` comment. `None` when the key is absent or its value is
568/// not a bare bool literal (an array or table form is the caller's concern).
569fn toml_bool_value(block: &str, key: &str) -> Option<bool> {
570    for line in block.lines() {
571        let rest = line.trim_start();
572        let Some(rest) = rest.strip_prefix(key) else {
573            continue;
574        };
575        // Whole-token match (else `publisher` would match `publish`).
576        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
577            continue;
578        }
579        let Some(rest) = rest.trim_start().strip_prefix('=') else {
580            continue;
581        };
582        match strip_toml_comment(rest).trim() {
583            "true" => return Some(true),
584            "false" => return Some(false),
585            _ => return None,
586        }
587    }
588    None
589}
590
591/// One table the dependency-edge scanner is inside while walking a member manifest.
592enum DepTable {
593    /// A `[dependencies]` / `[build-dependencies]` body — including a target-specific
594    /// `[target.<cfg>.dependencies]` / `[target.<cfg>.build-dependencies]`, which
595    /// gate publish order exactly like the unconditional tables (crates.io validates
596    /// them at `cargo publish`).
597    Table,
598    /// A `[dependencies.<name>]` sub-table body (plain or target-specific),
599    /// accumulating whether it is a **local** dep (a `path`/`workspace` key) and its
600    /// `package = "…"` rename, so the edge is emitted with the real crate name on flush.
601    Sub {
602        /// The sub-table key (the crate name, unless overridden by `package`).
603        key: String,
604        /// The `package = "…"` rename value, if the body declares one.
605        package: Option<String>,
606        /// Whether the body marks this an intra-workspace dep (`path`/`workspace`).
607        local: bool,
608        /// The literal `version = "…"` requirement the body declares, if any — carried
609        /// so the pin-rewrite derivation can key on the exact `=<ver>` lockstep string.
610        version: Option<String>,
611    },
612    /// A non-order-gating table (`[dev-dependencies]`, `[features]`, `[package]`, …).
613    Other,
614}
615
616/// The intra-workspace dependency edges a Cargo member manifest declares, each paired
617/// with the **literal version requirement** the manifest states for it (or `None` for a
618/// path-only / `workspace = true` edge whose requirement is not in this manifest). The
619/// raw edge set [`detect_rust_workspace`] intersects with the publishable member names.
620///
621/// **Only `path` / `workspace` dependencies are edges.** A registry dependency
622/// (`serde = "1"`, or an inline table with only `version`) is NOT an intra-workspace
623/// edge even when a workspace member happens to share its name — Cargo resolves
624/// `serde = "1"` to crates.io, never to the local member, so treating a name
625/// collision as an edge would invent a false ordering constraint (and a false cycle).
626/// This mirrors Cargo's own model: a member edge exists iff the dependency resolves
627/// to a `path`/`workspace` source.
628///
629/// The paired requirement is the precise-pin-rewrite source
630/// (`release-rust-workspace-multicrate` facet 3): the planner keeps only the edge whose
631/// requirement literally equals `=<from_version>`, so a caret/range/`workspace = true`
632/// edge is never clobbered.
633///
634/// Line-oriented (no TOML dependency — matching this module's parsing style). It reads:
635/// `[dependencies]` / `[build-dependencies]` and their target-specific
636/// (`[target.<cfg>.dependencies]`) and sub-table (`[dependencies.<name>]`) forms;
637/// inline tables (`dep = { path = "…", package = "real", version = "=X" }`), dotted keys
638/// (`dep.path = "…"`, `dep.workspace = true`), and the `package = "…"` rename in each.
639/// Dev-dependencies are excluded (they never gate publish order and can legitimately
640/// cycle).
641///
642/// **Known blind spots (each fails a cut CLOSED, never mis-publishes — see
643/// `detect_rust_workspace`):** a dependency inheriting a *rename* through the root
644/// `[workspace.dependencies]` (`alias.workspace = true` where the root maps `alias`
645/// to a differently-named crate) resolves to `alias`, missing the edge; a *multi-line*
646/// inline table is read only by its first physical line; a dotted `dep.version = "…"`
647/// requirement on a line separate from `dep.path` is not captured (so that edge's pin is
648/// left un-rewritten — fail closed).
649fn member_dependency_edges(text: &str) -> Vec<(String, Option<String>)> {
650    let mut state = DepTable::Other;
651    let mut out: Vec<(String, Option<String>)> = Vec::new();
652
653    for line in text.lines() {
654        let t = strip_toml_comment(line).trim();
655        if let Some(header) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
656            // Leaving a table: flush a completed sub-table's edge before switching.
657            flush_dep_subtable(&mut state, &mut out);
658            state = classify_dep_table(header.trim());
659            continue;
660        }
661        match &mut state {
662            DepTable::Table => {
663                if let Some(edge) = dep_edge_from_line(t) {
664                    out.push(edge);
665                }
666            }
667            DepTable::Sub {
668                package,
669                local,
670                version,
671                ..
672            } => {
673                // Accumulate the sub-table body: a `package` rename, the
674                // `path`/`workspace` markers that make it a local edge, and the
675                // `version` requirement.
676                if let Some((k, v)) = split_key_value(t) {
677                    match k {
678                        "package" if package.is_none() => *package = extract_quoted(v),
679                        "path" => *local = true,
680                        "workspace" if v.trim_start().starts_with("true") => *local = true,
681                        "version" if version.is_none() => *version = extract_quoted(v),
682                        _ => {}
683                    }
684                }
685            }
686            DepTable::Other => {}
687        }
688    }
689    flush_dep_subtable(&mut state, &mut out);
690    out
691}
692
693/// Classify a table header into the dependency-edge scanner's [`DepTable`] state.
694///
695/// Recognizes the sub-table forms (`[dependencies.<name>]`,
696/// `[target.<cfg>.dependencies.<name>]`) and the plain/target-specific table forms
697/// (`[dependencies]`, `[target.<cfg>.build-dependencies]`), while excluding every
698/// `dev-dependencies` variant. The `<cfg>` in a target header may contain dots and
699/// quotes; classification keys on the unquoted `.dependencies` / `.build-dependencies`
700/// suffix (and the `.dependencies.` / `.build-dependencies.` infix for sub-tables),
701/// which is robust regardless of the cfg contents.
702fn classify_dep_table(header: &str) -> DepTable {
703    if let Some(name) = dep_subtable_name(header) {
704        return DepTable::Sub {
705            key: name.trim().trim_matches(['"', '\'']).to_string(),
706            package: None,
707            local: false,
708            version: None,
709        };
710    }
711    // dev-dependencies (plain or target-specific) never gate publish order.
712    if !header.contains("dev-dependencies")
713        && (header == "dependencies"
714            || header == "build-dependencies"
715            || header.ends_with(".dependencies")
716            || header.ends_with(".build-dependencies"))
717    {
718        return DepTable::Table;
719    }
720    DepTable::Other
721}
722
723/// The `<name>` of a dependency sub-table header (`[dependencies.<name>]`,
724/// `[build-dependencies.<name>]`, or their target-specific
725/// `[target.<cfg>.dependencies.<name>]` forms), or `None` when the header is not a
726/// dependency sub-table. Every `dev-dependencies` form yields `None`.
727fn dep_subtable_name(header: &str) -> Option<&str> {
728    if header.contains("dev-dependencies") {
729        return None;
730    }
731    if let Some(name) = header
732        .strip_prefix("dependencies.")
733        .or_else(|| header.strip_prefix("build-dependencies."))
734    {
735        return Some(name);
736    }
737    // Target-specific: split on the LAST `.dependencies.` / `.build-dependencies.`
738    // (the cfg expression precedes it and may itself contain dots).
739    for infix in [".dependencies.", ".build-dependencies."] {
740        if let Some(idx) = header.rfind(infix) {
741            return Some(&header[idx + infix.len()..]);
742        }
743    }
744    None
745}
746
747/// The intra-workspace edge crate name a `key = value` line under a `[dependencies]`
748/// table declares, or `None` when the line is not a local (`path`/`workspace`) dep.
749///
750/// A registry dep (`foo = "1"`, or `foo = { version = "1" }` with no `path`) yields
751/// `None` — it is not a workspace edge. A local dep yields its crate name: an inline
752/// `package = "…"` rename when present, else the bare key. Dotted forms
753/// (`foo.path = "…"`, `foo.workspace = true`) are recognized; a lone `foo.version` /
754/// `foo.package` is not a local marker.
755fn dep_edge_from_line(line: &str) -> Option<(String, Option<String>)> {
756    let (key_full, val) = split_key_value(line)?;
757    let mut segs = key_full.split('.');
758    let crate_key = segs.next().unwrap_or("").trim().trim_matches(['"', '\'']);
759    if crate_key.is_empty() {
760        return None;
761    }
762    match segs.next().map(str::trim) {
763        // Dotted local markers: `foo.path = "…"` / `foo.workspace = true`. A dotted
764        // `foo.version = "…"` on a separate physical line is not carried here (each
765        // line is read independently) — a documented blind spot that fails a cut
766        // closed (a missing pin rewrite leaves a stale `=<from>` pin the publish
767        // rejects), never mis-rewrites.
768        Some("path") => Some((crate_key.to_string(), None)),
769        Some("workspace") if val.trim_start().starts_with("true") => {
770            Some((crate_key.to_string(), None))
771        }
772        // `foo.version`, `foo.package`, `foo.features`, `foo.optional`, … alone do not
773        // mark a local dep.
774        Some(_) => None,
775        // Simple key: only an inline table with a `path`/`workspace = true` is local.
776        None => {
777            if !val.starts_with('{') || !inline_table_is_local(val) {
778                return None;
779            }
780            let name = inline_table_package(val).unwrap_or_else(|| crate_key.to_string());
781            Some((name, inline_table_version(val)))
782        }
783    }
784}
785
786/// Emit a completed dependency sub-table's edge into `out` when the body marked it a
787/// local dep — its `package` rename if any, else the sub-table key. A registry
788/// sub-table (no `path`/`workspace`) emits nothing. Resets `state` to [`DepTable::Other`].
789fn flush_dep_subtable(state: &mut DepTable, out: &mut Vec<(String, Option<String>)>) {
790    if let DepTable::Sub {
791        key,
792        package,
793        local: true,
794        version,
795    } = state
796    {
797        out.push((
798            package.clone().unwrap_or_else(|| key.clone()),
799            version.clone(),
800        ));
801    }
802    *state = DepTable::Other;
803}
804
805/// Split a TOML `key = value` line into its trimmed, unquoted key and its trimmed
806/// value text, or `None` when the line has no `=` or an empty key (a blank or
807/// continuation line).
808fn split_key_value(line: &str) -> Option<(&str, &str)> {
809    let eq = line.find('=')?;
810    let key = line[..eq].trim().trim_matches(['"', '\'']);
811    if key.is_empty() {
812        return None;
813    }
814    Some((key, line[eq + 1..].trim()))
815}
816
817/// Whether an inline dependency table (`{ … }`) resolves to an intra-workspace member
818/// — it declares a whole-token `path` key or `workspace = true`. A table with only a
819/// `version` (a plain crates.io dep) is not local.
820fn inline_table_is_local(inline: &str) -> bool {
821    contains_toml_key(inline, "path") || toml_key_is_true(inline, "workspace")
822}
823
824/// Whether `s` contains `key` as a whole TOML key immediately followed (past spaces)
825/// by `=` — so `path = "…"` matches but a `path` substring inside another value, or a
826/// longer key like `no-default-features`, does not.
827fn contains_toml_key(s: &str, key: &str) -> bool {
828    scan_toml_key(s, key, |_after| true)
829}
830
831/// Whether `s` assigns `true` to a whole TOML key `key` (`workspace = true`).
832fn toml_key_is_true(s: &str, key: &str) -> bool {
833    scan_toml_key(s, key, |after| after.trim_start().starts_with("true"))
834}
835
836/// Scan `s` for a whole-token TOML `key` immediately followed (past spaces) by `=`,
837/// and test the post-`=` remainder with `accept`. "Whole token" = the char before
838/// `key` is not an identifier char (alphanumeric / `_` / `-`), so a substring inside a
839/// value or a longer key never matches. Returns `true` on the first accepted match.
840fn scan_toml_key(s: &str, key: &str, accept: impl Fn(&str) -> bool) -> bool {
841    let mut rest = s;
842    while let Some(pos) = rest.find(key) {
843        let prev_is_ident = rest[..pos]
844            .chars()
845            .next_back()
846            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
847        let after = rest[pos + key.len()..].trim_start();
848        if !prev_is_ident {
849            if let Some(value) = after.strip_prefix('=') {
850                if accept(value) {
851                    return true;
852                }
853            }
854        }
855        rest = &rest[pos + key.len()..];
856    }
857    false
858}
859
860/// The `package = "…"` value inside an inline dependency table (`{ package = "bar",
861/// version = "1" }`), or `None` when the table declares no rename. A best-effort
862/// single-line scan (inline tables are single-line in practice).
863///
864/// Matches `package` as a **whole key** (the preceding char is not an identifier
865/// char and the next non-space char is `=`), so a substring inside another value —
866/// e.g. a `path = "../my-package-dir"` — is never misread as a rename key.
867fn inline_table_package(inline: &str) -> Option<String> {
868    let mut rest = inline;
869    while let Some(pos) = rest.find("package") {
870        let prev_is_ident = rest[..pos]
871            .chars()
872            .next_back()
873            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
874        let after = rest[pos + "package".len()..].trim_start();
875        if !prev_is_ident {
876            if let Some(value) = after.strip_prefix('=') {
877                return extract_quoted(value.trim_start());
878            }
879        }
880        // Not the rename key (a substring, or `package` not followed by `=`): keep
881        // scanning past this occurrence.
882        rest = &rest[pos + "package".len()..];
883    }
884    None
885}
886
887/// The `version = "…"` value inside an inline dependency table (`{ path = "…",
888/// version = "=0.4.0" }`), or `None` when the table declares no explicit version. A
889/// best-effort single-line scan matching `version` as a **whole key** — the same
890/// whole-token discipline as [`inline_table_package`], so a `version` substring inside
891/// another value is never misread.
892fn inline_table_version(inline: &str) -> Option<String> {
893    let mut rest = inline;
894    while let Some(pos) = rest.find("version") {
895        let prev_is_ident = rest[..pos]
896            .chars()
897            .next_back()
898            .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
899        let after = rest[pos + "version".len()..].trim_start();
900        if !prev_is_ident {
901            if let Some(value) = after.strip_prefix('=') {
902                return extract_quoted(value.trim_start());
903            }
904        }
905        rest = &rest[pos + "version".len()..];
906    }
907    None
908}
909
910/// The literal parent directory of a trailing single-level glob member — `crates/*`
911/// → `Some("crates")`, bare `*` → `Some("")` — or `None` when `member` is not such
912/// a glob. A prefix that itself contains a glob metacharacter is not expandable.
913fn glob_parent(member: &str) -> Option<&str> {
914    if member == "*" {
915        return Some("");
916    }
917    member
918        .strip_suffix("/*")
919        .filter(|prefix| !prefix.contains(['*', '?']))
920}
921
922/// Resolve a workspace member's `(name, version)` from its manifest text, honoring
923/// `version.workspace = true` inheritance from the root `[workspace.package]`
924/// block. Crate names are never workspace-inherited, so `name` is taken verbatim.
925fn resolve_member_name_version(
926    member_text: &str,
927    ws_pkg: Option<&str>,
928) -> (Option<String>, Option<String>) {
929    let parsed = parse_cargo(member_text).unwrap_or_default();
930    let version = parsed.version.or_else(|| {
931        // Scope the inheritance probe to the member's own `[package]` block: a
932        // `version.workspace = true` in an unrelated table (`[package.metadata.*]`,
933        // a tool config) must not be read as `[package].version` inheritance.
934        let inherits = toml_section(member_text, "package")
935            .is_some_and(|block| field_inherits_workspace(&block, "version"));
936        if inherits {
937            ws_pkg.and_then(|block| toml_str_value(block, "version", false))
938        } else {
939            None
940        }
941    });
942    (parsed.package, version)
943}
944
945/// The description: first non-empty manifest `description`, else the first
946/// non-heading README line — both trimmed and truncated to 120 characters.
947fn detect_description(
948    repo_root: &Path,
949    fs: &dyn Fs,
950    packages: &[Package],
951    readme_text: Option<&str>,
952) -> Option<String> {
953    let manifest_desc = packages.iter().find_map(|p| {
954        let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
955        let desc = parse_manifest(&p.manifest, &text)?.description?;
956        (!desc.is_empty()).then_some(desc)
957    });
958    if let Some(desc) = manifest_desc {
959        return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
960    }
961    readme_text?.lines().find_map(|line| {
962        let s = line.trim();
963        let is_prose =
964            !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
965        is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
966    })
967}
968
969// ── Manifest parsing (name + version + description) ──────────────────────────
970
971/// The name/version/description parsed from one manifest.
972#[derive(Debug, Default)]
973struct ParsedManifest {
974    package: Option<String>,
975    version: Option<String>,
976    description: Option<String>,
977}
978
979/// Dispatch to the per-manifest parser. `setup.py` yields nothing (its metadata
980/// is executable, not declarative — the Python detector skips it too).
981///
982/// Dispatches on the manifest's *basename* so a member path
983/// (`crates/ossctl-core/Cargo.toml`) parses like a root `Cargo.toml` — the
984/// description pass re-reads member manifests by their stored relative path.
985fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
986    let base = Path::new(fname)
987        .file_name()
988        .and_then(|n| n.to_str())
989        .unwrap_or(fname);
990    match base {
991        "Cargo.toml" => parse_cargo(text),
992        "package.json" => parse_package_json(text),
993        "pyproject.toml" => Some(parse_pyproject(text)),
994        "go.mod" => Some(parse_gomod(text)),
995        _ => None, // setup.py
996    }
997}
998
999/// Parse a Cargo manifest's `[package]` block. Returns `None` for a virtual
1000/// workspace (no `[package]`), which still marks the repo rust upstream.
1001fn parse_cargo(text: &str) -> Option<ParsedManifest> {
1002    let block = toml_section(text, "package")?;
1003    Some(ParsedManifest {
1004        package: toml_str_value(&block, "name", false),
1005        version: toml_str_value(&block, "version", false),
1006        description: toml_str_value(&block, "description", true),
1007    })
1008}
1009
1010fn parse_package_json(text: &str) -> Option<ParsedManifest> {
1011    let value: serde_json::Value = serde_json::from_str(text).ok()?;
1012    let field = |key: &str| {
1013        value
1014            .get(key)
1015            .and_then(serde_json::Value::as_str)
1016            .map(str::to_string)
1017    };
1018    Some(ParsedManifest {
1019        package: field("name"),
1020        version: field("version"),
1021        description: field("description"),
1022    })
1023}
1024
1025/// Parse a `pyproject.toml`: the standard `[project]` table first, then a legacy
1026/// `[tool.poetry]` fallback when `[project]` names no package.
1027fn parse_pyproject(text: &str) -> ParsedManifest {
1028    let mut parsed = ParsedManifest::default();
1029    if let Some(block) = toml_section(text, "project") {
1030        parsed.package = toml_str_value(&block, "name", false);
1031        parsed.version = toml_str_value(&block, "version", false);
1032        parsed.description = toml_str_value(&block, "description", true);
1033    }
1034    if parsed.package.is_none() {
1035        if let Some(block) = toml_section(text, "tool.poetry") {
1036            parsed.package = toml_str_value(&block, "name", false);
1037            parsed.version = toml_str_value(&block, "version", false);
1038            parsed.description = toml_str_value(&block, "description", true);
1039        }
1040    }
1041    parsed
1042}
1043
1044/// Parse a `go.mod`'s `module <path>` line. Always yields a (possibly empty)
1045/// result — a `go.mod` marks the repo go regardless of a `module` line.
1046fn parse_gomod(text: &str) -> ParsedManifest {
1047    let module = text.lines().find_map(|line| {
1048        line.strip_prefix("module")
1049            .filter(|rest| rest.starts_with(char::is_whitespace))
1050            .and_then(|rest| rest.split_whitespace().next())
1051            .map(str::to_string)
1052    });
1053    ParsedManifest {
1054        package: module,
1055        version: None,
1056        description: None,
1057    }
1058}
1059
1060/// Extract a TOML `[header]` section body: every line after the header line up
1061/// to the next `[...]` line or end of file. `None` when the header is absent.
1062/// The header must begin the line (no indentation), matching the Python `^\[`.
1063fn toml_section(text: &str, header: &str) -> Option<String> {
1064    let needle = format!("[{header}]");
1065    let mut in_section = false;
1066    let mut out = String::new();
1067    for line in text.lines() {
1068        if in_section {
1069            if line.starts_with('[') {
1070                break;
1071            }
1072            out.push_str(line);
1073            out.push('\n');
1074        } else if line.starts_with(&needle) {
1075            in_section = true;
1076        }
1077    }
1078    in_section.then_some(out)
1079}
1080
1081/// Find `key = "value"` within a TOML section body and return the quoted value.
1082/// `allow_empty` controls whether an empty `""` counts (the Python `name`/
1083/// `version` patterns require non-empty; `description` allows empty).
1084fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
1085    for line in block.lines() {
1086        let rest = line.trim_start();
1087        let Some(rest) = rest.strip_prefix(key) else {
1088            continue;
1089        };
1090        // The key must be a whole token: the char after it is whitespace or `=`
1091        // (else `name` would spuriously match `nameservers`). Mirrors the Python
1092        // `^\s*<key>\s*=` anchor.
1093        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1094            continue;
1095        }
1096        let Some(rest) = rest.trim_start().strip_prefix('=') else {
1097            continue;
1098        };
1099        let Some(value) = extract_quoted(rest.trim_start()) else {
1100            continue;
1101        };
1102        if value.is_empty() && !allow_empty {
1103            return None;
1104        }
1105        return Some(value);
1106    }
1107    None
1108}
1109
1110/// Find `key = [ "a", "b", … ]` within a TOML section body and return the quoted
1111/// elements, in order. Handles both the single-line array and a multi-line array
1112/// that spans several lines (Cargo `members`/`exclude` lists are commonly
1113/// formatted either way), stripping `#` comments so a commented-out element is not
1114/// returned. Elements are returned as their raw quoted text — glob expansion and
1115/// path validation are the caller's job. `None` when the key is absent.
1116fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
1117    // Accumulate from the `key = [` line through the line holding the closing `]`.
1118    let mut acc = String::new();
1119    let mut collecting = false;
1120    for line in block.lines() {
1121        // Drop a trailing `#` comment first, so a commented-out element
1122        // (`# "old-member"`) or a `]` inside a comment does not corrupt the scan.
1123        let line = strip_toml_comment(line);
1124        if collecting {
1125            acc.push_str(line);
1126            acc.push('\n');
1127            if line.contains(']') {
1128                break;
1129            }
1130            continue;
1131        }
1132        let rest = line.trim_start();
1133        let Some(rest) = rest.strip_prefix(key) else {
1134            continue;
1135        };
1136        // The key must be a whole token (else `members-extra` would match).
1137        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1138            continue;
1139        }
1140        let Some(rest) = rest.trim_start().strip_prefix('=') else {
1141            continue;
1142        };
1143        acc.push_str(rest);
1144        acc.push('\n');
1145        collecting = true;
1146        if rest.contains(']') {
1147            break;
1148        }
1149    }
1150    if !collecting {
1151        return None;
1152    }
1153    // Slice between the first `[` and the first `]`, then pull quoted strings.
1154    let start = acc.find('[')?;
1155    let end = acc[start..].find(']')? + start;
1156    let mut inner = &acc[start + 1..end];
1157    let mut out = Vec::new();
1158    while let Some(pos) = inner.find(['"', '\'']) {
1159        let quote = inner.as_bytes()[pos] as char;
1160        let after = &inner[pos + 1..];
1161        let Some(close) = after.find(quote) else {
1162            break;
1163        };
1164        out.push(after[..close].to_string());
1165        inner = &after[close + 1..];
1166    }
1167    Some(out)
1168}
1169
1170/// Whether a member manifest block declares `<key>.workspace = true` (dotted) or
1171/// `<key> = { workspace = true }` (inline) — the two forms of Cargo workspace
1172/// field inheritance. Used to decide whether to inherit from `[workspace.package]`.
1173/// Callers pass the member's `[package]` block, not the whole file, so an unrelated
1174/// table cannot trip the match.
1175fn field_inherits_workspace(block: &str, key: &str) -> bool {
1176    let dotted = format!("{key}.workspace");
1177    for line in block.lines() {
1178        let t = strip_toml_comment(line).trim_start();
1179        // Dotted: `version.workspace = true`.
1180        if let Some(rest) = t.strip_prefix(&dotted) {
1181            let rest = rest.trim_start();
1182            if let Some(rest) = rest.strip_prefix('=') {
1183                if is_true_literal(rest.trim_start()) {
1184                    return true;
1185                }
1186            }
1187            continue;
1188        }
1189        // Inline table: `version = { workspace = true }`.
1190        if let Some(rest) = t.strip_prefix(key) {
1191            if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1192                continue;
1193            }
1194            let Some(rest) = rest.trim_start().strip_prefix('=') else {
1195                continue;
1196            };
1197            let v = rest.trim_start();
1198            // Require the exact `workspace = true` entry inside the inline table —
1199            // a substring test would accept `{ workspace = false, x = true }`.
1200            if v.starts_with('{') && inline_table_has_workspace_true(v) {
1201                return true;
1202            }
1203        }
1204    }
1205    false
1206}
1207
1208/// Whether `s` begins with the TOML boolean `true` as a whole token (not
1209/// `trueish`, not the string `"true"`), allowing a trailing comment or `}`.
1210fn is_true_literal(s: &str) -> bool {
1211    match s.strip_prefix("true") {
1212        Some(rest) => {
1213            let rest = rest.trim_start();
1214            rest.is_empty() || rest.starts_with(['#', '}', ','])
1215        }
1216        None => false,
1217    }
1218}
1219
1220/// Whether an inline table (`{ … }`) contains an exact `workspace = true` entry.
1221/// Whitespace-insensitive so `{workspace=true}` and `{ workspace = true }` both
1222/// match, while `{ workspace = false, other = true }` does not.
1223fn inline_table_has_workspace_true(inline: &str) -> bool {
1224    let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
1225    compact.trim_start_matches('{').split(',').any(|entry| {
1226        entry
1227            .strip_prefix("workspace=true")
1228            .is_some_and(|r| r.is_empty() || r == "}")
1229    })
1230}
1231
1232/// Return `line` with any trailing `#` comment removed, respecting `#` characters
1233/// that fall inside a `"`/`'` quoted string (which are literal, not comments).
1234fn strip_toml_comment(line: &str) -> &str {
1235    let mut quote: Option<u8> = None;
1236    for (i, &b) in line.as_bytes().iter().enumerate() {
1237        match quote {
1238            Some(q) => {
1239                if b == q {
1240                    quote = None;
1241                }
1242            }
1243            None => match b {
1244                b'"' | b'\'' => quote = Some(b),
1245                b'#' => return &line[..i],
1246                _ => {}
1247            },
1248        }
1249    }
1250    line
1251}
1252
1253/// Read a leading quoted string — `"..."` or `'...'`. TOML allows both basic
1254/// (double) and literal (single) strings, and `tomllib` accepts either, so both
1255/// are honored here for parity. No escape handling: neither this nor the Python
1256/// regex `"([^"]+)"` unescapes, and manifest name/version/description do not need
1257/// it in practice.
1258fn extract_quoted(s: &str) -> Option<String> {
1259    let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
1260    let s = &s[1..];
1261    let end = s.find(quote)?;
1262    Some(s[..end].to_string())
1263}
1264
1265// ── SemVer helpers ───────────────────────────────────────────────────────────
1266
1267/// Parse a possibly package-prefixed `SemVer` tag into
1268/// `(major, minor, patch, is_prerelease)`, or `None` if it is not `SemVer`.
1269///
1270/// Strips a monorepo `pkg-`/`pkg@`/`pkg/` prefix (e.g. `core-v1.2.3`,
1271/// `@acme/cli@2.0.0`) before parsing, mirroring the Python `_semver_parse`.
1272fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
1273    parse_semver_core(strip_pkg_prefix(tag))
1274}
1275
1276/// Strip everything up to and including the rightmost `@`/`/`/`-` that is
1277/// immediately followed by an optional `v` and a `X.Y.Z` version.
1278fn strip_pkg_prefix(tag: &str) -> &str {
1279    let bytes = tag.as_bytes();
1280    for i in (0..bytes.len()).rev() {
1281        if matches!(bytes[i], b'@' | b'/' | b'-') {
1282            let rest = &tag[i + 1..];
1283            if starts_with_version(rest) {
1284                return rest;
1285            }
1286        }
1287    }
1288    tag
1289}
1290
1291/// Whether `s` begins with `v?\d+\.\d+\.\d+` (the version-start lookahead).
1292fn starts_with_version(s: &str) -> bool {
1293    let mut rest = s.strip_prefix('v').unwrap_or(s);
1294    for i in 0..3 {
1295        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1296        if digits == 0 {
1297            return false;
1298        }
1299        rest = &rest[digits..];
1300        if i < 2 {
1301            match rest.strip_prefix('.') {
1302                Some(r) => rest = r,
1303                None => return false,
1304            }
1305        }
1306    }
1307    true
1308}
1309
1310/// Fully parse a version core `v?\d+\.\d+\.\d+(?:[-+].*)?`. The prerelease flag
1311/// is set when a `-` (not `+`) immediately follows `X.Y.Z`.
1312fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
1313    let mut rest = core.strip_prefix('v').unwrap_or(core);
1314    let mut nums = [0u64; 3];
1315    for (i, slot) in nums.iter_mut().enumerate() {
1316        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1317        if digits == 0 {
1318            return None;
1319        }
1320        *slot = rest[..digits].parse().ok()?;
1321        rest = &rest[digits..];
1322        if i < 2 {
1323            rest = rest.strip_prefix('.')?;
1324        }
1325    }
1326    let pre = match rest.chars().next() {
1327        None | Some('+') => false,
1328        Some('-') => true,
1329        Some(_) => return None, // trailing junk after X.Y.Z → not SemVer
1330    };
1331    Some((nums[0], nums[1], nums[2], pre))
1332}
1333
1334/// Whether a manifest version string is `>=1.0` (`v?\d+\.` with major `>=1`).
1335fn version_ge_1_0(version: Option<&str>) -> bool {
1336    let Some(v) = version else {
1337        return false;
1338    };
1339    let s = v.strip_prefix('v').unwrap_or(v);
1340    let digits = s.bytes().take_while(u8::is_ascii_digit).count();
1341    // A leading number followed by a `.` — bare `1` (no dot) does not qualify.
1342    if digits == 0 || !s[digits..].starts_with('.') {
1343        return false;
1344    }
1345    s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
1346}
1347
1348// ── Small helpers ────────────────────────────────────────────────────────────
1349
1350/// Read a file through the [`Fs`] port as lossy UTF-8, capped at `limit`
1351/// *characters* (not bytes) — the Python `_read` opens in text mode, so
1352/// `fh.read(limit)` counts decoded characters. Slicing bytes instead would
1353/// under-read a multibyte README (a 4000-byte cap holds only ~1333 CJK chars)
1354/// and could split a codepoint into a `U+FFFD`. `None` when the read fails.
1355fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
1356    let bytes = fs.read(path).ok()?;
1357    Some(
1358        String::from_utf8_lossy(&bytes)
1359            .chars()
1360            .take(limit)
1361            .collect(),
1362    )
1363}
1364
1365/// Count non-blank lines (git shortlog emits one per committer).
1366fn count_lines(text: String) -> usize {
1367    text.lines().filter(|l| !l.trim().is_empty()).count()
1368}
1369
1370/// Truncate to at most `n` characters (not bytes) — the Python `[:n]` slice.
1371fn truncate_chars(s: &str, n: usize) -> String {
1372    s.chars().take(n).collect()
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378    use std::collections::{HashMap, HashSet};
1379    use std::path::PathBuf;
1380
1381    // ── In-memory fakes for the ports ──────────────────────────────────────
1382
1383    #[derive(Default)]
1384    struct FakeFs {
1385        files: HashMap<PathBuf, Vec<u8>>,
1386        dirs: HashSet<PathBuf>,
1387    }
1388
1389    impl FakeFs {
1390        fn file(mut self, path: &str, contents: &str) -> Self {
1391            let p = PathBuf::from(path);
1392            // Register ancestor directories so `read_dir`/`is_dir` see them.
1393            let mut cur = p.parent();
1394            while let Some(dir) = cur {
1395                if dir.as_os_str().is_empty() {
1396                    break;
1397                }
1398                self.dirs.insert(dir.to_path_buf());
1399                cur = dir.parent();
1400            }
1401            self.files.insert(p, contents.as_bytes().to_vec());
1402            self
1403        }
1404
1405        fn dir(mut self, path: &str) -> Self {
1406            self.dirs.insert(PathBuf::from(path));
1407            self
1408        }
1409    }
1410
1411    impl Fs for FakeFs {
1412        fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
1413            self.files
1414                .get(path)
1415                .cloned()
1416                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1417        }
1418        fn exists(&self, path: &Path) -> bool {
1419            self.files.contains_key(path) || self.dirs.contains(path)
1420        }
1421        fn is_dir(&self, path: &Path) -> bool {
1422            self.dirs.contains(path)
1423        }
1424        fn is_file(&self, path: &Path) -> bool {
1425            self.files.contains_key(path)
1426        }
1427        fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
1428            if !self.dirs.contains(dir) {
1429                return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
1430            }
1431            let mut names: Vec<String> = self
1432                .files
1433                .keys()
1434                .chain(self.dirs.iter())
1435                .filter(|p| p.parent() == Some(dir))
1436                .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1437                .collect();
1438            names.sort();
1439            Ok(names)
1440        }
1441    }
1442
1443    #[derive(Default)]
1444    struct FakeGit {
1445        work_tree: bool,
1446        head: Option<String>,
1447        shortlog_all: String,
1448        shortlog_recent: String,
1449        tags: Vec<String>,
1450    }
1451
1452    impl GitRepo for FakeGit {
1453        fn head_commit(&self) -> std::io::Result<String> {
1454            self.head
1455                .clone()
1456                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1457        }
1458        fn is_work_tree(&self) -> bool {
1459            self.work_tree
1460        }
1461        fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
1462            if !self.work_tree {
1463                return Err(std::io::Error::from(std::io::ErrorKind::Other));
1464            }
1465            Ok(if since.is_some() {
1466                self.shortlog_recent.clone()
1467            } else {
1468                self.shortlog_all.clone()
1469            })
1470        }
1471        fn tags(&self) -> std::io::Result<Vec<String>> {
1472            if self.work_tree {
1473                Ok(self.tags.clone())
1474            } else {
1475                Err(std::io::Error::from(std::io::ErrorKind::Other))
1476            }
1477        }
1478        fn git_common_dir(&self) -> std::io::Result<PathBuf> {
1479            Ok(PathBuf::from("/repo/.git"))
1480        }
1481    }
1482
1483    /// A git repo with `n` distinct committers total / recent and the given tags.
1484    fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
1485        let lines = |n: usize| {
1486            (0..n)
1487                .map(|i| format!("     3\tDev {i} <dev{i}@example.com>"))
1488                .collect::<Vec<_>>()
1489                .join("\n")
1490        };
1491        FakeGit {
1492            work_tree: true,
1493            head: Some("deadbeef".to_string()),
1494            shortlog_all: lines(total),
1495            shortlog_recent: lines(recent),
1496            tags: tags.iter().map(|t| (*t).to_string()).collect(),
1497        }
1498    }
1499
1500    fn repo() -> &'static Path {
1501        Path::new("/repo")
1502    }
1503
1504    // ── Empty / unborn repo ────────────────────────────────────────────────
1505
1506    #[test]
1507    fn empty_repo_is_spike_binary() {
1508        let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1509        assert!(!facts.is_git);
1510        assert!(!facts.has_commits);
1511        assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1512        assert!(facts.packages.is_empty());
1513        assert_eq!(facts.committers_total, 0);
1514        assert_eq!(facts.committers_recent_year, 0);
1515        assert!(facts.tags.is_empty());
1516        assert!(!facts.has_ci);
1517        assert_eq!(facts.dependency_bot, None);
1518        assert_eq!(facts.description, None);
1519        // No CI, no SemVer tag, <=1 committer → spike.
1520        assert!(facts.maturity_signals.spike);
1521        assert_eq!(facts.inferred_maturity, Maturity::Spike);
1522    }
1523
1524    #[test]
1525    fn unborn_repo_has_no_commits() {
1526        // A work tree whose HEAD does not resolve (no commits yet): is_git true,
1527        // has_commits false, so no committers/tags are read.
1528        let git = FakeGit {
1529            work_tree: true,
1530            head: None,
1531            ..FakeGit::default()
1532        };
1533        let facts = gather(repo(), &FakeFs::default(), &git);
1534        assert!(facts.is_git);
1535        assert!(!facts.has_commits);
1536        assert_eq!(facts.committers_total, 0);
1537        assert!(facts.tags.is_empty());
1538    }
1539
1540    // ── Ecosystem + manifest detection ─────────────────────────────────────
1541
1542    #[test]
1543    fn cargo_package_name_version_description() {
1544        let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1545                     description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1546        let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1547        let facts = gather(repo(), &fs, &FakeGit::default());
1548        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1549        assert_eq!(facts.packages.len(), 1);
1550        let p = &facts.packages[0];
1551        assert_eq!(p.ecosystem, Ecosystem::Rust);
1552        assert_eq!(p.manifest, "Cargo.toml");
1553        assert_eq!(p.package.as_deref(), Some("rg"));
1554        assert_eq!(p.version.as_deref(), Some("0.3.0"));
1555        assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1556    }
1557
1558    #[test]
1559    fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1560        // A virtual workspace whose only member manifest is absent falls back to
1561        // the null root entry: the repo is still rust, and the null-named entry
1562        // preserves today's signal rather than emitting nothing.
1563        let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
1564        let facts = gather(repo(), &fs, &FakeGit::default());
1565        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1566        assert_eq!(facts.packages.len(), 1);
1567        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1568        assert_eq!(facts.packages[0].package, None);
1569        assert_eq!(facts.packages[0].version, None);
1570    }
1571
1572    #[test]
1573    fn cargo_virtual_workspace_enumerates_members() {
1574        // A virtual-workspace root + two members: one inherits the workspace
1575        // version (`version.workspace = true`), one pins its own literal version.
1576        let root = "[workspace]\nresolver = \"2\"\n\
1577                    members = [\"crates/core\", \"crates/cli\"]\n\n\
1578                    [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
1579        let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
1580                    edition.workspace = true\ndescription = \"the core lib\"\n";
1581        let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
1582                   description = \"the cli\"\n";
1583        let fs = FakeFs::default()
1584            .file("/repo/Cargo.toml", root)
1585            .file("/repo/crates/core/Cargo.toml", core)
1586            .file("/repo/crates/cli/Cargo.toml", cli);
1587        let facts = gather(repo(), &fs, &FakeGit::default());
1588        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1589        assert_eq!(facts.packages.len(), 2);
1590        // Declaration order is preserved.
1591        let core_pkg = &facts.packages[0];
1592        assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
1593        assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
1594        assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
1595        // `version.workspace = true` inherits 0.1.0 from [workspace.package].
1596        assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
1597        let cli_pkg = &facts.packages[1];
1598        assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
1599        assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
1600        assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
1601        // The description pass re-reads the first member manifest by its path.
1602        assert_eq!(facts.description.as_deref(), Some("the core lib"));
1603    }
1604
1605    // ── rust_workspace graph (release-planner plumbing) ────────────────────────
1606
1607    #[test]
1608    fn rust_workspace_graph_captures_lib_bin_edge() {
1609        // The canonical lib+bin shape: the bin pins the lib by exact version, the
1610        // exact `dep = { path, version = "=X" }` form `/oss-init` emits.
1611        let root = "[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n\n\
1612                    [workspace.package]\nversion = \"0.1.6\"\n";
1613        let core = "[package]\nname = \"octl-core\"\nversion.workspace = true\n\
1614                    publish = true\n";
1615        let cli = "[package]\nname = \"orchestratectl\"\nversion.workspace = true\n\
1616                   publish = true\n\n[dependencies]\n\
1617                   octl-core = { path = \"../core\", version = \"=0.1.6\" }\n\
1618                   serde = \"1\"\n";
1619        let fs = FakeFs::default()
1620            .file("/repo/Cargo.toml", root)
1621            .file("/repo/crates/core/Cargo.toml", core)
1622            .file("/repo/crates/cli/Cargo.toml", cli);
1623        let ws = detect_rust_workspace(repo(), &fs).expect("a members-bearing workspace");
1624        // Both members, in declaration order (planner applies the topo order).
1625        let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
1626        assert_eq!(names, vec!["octl-core", "orchestratectl"]);
1627        // The lib has no intra-workspace deps; the bin depends on the lib only
1628        // (`serde` is an external crate, not a member, so it is not an edge).
1629        assert!(ws.members[0].workspace_deps.is_empty());
1630        assert_eq!(ws.members[1].workspace_deps, vec!["octl-core".to_string()]);
1631        assert_eq!(ws.members[0].version.as_deref(), Some("0.1.6"));
1632    }
1633
1634    #[test]
1635    fn rust_workspace_graph_drops_unpublishable_members() {
1636        // `publish = false` and a non-crates.io registry restriction both exclude a
1637        // member from the publish set (matching the cargo adapter's metadata filter);
1638        // an edge to an excluded member is not an ordering edge.
1639        let root = "[workspace]\nmembers = [\"a\", \"b\", \"c\"]\n";
1640        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n"; // publishable (absent)
1641        let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n";
1642        let c = "[package]\nname = \"c\"\nversion = \"1.0.0\"\n\
1643                 publish = [\"my-registry\"]\n\n[dependencies]\n\
1644                 b = { path = \"../b\" }\n";
1645        let fs = FakeFs::default()
1646            .file("/repo/Cargo.toml", root)
1647            .file("/repo/a/Cargo.toml", a)
1648            .file("/repo/b/Cargo.toml", b)
1649            .file("/repo/c/Cargo.toml", c);
1650        let ws = detect_rust_workspace(repo(), &fs).expect("at least `a` is publishable");
1651        let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
1652        assert_eq!(names, vec!["a"], "only the publishable member survives");
1653    }
1654
1655    #[test]
1656    fn rust_workspace_graph_honors_crates_io_allow_list() {
1657        // `publish = ["crates-io"]` is publishable; the array form is not `publish = false`.
1658        let root = "[workspace]\nmembers = [\"a\"]\n";
1659        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\
1660                 publish = [\"crates-io\"]\n";
1661        let fs = FakeFs::default()
1662            .file("/repo/Cargo.toml", root)
1663            .file("/repo/a/Cargo.toml", a);
1664        let ws = detect_rust_workspace(repo(), &fs).expect("crates-io allow-listed");
1665        assert_eq!(ws.members.len(), 1);
1666    }
1667
1668    #[test]
1669    fn rust_workspace_graph_none_for_single_crate_repo() {
1670        // A single root crate (no `[workspace].members`) is not a multi-crate
1671        // workspace — the planner has nothing to expand.
1672        let cargo = "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n";
1673        let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1674        assert!(detect_rust_workspace(repo(), &fs).is_none());
1675    }
1676
1677    #[test]
1678    fn rust_workspace_graph_dep_rename_via_inline_package_key() {
1679        // A renamed dependency (`alias = { package = "real-core" }`) resolves to the
1680        // real crate name, so the edge to the workspace member is still detected.
1681        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1682        let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
1683        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1684                   alias = { package = \"real-core\", path = \"../core\" }\n";
1685        let fs = FakeFs::default()
1686            .file("/repo/Cargo.toml", root)
1687            .file("/repo/core/Cargo.toml", core)
1688            .file("/repo/cli/Cargo.toml", cli);
1689        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1690        let cli = ws
1691            .members
1692            .iter()
1693            .find(|m| m.package == "cli")
1694            .expect("cli member");
1695        assert_eq!(cli.workspace_deps, vec!["real-core".to_string()]);
1696    }
1697
1698    #[test]
1699    fn rust_workspace_graph_ignores_registry_dep_sharing_a_member_name() {
1700        // A registry dependency (`shared = "1"`) is NEVER an intra-workspace edge, even
1701        // when a workspace member happens to be named `shared` — Cargo resolves it to
1702        // crates.io. Treating the name collision as an edge would invent a false
1703        // ordering constraint (and here a false cycle: cli↔shared).
1704        let root = "[workspace]\nmembers = [\"shared\", \"cli\"]\n";
1705        let shared = "[package]\nname = \"shared\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1706                      cli = { path = \"../cli\" }\n"; // shared really depends on cli
1707        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1708                   shared = \"1\"\n"; // a crates.io `shared`, NOT the workspace member
1709        let fs = FakeFs::default()
1710            .file("/repo/Cargo.toml", root)
1711            .file("/repo/shared/Cargo.toml", shared)
1712            .file("/repo/cli/Cargo.toml", cli);
1713        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1714        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1715        let shared = ws.members.iter().find(|m| m.package == "shared").unwrap();
1716        assert!(
1717            cli.workspace_deps.is_empty(),
1718            "a registry dep sharing a member name is not an edge"
1719        );
1720        assert_eq!(
1721            shared.workspace_deps,
1722            vec!["cli".to_string()],
1723            "the real path edge is still detected"
1724        );
1725    }
1726
1727    #[test]
1728    fn rust_workspace_graph_ignores_registry_inline_table_without_path() {
1729        // An inline table with only `version` is a crates.io dep, not a workspace edge.
1730        let root = "[workspace]\nmembers = [\"a\", \"b\"]\n";
1731        let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n";
1732        let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1733                 a = { version = \"1\", features = [\"x\"] }\n";
1734        let fs = FakeFs::default()
1735            .file("/repo/Cargo.toml", root)
1736            .file("/repo/a/Cargo.toml", a)
1737            .file("/repo/b/Cargo.toml", b);
1738        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1739        let b = ws.members.iter().find(|m| m.package == "b").unwrap();
1740        assert!(
1741            b.workspace_deps.is_empty(),
1742            "an inline registry dep (no path/workspace) is not an edge"
1743        );
1744    }
1745
1746    #[test]
1747    fn rust_workspace_graph_records_the_lockstep_pin_requirement() {
1748        // facet 3: an inline `= "=X"` pin's requirement is carried in dep_reqs so the
1749        // planner can rewrite only the exact lockstep edge.
1750        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1751        let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
1752        let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
1753                   octl-core = { path = \"../core\", version = \"=0.4.0\" }\n";
1754        let fs = FakeFs::default()
1755            .file("/repo/Cargo.toml", root)
1756            .file("/repo/core/Cargo.toml", core)
1757            .file("/repo/cli/Cargo.toml", cli);
1758        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1759        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1760        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1761        assert_eq!(
1762            cli.dep_reqs.get("octl-core").map(String::as_str),
1763            Some("=0.4.0")
1764        );
1765    }
1766
1767    #[test]
1768    fn rust_workspace_graph_omits_req_for_a_pathonly_edge() {
1769        // A path-only edge (no version) records the edge but no requirement, so the
1770        // planner emits no pin rewrite for it (fail closed — the publish would surface
1771        // a real error if a pin were needed).
1772        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1773        let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
1774        let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
1775                   octl-core = { path = \"../core\" }\n";
1776        let fs = FakeFs::default()
1777            .file("/repo/Cargo.toml", root)
1778            .file("/repo/core/Cargo.toml", core)
1779            .file("/repo/cli/Cargo.toml", cli);
1780        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1781        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1782        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1783        assert!(!cli.dep_reqs.contains_key("octl-core"));
1784    }
1785
1786    #[test]
1787    fn rust_workspace_graph_reads_target_specific_dependency_edges() {
1788        // A `[target.'cfg(...)'.dependencies]` normal dep gates publish order too —
1789        // crates.io validates it at `cargo publish`.
1790        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1791        let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
1792        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
1793                   [target.'cfg(unix)'.dependencies]\n\
1794                   octl-core = { path = \"../core\", version = \"=1.0.0\" }\n";
1795        let fs = FakeFs::default()
1796            .file("/repo/Cargo.toml", root)
1797            .file("/repo/core/Cargo.toml", core)
1798            .file("/repo/cli/Cargo.toml", cli);
1799        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1800        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1801        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1802    }
1803
1804    #[test]
1805    fn rust_workspace_graph_reads_subtable_path_edge_and_rename() {
1806        // `[dependencies.<name>]` sub-table with a `path` is a local edge; a `package`
1807        // key inside renames it. A version-only sub-table is NOT an edge.
1808        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1809        let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
1810        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
1811                   [dependencies.alias]\npackage = \"real-core\"\npath = \"../core\"\n\
1812                   version = \"=1.0.0\"\n\n\
1813                   [dependencies.serde]\nversion = \"1\"\n"; // registry sub-table, not an edge
1814        let fs = FakeFs::default()
1815            .file("/repo/Cargo.toml", root)
1816            .file("/repo/core/Cargo.toml", core)
1817            .file("/repo/cli/Cargo.toml", cli);
1818        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1819        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1820        assert_eq!(
1821            cli.workspace_deps,
1822            vec!["real-core".to_string()],
1823            "the renamed path sub-table is the only edge; the version-only one is not"
1824        );
1825    }
1826
1827    #[test]
1828    fn rust_workspace_graph_reads_dotted_key_path_edge() {
1829        // Dotted-key form: `dep.path = "..."` (and `dep.workspace = true`) are local
1830        // edges; a lone `dep.version` is not.
1831        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1832        let core = "[package]\nname = \"core\"\nversion = \"1.0.0\"\n";
1833        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1834                   core.path = \"../core\"\ncore.version = \"=1.0.0\"\n";
1835        let fs = FakeFs::default()
1836            .file("/repo/Cargo.toml", root)
1837            .file("/repo/core/Cargo.toml", core)
1838            .file("/repo/cli/Cargo.toml", cli);
1839        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1840        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1841        assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
1842    }
1843
1844    #[test]
1845    fn rust_workspace_graph_dep_rename_ignores_package_substring_in_a_path() {
1846        // A path value that literally contains "package" must NOT be misread as a
1847        // `package = ` rename key: the edge stays on the bare key `octl-core`.
1848        let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1849        let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
1850        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1851                   octl-core = { path = \"../my-package-core\", version = \"1\" }\n";
1852        let fs = FakeFs::default()
1853            .file("/repo/Cargo.toml", root)
1854            .file("/repo/core/Cargo.toml", core)
1855            .file("/repo/cli/Cargo.toml", cli);
1856        // `octl-core` is not a member name here (the lib is named `octl-core`, the dep
1857        // key is `octl-core`) — assert the key resolves, not a spurious path substring.
1858        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1859        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1860        assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1861    }
1862
1863    #[test]
1864    fn rust_workspace_graph_reads_build_dependency_edges() {
1865        // A build-dependency on a workspace member gates publish order too.
1866        let root = "[workspace]\nmembers = [\"gen\", \"app\"]\n";
1867        let gen = "[package]\nname = \"gen\"\nversion = \"1.0.0\"\n";
1868        let app = "[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n\
1869                   [build-dependencies]\ngen = { path = \"../gen\" }\n";
1870        let fs = FakeFs::default()
1871            .file("/repo/Cargo.toml", root)
1872            .file("/repo/gen/Cargo.toml", gen)
1873            .file("/repo/app/Cargo.toml", app);
1874        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1875        let app = ws.members.iter().find(|m| m.package == "app").unwrap();
1876        assert_eq!(app.workspace_deps, vec!["gen".to_string()]);
1877    }
1878
1879    #[test]
1880    fn rust_workspace_graph_excludes_dev_dependency_edges() {
1881        // A dev-dependency never gates publish order (and can legitimately cycle:
1882        // a lib that dev-depends on the CLI for integration tests).
1883        let root = "[workspace]\nmembers = [\"lib\", \"cli\"]\n";
1884        let lib = "[package]\nname = \"lib\"\nversion = \"1.0.0\"\n\n\
1885                   [dev-dependencies]\ncli = { path = \"../cli\" }\n";
1886        let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1887                   lib = { path = \"../lib\" }\n";
1888        let fs = FakeFs::default()
1889            .file("/repo/Cargo.toml", root)
1890            .file("/repo/lib/Cargo.toml", lib)
1891            .file("/repo/cli/Cargo.toml", cli);
1892        let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1893        let lib = ws.members.iter().find(|m| m.package == "lib").unwrap();
1894        let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1895        assert!(
1896            lib.workspace_deps.is_empty(),
1897            "dev-dependency edge is not an ordering edge"
1898        );
1899        assert_eq!(cli.workspace_deps, vec!["lib".to_string()]);
1900    }
1901
1902    #[test]
1903    fn cargo_workspace_inline_version_inheritance() {
1904        // The inline-table inheritance form `version = { workspace = true }`.
1905        let root = "[workspace]\nmembers = [\"m\"]\n\n\
1906                    [workspace.package]\nversion = \"1.5.0\"\n";
1907        let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
1908        let fs = FakeFs::default()
1909            .file("/repo/Cargo.toml", root)
1910            .file("/repo/m/Cargo.toml", member);
1911        let facts = gather(repo(), &fs, &FakeGit::default());
1912        assert_eq!(facts.packages.len(), 1);
1913        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1914        assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
1915        // A member at >=1.0 (inherited) drives has_ge_1_0_release.
1916        assert!(facts.has_ge_1_0_release);
1917    }
1918
1919    #[test]
1920    fn cargo_workspace_multiline_members_array() {
1921        // Members formatted across several lines (the common rustfmt layout).
1922        let root = "[workspace]\nmembers = [\n    \"a\",\n    \"b\",\n]\n\n\
1923                    [workspace.package]\nversion = \"0.2.0\"\n";
1924        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1925        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1926        let fs = FakeFs::default()
1927            .file("/repo/Cargo.toml", root)
1928            .file("/repo/a/Cargo.toml", a)
1929            .file("/repo/b/Cargo.toml", b);
1930        let facts = gather(repo(), &fs, &FakeGit::default());
1931        let names: Vec<_> = facts
1932            .packages
1933            .iter()
1934            .map(|p| p.package.as_deref())
1935            .collect();
1936        assert_eq!(names, vec![Some("a"), Some("b")]);
1937        assert!(facts
1938            .packages
1939            .iter()
1940            .all(|p| p.version.as_deref() == Some("0.2.0")));
1941    }
1942
1943    #[test]
1944    fn cargo_workspace_member_without_workspace_package_table() {
1945        // `version.workspace = true` but no `[workspace.package]` to inherit from:
1946        // the version resolves to null (nothing to inherit), name still reported.
1947        let root = "[workspace]\nmembers = [\"m\"]\n";
1948        let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
1949        let fs = FakeFs::default()
1950            .file("/repo/Cargo.toml", root)
1951            .file("/repo/m/Cargo.toml", member);
1952        let facts = gather(repo(), &fs, &FakeGit::default());
1953        assert_eq!(facts.packages.len(), 1);
1954        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1955        assert_eq!(facts.packages[0].version, None);
1956    }
1957
1958    #[test]
1959    fn cargo_workspace_glob_members_are_expanded() {
1960        // `members = ["crates/*"]` expands one directory level, sorted, through the
1961        // Fs port. A non-crate entry (no Cargo.toml) is skipped.
1962        let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
1963                    [workspace.package]\nversion = \"0.4.0\"\n";
1964        let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
1965        let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
1966        let fs = FakeFs::default()
1967            .file("/repo/Cargo.toml", root)
1968            .file("/repo/crates/za/Cargo.toml", a)
1969            .file("/repo/crates/mb/Cargo.toml", b)
1970            .file("/repo/crates/README.md", "not a crate\n");
1971        let facts = gather(repo(), &fs, &FakeGit::default());
1972        // Directory order is sorted (mb before za), not declaration order.
1973        let names: Vec<_> = facts
1974            .packages
1975            .iter()
1976            .map(|p| p.package.as_deref())
1977            .collect();
1978        assert_eq!(names, vec![Some("mb"), Some("za")]);
1979        assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
1980        assert!(facts
1981            .packages
1982            .iter()
1983            .all(|p| p.version.as_deref() == Some("0.4.0")));
1984    }
1985
1986    #[test]
1987    fn cargo_workspace_exclude_and_dedup() {
1988        // A glob and an explicit member overlap (dedup to one entry); `exclude`
1989        // drops a matched member.
1990        let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
1991                    exclude = [\"crates/b\"]\n\n\
1992                    [workspace.package]\nversion = \"0.1.0\"\n";
1993        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1994        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1995        let fs = FakeFs::default()
1996            .file("/repo/Cargo.toml", root)
1997            .file("/repo/crates/a/Cargo.toml", a)
1998            .file("/repo/crates/b/Cargo.toml", b);
1999        let facts = gather(repo(), &fs, &FakeGit::default());
2000        // `b` excluded; `a` matched by both the glob and the explicit entry → once.
2001        let names: Vec<_> = facts
2002            .packages
2003            .iter()
2004            .map(|p| p.package.as_deref())
2005            .collect();
2006        assert_eq!(names, vec![Some("a")]);
2007    }
2008
2009    #[test]
2010    fn cargo_workspace_commented_out_member_is_ignored() {
2011        // A commented-out member line must not be emitted, even if the path exists.
2012        let root = "[workspace]\nmembers = [\n    \"a\",\n    # \"b\",\n]\n\n\
2013                    [workspace.package]\nversion = \"0.1.0\"\n";
2014        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2015        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2016        let fs = FakeFs::default()
2017            .file("/repo/Cargo.toml", root)
2018            .file("/repo/a/Cargo.toml", a)
2019            .file("/repo/b/Cargo.toml", b);
2020        let facts = gather(repo(), &fs, &FakeGit::default());
2021        let names: Vec<_> = facts
2022            .packages
2023            .iter()
2024            .map(|p| p.package.as_deref())
2025            .collect();
2026        assert_eq!(names, vec![Some("a")]);
2027    }
2028
2029    #[test]
2030    fn cargo_workspace_rejects_escaping_member_paths() {
2031        // Absolute and `..` members are rejected (a fact detector reports only the
2032        // repo's own packages) → no member resolves → null root entry fallback.
2033        let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
2034        let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
2035        let fs = FakeFs::default()
2036            .file("/repo/Cargo.toml", root)
2037            .file("/outside/Cargo.toml", outside)
2038            .file("/abs/Cargo.toml", outside);
2039        let facts = gather(repo(), &fs, &FakeGit::default());
2040        assert_eq!(facts.packages.len(), 1);
2041        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2042        assert_eq!(facts.packages[0].package, None);
2043    }
2044
2045    #[test]
2046    fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
2047        // `version.workspace = true` in `[package.metadata.*]` must NOT be read as
2048        // `[package].version` inheritance; and `= trueish` is not the bool `true`.
2049        let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
2050                    [workspace.package]\nversion = \"7.7.7\"\n";
2051        let m = "[package]\nname = \"m\"\n\n\
2052                 [package.metadata.tool]\nversion.workspace = true\n";
2053        let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
2054        let fs = FakeFs::default()
2055            .file("/repo/Cargo.toml", root)
2056            .file("/repo/m/Cargo.toml", m)
2057            .file("/repo/n/Cargo.toml", n);
2058        let facts = gather(repo(), &fs, &FakeGit::default());
2059        // Neither inherits the workspace version.
2060        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2061        assert_eq!(facts.packages[0].version, None);
2062        assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
2063        assert_eq!(facts.packages[1].version, None);
2064    }
2065
2066    #[test]
2067    fn cargo_workspace_inline_inheritance_rejects_false_positive() {
2068        // `version = { workspace = false, … }` must not inherit; a genuine
2069        // `{ workspace = true }` must.
2070        let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
2071                    [workspace.package]\nversion = \"3.0.0\"\n";
2072        let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
2073        let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
2074        let fs = FakeFs::default()
2075            .file("/repo/Cargo.toml", root)
2076            .file("/repo/yes/Cargo.toml", yes)
2077            .file("/repo/no/Cargo.toml", no);
2078        let facts = gather(repo(), &fs, &FakeGit::default());
2079        assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
2080        assert_eq!(facts.packages[1].version, None);
2081    }
2082
2083    #[test]
2084    fn package_json_parsed_and_go_mod_module() {
2085        let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
2086        let fs = FakeFs::default()
2087            .file("/repo/package.json", pkg)
2088            .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
2089        let facts = gather(repo(), &fs, &FakeGit::default());
2090        assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
2091        let node = facts
2092            .packages
2093            .iter()
2094            .find(|p| p.ecosystem == Ecosystem::Node)
2095            .unwrap();
2096        assert_eq!(node.package.as_deref(), Some("@acme/cli"));
2097        assert_eq!(node.version.as_deref(), Some("2.0.0"));
2098        let go = facts
2099            .packages
2100            .iter()
2101            .find(|p| p.ecosystem == Ecosystem::Go)
2102            .unwrap();
2103        assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
2104        // package.json's description wins (first package with a description).
2105        assert_eq!(facts.description.as_deref(), Some("cli"));
2106    }
2107
2108    #[test]
2109    fn pyproject_project_then_poetry_fallback() {
2110        let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
2111                       description = \"a widget\"\n";
2112        let facts = gather(
2113            repo(),
2114            &FakeFs::default().file("/repo/pyproject.toml", project),
2115            &FakeGit::default(),
2116        );
2117        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2118        assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
2119
2120        let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
2121        let facts = gather(
2122            repo(),
2123            &FakeFs::default().file("/repo/pyproject.toml", poetry),
2124            &FakeGit::default(),
2125        );
2126        assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
2127    }
2128
2129    #[test]
2130    fn pyproject_single_quoted_strings_parse() {
2131        // TOML literal (single-quoted) strings are valid and `tomllib` accepts
2132        // them; the scanner must too, or a >=1.0 release would be missed.
2133        let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
2134                       description = 'a widget'\n";
2135        let facts = gather(
2136            repo(),
2137            &FakeFs::default().file("/repo/pyproject.toml", project),
2138            &FakeGit::default(),
2139        );
2140        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2141        assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
2142        assert!(facts.has_ge_1_0_release);
2143        assert_eq!(facts.description.as_deref(), Some("a widget"));
2144    }
2145
2146    #[test]
2147    fn toml_key_matches_whole_token_not_prefix() {
2148        // `version-code` / `namespace` must not satisfy the `version` / `name`
2149        // key match.
2150        let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
2151                     name = \"real\"\nversion = \"0.2.0\"\n";
2152        let facts = gather(
2153            repo(),
2154            &FakeFs::default().file("/repo/Cargo.toml", cargo),
2155            &FakeGit::default(),
2156        );
2157        assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
2158        assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
2159    }
2160
2161    #[test]
2162    fn setup_py_marks_python_but_adds_no_package() {
2163        let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
2164        let facts = gather(repo(), &fs, &FakeGit::default());
2165        assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
2166        assert!(facts.packages.is_empty());
2167    }
2168
2169    #[test]
2170    fn ecosystems_emit_in_canonical_order() {
2171        // Files added out of order; output follows the MANIFESTS order.
2172        let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
2173            "/repo/Cargo.toml",
2174            "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
2175        );
2176        let facts = gather(repo(), &fs, &FakeGit::default());
2177        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
2178    }
2179
2180    // ── CI / bot / issues signals ──────────────────────────────────────────
2181
2182    #[test]
2183    fn workflows_dir_counts_only_when_non_empty() {
2184        // Empty workflows dir → no CI.
2185        let empty = FakeFs::default().dir("/repo/.github/workflows").file(
2186            "/repo/Cargo.toml",
2187            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2188        );
2189        assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
2190
2191        // A file inside it → CI present.
2192        let with_wf = FakeFs::default()
2193            .file("/repo/.github/workflows/ci.yml", "on: push\n")
2194            .file(
2195                "/repo/Cargo.toml",
2196                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2197            );
2198        assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
2199    }
2200
2201    #[test]
2202    fn single_file_ci_configs_count_on_existence() {
2203        for name in [
2204            ".gitlab-ci.yml",
2205            "azure-pipelines.yml",
2206            ".drone.yml",
2207            "Jenkinsfile",
2208        ] {
2209            let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
2210            assert!(
2211                gather(repo(), &fs, &FakeGit::default()).has_ci,
2212                "{name} should count as CI"
2213            );
2214        }
2215    }
2216
2217    #[test]
2218    fn dependency_bot_and_issues_dir() {
2219        let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
2220        assert_eq!(
2221            gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
2222            Some("dependabot".to_string())
2223        );
2224        let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
2225        assert_eq!(
2226            gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
2227            Some("renovate".to_string())
2228        );
2229        // dependabot takes precedence when both are present.
2230        let both = FakeFs::default()
2231            .file("/repo/.github/dependabot.yml", "version: 2\n")
2232            .file("/repo/renovate.json", "{}\n");
2233        assert_eq!(
2234            gather(repo(), &both, &FakeGit::default()).dependency_bot,
2235            Some("dependabot".to_string())
2236        );
2237        let issues = FakeFs::default().dir("/repo/issues");
2238        assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
2239    }
2240
2241    // ── README self-label + description fallback ───────────────────────────
2242
2243    #[test]
2244    fn readme_self_label_and_prose_description() {
2245        let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
2246        let fs = FakeFs::default().file("/repo/README.md", readme);
2247        let facts = gather(repo(), &fs, &FakeGit::default());
2248        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
2249        // First non-heading, non-`!`, non-`>` line.
2250        assert_eq!(
2251            facts.description.as_deref(),
2252            Some("Status: private, early. Not much yet.")
2253        );
2254    }
2255
2256    #[test]
2257    fn description_truncates_to_120_chars() {
2258        let long = "x".repeat(200);
2259        let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
2260        let facts = gather(repo(), &fs, &FakeGit::default());
2261        // "intro" is the first prose line; assert truncation on a long manifest
2262        // description instead to exercise the cap.
2263        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
2264        let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
2265        let facts2 = gather(repo(), &fs2, &FakeGit::default());
2266        assert_eq!(facts.description.as_deref(), Some("intro"));
2267        // Count characters, not bytes — the cap is a char cap.
2268        assert_eq!(
2269            facts2.description.as_deref().map(|d| d.chars().count()),
2270            Some(120)
2271        );
2272    }
2273
2274    #[test]
2275    fn read_limit_counts_chars_not_bytes() {
2276        // A multibyte description right at the boundary: a byte-slice cap would
2277        // truncate/corrupt it; the char cap keeps it whole. `く` is 3 bytes.
2278        let desc = "く".repeat(60); // 60 chars, 180 bytes — under the 120 char cap
2279        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
2280        let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
2281        let facts = gather(repo(), &fs, &FakeGit::default());
2282        assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
2283        // No replacement character crept in from a mid-codepoint byte slice.
2284        assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
2285    }
2286
2287    // ── SemVer tag handling ────────────────────────────────────────────────
2288
2289    #[test]
2290    fn semver_parse_plain_prefixed_and_prerelease() {
2291        assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
2292        assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
2293        assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
2294        assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
2295        assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
2296        assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
2297        assert_eq!(semver_parse("nightly"), None);
2298        assert_eq!(semver_parse("1.2"), None);
2299        assert_eq!(semver_parse("1.2.3.4"), None);
2300    }
2301
2302    #[test]
2303    fn ge_1_0_release_from_tag_but_not_from_prerelease() {
2304        let fs = FakeFs::default().file(
2305            "/repo/Cargo.toml",
2306            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
2307        );
2308        // A 1.0.0 tag → has_ge_1_0_release.
2309        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
2310        assert!(facts.has_semver_tag);
2311        assert!(facts.has_ge_1_0_release);
2312        // Only a 1.0.0-rc prerelease tag → not a >=1.0 release.
2313        let fs2 = FakeFs::default().file(
2314            "/repo/Cargo.toml",
2315            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
2316        );
2317        let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
2318        assert!(facts2.has_semver_tag);
2319        assert!(!facts2.has_ge_1_0_release);
2320    }
2321
2322    #[test]
2323    fn ge_1_0_release_from_manifest_version() {
2324        let fs = FakeFs::default().file(
2325            "/repo/Cargo.toml",
2326            "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
2327        );
2328        let facts = gather(repo(), &fs, &FakeGit::default());
2329        assert!(facts.has_ge_1_0_release);
2330    }
2331
2332    #[test]
2333    fn version_ge_1_0_requires_dot_after_major() {
2334        assert!(version_ge_1_0(Some("1.0.0")));
2335        assert!(version_ge_1_0(Some("v2.3")));
2336        assert!(version_ge_1_0(Some("2024.1")));
2337        assert!(!version_ge_1_0(Some("0.9.9")));
2338        assert!(!version_ge_1_0(Some("1"))); // no dot
2339        assert!(!version_ge_1_0(None));
2340    }
2341
2342    // ── Maturity truth table ───────────────────────────────────────────────
2343
2344    #[test]
2345    fn production_needs_two_recent_committers_ge_1_0_and_ci() {
2346        let fs = FakeFs::default()
2347            .file(
2348                "/repo/Cargo.toml",
2349                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
2350            )
2351            .file("/repo/.github/workflows/ci.yml", "on: push\n");
2352        let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
2353        assert!(facts.has_ci);
2354        assert!(facts.has_ge_1_0_release);
2355        assert!(facts.maturity_signals.production);
2356        assert_eq!(facts.inferred_maturity, Maturity::Production);
2357    }
2358
2359    #[test]
2360    fn mvp_when_ci_present_but_not_production_grade() {
2361        // Has CI (so not spike) but only one recent committer and no >=1.0.
2362        let fs = FakeFs::default()
2363            .file(
2364                "/repo/Cargo.toml",
2365                "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
2366            )
2367            .file("/repo/.github/workflows/ci.yml", "on: push\n");
2368        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
2369        assert!(!facts.maturity_signals.production);
2370        assert!(!facts.maturity_signals.spike);
2371        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2372    }
2373
2374    /// A `ZeroVer` repo with the full release process: CI + a dependency bot +
2375    /// `n` shipped `>=0.1.0` release tags. `bot` chooses the dependency-bot file.
2376    fn zerover_fs(bot: &str) -> FakeFs {
2377        FakeFs::default()
2378            .file(
2379                "/repo/Cargo.toml",
2380                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2381            )
2382            .file("/repo/.github/workflows/ci.yml", "on: push\n")
2383            .file(&format!("/repo/{bot}"), "version: 2\n")
2384    }
2385
2386    #[test]
2387    fn pre_1_0_with_full_release_infra_is_production() {
2388        // A deliberately-0.x (ZeroVer) repo with a maintained release process: CI,
2389        // a dependency-update bot, ≥2 recent committers, and a release cadence of
2390        // two shipped ≥0.1.0 releases — but NO ≥1.0 release. It reaches
2391        // `production` via the ZeroVer path even though `has_ge_1_0_release` is
2392        // false.
2393        let fs = zerover_fs(".github/dependabot.yml");
2394        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
2395        assert!(facts.has_ci);
2396        assert!(!facts.has_ge_1_0_release);
2397        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
2398        assert!(facts.maturity_signals.production);
2399        assert_eq!(facts.inferred_maturity, Maturity::Production);
2400    }
2401
2402    #[test]
2403    fn renovate_unlocks_the_zerover_release_path() {
2404        // The ZeroVer path is bot-agnostic: a `renovate.json` unlocks it exactly
2405        // as `dependabot.yml` does.
2406        let fs = zerover_fs("renovate.json");
2407        let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
2408        assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
2409        assert!(facts.maturity_signals.production);
2410        assert_eq!(facts.inferred_maturity, Maturity::Production);
2411    }
2412
2413    #[test]
2414    fn bare_0x_with_only_a_tag_is_not_production() {
2415        // The guard: a 0.x repo with ONLY a SemVer tag — no CI, no dependency
2416        // bot — must NOT inflate to `production`. It has a shipped tag and ≥2
2417        // recent committers, but the substantive signals (CI + a dep bot +
2418        // cadence) are absent.
2419        let fs = FakeFs::default().file(
2420            "/repo/Cargo.toml",
2421            "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2422        );
2423        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
2424        assert!(!facts.has_ci);
2425        assert!(!facts.has_ge_1_0_release);
2426        assert_eq!(facts.dependency_bot, None);
2427        assert!(!facts.maturity_signals.production);
2428        // Has a SemVer tag → not spike; the tie resolves to mvp.
2429        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2430    }
2431
2432    #[test]
2433    fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
2434        // The gaming guard: CI + a dep bot + ≥2 recent committers, but the only
2435        // tags are `0.0.x` — SemVer's initial-scratch space. Those are not
2436        // shipped releases, so the ZeroVer path stays closed → mvp. This blocks
2437        // the "empty workflow + empty dependabot.yml + `v0.0.1`" inflation.
2438        let fs = zerover_fs(".github/dependabot.yml");
2439        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
2440        assert!(facts.has_ci);
2441        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
2442        assert!(!facts.maturity_signals.production);
2443        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2444    }
2445
2446    #[test]
2447    fn pre_1_0_release_infra_requires_release_cadence() {
2448        // CI + a dep bot + ≥2 recent committers + a single shipped ≥0.1.0 tag →
2449        // one release is a moment, not a cadence → not production. Two shipped
2450        // releases are required, so a lone `git tag` can't unlock the path.
2451        let fs = zerover_fs(".github/dependabot.yml");
2452        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
2453        assert!(!facts.maturity_signals.production);
2454        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2455    }
2456
2457    #[test]
2458    fn pre_1_0_release_infra_requires_dependency_bot() {
2459        // CI + a release cadence (two shipped tags) + ≥2 recent committers but NO
2460        // dependency bot → the ZeroVer path is incomplete → mvp. The dep bot is
2461        // the sole missing signal here, isolating its requirement.
2462        let fs = FakeFs::default()
2463            .file(
2464                "/repo/Cargo.toml",
2465                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2466            )
2467            .file("/repo/.github/workflows/ci.yml", "on: push\n");
2468        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
2469        assert_eq!(facts.dependency_bot, None);
2470        assert!(!facts.maturity_signals.production);
2471        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2472    }
2473
2474    #[test]
2475    fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
2476        // CI + a dep bot + ≥2 recent committers, but the tags are one shipped
2477        // release plus prereleases (`v0.6.0-rc1`, `v0.7.0-rc1`) → only one
2478        // non-prerelease ≥0.1.0 tag → no cadence → not production. Confirms
2479        // prereleases don't pad the shipped-release count.
2480        let fs = zerover_fs(".github/dependabot.yml");
2481        let facts = gather(
2482            repo(),
2483            &fs,
2484            &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
2485        );
2486        assert!(facts.has_semver_tag);
2487        assert!(!facts.maturity_signals.production);
2488        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2489    }
2490
2491    #[test]
2492    fn pre_1_0_release_infra_requires_two_recent_committers() {
2493        // Full ZeroVer release evidence (CI + dep bot + cadence) but a single
2494        // recent committer → not production (solo maintenance).
2495        let fs = zerover_fs(".github/dependabot.yml");
2496        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
2497        assert!(!facts.maturity_signals.production);
2498        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2499    }
2500
2501    #[test]
2502    fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
2503        // Regression: the ≥1.0 path is unchanged — a ≥1.0 release + CI + ≥2
2504        // recent committers reaches production with NO dependency bot and no
2505        // cadence requirement. The bot asymmetry applies only below 1.0.
2506        let fs = FakeFs::default()
2507            .file(
2508                "/repo/Cargo.toml",
2509                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
2510            )
2511            .file("/repo/.github/workflows/ci.yml", "on: push\n");
2512        let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
2513        assert!(facts.has_ge_1_0_release);
2514        assert_eq!(facts.dependency_bot, None);
2515        assert!(facts.maturity_signals.production);
2516        assert_eq!(facts.inferred_maturity, Maturity::Production);
2517    }
2518
2519    #[test]
2520    fn spike_forced_by_readme_label_even_with_multiple_committers() {
2521        // No CI, no SemVer tag, but 3 committers — the README label flips spike.
2522        let fs = FakeFs::default()
2523            .file(
2524                "/repo/Cargo.toml",
2525                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2526            )
2527            .file(
2528                "/repo/README.md",
2529                "# X\n\nThis is an experimental prototype.\n",
2530            );
2531        let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
2532        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
2533        assert!(facts.maturity_signals.spike);
2534        assert_eq!(facts.inferred_maturity, Maturity::Spike);
2535    }
2536
2537    #[test]
2538    fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
2539        // No CI, no tag, >1 committer, no label → spike's committer clause fails
2540        // → mvp (the tie-breaker).
2541        let fs = FakeFs::default().file(
2542            "/repo/Cargo.toml",
2543            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2544        );
2545        let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
2546        assert!(!facts.maturity_signals.spike);
2547        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2548    }
2549
2550    // ── Determinism ────────────────────────────────────────────────────────
2551
2552    #[test]
2553    fn same_repo_same_facts() {
2554        let build = || {
2555            FakeFs::default()
2556                .file(
2557                    "/repo/Cargo.toml",
2558                    "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
2559                )
2560                .file("/repo/.github/workflows/ci.yml", "on: push\n")
2561        };
2562        let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
2563        let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
2564        assert_eq!(
2565            serde_json::to_string(&a).unwrap(),
2566            serde_json::to_string(&b).unwrap()
2567        );
2568    }
2569}