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};
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
104    // ── committers (mailmap-aware, whole `--all` history) ──
105    let (committers_total, committers_recent_year) = if has_commits {
106        let total = git.shortlog(None).map_or(0, count_lines);
107        let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
108        (total, recent)
109    } else {
110        (0, 0)
111    };
112
113    // ── tags / releases ──
114    let tags = if has_commits {
115        git.tags().unwrap_or_default()
116    } else {
117        Vec::new()
118    };
119    let semver_tags: Vec<(u64, u64, u64, bool)> =
120        tags.iter().filter_map(|t| semver_parse(t)).collect();
121    let has_semver_tag = !semver_tags.is_empty();
122    // Count *shipped* releases: non-prerelease SemVer tags at `>=0.1.0`. A `0.0.x`
123    // tag is SemVer's initial-scratch space ("anything MAY change at any time"),
124    // and a `-rc`/`-alpha` prerelease is not shipped — neither is counted. A
125    // consumer can recompute this from the emitted `tags` with the same parse.
126    let shipped_release_tags = semver_tags
127        .iter()
128        .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
129        .count();
130    let ge_1_0_tag = semver_tags
131        .iter()
132        .any(|&(major, _, _, pre)| major >= 1 && !pre);
133    let manifest_ge_1_0 = packages
134        .iter()
135        .any(|p| version_ge_1_0(p.version.as_deref()));
136    let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
137
138    // ── CI presence ──
139    let has_ci = CI_GLOBS.iter().any(|glob| {
140        let path = repo_root.join(glob);
141        if !fs.exists(&path) {
142            return false;
143        }
144        if glob.ends_with("workflows") {
145            // A workflows *directory* counts only when it holds an entry.
146            fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
147        } else {
148            true
149        }
150    });
151
152    // ── dependency bot ──
153    let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
154        Some("dependabot".to_string())
155    } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
156        .iter()
157        .any(|f| fs.is_file(&repo_root.join(f)))
158    {
159        Some("renovate".to_string())
160    } else {
161        None
162    };
163
164    let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
165
166    // ── README self-label + description ──
167    let readme_text = ["README.md", "README.rst", "README.txt", "README"]
168        .iter()
169        .find_map(|name| {
170            let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
171            (!text.is_empty()).then_some(text)
172        });
173    let readme_self_label = readme_text.as_deref().and_then(|text| {
174        let low = text.to_lowercase();
175        SPIKE_LABELS
176            .iter()
177            .any(|label| low.contains(label))
178            .then(|| "spike".to_string())
179    });
180    let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
181
182    // ── maturity inference (SCHEMA.md §4, production first, tie → mvp) ──
183    //
184    // A deliberately-pre-1.0 (ZeroVer) project can still be production-grade: the
185    // version number is not release maturity. The `>=1.0` gate is really a
186    // stability *declaration*; below 1.0 that declaration is absent, so the
187    // `zerover_release_evidence` path requires compensating evidence of a
188    // maintained release process — a dependency-update bot configured **and** a
189    // release *cadence* (>=2 shipped `>=0.1.0` releases). A single tag is a
190    // moment; two prove the project has actually iterated a release more than
191    // once, which a lone `git tag` cannot fake. Combined with the always-required
192    // CI and >=2 recent committers, this is materially harder to inflate than the
193    // old "only a tag" concern.
194    //
195    // The asymmetry (a `>=1.0` project reaches `production` without a bot, a 0.x
196    // one does not) is intentional: `>=1.0` already carries the stability signal
197    // this path has to reconstruct. These remain presence/name heuristics over a
198    // cooperative repo (CI/bot detected by path, tags by name) — not adversarial
199    // proofs — and `/oss-init` surfaces every signal to a human before it lands
200    // in the contract. Each input is already in the report (`has_ci`,
201    // `dependency_bot`, `tags` — from which `shipped_release_tags` recomputes via
202    // the same parse — `committers_recent_year`, `has_ge_1_0_release`), so the
203    // decision is re-derivable without a new wire field.
204    let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
205    let release_gate = has_ge_1_0_release || zerover_release_evidence;
206    let production = committers_recent_year >= 2 && has_ci && release_gate;
207    let spike =
208        !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
209    let inferred_maturity = if production {
210        Maturity::Production
211    } else if spike {
212        Maturity::Spike
213    } else {
214        Maturity::Mvp
215    };
216
217    Facts {
218        repo_root: repo_root.display().to_string(),
219        is_git,
220        has_commits,
221        ecosystems,
222        packages,
223        committers_total,
224        committers_recent_year,
225        tags,
226        has_semver_tag,
227        has_ge_1_0_release,
228        has_ci,
229        dependency_bot,
230        has_issues_dir,
231        readme_self_label,
232        description,
233        maturity_signals: MaturitySignals { production, spike },
234        inferred_maturity,
235    }
236}
237
238/// Sniff root-level manifests into the ordered `ecosystems` + `packages` lists.
239fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
240    let mut ecosystems: Vec<Ecosystem> = Vec::new();
241    let mut packages: Vec<Package> = Vec::new();
242    for &(fname, eco) in MANIFESTS {
243        let path = repo_root.join(fname);
244        if !fs.is_file(&path) {
245            continue;
246        }
247        let text = read_text(fs, &path, MANIFEST_LIMIT);
248        let parsed = text
249            .as_deref()
250            .and_then(|t| parse_manifest(fname, t))
251            .unwrap_or_default();
252        // A Cargo virtual workspace (no `[package]`) still marks the repo rust.
253        if !ecosystems.contains(&eco) {
254            ecosystems.push(eco);
255        }
256        // A Cargo *virtual workspace* (no `[package]`) declares its real crates in
257        // `[workspace].members`: descend into each member manifest and emit one
258        // entry per member with its resolved name + version, rather than a single
259        // null-named root entry.
260        if fname == "Cargo.toml" && parsed.package.is_none() {
261            if let Some(text) = text.as_deref() {
262                if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
263                    continue;
264                }
265            }
266            // Not a members-bearing virtual workspace (or no member manifest
267            // resolved): fall through to the null root entry — the repo is still
268            // rust, and the null-named entry preserves today's signal.
269        }
270        // `Cargo.toml`/`go.mod` always yield a package entry (even without a
271        // declared name); the others only when they name a package.
272        if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
273            packages.push(Package {
274                ecosystem: eco,
275                manifest: fname.to_string(),
276                package: parsed.package,
277                version: parsed.version,
278            });
279        }
280    }
281    // `binary` only when NO package ecosystem is detected — never additive.
282    if ecosystems.is_empty() {
283        ecosystems.push(Ecosystem::Binary);
284    }
285    (ecosystems, packages)
286}
287
288/// Enumerate a Cargo virtual workspace's members into `packages`, one entry per
289/// member manifest that resolves through `fs`. Explicit members are read in
290/// declaration order; a trailing single-level glob (`crates/*`) is expanded by
291/// listing its directory through the `Fs` port and sorting the entries, so the
292/// emitted order is deterministic regardless of the underlying read-dir order.
293/// `[workspace].exclude` entries are dropped, and duplicate member paths (e.g.
294/// an explicit member also matched by a glob) are emitted once.
295///
296/// Each member reports its own `[package].name`; the version is its literal
297/// `[package].version`, or — when the member declares `version.workspace = true`
298/// (dotted) or `version = { workspace = true }` (inline) — the version inherited
299/// from the root `[workspace.package]` table.
300///
301/// Returns `true` when at least one member manifest was emitted (the caller then
302/// skips the null root entry); `false` when the root has no `members` array or no
303/// listed member manifest resolved (the caller keeps today's null-entry behavior).
304fn push_workspace_members(
305    repo_root: &Path,
306    fs: &dyn Fs,
307    root_text: &str,
308    eco: Ecosystem,
309    packages: &mut Vec<Package>,
310) -> bool {
311    let ws_block = match toml_section(root_text, "workspace") {
312        Some(block) => block,
313        None => return false,
314    };
315    let members = match toml_str_array(&ws_block, "members") {
316        Some(members) if !members.is_empty() => members,
317        _ => return false,
318    };
319    let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
320        .unwrap_or_default()
321        .iter()
322        .map(|e| e.trim_end_matches('/').to_string())
323        .collect();
324    let ws_pkg = toml_section(root_text, "workspace.package");
325    let before = packages.len();
326    // Manifest-relative paths already emitted — dedup preserving first-seen order.
327    let mut seen: Vec<String> = Vec::new();
328    for member in members {
329        let member = member.trim_end_matches('/');
330        // A fact detector reports the repo's OWN packages: reject a member that
331        // escapes the tree (absolute, or a `..` component) — with a real `Fs`
332        // those would read manifests outside `repo_root` and taint the facts.
333        if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
334            continue;
335        }
336        if let Some(prefix) = glob_parent(member) {
337            // Trailing single-level glob (`crates/*`, bare `*`): expand one level.
338            let dir = if prefix.is_empty() {
339                repo_root.to_path_buf()
340            } else {
341                repo_root.join(prefix)
342            };
343            let mut names = fs.read_dir(&dir).unwrap_or_default();
344            names.sort();
345            for name in names {
346                let rel = if prefix.is_empty() {
347                    name
348                } else {
349                    format!("{prefix}/{name}")
350                };
351                push_one_member(
352                    repo_root,
353                    fs,
354                    &rel,
355                    ws_pkg.as_deref(),
356                    eco,
357                    &exclude,
358                    &mut seen,
359                    packages,
360                );
361            }
362            continue;
363        }
364        // A glob shape we do not expand (`?`, character classes, a non-trailing
365        // `*`): skip rather than probe a literal metacharacter path.
366        if member.contains(['*', '?']) {
367            continue;
368        }
369        push_one_member(
370            repo_root,
371            fs,
372            member,
373            ws_pkg.as_deref(),
374            eco,
375            &exclude,
376            &mut seen,
377            packages,
378        );
379    }
380    packages.len() > before
381}
382
383/// Emit one workspace member at manifest-relative `rel` (no trailing slash needed)
384/// into `packages`, unless it is `exclude`d, already `seen`, or its `Cargo.toml`
385/// does not resolve through `fs`. Records emitted members in `seen` for dedup.
386#[allow(clippy::too_many_arguments)]
387fn push_one_member(
388    repo_root: &Path,
389    fs: &dyn Fs,
390    rel: &str,
391    ws_pkg: Option<&str>,
392    eco: Ecosystem,
393    exclude: &[String],
394    seen: &mut Vec<String>,
395    packages: &mut Vec<Package>,
396) {
397    let rel = rel.trim_end_matches('/');
398    if exclude.iter().any(|e| e == rel) || seen.iter().any(|s| s == rel) {
399        return;
400    }
401    let manifest_path = repo_root.join(rel).join("Cargo.toml");
402    if !fs.is_file(&manifest_path) {
403        return;
404    }
405    let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
406        return;
407    };
408    seen.push(rel.to_string());
409    let (package, version) = resolve_member_name_version(&member_text, ws_pkg);
410    packages.push(Package {
411        ecosystem: eco,
412        manifest: format!("{rel}/Cargo.toml"),
413        package,
414        version,
415    });
416}
417
418/// The literal parent directory of a trailing single-level glob member — `crates/*`
419/// → `Some("crates")`, bare `*` → `Some("")` — or `None` when `member` is not such
420/// a glob. A prefix that itself contains a glob metacharacter is not expandable.
421fn glob_parent(member: &str) -> Option<&str> {
422    if member == "*" {
423        return Some("");
424    }
425    member
426        .strip_suffix("/*")
427        .filter(|prefix| !prefix.contains(['*', '?']))
428}
429
430/// Resolve a workspace member's `(name, version)` from its manifest text, honoring
431/// `version.workspace = true` inheritance from the root `[workspace.package]`
432/// block. Crate names are never workspace-inherited, so `name` is taken verbatim.
433fn resolve_member_name_version(
434    member_text: &str,
435    ws_pkg: Option<&str>,
436) -> (Option<String>, Option<String>) {
437    let parsed = parse_cargo(member_text).unwrap_or_default();
438    let version = parsed.version.or_else(|| {
439        // Scope the inheritance probe to the member's own `[package]` block: a
440        // `version.workspace = true` in an unrelated table (`[package.metadata.*]`,
441        // a tool config) must not be read as `[package].version` inheritance.
442        let inherits = toml_section(member_text, "package")
443            .is_some_and(|block| field_inherits_workspace(&block, "version"));
444        if inherits {
445            ws_pkg.and_then(|block| toml_str_value(block, "version", false))
446        } else {
447            None
448        }
449    });
450    (parsed.package, version)
451}
452
453/// The description: first non-empty manifest `description`, else the first
454/// non-heading README line — both trimmed and truncated to 120 characters.
455fn detect_description(
456    repo_root: &Path,
457    fs: &dyn Fs,
458    packages: &[Package],
459    readme_text: Option<&str>,
460) -> Option<String> {
461    let manifest_desc = packages.iter().find_map(|p| {
462        let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
463        let desc = parse_manifest(&p.manifest, &text)?.description?;
464        (!desc.is_empty()).then_some(desc)
465    });
466    if let Some(desc) = manifest_desc {
467        return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
468    }
469    readme_text?.lines().find_map(|line| {
470        let s = line.trim();
471        let is_prose =
472            !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
473        is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
474    })
475}
476
477// ── Manifest parsing (name + version + description) ──────────────────────────
478
479/// The name/version/description parsed from one manifest.
480#[derive(Debug, Default)]
481struct ParsedManifest {
482    package: Option<String>,
483    version: Option<String>,
484    description: Option<String>,
485}
486
487/// Dispatch to the per-manifest parser. `setup.py` yields nothing (its metadata
488/// is executable, not declarative — the Python detector skips it too).
489///
490/// Dispatches on the manifest's *basename* so a member path
491/// (`crates/ossctl-core/Cargo.toml`) parses like a root `Cargo.toml` — the
492/// description pass re-reads member manifests by their stored relative path.
493fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
494    let base = Path::new(fname)
495        .file_name()
496        .and_then(|n| n.to_str())
497        .unwrap_or(fname);
498    match base {
499        "Cargo.toml" => parse_cargo(text),
500        "package.json" => parse_package_json(text),
501        "pyproject.toml" => Some(parse_pyproject(text)),
502        "go.mod" => Some(parse_gomod(text)),
503        _ => None, // setup.py
504    }
505}
506
507/// Parse a Cargo manifest's `[package]` block. Returns `None` for a virtual
508/// workspace (no `[package]`), which still marks the repo rust upstream.
509fn parse_cargo(text: &str) -> Option<ParsedManifest> {
510    let block = toml_section(text, "package")?;
511    Some(ParsedManifest {
512        package: toml_str_value(&block, "name", false),
513        version: toml_str_value(&block, "version", false),
514        description: toml_str_value(&block, "description", true),
515    })
516}
517
518fn parse_package_json(text: &str) -> Option<ParsedManifest> {
519    let value: serde_json::Value = serde_json::from_str(text).ok()?;
520    let field = |key: &str| {
521        value
522            .get(key)
523            .and_then(serde_json::Value::as_str)
524            .map(str::to_string)
525    };
526    Some(ParsedManifest {
527        package: field("name"),
528        version: field("version"),
529        description: field("description"),
530    })
531}
532
533/// Parse a `pyproject.toml`: the standard `[project]` table first, then a legacy
534/// `[tool.poetry]` fallback when `[project]` names no package.
535fn parse_pyproject(text: &str) -> ParsedManifest {
536    let mut parsed = ParsedManifest::default();
537    if let Some(block) = toml_section(text, "project") {
538        parsed.package = toml_str_value(&block, "name", false);
539        parsed.version = toml_str_value(&block, "version", false);
540        parsed.description = toml_str_value(&block, "description", true);
541    }
542    if parsed.package.is_none() {
543        if let Some(block) = toml_section(text, "tool.poetry") {
544            parsed.package = toml_str_value(&block, "name", false);
545            parsed.version = toml_str_value(&block, "version", false);
546            parsed.description = toml_str_value(&block, "description", true);
547        }
548    }
549    parsed
550}
551
552/// Parse a `go.mod`'s `module <path>` line. Always yields a (possibly empty)
553/// result — a `go.mod` marks the repo go regardless of a `module` line.
554fn parse_gomod(text: &str) -> ParsedManifest {
555    let module = text.lines().find_map(|line| {
556        line.strip_prefix("module")
557            .filter(|rest| rest.starts_with(char::is_whitespace))
558            .and_then(|rest| rest.split_whitespace().next())
559            .map(str::to_string)
560    });
561    ParsedManifest {
562        package: module,
563        version: None,
564        description: None,
565    }
566}
567
568/// Extract a TOML `[header]` section body: every line after the header line up
569/// to the next `[...]` line or end of file. `None` when the header is absent.
570/// The header must begin the line (no indentation), matching the Python `^\[`.
571fn toml_section(text: &str, header: &str) -> Option<String> {
572    let needle = format!("[{header}]");
573    let mut in_section = false;
574    let mut out = String::new();
575    for line in text.lines() {
576        if in_section {
577            if line.starts_with('[') {
578                break;
579            }
580            out.push_str(line);
581            out.push('\n');
582        } else if line.starts_with(&needle) {
583            in_section = true;
584        }
585    }
586    in_section.then_some(out)
587}
588
589/// Find `key = "value"` within a TOML section body and return the quoted value.
590/// `allow_empty` controls whether an empty `""` counts (the Python `name`/
591/// `version` patterns require non-empty; `description` allows empty).
592fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
593    for line in block.lines() {
594        let rest = line.trim_start();
595        let Some(rest) = rest.strip_prefix(key) else {
596            continue;
597        };
598        // The key must be a whole token: the char after it is whitespace or `=`
599        // (else `name` would spuriously match `nameservers`). Mirrors the Python
600        // `^\s*<key>\s*=` anchor.
601        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
602            continue;
603        }
604        let Some(rest) = rest.trim_start().strip_prefix('=') else {
605            continue;
606        };
607        let Some(value) = extract_quoted(rest.trim_start()) else {
608            continue;
609        };
610        if value.is_empty() && !allow_empty {
611            return None;
612        }
613        return Some(value);
614    }
615    None
616}
617
618/// Find `key = [ "a", "b", … ]` within a TOML section body and return the quoted
619/// elements, in order. Handles both the single-line array and a multi-line array
620/// that spans several lines (Cargo `members`/`exclude` lists are commonly
621/// formatted either way), stripping `#` comments so a commented-out element is not
622/// returned. Elements are returned as their raw quoted text — glob expansion and
623/// path validation are the caller's job. `None` when the key is absent.
624fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
625    // Accumulate from the `key = [` line through the line holding the closing `]`.
626    let mut acc = String::new();
627    let mut collecting = false;
628    for line in block.lines() {
629        // Drop a trailing `#` comment first, so a commented-out element
630        // (`# "old-member"`) or a `]` inside a comment does not corrupt the scan.
631        let line = strip_toml_comment(line);
632        if collecting {
633            acc.push_str(line);
634            acc.push('\n');
635            if line.contains(']') {
636                break;
637            }
638            continue;
639        }
640        let rest = line.trim_start();
641        let Some(rest) = rest.strip_prefix(key) else {
642            continue;
643        };
644        // The key must be a whole token (else `members-extra` would match).
645        if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
646            continue;
647        }
648        let Some(rest) = rest.trim_start().strip_prefix('=') else {
649            continue;
650        };
651        acc.push_str(rest);
652        acc.push('\n');
653        collecting = true;
654        if rest.contains(']') {
655            break;
656        }
657    }
658    if !collecting {
659        return None;
660    }
661    // Slice between the first `[` and the first `]`, then pull quoted strings.
662    let start = acc.find('[')?;
663    let end = acc[start..].find(']')? + start;
664    let mut inner = &acc[start + 1..end];
665    let mut out = Vec::new();
666    while let Some(pos) = inner.find(['"', '\'']) {
667        let quote = inner.as_bytes()[pos] as char;
668        let after = &inner[pos + 1..];
669        let Some(close) = after.find(quote) else {
670            break;
671        };
672        out.push(after[..close].to_string());
673        inner = &after[close + 1..];
674    }
675    Some(out)
676}
677
678/// Whether a member manifest block declares `<key>.workspace = true` (dotted) or
679/// `<key> = { workspace = true }` (inline) — the two forms of Cargo workspace
680/// field inheritance. Used to decide whether to inherit from `[workspace.package]`.
681/// Callers pass the member's `[package]` block, not the whole file, so an unrelated
682/// table cannot trip the match.
683fn field_inherits_workspace(block: &str, key: &str) -> bool {
684    let dotted = format!("{key}.workspace");
685    for line in block.lines() {
686        let t = strip_toml_comment(line).trim_start();
687        // Dotted: `version.workspace = true`.
688        if let Some(rest) = t.strip_prefix(&dotted) {
689            let rest = rest.trim_start();
690            if let Some(rest) = rest.strip_prefix('=') {
691                if is_true_literal(rest.trim_start()) {
692                    return true;
693                }
694            }
695            continue;
696        }
697        // Inline table: `version = { workspace = true }`.
698        if let Some(rest) = t.strip_prefix(key) {
699            if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
700                continue;
701            }
702            let Some(rest) = rest.trim_start().strip_prefix('=') else {
703                continue;
704            };
705            let v = rest.trim_start();
706            // Require the exact `workspace = true` entry inside the inline table —
707            // a substring test would accept `{ workspace = false, x = true }`.
708            if v.starts_with('{') && inline_table_has_workspace_true(v) {
709                return true;
710            }
711        }
712    }
713    false
714}
715
716/// Whether `s` begins with the TOML boolean `true` as a whole token (not
717/// `trueish`, not the string `"true"`), allowing a trailing comment or `}`.
718fn is_true_literal(s: &str) -> bool {
719    match s.strip_prefix("true") {
720        Some(rest) => {
721            let rest = rest.trim_start();
722            rest.is_empty() || rest.starts_with(['#', '}', ','])
723        }
724        None => false,
725    }
726}
727
728/// Whether an inline table (`{ … }`) contains an exact `workspace = true` entry.
729/// Whitespace-insensitive so `{workspace=true}` and `{ workspace = true }` both
730/// match, while `{ workspace = false, other = true }` does not.
731fn inline_table_has_workspace_true(inline: &str) -> bool {
732    let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
733    compact.trim_start_matches('{').split(',').any(|entry| {
734        entry
735            .strip_prefix("workspace=true")
736            .is_some_and(|r| r.is_empty() || r == "}")
737    })
738}
739
740/// Return `line` with any trailing `#` comment removed, respecting `#` characters
741/// that fall inside a `"`/`'` quoted string (which are literal, not comments).
742fn strip_toml_comment(line: &str) -> &str {
743    let mut quote: Option<u8> = None;
744    for (i, &b) in line.as_bytes().iter().enumerate() {
745        match quote {
746            Some(q) => {
747                if b == q {
748                    quote = None;
749                }
750            }
751            None => match b {
752                b'"' | b'\'' => quote = Some(b),
753                b'#' => return &line[..i],
754                _ => {}
755            },
756        }
757    }
758    line
759}
760
761/// Read a leading quoted string — `"..."` or `'...'`. TOML allows both basic
762/// (double) and literal (single) strings, and `tomllib` accepts either, so both
763/// are honored here for parity. No escape handling: neither this nor the Python
764/// regex `"([^"]+)"` unescapes, and manifest name/version/description do not need
765/// it in practice.
766fn extract_quoted(s: &str) -> Option<String> {
767    let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
768    let s = &s[1..];
769    let end = s.find(quote)?;
770    Some(s[..end].to_string())
771}
772
773// ── SemVer helpers ───────────────────────────────────────────────────────────
774
775/// Parse a possibly package-prefixed `SemVer` tag into
776/// `(major, minor, patch, is_prerelease)`, or `None` if it is not `SemVer`.
777///
778/// Strips a monorepo `pkg-`/`pkg@`/`pkg/` prefix (e.g. `core-v1.2.3`,
779/// `@acme/cli@2.0.0`) before parsing, mirroring the Python `_semver_parse`.
780fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
781    parse_semver_core(strip_pkg_prefix(tag))
782}
783
784/// Strip everything up to and including the rightmost `@`/`/`/`-` that is
785/// immediately followed by an optional `v` and a `X.Y.Z` version.
786fn strip_pkg_prefix(tag: &str) -> &str {
787    let bytes = tag.as_bytes();
788    for i in (0..bytes.len()).rev() {
789        if matches!(bytes[i], b'@' | b'/' | b'-') {
790            let rest = &tag[i + 1..];
791            if starts_with_version(rest) {
792                return rest;
793            }
794        }
795    }
796    tag
797}
798
799/// Whether `s` begins with `v?\d+\.\d+\.\d+` (the version-start lookahead).
800fn starts_with_version(s: &str) -> bool {
801    let mut rest = s.strip_prefix('v').unwrap_or(s);
802    for i in 0..3 {
803        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
804        if digits == 0 {
805            return false;
806        }
807        rest = &rest[digits..];
808        if i < 2 {
809            match rest.strip_prefix('.') {
810                Some(r) => rest = r,
811                None => return false,
812            }
813        }
814    }
815    true
816}
817
818/// Fully parse a version core `v?\d+\.\d+\.\d+(?:[-+].*)?`. The prerelease flag
819/// is set when a `-` (not `+`) immediately follows `X.Y.Z`.
820fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
821    let mut rest = core.strip_prefix('v').unwrap_or(core);
822    let mut nums = [0u64; 3];
823    for (i, slot) in nums.iter_mut().enumerate() {
824        let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
825        if digits == 0 {
826            return None;
827        }
828        *slot = rest[..digits].parse().ok()?;
829        rest = &rest[digits..];
830        if i < 2 {
831            rest = rest.strip_prefix('.')?;
832        }
833    }
834    let pre = match rest.chars().next() {
835        None | Some('+') => false,
836        Some('-') => true,
837        Some(_) => return None, // trailing junk after X.Y.Z → not SemVer
838    };
839    Some((nums[0], nums[1], nums[2], pre))
840}
841
842/// Whether a manifest version string is `>=1.0` (`v?\d+\.` with major `>=1`).
843fn version_ge_1_0(version: Option<&str>) -> bool {
844    let Some(v) = version else {
845        return false;
846    };
847    let s = v.strip_prefix('v').unwrap_or(v);
848    let digits = s.bytes().take_while(u8::is_ascii_digit).count();
849    // A leading number followed by a `.` — bare `1` (no dot) does not qualify.
850    if digits == 0 || !s[digits..].starts_with('.') {
851        return false;
852    }
853    s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
854}
855
856// ── Small helpers ────────────────────────────────────────────────────────────
857
858/// Read a file through the [`Fs`] port as lossy UTF-8, capped at `limit`
859/// *characters* (not bytes) — the Python `_read` opens in text mode, so
860/// `fh.read(limit)` counts decoded characters. Slicing bytes instead would
861/// under-read a multibyte README (a 4000-byte cap holds only ~1333 CJK chars)
862/// and could split a codepoint into a `U+FFFD`. `None` when the read fails.
863fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
864    let bytes = fs.read(path).ok()?;
865    Some(
866        String::from_utf8_lossy(&bytes)
867            .chars()
868            .take(limit)
869            .collect(),
870    )
871}
872
873/// Count non-blank lines (git shortlog emits one per committer).
874fn count_lines(text: String) -> usize {
875    text.lines().filter(|l| !l.trim().is_empty()).count()
876}
877
878/// Truncate to at most `n` characters (not bytes) — the Python `[:n]` slice.
879fn truncate_chars(s: &str, n: usize) -> String {
880    s.chars().take(n).collect()
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use std::collections::{HashMap, HashSet};
887    use std::path::PathBuf;
888
889    // ── In-memory fakes for the ports ──────────────────────────────────────
890
891    #[derive(Default)]
892    struct FakeFs {
893        files: HashMap<PathBuf, Vec<u8>>,
894        dirs: HashSet<PathBuf>,
895    }
896
897    impl FakeFs {
898        fn file(mut self, path: &str, contents: &str) -> Self {
899            let p = PathBuf::from(path);
900            // Register ancestor directories so `read_dir`/`is_dir` see them.
901            let mut cur = p.parent();
902            while let Some(dir) = cur {
903                if dir.as_os_str().is_empty() {
904                    break;
905                }
906                self.dirs.insert(dir.to_path_buf());
907                cur = dir.parent();
908            }
909            self.files.insert(p, contents.as_bytes().to_vec());
910            self
911        }
912
913        fn dir(mut self, path: &str) -> Self {
914            self.dirs.insert(PathBuf::from(path));
915            self
916        }
917    }
918
919    impl Fs for FakeFs {
920        fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
921            self.files
922                .get(path)
923                .cloned()
924                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
925        }
926        fn exists(&self, path: &Path) -> bool {
927            self.files.contains_key(path) || self.dirs.contains(path)
928        }
929        fn is_dir(&self, path: &Path) -> bool {
930            self.dirs.contains(path)
931        }
932        fn is_file(&self, path: &Path) -> bool {
933            self.files.contains_key(path)
934        }
935        fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
936            if !self.dirs.contains(dir) {
937                return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
938            }
939            let mut names: Vec<String> = self
940                .files
941                .keys()
942                .chain(self.dirs.iter())
943                .filter(|p| p.parent() == Some(dir))
944                .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
945                .collect();
946            names.sort();
947            Ok(names)
948        }
949    }
950
951    #[derive(Default)]
952    struct FakeGit {
953        work_tree: bool,
954        head: Option<String>,
955        shortlog_all: String,
956        shortlog_recent: String,
957        tags: Vec<String>,
958    }
959
960    impl GitRepo for FakeGit {
961        fn head_commit(&self) -> std::io::Result<String> {
962            self.head
963                .clone()
964                .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
965        }
966        fn is_work_tree(&self) -> bool {
967            self.work_tree
968        }
969        fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
970            if !self.work_tree {
971                return Err(std::io::Error::from(std::io::ErrorKind::Other));
972            }
973            Ok(if since.is_some() {
974                self.shortlog_recent.clone()
975            } else {
976                self.shortlog_all.clone()
977            })
978        }
979        fn tags(&self) -> std::io::Result<Vec<String>> {
980            if self.work_tree {
981                Ok(self.tags.clone())
982            } else {
983                Err(std::io::Error::from(std::io::ErrorKind::Other))
984            }
985        }
986        fn git_common_dir(&self) -> std::io::Result<PathBuf> {
987            Ok(PathBuf::from("/repo/.git"))
988        }
989    }
990
991    /// A git repo with `n` distinct committers total / recent and the given tags.
992    fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
993        let lines = |n: usize| {
994            (0..n)
995                .map(|i| format!("     3\tDev {i} <dev{i}@example.com>"))
996                .collect::<Vec<_>>()
997                .join("\n")
998        };
999        FakeGit {
1000            work_tree: true,
1001            head: Some("deadbeef".to_string()),
1002            shortlog_all: lines(total),
1003            shortlog_recent: lines(recent),
1004            tags: tags.iter().map(|t| (*t).to_string()).collect(),
1005        }
1006    }
1007
1008    fn repo() -> &'static Path {
1009        Path::new("/repo")
1010    }
1011
1012    // ── Empty / unborn repo ────────────────────────────────────────────────
1013
1014    #[test]
1015    fn empty_repo_is_spike_binary() {
1016        let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1017        assert!(!facts.is_git);
1018        assert!(!facts.has_commits);
1019        assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1020        assert!(facts.packages.is_empty());
1021        assert_eq!(facts.committers_total, 0);
1022        assert_eq!(facts.committers_recent_year, 0);
1023        assert!(facts.tags.is_empty());
1024        assert!(!facts.has_ci);
1025        assert_eq!(facts.dependency_bot, None);
1026        assert_eq!(facts.description, None);
1027        // No CI, no SemVer tag, <=1 committer → spike.
1028        assert!(facts.maturity_signals.spike);
1029        assert_eq!(facts.inferred_maturity, Maturity::Spike);
1030    }
1031
1032    #[test]
1033    fn unborn_repo_has_no_commits() {
1034        // A work tree whose HEAD does not resolve (no commits yet): is_git true,
1035        // has_commits false, so no committers/tags are read.
1036        let git = FakeGit {
1037            work_tree: true,
1038            head: None,
1039            ..FakeGit::default()
1040        };
1041        let facts = gather(repo(), &FakeFs::default(), &git);
1042        assert!(facts.is_git);
1043        assert!(!facts.has_commits);
1044        assert_eq!(facts.committers_total, 0);
1045        assert!(facts.tags.is_empty());
1046    }
1047
1048    // ── Ecosystem + manifest detection ─────────────────────────────────────
1049
1050    #[test]
1051    fn cargo_package_name_version_description() {
1052        let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1053                     description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1054        let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1055        let facts = gather(repo(), &fs, &FakeGit::default());
1056        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1057        assert_eq!(facts.packages.len(), 1);
1058        let p = &facts.packages[0];
1059        assert_eq!(p.ecosystem, Ecosystem::Rust);
1060        assert_eq!(p.manifest, "Cargo.toml");
1061        assert_eq!(p.package.as_deref(), Some("rg"));
1062        assert_eq!(p.version.as_deref(), Some("0.3.0"));
1063        assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1064    }
1065
1066    #[test]
1067    fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1068        // A virtual workspace whose only member manifest is absent falls back to
1069        // the null root entry: the repo is still rust, and the null-named entry
1070        // preserves today's signal rather than emitting nothing.
1071        let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
1072        let facts = gather(repo(), &fs, &FakeGit::default());
1073        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1074        assert_eq!(facts.packages.len(), 1);
1075        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1076        assert_eq!(facts.packages[0].package, None);
1077        assert_eq!(facts.packages[0].version, None);
1078    }
1079
1080    #[test]
1081    fn cargo_virtual_workspace_enumerates_members() {
1082        // A virtual-workspace root + two members: one inherits the workspace
1083        // version (`version.workspace = true`), one pins its own literal version.
1084        let root = "[workspace]\nresolver = \"2\"\n\
1085                    members = [\"crates/core\", \"crates/cli\"]\n\n\
1086                    [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
1087        let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
1088                    edition.workspace = true\ndescription = \"the core lib\"\n";
1089        let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
1090                   description = \"the cli\"\n";
1091        let fs = FakeFs::default()
1092            .file("/repo/Cargo.toml", root)
1093            .file("/repo/crates/core/Cargo.toml", core)
1094            .file("/repo/crates/cli/Cargo.toml", cli);
1095        let facts = gather(repo(), &fs, &FakeGit::default());
1096        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1097        assert_eq!(facts.packages.len(), 2);
1098        // Declaration order is preserved.
1099        let core_pkg = &facts.packages[0];
1100        assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
1101        assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
1102        assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
1103        // `version.workspace = true` inherits 0.1.0 from [workspace.package].
1104        assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
1105        let cli_pkg = &facts.packages[1];
1106        assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
1107        assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
1108        assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
1109        // The description pass re-reads the first member manifest by its path.
1110        assert_eq!(facts.description.as_deref(), Some("the core lib"));
1111    }
1112
1113    #[test]
1114    fn cargo_workspace_inline_version_inheritance() {
1115        // The inline-table inheritance form `version = { workspace = true }`.
1116        let root = "[workspace]\nmembers = [\"m\"]\n\n\
1117                    [workspace.package]\nversion = \"1.5.0\"\n";
1118        let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
1119        let fs = FakeFs::default()
1120            .file("/repo/Cargo.toml", root)
1121            .file("/repo/m/Cargo.toml", member);
1122        let facts = gather(repo(), &fs, &FakeGit::default());
1123        assert_eq!(facts.packages.len(), 1);
1124        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1125        assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
1126        // A member at >=1.0 (inherited) drives has_ge_1_0_release.
1127        assert!(facts.has_ge_1_0_release);
1128    }
1129
1130    #[test]
1131    fn cargo_workspace_multiline_members_array() {
1132        // Members formatted across several lines (the common rustfmt layout).
1133        let root = "[workspace]\nmembers = [\n    \"a\",\n    \"b\",\n]\n\n\
1134                    [workspace.package]\nversion = \"0.2.0\"\n";
1135        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1136        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1137        let fs = FakeFs::default()
1138            .file("/repo/Cargo.toml", root)
1139            .file("/repo/a/Cargo.toml", a)
1140            .file("/repo/b/Cargo.toml", b);
1141        let facts = gather(repo(), &fs, &FakeGit::default());
1142        let names: Vec<_> = facts
1143            .packages
1144            .iter()
1145            .map(|p| p.package.as_deref())
1146            .collect();
1147        assert_eq!(names, vec![Some("a"), Some("b")]);
1148        assert!(facts
1149            .packages
1150            .iter()
1151            .all(|p| p.version.as_deref() == Some("0.2.0")));
1152    }
1153
1154    #[test]
1155    fn cargo_workspace_member_without_workspace_package_table() {
1156        // `version.workspace = true` but no `[workspace.package]` to inherit from:
1157        // the version resolves to null (nothing to inherit), name still reported.
1158        let root = "[workspace]\nmembers = [\"m\"]\n";
1159        let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
1160        let fs = FakeFs::default()
1161            .file("/repo/Cargo.toml", root)
1162            .file("/repo/m/Cargo.toml", member);
1163        let facts = gather(repo(), &fs, &FakeGit::default());
1164        assert_eq!(facts.packages.len(), 1);
1165        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1166        assert_eq!(facts.packages[0].version, None);
1167    }
1168
1169    #[test]
1170    fn cargo_workspace_glob_members_are_expanded() {
1171        // `members = ["crates/*"]` expands one directory level, sorted, through the
1172        // Fs port. A non-crate entry (no Cargo.toml) is skipped.
1173        let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
1174                    [workspace.package]\nversion = \"0.4.0\"\n";
1175        let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
1176        let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
1177        let fs = FakeFs::default()
1178            .file("/repo/Cargo.toml", root)
1179            .file("/repo/crates/za/Cargo.toml", a)
1180            .file("/repo/crates/mb/Cargo.toml", b)
1181            .file("/repo/crates/README.md", "not a crate\n");
1182        let facts = gather(repo(), &fs, &FakeGit::default());
1183        // Directory order is sorted (mb before za), not declaration order.
1184        let names: Vec<_> = facts
1185            .packages
1186            .iter()
1187            .map(|p| p.package.as_deref())
1188            .collect();
1189        assert_eq!(names, vec![Some("mb"), Some("za")]);
1190        assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
1191        assert!(facts
1192            .packages
1193            .iter()
1194            .all(|p| p.version.as_deref() == Some("0.4.0")));
1195    }
1196
1197    #[test]
1198    fn cargo_workspace_exclude_and_dedup() {
1199        // A glob and an explicit member overlap (dedup to one entry); `exclude`
1200        // drops a matched member.
1201        let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
1202                    exclude = [\"crates/b\"]\n\n\
1203                    [workspace.package]\nversion = \"0.1.0\"\n";
1204        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1205        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1206        let fs = FakeFs::default()
1207            .file("/repo/Cargo.toml", root)
1208            .file("/repo/crates/a/Cargo.toml", a)
1209            .file("/repo/crates/b/Cargo.toml", b);
1210        let facts = gather(repo(), &fs, &FakeGit::default());
1211        // `b` excluded; `a` matched by both the glob and the explicit entry → once.
1212        let names: Vec<_> = facts
1213            .packages
1214            .iter()
1215            .map(|p| p.package.as_deref())
1216            .collect();
1217        assert_eq!(names, vec![Some("a")]);
1218    }
1219
1220    #[test]
1221    fn cargo_workspace_commented_out_member_is_ignored() {
1222        // A commented-out member line must not be emitted, even if the path exists.
1223        let root = "[workspace]\nmembers = [\n    \"a\",\n    # \"b\",\n]\n\n\
1224                    [workspace.package]\nversion = \"0.1.0\"\n";
1225        let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1226        let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1227        let fs = FakeFs::default()
1228            .file("/repo/Cargo.toml", root)
1229            .file("/repo/a/Cargo.toml", a)
1230            .file("/repo/b/Cargo.toml", b);
1231        let facts = gather(repo(), &fs, &FakeGit::default());
1232        let names: Vec<_> = facts
1233            .packages
1234            .iter()
1235            .map(|p| p.package.as_deref())
1236            .collect();
1237        assert_eq!(names, vec![Some("a")]);
1238    }
1239
1240    #[test]
1241    fn cargo_workspace_rejects_escaping_member_paths() {
1242        // Absolute and `..` members are rejected (a fact detector reports only the
1243        // repo's own packages) → no member resolves → null root entry fallback.
1244        let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
1245        let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
1246        let fs = FakeFs::default()
1247            .file("/repo/Cargo.toml", root)
1248            .file("/outside/Cargo.toml", outside)
1249            .file("/abs/Cargo.toml", outside);
1250        let facts = gather(repo(), &fs, &FakeGit::default());
1251        assert_eq!(facts.packages.len(), 1);
1252        assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1253        assert_eq!(facts.packages[0].package, None);
1254    }
1255
1256    #[test]
1257    fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
1258        // `version.workspace = true` in `[package.metadata.*]` must NOT be read as
1259        // `[package].version` inheritance; and `= trueish` is not the bool `true`.
1260        let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
1261                    [workspace.package]\nversion = \"7.7.7\"\n";
1262        let m = "[package]\nname = \"m\"\n\n\
1263                 [package.metadata.tool]\nversion.workspace = true\n";
1264        let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
1265        let fs = FakeFs::default()
1266            .file("/repo/Cargo.toml", root)
1267            .file("/repo/m/Cargo.toml", m)
1268            .file("/repo/n/Cargo.toml", n);
1269        let facts = gather(repo(), &fs, &FakeGit::default());
1270        // Neither inherits the workspace version.
1271        assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1272        assert_eq!(facts.packages[0].version, None);
1273        assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
1274        assert_eq!(facts.packages[1].version, None);
1275    }
1276
1277    #[test]
1278    fn cargo_workspace_inline_inheritance_rejects_false_positive() {
1279        // `version = { workspace = false, … }` must not inherit; a genuine
1280        // `{ workspace = true }` must.
1281        let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
1282                    [workspace.package]\nversion = \"3.0.0\"\n";
1283        let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
1284        let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
1285        let fs = FakeFs::default()
1286            .file("/repo/Cargo.toml", root)
1287            .file("/repo/yes/Cargo.toml", yes)
1288            .file("/repo/no/Cargo.toml", no);
1289        let facts = gather(repo(), &fs, &FakeGit::default());
1290        assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
1291        assert_eq!(facts.packages[1].version, None);
1292    }
1293
1294    #[test]
1295    fn package_json_parsed_and_go_mod_module() {
1296        let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
1297        let fs = FakeFs::default()
1298            .file("/repo/package.json", pkg)
1299            .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
1300        let facts = gather(repo(), &fs, &FakeGit::default());
1301        assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
1302        let node = facts
1303            .packages
1304            .iter()
1305            .find(|p| p.ecosystem == Ecosystem::Node)
1306            .unwrap();
1307        assert_eq!(node.package.as_deref(), Some("@acme/cli"));
1308        assert_eq!(node.version.as_deref(), Some("2.0.0"));
1309        let go = facts
1310            .packages
1311            .iter()
1312            .find(|p| p.ecosystem == Ecosystem::Go)
1313            .unwrap();
1314        assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
1315        // package.json's description wins (first package with a description).
1316        assert_eq!(facts.description.as_deref(), Some("cli"));
1317    }
1318
1319    #[test]
1320    fn pyproject_project_then_poetry_fallback() {
1321        let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
1322                       description = \"a widget\"\n";
1323        let facts = gather(
1324            repo(),
1325            &FakeFs::default().file("/repo/pyproject.toml", project),
1326            &FakeGit::default(),
1327        );
1328        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
1329        assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
1330
1331        let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
1332        let facts = gather(
1333            repo(),
1334            &FakeFs::default().file("/repo/pyproject.toml", poetry),
1335            &FakeGit::default(),
1336        );
1337        assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
1338    }
1339
1340    #[test]
1341    fn pyproject_single_quoted_strings_parse() {
1342        // TOML literal (single-quoted) strings are valid and `tomllib` accepts
1343        // them; the scanner must too, or a >=1.0 release would be missed.
1344        let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
1345                       description = 'a widget'\n";
1346        let facts = gather(
1347            repo(),
1348            &FakeFs::default().file("/repo/pyproject.toml", project),
1349            &FakeGit::default(),
1350        );
1351        assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
1352        assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
1353        assert!(facts.has_ge_1_0_release);
1354        assert_eq!(facts.description.as_deref(), Some("a widget"));
1355    }
1356
1357    #[test]
1358    fn toml_key_matches_whole_token_not_prefix() {
1359        // `version-code` / `namespace` must not satisfy the `version` / `name`
1360        // key match.
1361        let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
1362                     name = \"real\"\nversion = \"0.2.0\"\n";
1363        let facts = gather(
1364            repo(),
1365            &FakeFs::default().file("/repo/Cargo.toml", cargo),
1366            &FakeGit::default(),
1367        );
1368        assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
1369        assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
1370    }
1371
1372    #[test]
1373    fn setup_py_marks_python_but_adds_no_package() {
1374        let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
1375        let facts = gather(repo(), &fs, &FakeGit::default());
1376        assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
1377        assert!(facts.packages.is_empty());
1378    }
1379
1380    #[test]
1381    fn ecosystems_emit_in_canonical_order() {
1382        // Files added out of order; output follows the MANIFESTS order.
1383        let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
1384            "/repo/Cargo.toml",
1385            "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
1386        );
1387        let facts = gather(repo(), &fs, &FakeGit::default());
1388        assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
1389    }
1390
1391    // ── CI / bot / issues signals ──────────────────────────────────────────
1392
1393    #[test]
1394    fn workflows_dir_counts_only_when_non_empty() {
1395        // Empty workflows dir → no CI.
1396        let empty = FakeFs::default().dir("/repo/.github/workflows").file(
1397            "/repo/Cargo.toml",
1398            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1399        );
1400        assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
1401
1402        // A file inside it → CI present.
1403        let with_wf = FakeFs::default()
1404            .file("/repo/.github/workflows/ci.yml", "on: push\n")
1405            .file(
1406                "/repo/Cargo.toml",
1407                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1408            );
1409        assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
1410    }
1411
1412    #[test]
1413    fn single_file_ci_configs_count_on_existence() {
1414        for name in [
1415            ".gitlab-ci.yml",
1416            "azure-pipelines.yml",
1417            ".drone.yml",
1418            "Jenkinsfile",
1419        ] {
1420            let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
1421            assert!(
1422                gather(repo(), &fs, &FakeGit::default()).has_ci,
1423                "{name} should count as CI"
1424            );
1425        }
1426    }
1427
1428    #[test]
1429    fn dependency_bot_and_issues_dir() {
1430        let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
1431        assert_eq!(
1432            gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
1433            Some("dependabot".to_string())
1434        );
1435        let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
1436        assert_eq!(
1437            gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
1438            Some("renovate".to_string())
1439        );
1440        // dependabot takes precedence when both are present.
1441        let both = FakeFs::default()
1442            .file("/repo/.github/dependabot.yml", "version: 2\n")
1443            .file("/repo/renovate.json", "{}\n");
1444        assert_eq!(
1445            gather(repo(), &both, &FakeGit::default()).dependency_bot,
1446            Some("dependabot".to_string())
1447        );
1448        let issues = FakeFs::default().dir("/repo/issues");
1449        assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
1450    }
1451
1452    // ── README self-label + description fallback ───────────────────────────
1453
1454    #[test]
1455    fn readme_self_label_and_prose_description() {
1456        let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
1457        let fs = FakeFs::default().file("/repo/README.md", readme);
1458        let facts = gather(repo(), &fs, &FakeGit::default());
1459        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
1460        // First non-heading, non-`!`, non-`>` line.
1461        assert_eq!(
1462            facts.description.as_deref(),
1463            Some("Status: private, early. Not much yet.")
1464        );
1465    }
1466
1467    #[test]
1468    fn description_truncates_to_120_chars() {
1469        let long = "x".repeat(200);
1470        let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
1471        let facts = gather(repo(), &fs, &FakeGit::default());
1472        // "intro" is the first prose line; assert truncation on a long manifest
1473        // description instead to exercise the cap.
1474        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
1475        let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
1476        let facts2 = gather(repo(), &fs2, &FakeGit::default());
1477        assert_eq!(facts.description.as_deref(), Some("intro"));
1478        // Count characters, not bytes — the cap is a char cap.
1479        assert_eq!(
1480            facts2.description.as_deref().map(|d| d.chars().count()),
1481            Some(120)
1482        );
1483    }
1484
1485    #[test]
1486    fn read_limit_counts_chars_not_bytes() {
1487        // A multibyte description right at the boundary: a byte-slice cap would
1488        // truncate/corrupt it; the char cap keeps it whole. `く` is 3 bytes.
1489        let desc = "く".repeat(60); // 60 chars, 180 bytes — under the 120 char cap
1490        let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
1491        let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
1492        let facts = gather(repo(), &fs, &FakeGit::default());
1493        assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
1494        // No replacement character crept in from a mid-codepoint byte slice.
1495        assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
1496    }
1497
1498    // ── SemVer tag handling ────────────────────────────────────────────────
1499
1500    #[test]
1501    fn semver_parse_plain_prefixed_and_prerelease() {
1502        assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
1503        assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
1504        assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
1505        assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
1506        assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
1507        assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
1508        assert_eq!(semver_parse("nightly"), None);
1509        assert_eq!(semver_parse("1.2"), None);
1510        assert_eq!(semver_parse("1.2.3.4"), None);
1511    }
1512
1513    #[test]
1514    fn ge_1_0_release_from_tag_but_not_from_prerelease() {
1515        let fs = FakeFs::default().file(
1516            "/repo/Cargo.toml",
1517            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
1518        );
1519        // A 1.0.0 tag → has_ge_1_0_release.
1520        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
1521        assert!(facts.has_semver_tag);
1522        assert!(facts.has_ge_1_0_release);
1523        // Only a 1.0.0-rc prerelease tag → not a >=1.0 release.
1524        let fs2 = FakeFs::default().file(
1525            "/repo/Cargo.toml",
1526            "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
1527        );
1528        let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
1529        assert!(facts2.has_semver_tag);
1530        assert!(!facts2.has_ge_1_0_release);
1531    }
1532
1533    #[test]
1534    fn ge_1_0_release_from_manifest_version() {
1535        let fs = FakeFs::default().file(
1536            "/repo/Cargo.toml",
1537            "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
1538        );
1539        let facts = gather(repo(), &fs, &FakeGit::default());
1540        assert!(facts.has_ge_1_0_release);
1541    }
1542
1543    #[test]
1544    fn version_ge_1_0_requires_dot_after_major() {
1545        assert!(version_ge_1_0(Some("1.0.0")));
1546        assert!(version_ge_1_0(Some("v2.3")));
1547        assert!(version_ge_1_0(Some("2024.1")));
1548        assert!(!version_ge_1_0(Some("0.9.9")));
1549        assert!(!version_ge_1_0(Some("1"))); // no dot
1550        assert!(!version_ge_1_0(None));
1551    }
1552
1553    // ── Maturity truth table ───────────────────────────────────────────────
1554
1555    #[test]
1556    fn production_needs_two_recent_committers_ge_1_0_and_ci() {
1557        let fs = FakeFs::default()
1558            .file(
1559                "/repo/Cargo.toml",
1560                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
1561            )
1562            .file("/repo/.github/workflows/ci.yml", "on: push\n");
1563        let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
1564        assert!(facts.has_ci);
1565        assert!(facts.has_ge_1_0_release);
1566        assert!(facts.maturity_signals.production);
1567        assert_eq!(facts.inferred_maturity, Maturity::Production);
1568    }
1569
1570    #[test]
1571    fn mvp_when_ci_present_but_not_production_grade() {
1572        // Has CI (so not spike) but only one recent committer and no >=1.0.
1573        let fs = FakeFs::default()
1574            .file(
1575                "/repo/Cargo.toml",
1576                "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
1577            )
1578            .file("/repo/.github/workflows/ci.yml", "on: push\n");
1579        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
1580        assert!(!facts.maturity_signals.production);
1581        assert!(!facts.maturity_signals.spike);
1582        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1583    }
1584
1585    /// A `ZeroVer` repo with the full release process: CI + a dependency bot +
1586    /// `n` shipped `>=0.1.0` release tags. `bot` chooses the dependency-bot file.
1587    fn zerover_fs(bot: &str) -> FakeFs {
1588        FakeFs::default()
1589            .file(
1590                "/repo/Cargo.toml",
1591                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1592            )
1593            .file("/repo/.github/workflows/ci.yml", "on: push\n")
1594            .file(&format!("/repo/{bot}"), "version: 2\n")
1595    }
1596
1597    #[test]
1598    fn pre_1_0_with_full_release_infra_is_production() {
1599        // A deliberately-0.x (ZeroVer) repo with a maintained release process: CI,
1600        // a dependency-update bot, ≥2 recent committers, and a release cadence of
1601        // two shipped ≥0.1.0 releases — but NO ≥1.0 release. It reaches
1602        // `production` via the ZeroVer path even though `has_ge_1_0_release` is
1603        // false.
1604        let fs = zerover_fs(".github/dependabot.yml");
1605        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
1606        assert!(facts.has_ci);
1607        assert!(!facts.has_ge_1_0_release);
1608        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
1609        assert!(facts.maturity_signals.production);
1610        assert_eq!(facts.inferred_maturity, Maturity::Production);
1611    }
1612
1613    #[test]
1614    fn renovate_unlocks_the_zerover_release_path() {
1615        // The ZeroVer path is bot-agnostic: a `renovate.json` unlocks it exactly
1616        // as `dependabot.yml` does.
1617        let fs = zerover_fs("renovate.json");
1618        let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
1619        assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
1620        assert!(facts.maturity_signals.production);
1621        assert_eq!(facts.inferred_maturity, Maturity::Production);
1622    }
1623
1624    #[test]
1625    fn bare_0x_with_only_a_tag_is_not_production() {
1626        // The guard: a 0.x repo with ONLY a SemVer tag — no CI, no dependency
1627        // bot — must NOT inflate to `production`. It has a shipped tag and ≥2
1628        // recent committers, but the substantive signals (CI + a dep bot +
1629        // cadence) are absent.
1630        let fs = FakeFs::default().file(
1631            "/repo/Cargo.toml",
1632            "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1633        );
1634        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
1635        assert!(!facts.has_ci);
1636        assert!(!facts.has_ge_1_0_release);
1637        assert_eq!(facts.dependency_bot, None);
1638        assert!(!facts.maturity_signals.production);
1639        // Has a SemVer tag → not spike; the tie resolves to mvp.
1640        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1641    }
1642
1643    #[test]
1644    fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
1645        // The gaming guard: CI + a dep bot + ≥2 recent committers, but the only
1646        // tags are `0.0.x` — SemVer's initial-scratch space. Those are not
1647        // shipped releases, so the ZeroVer path stays closed → mvp. This blocks
1648        // the "empty workflow + empty dependabot.yml + `v0.0.1`" inflation.
1649        let fs = zerover_fs(".github/dependabot.yml");
1650        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
1651        assert!(facts.has_ci);
1652        assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
1653        assert!(!facts.maturity_signals.production);
1654        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1655    }
1656
1657    #[test]
1658    fn pre_1_0_release_infra_requires_release_cadence() {
1659        // CI + a dep bot + ≥2 recent committers + a single shipped ≥0.1.0 tag →
1660        // one release is a moment, not a cadence → not production. Two shipped
1661        // releases are required, so a lone `git tag` can't unlock the path.
1662        let fs = zerover_fs(".github/dependabot.yml");
1663        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
1664        assert!(!facts.maturity_signals.production);
1665        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1666    }
1667
1668    #[test]
1669    fn pre_1_0_release_infra_requires_dependency_bot() {
1670        // CI + a release cadence (two shipped tags) + ≥2 recent committers but NO
1671        // dependency bot → the ZeroVer path is incomplete → mvp. The dep bot is
1672        // the sole missing signal here, isolating its requirement.
1673        let fs = FakeFs::default()
1674            .file(
1675                "/repo/Cargo.toml",
1676                "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1677            )
1678            .file("/repo/.github/workflows/ci.yml", "on: push\n");
1679        let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
1680        assert_eq!(facts.dependency_bot, None);
1681        assert!(!facts.maturity_signals.production);
1682        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1683    }
1684
1685    #[test]
1686    fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
1687        // CI + a dep bot + ≥2 recent committers, but the tags are one shipped
1688        // release plus prereleases (`v0.6.0-rc1`, `v0.7.0-rc1`) → only one
1689        // non-prerelease ≥0.1.0 tag → no cadence → not production. Confirms
1690        // prereleases don't pad the shipped-release count.
1691        let fs = zerover_fs(".github/dependabot.yml");
1692        let facts = gather(
1693            repo(),
1694            &fs,
1695            &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
1696        );
1697        assert!(facts.has_semver_tag);
1698        assert!(!facts.maturity_signals.production);
1699        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1700    }
1701
1702    #[test]
1703    fn pre_1_0_release_infra_requires_two_recent_committers() {
1704        // Full ZeroVer release evidence (CI + dep bot + cadence) but a single
1705        // recent committer → not production (solo maintenance).
1706        let fs = zerover_fs(".github/dependabot.yml");
1707        let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
1708        assert!(!facts.maturity_signals.production);
1709        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1710    }
1711
1712    #[test]
1713    fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
1714        // Regression: the ≥1.0 path is unchanged — a ≥1.0 release + CI + ≥2
1715        // recent committers reaches production with NO dependency bot and no
1716        // cadence requirement. The bot asymmetry applies only below 1.0.
1717        let fs = FakeFs::default()
1718            .file(
1719                "/repo/Cargo.toml",
1720                "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
1721            )
1722            .file("/repo/.github/workflows/ci.yml", "on: push\n");
1723        let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
1724        assert!(facts.has_ge_1_0_release);
1725        assert_eq!(facts.dependency_bot, None);
1726        assert!(facts.maturity_signals.production);
1727        assert_eq!(facts.inferred_maturity, Maturity::Production);
1728    }
1729
1730    #[test]
1731    fn spike_forced_by_readme_label_even_with_multiple_committers() {
1732        // No CI, no SemVer tag, but 3 committers — the README label flips spike.
1733        let fs = FakeFs::default()
1734            .file(
1735                "/repo/Cargo.toml",
1736                "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1737            )
1738            .file(
1739                "/repo/README.md",
1740                "# X\n\nThis is an experimental prototype.\n",
1741            );
1742        let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
1743        assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
1744        assert!(facts.maturity_signals.spike);
1745        assert_eq!(facts.inferred_maturity, Maturity::Spike);
1746    }
1747
1748    #[test]
1749    fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
1750        // No CI, no tag, >1 committer, no label → spike's committer clause fails
1751        // → mvp (the tie-breaker).
1752        let fs = FakeFs::default().file(
1753            "/repo/Cargo.toml",
1754            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1755        );
1756        let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
1757        assert!(!facts.maturity_signals.spike);
1758        assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1759    }
1760
1761    // ── Determinism ────────────────────────────────────────────────────────
1762
1763    #[test]
1764    fn same_repo_same_facts() {
1765        let build = || {
1766            FakeFs::default()
1767                .file(
1768                    "/repo/Cargo.toml",
1769                    "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
1770                )
1771                .file("/repo/.github/workflows/ci.yml", "on: push\n")
1772        };
1773        let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
1774        let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
1775        assert_eq!(
1776            serde_json::to_string(&a).unwrap(),
1777            serde_json::to_string(&b).unwrap()
1778        );
1779    }
1780}