Skip to main content

release_kit/commands/
assess.rs

1//! `rk assess`: classify a target before anything lands.
2//!
3//! Reporting only, like `rk doctor`: the evidence is gathered read-only,
4//! the verdict is computed by the rule in `crate::assess`, and every
5//! classification exits 0. What the verdict routes to is stated as the
6//! `next` lines, so a skill reads the same answer an operator does.
7
8use serde::Serialize;
9
10use crate::assess::{self, Classification, Evidence};
11use crate::cli::assess::AssessArgs;
12use crate::diagnostic::{Diagnostic, Reason};
13use crate::error::RkError;
14use crate::output::Output;
15
16/// The machine form of an assessment: evidence first, one verdict from it.
17#[derive(Debug, Serialize)]
18struct Report<'a> {
19    /// The shape version of this document.
20    schema: &'static str,
21    /// The assessed repository.
22    target: String,
23    /// The verdict the evidence produces.
24    classification: Classification,
25    /// The evidence, flattened beside the verdict.
26    #[serde(flatten)]
27    evidence: &'a Evidence,
28    /// What plausibly follows.
29    next: Vec<String>,
30}
31
32/// Classify the target and report it.
33///
34/// # Errors
35///
36/// Returns [`RkError::Missing`] for a target that is not a directory, and
37/// the record's own failure taxonomy for a landing record that exists but
38/// cannot be trusted; a verdict is never an error.
39pub fn run(args: &AssessArgs) -> Result<(), RkError> {
40    let out = Output::new(args.json);
41    if !args.target.is_dir() {
42        return Err(RkError::missing(
43            Diagnostic::new(
44                Reason::TargetNotFound,
45                format!("target {} is not a directory", args.target),
46            )
47            .expected("an existing repository to classify"),
48        ));
49    }
50    let evidence = assess::gather(&args.target)?;
51    let classification = assess::classify(&evidence);
52    let next = next_lines(args, &evidence, classification);
53
54    out.result_line(format!("classification: {}", classification.as_str()));
55    out.result_line(format!(
56        "landing: {}",
57        evidence.landing.rk_version.as_deref().map_or_else(
58            || "none".to_owned(),
59            |version| format!("recorded at release-kit {version}")
60        )
61    ));
62    out.result_line(format!("tech: {}", evidence.tech.unwrap_or("undetected")));
63    out.result_line(format!(
64        "forge: {}",
65        match (evidence.forge, evidence.repo.as_deref()) {
66            (Some(forge), Some(repo)) => format!("{forge} ({repo})"),
67            (Some(forge), None) => forge.to_owned(),
68            (None, _) => "undetected".to_owned(),
69        }
70    ));
71    out.result_line(format!(
72        "release markers: {}",
73        join_or_none(&evidence.release_markers)
74    ));
75    out.result_line(format!(
76        "payload collisions: {}",
77        join_or_none(&evidence.collisions)
78    ));
79    if evidence.git {
80        out.result_line(format!("tags: {}", evidence.tags));
81        out.result_line(format!(
82            "long-lived branches: {}",
83            join_or_none(&evidence.long_lived_branches)
84        ));
85    } else {
86        out.result_line("git: not a repository");
87    }
88    out.next(&next);
89
90    out.emit(&Report {
91        schema: "rk.assess/1",
92        target: args.target.to_string(),
93        classification,
94        evidence: &evidence,
95        next,
96    })
97}
98
99/// What each verdict routes to. A recorded landing routes by its status
100/// report whatever the corpus verdict says: the verdict describes every
101/// healthy landing as brownfield, and that is not a migration.
102fn next_lines(args: &AssessArgs, evidence: &Evidence, verdict: Classification) -> Vec<String> {
103    let target = &args.target;
104    if evidence.landing.recorded {
105        return vec![
106            format!(
107                "rk status --target {target} reports this landing; a recorded target routes by its status, not by classification"
108            ),
109            format!("rk upgrade --target {target} takes it to this binary's payload"),
110        ];
111    }
112    match verdict {
113        Classification::Greenfield => vec![
114            format!("rk init --tech <tech> --target {target} lands the workflow; nothing is here to migrate"),
115        ],
116        Classification::Brownfield => vec![
117            "rk guide migration carries the migration procedure".to_owned(),
118            format!("rk adopt --target {target} previews whether what is here matches one rendered candidate"),
119            format!("rk setup check --target {target} reports what the forge already enforces"),
120        ],
121        Classification::NeedsDecision => vec![
122            "the operator says what the release activity is before any plan claims to know; rk guide migration carries the procedure once it is a migration".to_owned(),
123        ],
124    }
125}
126
127fn join_or_none(items: &[String]) -> String {
128    if items.is_empty() {
129        "none".to_owned()
130    } else {
131        items.join(", ")
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    #![allow(clippy::expect_used)]
138
139    use super::Report;
140    use crate::assess::{Classification, Evidence, Landing};
141
142    /// The complete `rk.assess/1` shape, held by snapshot.
143    #[test]
144    fn the_assess_report_schema_snapshot_holds() {
145        let evidence = Evidence {
146            landing: Landing {
147                recorded: false,
148                rk_version: None,
149            },
150            tech: Some("rust"),
151            forge: Some("github"),
152            repo: Some("acme/widget".into()),
153            release_markers: vec!["CHANGELOG.md".into()],
154            collisions: vec!["release-plz.toml".into()],
155            git: true,
156            tags: 3,
157            long_lived_branches: vec!["develop".into()],
158        };
159        let report = Report {
160            schema: "rk.assess/1",
161            target: "/tmp/t".into(),
162            classification: Classification::Brownfield,
163            evidence: &evidence,
164            next: vec!["rk guide migration carries the migration procedure".into()],
165        };
166        assert_eq!(
167            serde_json::to_string(&report).expect("a report serializes"),
168            r#"{"schema":"rk.assess/1","target":"/tmp/t","classification":"brownfield","landing":{"recorded":false},"tech":"rust","forge":"github","repo":"acme/widget","release_markers":["CHANGELOG.md"],"collisions":["release-plz.toml"],"git":true,"tags":3,"long_lived_branches":["develop"],"next":["rk guide migration carries the migration procedure"]}"#
169        );
170    }
171}