Skip to main content

release_kit/
assess.rs

1//! Classify a target repository before anything lands.
2//!
3//! The assessment is read-only evidence plus one classification computed
4//! from it by an explicit rule: `greenfield` when the target carries no
5//! release mechanism and no release history, `brownfield` when a release
6//! mechanism is already in place — a tool's configuration, a landed
7//! destination, a landed block — and `needs-decision` when the target
8//! shows release activity that no recognized mechanism explains: tags
9//! with no tool behind them, or a second long-lived branch. The rule
10//! lives here so a routing skill reads a verdict it can cite instead of
11//! judging "some release setup" by feel. The gathering spawns git and
12//! reads the disk; the rule itself is pure and unit-tested.
13
14use std::process::Command;
15
16use camino::Utf8Path;
17use serde::Serialize;
18
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::error::RkError;
21use crate::landing::{self, manifest};
22
23// The trunk and the release-line prefix come from the target's own
24// committed configuration; a target that states neither keeps the
25// compiled defaults in `crate::config`.
26
27/// Files that mark a release mechanism, whichever tool owns it, beside
28/// the configuration names of semantic-release and `GoReleaser`.
29///
30/// release-kit's own destinations are judged separately, as collisions;
31/// this list is what other tools leave behind: release-plz's dotted form,
32/// the workflow names a hand-rolled publish commonly takes, and a
33/// changelog. The configuration names of semantic-release and
34/// `GoReleaser` come from the migration inventory, which owns them,
35/// through [`marker_names`]. `package.json` joins the list only when it
36/// carries the top-level `release` key semantic-release reads, judged in
37/// [`gather`].
38pub const RELEASE_MARKERS: [&str; 7] = [
39    ".release-plz.toml",
40    ".github/workflows/publish.yml",
41    ".github/workflows/publish.yaml",
42    ".github/workflows/release.yaml",
43    ".github/workflows/release-drafter.yml",
44    "CHANGELOG.md",
45    "CHANGES.md",
46];
47
48/// Every file name that marks a release mechanism: [`RELEASE_MARKERS`]
49/// and the configuration names of semantic-release and `GoReleaser` that
50/// the migration inventory owns.
51pub fn marker_names() -> impl Iterator<Item = &'static str> {
52    use crate::migrate::inventory::{GORELEASER, SEMANTIC_RELEASE, release_configs};
53    RELEASE_MARKERS
54        .into_iter()
55        .chain(release_configs(SEMANTIC_RELEASE))
56        .chain(release_configs(GORELEASER))
57}
58
59/// Branch names that conventionally outlive a topic.
60///
61/// A second one beside the trunk is the retired two-branch flow, or a
62/// trunk under another name, and either is a migration step. A
63/// `release/<line>` branch — the convention's own long-lived form — is
64/// recognized by its prefix.
65pub const LONG_LIVED_BRANCHES: [&str; 11] = [
66    "master",
67    "main",
68    "trunk",
69    "develop",
70    "development",
71    "dev",
72    "staging",
73    "next",
74    "release",
75    "production",
76    "prod",
77];
78
79/// What the target is, for routing: the corpus verdict, computed from the
80/// repository's evidence alone.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum Classification {
84    /// No release mechanism and no release history: land the workflow.
85    Greenfield,
86    /// A release mechanism is in place: migrate, never land beside it.
87    Brownfield,
88    /// Release activity no mechanism explains: the operator decides.
89    NeedsDecision,
90}
91
92impl Classification {
93    /// The kebab-case verdict word, as the JSON serializes it.
94    #[must_use]
95    pub const fn as_str(self) -> &'static str {
96        match self {
97            Self::Greenfield => "greenfield",
98            Self::Brownfield => "brownfield",
99            Self::NeedsDecision => "needs-decision",
100        }
101    }
102}
103
104/// The landing record's presence, the one fact `rk status` owns that the
105/// routing needs before it reads the full report.
106#[derive(Debug, Serialize)]
107pub struct Landing {
108    /// Whether `.release-kit/manifest.json` exists and reads.
109    pub recorded: bool,
110    /// The release-kit version the record names, where one exists.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub rk_version: Option<String>,
113}
114
115/// The evidence the classification is computed from.
116#[derive(Debug, Serialize)]
117pub struct Evidence {
118    /// The landing record, present or not.
119    pub landing: Landing,
120    /// The technologies the version files name, zero or many.
121    pub technologies: Vec<&'static str>,
122    /// The forge the origin remote maps to, where one is recognized.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub forge: Option<&'static str>,
125    /// The project path from the origin remote, where one exists.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub repo: Option<String>,
128    /// Release-mechanism files of other tools found at the target.
129    pub release_markers: Vec<String>,
130    /// Landable destinations already present: a whole file that exists, or
131    /// a block destination whose marked block is present.
132    pub collisions: Vec<String>,
133    /// Whether the target is a git repository the evidence below reads.
134    pub git: bool,
135    /// How many tags the repository holds.
136    pub tags: usize,
137    /// Long-lived branches found besides the trunk, local or remote.
138    pub long_lived_branches: Vec<String>,
139}
140
141/// Compute the verdict from the evidence. Pure, so the rule is testable
142/// without a repository.
143#[must_use]
144pub const fn classify(evidence: &Evidence) -> Classification {
145    verdict(
146        evidence.release_markers.as_slice(),
147        evidence.collisions.as_slice(),
148        evidence.tags,
149        evidence.long_lived_branches.as_slice(),
150    )
151}
152
153/// The one rule both readers apply: a mechanism in place outranks
154/// unexplained activity, and nothing at all is a fresh start.
155const fn verdict(
156    release_markers: &[String],
157    collisions: &[String],
158    tags: usize,
159    long_lived_branches: &[String],
160) -> Classification {
161    if !release_markers.is_empty() || !collisions.is_empty() {
162        return Classification::Brownfield;
163    }
164    if tags > 0 || !long_lived_branches.is_empty() {
165        return Classification::NeedsDecision;
166    }
167    Classification::Greenfield
168}
169
170/// What marks release-kit's own landing at a target that carries no
171/// record: the evidence the adoption detector reads.
172#[derive(Debug, Default, Serialize)]
173pub struct OwnLanding {
174    /// Block destinations whose release-kit marked region is present.
175    pub regions: Vec<String>,
176    /// Whole-file rendered destinations holding exactly the bytes this
177    /// binary renders for them at this target.
178    pub rendered: Vec<String>,
179}
180
181impl OwnLanding {
182    /// Whether any of release-kit's own landing is present.
183    #[must_use]
184    pub const fn present(&self) -> bool {
185        !self.regions.is_empty() || !self.rendered.is_empty()
186    }
187}
188
189/// Where a target with no record stands, by the one rule above and the
190/// adoption detector before it.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum Unrecorded {
193    /// release-kit's own landing is present and only the record is
194    /// missing.
195    Adopt,
196    /// Everything else, by the assessment's verdict.
197    Assessed(Classification),
198}
199
200/// Classify a target with no record.
201///
202/// Pure, so the precedence is testable without a repository:
203/// release-kit's own landing outranks every other mechanism, because its
204/// marked regions and its rendered files are what a landing made before
205/// the record existed leaves behind, and they would otherwise read as
206/// another tool's collisions.
207#[must_use]
208pub const fn classify_unrecorded(facts: &Facts, own: &OwnLanding) -> Unrecorded {
209    if own.present() {
210        return Unrecorded::Adopt;
211    }
212    Unrecorded::Assessed(verdict(
213        facts.release_markers.as_slice(),
214        facts.collisions.as_slice(),
215        facts.tags,
216        facts.long_lived_branches.as_slice(),
217    ))
218}
219
220/// Where a target with no record stands, by the precedence `rk status`
221/// reads it with.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum Standing {
224    /// A migration is open: its archive emptied the target on purpose,
225    /// and this outranks every other reading until it closes.
226    Migrating,
227    /// No migration is open, so the adoption detector and the rule.
228    Unrecorded(Unrecorded),
229}
230
231/// Read where a target with no record stands: an open migration first,
232/// then the adoption detector and the assessment's rule.
233///
234/// # Errors
235///
236/// [`RkError::Io`] for a read of the target that fails for a reason
237/// other than absence.
238pub fn standing(
239    target: &Utf8Path,
240    config: Option<&crate::config::Config>,
241) -> Result<Standing, RkError> {
242    if config.is_some_and(|config| config.migration.is_some()) {
243        return Ok(Standing::Migrating);
244    }
245    let facts = gather_facts(target)?;
246    let own = own_landing(target, config)?;
247    Ok(Standing::Unrecorded(classify_unrecorded(&facts, &own)))
248}
249
250/// Find release-kit's own landing at `target`, reading and never
251/// writing.
252///
253/// A marked region carries release-kit's own name, so its presence
254/// alone is the evidence. A whole file is release-kit's only where it
255/// holds exactly the bytes this binary renders for it here, because a
256/// file at the same path from another tool is a migration's business.
257/// Where this binary cannot resolve the parameters a render needs, the
258/// rendered half reads as empty rather than failing: the regions still
259/// answer.
260///
261/// # Errors
262///
263/// [`RkError::Io`] for a read that fails for a reason other than
264/// absence.
265pub fn own_landing(
266    target: &Utf8Path,
267    config: Option<&crate::config::Config>,
268) -> Result<OwnLanding, RkError> {
269    let mut own = OwnLanding::default();
270    for destination in landing::BLOCK_DESTINATIONS {
271        if landing::read_recorded(target, destination)?.is_some() {
272            own.regions.push(destination.to_owned());
273        }
274    }
275    let params = landing::Params::resolve(
276        target,
277        &landing::Inputs::default(),
278        config,
279        None,
280        landing::Purpose::Preview,
281    );
282    let Ok(params) = params else {
283        return Ok(own);
284    };
285    let Ok(evidence) = crate::projection::TargetEvidence::gather(target, None) else {
286        return Ok(own);
287    };
288    let Ok(projection) =
289        crate::projection::Projection::compute(&crate::projection::ProjectionInput {
290            params,
291            evidence,
292        })
293    else {
294        return Ok(own);
295    };
296    for candidate in &projection.candidates {
297        // A skill copy is no evidence of a landing: an agent's own skill
298        // installer can write the same bytes into a project.
299        if candidate.kind != landing::Kind::Rendered
300            || candidate.region.is_some()
301            || crate::projection::skill_folder(&candidate.destination).is_some()
302        {
303            continue;
304        }
305        if landing::read_recorded(target, &candidate.destination)?.as_deref()
306            == Some(candidate.bytes.as_slice())
307        {
308            own.rendered.push(candidate.destination.clone());
309        }
310    }
311    own.rendered.sort();
312    Ok(own)
313}
314
315/// Whether `target` sits in a non-bare Git work tree.
316///
317/// A directory Git reports as no repository, and a bare repository,
318/// answer `false`: both are facts the setup skill stops on, because the
319/// method lands files a work tree commits.
320///
321/// # Errors
322///
323/// [`RkError::Subprocess`] where git does not run or refuses for a
324/// reason other than the target not being a repository.
325pub fn git_work_tree(target: &Utf8Path) -> Result<bool, RkError> {
326    match git_lines(target, &["rev-parse", "--is-inside-work-tree"]) {
327        Ok(lines) => Ok(lines.first().is_some_and(|line| line == "true")),
328        Err(GitFailure::NotARepository) => Ok(false),
329        Err(GitFailure::Other(error)) => Err(error),
330    }
331}
332
333/// The repository's facts alone, with no record read: what the planner
334/// gathers beside its own read of the record.
335#[derive(Debug)]
336pub struct Facts {
337    /// The technologies the version files name, zero or many.
338    pub technologies: Vec<&'static str>,
339    /// The forge the origin remote maps to, where one is recognized.
340    pub forge: Option<&'static str>,
341    /// The project path from the origin remote, where one exists.
342    pub repo: Option<String>,
343    /// Release-mechanism files of other tools found at the target.
344    pub release_markers: Vec<String>,
345    /// Landable destinations already present.
346    pub collisions: Vec<String>,
347    /// Whether the target is a git repository.
348    pub git: bool,
349    /// How many tags the repository holds.
350    pub tags: usize,
351    /// Long-lived branches found besides the trunk.
352    pub long_lived_branches: Vec<String>,
353}
354
355/// Gather the evidence at `target`, reading and never writing.
356///
357/// # Errors
358///
359/// Returns the record's own failure taxonomy for an unreadable or unknown
360/// landing record — a broken record must not silently classify —
361/// [`RkError::Io`] for a disk read that fails for a reason other than
362/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
363/// for a repository, because an observation that cannot be read is not a
364/// pass and must never read as an absent release history.
365pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
366    let record = manifest::load(target)?;
367    let landing = Landing {
368        recorded: record.is_some(),
369        rk_version: record.map(|manifest| manifest.rk_version),
370    };
371    let facts = gather_facts(target)?;
372    Ok(Evidence {
373        landing,
374        technologies: facts.technologies,
375        forge: facts.forge,
376        repo: facts.repo,
377        release_markers: facts.release_markers,
378        collisions: facts.collisions,
379        git: facts.git,
380        tags: facts.tags,
381        long_lived_branches: facts.long_lived_branches,
382    })
383}
384
385/// Gather the repository's facts at `target`, the record aside.
386///
387/// # Errors
388///
389/// [`RkError::Io`] for a disk read that fails for a reason other than
390/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
391/// for a repository.
392pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
393    let detected = crate::detect::detect(target.as_std_path());
394    let mut release_markers: Vec<String> = marker_names()
395        .filter(|marker| target.join(marker).is_file())
396        .map(str::to_owned)
397        .collect();
398    if package_json_names_a_release(target)? {
399        release_markers.push("package.json".to_owned());
400    }
401    release_markers.sort();
402    let mut collisions = Vec::new();
403    for destination in landing::destinations() {
404        if landing::read_recorded(target, destination)?.is_some() {
405            collisions.push(destination.to_owned());
406        }
407    }
408    collisions.sort();
409    let (git, tags, long_lived_branches) = git_evidence(target)?;
410    Ok(Facts {
411        technologies: crate::detect::technologies_of(target.as_std_path()),
412        forge: detected.forge.map(crate::detect::Forge::as_str),
413        repo: detected.repo,
414        release_markers,
415        collisions,
416        git,
417        tags,
418        long_lived_branches,
419    })
420}
421
422/// Whether `package.json` carries the top-level `release` key
423/// semantic-release reads its configuration from. An ordinary Node
424/// project's manifest is not a release marker; only that key is.
425fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
426    let path = target.join("package.json");
427    let bytes = match std::fs::read(&path) {
428        Ok(bytes) => bytes,
429        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
430        Err(e) => return Err(RkError::Io(e)),
431    };
432    // A manifest that does not parse is not evidence of a release
433    // mechanism; the tool that would read it fails on it too.
434    Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
435        .ok()
436        .and_then(|value| value.get("release").map(|_| ()))
437        .is_some())
438}
439
440/// The git-borne evidence: whether the target is a repository, how many
441/// tags it holds, and which long-lived branches stand beside the trunk.
442///
443/// A directory git positively reports as no repository answers `false`
444/// and empty — an observation, never a failure, because a plain
445/// directory is a legitimate greenfield. Every other refusal — a
446/// corrupt repository, an ownership refusal, a git that does not run —
447/// is an error, because an unreadable history must not read as none.
448fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
449    let trunk = crate::config::trunk_of(target.as_std_path())?;
450    let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
451    let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
452    match git_lines(target, &["rev-parse", "--git-dir"]) {
453        Ok(_) => {}
454        Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
455        Err(GitFailure::Other(error)) => return Err(error),
456    }
457    let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
458    let refs = git_lines(
459        target,
460        &[
461            "for-each-ref",
462            "--format=%(refname)",
463            "refs/heads",
464            "refs/remotes",
465        ],
466    )
467    .map_err(GitFailure::into_error)?;
468    Ok((
469        true,
470        tags.len(),
471        long_lived_among(&refs, trunk, line_prefix),
472    ))
473}
474
475/// The long-lived branch names among `refs`, given as full ref names.
476///
477/// `refs/heads/<name>` keeps its whole name, `refs/remotes/<remote>/<name>`
478/// drops the remote alone, and a remote `HEAD` pointer is skipped. A
479/// name is long-lived when it is a catalog entry other than the trunk or
480/// carries the release-line prefix; each appears once, sorted.
481#[must_use]
482pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
483    let mut names = std::collections::BTreeSet::new();
484    for reference in refs {
485        let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
486            local
487        } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
488            match remote.split_once('/') {
489                Some((_, "HEAD")) | None => continue,
490                Some((_, name)) => name,
491            }
492        } else {
493            continue;
494        };
495        let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
496        if catalogued || name.starts_with(line_prefix) {
497            names.insert(name.to_owned());
498        }
499    }
500    names.into_iter().collect()
501}
502
503/// Why one git call gave no answer.
504enum GitFailure {
505    /// Git ran and said the target is not a repository.
506    NotARepository,
507    /// Git did not run, or ran and refused for another reason.
508    Other(RkError),
509}
510
511impl GitFailure {
512    /// After the target is known to be a repository, every failure is
513    /// the same kind: a history that cannot be read.
514    fn into_error(self) -> RkError {
515        match self {
516            Self::NotARepository => RkError::subprocess(
517                Diagnostic::new(
518                    Reason::SubprocessFailed,
519                    "git stopped answering for a repository it had just recognized",
520                )
521                .expected("a readable repository"),
522            ),
523            Self::Other(error) => error,
524        }
525    }
526}
527
528/// The non-empty stdout lines of one git call.
529///
530/// The call answers for the `-C` target alone: the variables a running
531/// hook exports are scrubbed, so an inherited `GIT_DIR` cannot redirect
532/// the probe at another repository, and the locale is pinned to `C`, so
533/// the one diagnostic this module reads — git's own "not a git
534/// repository" — arrives untranslated.
535fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
536    let mut command = Command::new(crate::probes::git_bin());
537    for var in crate::maintenance::GIT_HOOK_VARS {
538        command.env_remove(var);
539    }
540    let out = command
541        .env("LC_ALL", "C")
542        .env_remove("LANGUAGE")
543        .arg("-C")
544        .arg(target)
545        .args(args)
546        .output()
547        .map_err(|error| {
548            GitFailure::Other(RkError::subprocess(
549                Diagnostic::new(
550                    Reason::SubprocessSpawn,
551                    format!("git could not be spawned: {error}"),
552                )
553                .expected("git on PATH, or RK_GIT_BIN naming it"),
554            ))
555        })?;
556    if !out.status.success() {
557        let stderr = String::from_utf8_lossy(&out.stderr);
558        if stderr.contains("not a git repository") {
559            return Err(GitFailure::NotARepository);
560        }
561        return Err(GitFailure::Other(RkError::subprocess(
562            Diagnostic::new(
563                Reason::SubprocessFailed,
564                format!(
565                    "git {} failed at {target}: {}",
566                    args.join(" "),
567                    stderr.trim()
568                ),
569            )
570            .expected("git answering for the target, or a target that is not a repository")
571            .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
572        )));
573    }
574    Ok(String::from_utf8_lossy(&out.stdout)
575        .lines()
576        .map(str::trim)
577        .filter(|line| !line.is_empty())
578        .map(str::to_owned)
579        .collect())
580}
581
582#[cfg(test)]
583mod tests {
584    use super::{
585        Classification, Evidence, Landing, RELEASE_MARKERS, classify, long_lived_among,
586        marker_names,
587    };
588    use crate::migrate::inventory::{GORELEASER, Kind, PREDECESSORS, SEMANTIC_RELEASE};
589
590    /// Whether a name has the shape of a semantic-release configuration
591    /// file, `.releaserc` with or without an extension, or
592    /// `release.config.<ext>`.
593    fn semantic_release_shaped(name: &str) -> bool {
594        name == ".releaserc"
595            || name.starts_with(".releaserc.")
596            || name.starts_with("release.config.")
597    }
598
599    /// `rk assess` and `rk migrate` name the same semantic-release
600    /// configuration files: the inventory owns them, the assess list holds
601    /// no second copy, and the TypeScript names are among them.
602    #[test]
603    fn assess_and_the_inventory_name_the_same_semantic_release_files() {
604        let inventory: std::collections::BTreeSet<&str> = PREDECESSORS
605            .iter()
606            .filter(|(path, tool, kind)| {
607                (*tool == SEMANTIC_RELEASE && *kind == Kind::ReleaseConfig)
608                    || semantic_release_shaped(path)
609            })
610            .map(|(path, _, _)| *path)
611            .collect();
612        let assess: std::collections::BTreeSet<&str> = marker_names()
613            .filter(|name| semantic_release_shaped(name))
614            .collect();
615        assert_eq!(assess, inventory);
616        assert!(
617            !RELEASE_MARKERS
618                .iter()
619                .any(|name| semantic_release_shaped(name)),
620            "the assess list holds a second copy of a semantic-release name"
621        );
622        for name in [".releaserc.ts", "release.config.ts"] {
623            assert!(assess.contains(name), "{name}");
624        }
625    }
626
627    /// `rk assess` and `rk migrate` name the same `GoReleaser`
628    /// configuration files: the inventory owns them, and the assess list
629    /// holds no second copy.
630    #[test]
631    fn assess_and_the_inventory_name_the_same_goreleaser_files() {
632        let shaped = |name: &str| name.contains("goreleaser.y");
633        let inventory: std::collections::BTreeSet<&str> = PREDECESSORS
634            .iter()
635            .filter(|(path, tool, kind)| {
636                (*tool == GORELEASER && *kind == Kind::ReleaseConfig) || shaped(path)
637            })
638            .filter(|(path, _, _)| !path.starts_with(".github/"))
639            .map(|(path, _, _)| *path)
640            .collect();
641        let assess: std::collections::BTreeSet<&str> =
642            marker_names().filter(|name| shaped(name)).collect();
643        assert_eq!(assess, inventory);
644        assert_eq!(assess.len(), 6, "{assess:?}");
645        assert!(
646            !RELEASE_MARKERS.iter().any(|name| shaped(name)),
647            "the assess list holds a second copy of a GoReleaser name"
648        );
649    }
650
651    /// The trunk is never evidence against itself; only the remote
652    /// segment is stripped, so a topic branch whose last segment is a
653    /// catalog name stays a topic branch; a release line is recognized
654    /// by its prefix; a remote HEAD pointer is skipped; each name once.
655    #[test]
656    fn long_lived_branches_are_read_from_the_full_ref_names() {
657        let refs: Vec<String> = [
658            "refs/heads/master",
659            "refs/remotes/origin/master",
660            "refs/remotes/origin/HEAD",
661            "refs/heads/develop",
662            "refs/remotes/origin/develop",
663            "refs/heads/feat/x",
664            "refs/heads/feat/develop",
665            "refs/remotes/origin/main",
666            "refs/heads/release/1.2",
667            "refs/remotes/upstream/release/1.2",
668        ]
669        .iter()
670        .map(|name| (*name).to_owned())
671        .collect();
672        assert_eq!(
673            long_lived_among(&refs, "master", "release/"),
674            vec!["develop", "main", "release/1.2"]
675        );
676        assert!(
677            long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
678        );
679        assert!(
680            long_lived_among(
681                &["refs/heads/feat/develop".to_owned()],
682                "master",
683                "release/"
684            )
685            .is_empty()
686        );
687    }
688
689    #[test]
690    fn the_verdict_words_are_the_wire_form() {
691        for (classification, word) in [
692            (Classification::Greenfield, "greenfield"),
693            (Classification::Brownfield, "brownfield"),
694            (Classification::NeedsDecision, "needs-decision"),
695        ] {
696            assert_eq!(classification.as_str(), word);
697            assert_eq!(
698                serde_json::to_string(&classification).expect("serializes"),
699                format!("\"{word}\"")
700            );
701        }
702    }
703
704    fn evidence() -> Evidence {
705        Evidence {
706            landing: Landing {
707                recorded: false,
708                rk_version: None,
709            },
710            technologies: Vec::new(),
711            forge: None,
712            repo: None,
713            release_markers: Vec::new(),
714            collisions: Vec::new(),
715            git: true,
716            tags: 0,
717            long_lived_branches: Vec::new(),
718        }
719    }
720
721    #[test]
722    fn nothing_is_greenfield() {
723        assert_eq!(classify(&evidence()), Classification::Greenfield);
724    }
725
726    #[test]
727    fn a_release_marker_or_a_collision_is_brownfield() {
728        let with_marker = Evidence {
729            release_markers: vec!["CHANGELOG.md".into()],
730            ..evidence()
731        };
732        assert_eq!(classify(&with_marker), Classification::Brownfield);
733        let with_collision = Evidence {
734            collisions: vec!["release-plz.toml".into()],
735            ..evidence()
736        };
737        assert_eq!(classify(&with_collision), Classification::Brownfield);
738    }
739
740    /// A mechanism outranks unexplained activity: tags beside a marker
741    /// are a history the mechanism made, not a question.
742    #[test]
743    fn a_mechanism_beside_activity_is_still_brownfield() {
744        let both = Evidence {
745            release_markers: vec!["CHANGELOG.md".into()],
746            tags: 7,
747            long_lived_branches: vec!["develop".into()],
748            ..evidence()
749        };
750        assert_eq!(classify(&both), Classification::Brownfield);
751    }
752
753    #[test]
754    fn activity_with_no_mechanism_needs_a_decision() {
755        let tagged = Evidence {
756            tags: 1,
757            ..evidence()
758        };
759        assert_eq!(classify(&tagged), Classification::NeedsDecision);
760        let branched = Evidence {
761            long_lived_branches: vec!["develop".into()],
762            ..evidence()
763        };
764        assert_eq!(classify(&branched), Classification::NeedsDecision);
765    }
766}