Skip to main content

release_kit/commands/
profile.rs

1//! `rk profile`: what a target resolves to, and what the catalog selects
2//! for it.
3//!
4//! Read-only. The command resolves the target configuration the way every
5//! landing verb does, keeps the source of each value, computes the one
6//! projection, and reports every domain value with its source, the
7//! unknown categories, the observation's proposal where it answered the
8//! release mode, every capability with its status, the omissions, and the
9//! conflicts. It writes nothing and judges nothing: judgment belongs to
10//! `rk status --check` and `rk setup check`.
11//!
12//! SATISFIES project-profile:the-profile-command-writes-nothing
13
14use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18use crate::cli::profile::ProfileArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::error::RkError;
21use crate::landing::manifest::{self, Provider};
22use crate::landing::{self, Params};
23use crate::output::Output;
24use crate::profile::{self, CapabilityRequests, GitWorkflow, ProfileSnapshot, Proposal, Source};
25use crate::projection::{Projection, ProjectionInput, TargetEvidence};
26use crate::stage::{CapabilityNote, Note};
27
28/// The machine form of a profile report.
29#[derive(Debug, Serialize)]
30struct Report {
31    /// The shape version of this document.
32    schema: &'static str,
33    /// The target directory.
34    target: String,
35    /// What the project is.
36    profile: ProfileSnapshot,
37    /// How topic branches reach the trunk.
38    git: GitWorkflow,
39    /// Which optional products the target requests.
40    capabilities: CapabilityRequests,
41    /// The project path on the forge, empty where the project has none.
42    repo: String,
43    /// The two security answers.
44    security: Security,
45    /// The setup answers the release gate renders.
46    setup: Setup,
47    /// The source of each value, keyed by its configuration path.
48    sources: BTreeMap<&'static str, Source>,
49    /// The category names the catalog does not know.
50    unknown: Vec<String>,
51    /// What the observation proposed for the release, where nothing above
52    /// it answered the mode.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    proposal: Option<Proposal>,
55    /// Every capability, in catalog order, with its status and its
56    /// destinations.
57    selection: Vec<CapabilityNote>,
58    /// The destinations the target's own state withholds.
59    omissions: Vec<Note>,
60    /// The block destinations whose document offers the block no place.
61    collisions: Vec<Note>,
62    /// What plausibly follows.
63    next: Vec<String>,
64}
65
66/// The two security answers.
67#[derive(Debug, Serialize)]
68struct Security {
69    /// The contact, empty for the forge's own wording.
70    contact: String,
71    /// The acknowledgment window.
72    response: String,
73}
74
75/// The setup answers a landing renders.
76#[derive(Debug, Serialize)]
77struct Setup {
78    /// The check the release gate believes, empty where nothing answered it.
79    required_check: String,
80    /// The workflow whose completion wakes the release gate, empty where
81    /// nothing answered it.
82    required_workflow: String,
83}
84
85/// One line stating the three domains and the identity, for every human
86/// report that names a resolved target.
87#[must_use]
88pub fn describe(
89    profile: &ProfileSnapshot,
90    git: &GitWorkflow,
91    capabilities: &CapabilityRequests,
92    repo: &str,
93) -> String {
94    let technologies = if profile.technologies.is_empty() {
95        "none".to_owned()
96    } else {
97        profile.technologies.join(", ")
98    };
99    let release = match profile.release.mode {
100        profile::ReleaseMode::Automatic => format!(
101            "automatic (driver {}, style {}, line prefix {})",
102            profile.release.driver.as_deref().unwrap_or("unresolved"),
103            profile
104                .release
105                .style
106                .map_or("unresolved", crate::landing::Style::as_str),
107            profile
108                .release
109                .line_prefix
110                .as_deref()
111                .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
112        ),
113        other => other.as_str().to_owned(),
114    };
115    let mut requested: Vec<&str> = Vec::new();
116    if capabilities.nix_packaging {
117        requested.push("nix_packaging");
118    }
119    if capabilities.reporting_policy {
120        requested.push("reporting_policy");
121    }
122    if capabilities.scorecard {
123        requested.push("scorecard");
124    }
125    let scanning = capabilities.code_scanning.map(Provider::as_str);
126    format!(
127        "technologies {technologies}; forge {}; repo {}; release {release}; trunk {}; checkout mode {}; integration {}; requests {}{}",
128        profile.forge.as_deref().unwrap_or("none"),
129        match repo {
130            "" => "none",
131            crate::projection::REPO_PLACEHOLDER => "unresolved",
132            named => named,
133        },
134        git.trunk,
135        git.checkout_mode.as_str(),
136        git.integration.as_str(),
137        if requested.is_empty() {
138            "none".to_owned()
139        } else {
140            requested.join(", ")
141        },
142        scanning.map_or_else(String::new, |provider| format!(
143            ", code_scanning {provider}"
144        ))
145    )
146}
147
148/// Report the target's resolution and selection.
149///
150/// # Errors
151///
152/// Returns [`RkError::Missing`] for a target that is not a directory, and
153/// the resolution's own refusals for a malformed release intent.
154#[allow(
155    clippy::too_many_lines,
156    reason = "one pass reports every domain, source, capability, and omission, and splitting it would separate a value from the report line that states it"
157)]
158pub fn run(args: &ProfileArgs) -> Result<(), RkError> {
159    let out = Output::new(args.json);
160    if !args.target.is_dir() {
161        return Err(RkError::missing(
162            Diagnostic::new(
163                Reason::TargetNotFound,
164                format!("target {} is not a directory", args.target),
165            )
166            .expected("an existing repository to resolve for"),
167        ));
168    }
169    let config = crate::config::load(args.target.as_std_path())?;
170    let record = manifest::load(&args.target)?;
171    let resolved = profile::resolve(
172        &args.target,
173        &landing::Inputs {
174            nix: args.nix_packaging.then_some(true),
175            reporting_policy: args.reporting_policy.then_some(true),
176            scorecard: args.scorecard.then_some(true),
177            code_scanning: args
178                .code_scanning
179                .as_deref()
180                .map(Provider::parse)
181                .transpose()?,
182            ..args.profile.inputs()?
183        },
184        config.as_ref(),
185        record.as_ref(),
186        landing::Purpose::Preview,
187    )?;
188    let params: &Params = &resolved.params;
189    let evidence = TargetEvidence::gather(&args.target, record.as_ref())?;
190    let projection = Projection::compute(&ProjectionInput {
191        params: params.clone(),
192        evidence,
193    })?;
194
195    out.result_line(format!(
196        "profile: {}",
197        describe(
198            params.profile(),
199            params.git(),
200            params.capabilities(),
201            params.repo()
202        )
203    ));
204    for (key, source) in &resolved.sources {
205        out.result_line(format!("source {key}: {}", source.as_str()));
206    }
207    for name in &resolved.unknown {
208        out.result_line(format!("unknown {name}: preserved; no adapter drives it"));
209    }
210    if let Some(proposal) = &resolved.proposal {
211        out.result_line(format!(
212            "proposal: {}",
213            match proposal {
214                Proposal::None => "no release-bearing technology, so the release mode is none".to_owned(),
215                Proposal::Automatic { driver } => format!("an automatic release driven by {driver}"),
216                Proposal::Ambiguous { drivers } => format!(
217                    "ambiguous: {} are release-bearing; an apply refuses until --release-driver names one",
218                    drivers.join(" and ")
219                ),
220            }
221        ));
222    }
223    let selection: Vec<CapabilityNote> = projection
224        .capabilities
225        .iter()
226        .map(|selection| CapabilityNote::of(selection, &projection))
227        .collect();
228    for note in &selection {
229        let mut line = format!("{} {}", note.status, note.id);
230        if !note.destinations.is_empty() {
231            line.push_str(": ");
232            line.push_str(&note.destinations.join(", "));
233        }
234        if let Some(reason) = &note.reason {
235            line.push_str(" (");
236            line.push_str(reason);
237            line.push(')');
238        }
239        out.result_line(line);
240        if let Some(action) = &note.action {
241            out.result_line(format!("  action: {action}"));
242        }
243    }
244    let omissions: Vec<Note> = projection
245        .omissions
246        .iter()
247        .map(|omission| Note {
248            destination: omission.destination.clone(),
249            reason: omission.reason.clone(),
250            action: omission.action.clone(),
251        })
252        .collect();
253    let collisions: Vec<Note> = projection
254        .collisions
255        .iter()
256        .map(|collision| Note {
257            destination: collision.destination.clone(),
258            reason: collision.reason.clone(),
259            action: None,
260        })
261        .collect();
262    for note in &omissions {
263        out.result_line(format!("withheld {}: {}", note.destination, note.reason));
264    }
265    for note in &collisions {
266        out.result_line(format!("collision {}: {}", note.destination, note.reason));
267    }
268    // `rk upgrade` takes each capability as `on|off`, and `rk init` takes
269    // the boolean ones as bare flags. The follow-up command must parse, so
270    // it renders the verb's own spelling rather than one of them twice.
271    let (verb, capabilities) = if record.is_some() {
272        ("upgrade", params.capability_toggles())
273    } else {
274        ("init", params.capability_flags())
275    };
276    let mut next = vec![format!(
277        "rk {verb}{}{capabilities} --target {} previews the landing under these answers",
278        params.canonical_flags(),
279        args.target
280    )];
281    if let Some(Proposal::Ambiguous { drivers }) = &resolved.proposal {
282        next.insert(
283            0,
284            format!(
285                "name the driver before an apply: --release-driver <{}>",
286                drivers.join("|")
287            ),
288        );
289    }
290    if let Some(reason) = projection.release_unavailable() {
291        next.insert(
292            0,
293            format!("an apply refuses until the release automation resolves: {reason}"),
294        );
295    }
296    next.push(format!(
297        "rk stage --target {} stages the complete candidate for a byte comparison",
298        args.target
299    ));
300    out.next(&next);
301    out.emit(&Report {
302        schema: "rk.profile/2",
303        target: args.target.to_string(),
304        profile: params.profile().clone(),
305        git: params.git().clone(),
306        capabilities: params.capabilities().clone(),
307        repo: params.repo().to_owned(),
308        security: Security {
309            contact: params.security_contact().to_owned(),
310            response: params.security_response().to_owned(),
311        },
312        setup: Setup {
313            required_check: params.required_check().to_owned(),
314            required_workflow: params.required_workflow().to_owned(),
315        },
316        sources: resolved.sources,
317        unknown: resolved.unknown,
318        proposal: resolved.proposal,
319        selection,
320        omissions,
321        collisions,
322        next,
323    })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::{Report, Security, Setup};
329    use crate::landing::{CheckoutMode, Integration};
330    use crate::profile::{
331        CapabilityRequests, GitWorkflow, ProfileSnapshot, Proposal, ReleaseIntent, ReleaseMode,
332        Source,
333    };
334    use crate::stage::{CapabilityNote, Note};
335
336    /// The complete `rk.profile/1` shape, held by snapshot.
337    #[test]
338    fn the_profile_report_schema_snapshot_holds() {
339        let report = Report {
340            schema: "rk.profile/2",
341            target: "/tmp/t".into(),
342            profile: ProfileSnapshot {
343                technologies: vec!["python".into(), "rust".into()],
344                forge: Some("github".into()),
345                release: ReleaseIntent {
346                    mode: ReleaseMode::Automatic,
347                    driver: Some("rust".into()),
348                    style: Some(crate::landing::Style::Trunk),
349                    line_prefix: Some("release/".into()),
350                },
351            },
352            git: GitWorkflow {
353                trunk: "master".into(),
354                checkout_mode: CheckoutMode::LinkedWorktree,
355                integration: Integration::Local,
356            },
357            capabilities: CapabilityRequests {
358                nix_packaging: false,
359                reporting_policy: true,
360                scorecard: false,
361                code_scanning: None,
362            },
363            repo: "acme/widget".into(),
364            security: Security {
365                contact: String::new(),
366                response: "best-effort".into(),
367            },
368            setup: Setup {
369                required_check: String::new(),
370                required_workflow: String::new(),
371            },
372            sources: std::iter::once(("profile.technologies", Source::Observation)).collect(),
373            unknown: vec![],
374            proposal: Some(Proposal::Ambiguous {
375                drivers: vec!["python".into(), "rust".into()],
376            }),
377            selection: vec![CapabilityNote {
378                id: "git.guards".into(),
379                status: "selected".into(),
380                reason: None,
381                action: None,
382                destinations: vec!["AGENTS.md".into()],
383            }],
384            omissions: vec![Note {
385                destination: ".gitlab-ci.yml".into(),
386                reason: "the target owns it".into(),
387                action: Some("add the include".into()),
388            }],
389            collisions: vec![],
390            next: vec!["rk init --target /tmp/t previews the landing".into()],
391        };
392        assert_eq!(
393            serde_json::to_string(&report).expect("a report serializes"),
394            r#"{"schema":"rk.profile/2","target":"/tmp/t","profile":{"technologies":["python","rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false},"repo":"acme/widget","security":{"contact":"","response":"best-effort"},"setup":{"required_check":"","required_workflow":""},"sources":{"profile.technologies":"observation"},"unknown":[],"proposal":{"state":"ambiguous","drivers":["python","rust"]},"selection":[{"id":"git.guards","status":"selected","destinations":["AGENTS.md"]}],"omissions":[{"destination":".gitlab-ci.yml","reason":"the target owns it","action":"add the include"}],"collisions":[],"next":["rk init --target /tmp/t previews the landing"]}"#
395        );
396    }
397}