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 landing declaration: the rendered destinations the project
48    /// deliberately edits. Its own domain, never a profile fact or a
49    /// capability request.
50    landing: LandingDeclaration,
51    /// The source of each value, keyed by its configuration path.
52    sources: BTreeMap<&'static str, Source>,
53    /// The category names the catalog does not know.
54    unknown: Vec<String>,
55    /// What the observation proposed for the release, where nothing above
56    /// it answered the mode.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    proposal: Option<Proposal>,
59    /// Every capability, in catalog order, with its status and its
60    /// destinations.
61    selection: Vec<CapabilityNote>,
62    /// The destinations the target's own state withholds.
63    omissions: Vec<Note>,
64    /// The block destinations whose document offers the block no place.
65    collisions: Vec<Note>,
66    /// What plausibly follows.
67    next: Vec<String>,
68}
69
70/// The landing declaration, by destination.
71#[derive(Debug, Serialize)]
72struct LandingDeclaration {
73    /// Each accepted deviation, with its reason and accepted digest.
74    accepted_drift: BTreeMap<String, crate::config::AcceptedDrift>,
75}
76
77/// The three security answers.
78#[derive(Debug, Serialize)]
79struct Security {
80    /// The contact, empty for the forge's own wording.
81    contact: String,
82    /// The acknowledgment window.
83    response: String,
84    /// The paragraph naming what the project does not own, empty where the
85    /// policy states none.
86    scope: String,
87}
88
89/// The setup answers a landing renders.
90#[derive(Debug, Serialize)]
91struct Setup {
92    /// The check the release gate believes, empty where nothing answered it.
93    required_check: String,
94    /// The workflow whose completion wakes the release gate, empty where
95    /// nothing answered it.
96    required_workflow: String,
97    /// The bot identity's name, empty where the forge's default applies.
98    bot_name: String,
99}
100
101/// One line stating the three domains and the identity, for every human
102/// report that names a resolved target.
103#[must_use]
104pub fn describe(
105    profile: &ProfileSnapshot,
106    git: &GitWorkflow,
107    capabilities: &CapabilityRequests,
108    repo: &str,
109) -> String {
110    let technologies = if profile.technologies.is_empty() {
111        "none".to_owned()
112    } else {
113        profile.technologies.join(", ")
114    };
115    let release = match profile.release.mode {
116        profile::ReleaseMode::Automatic => format!(
117            "automatic (driver {}, style {}, line prefix {})",
118            profile.release.driver.as_deref().unwrap_or("unresolved"),
119            profile
120                .release
121                .style
122                .map_or("unresolved", crate::landing::Style::as_str),
123            profile
124                .release
125                .line_prefix
126                .as_deref()
127                .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
128        ),
129        other => other.as_str().to_owned(),
130    };
131    let mut requested: Vec<&str> = Vec::new();
132    if capabilities.nix_packaging {
133        requested.push("nix_packaging");
134    }
135    if capabilities.reporting_policy {
136        requested.push("reporting_policy");
137    }
138    if capabilities.scorecard {
139        requested.push("scorecard");
140    }
141    if capabilities.workflow_guards() {
142        requested.push("workflow_guards");
143    }
144    let scanning = capabilities.code_scanning.map(Provider::as_str);
145    format!(
146        "technologies {technologies}; forge {}; repo {}; release {release}; trunk {}; checkout mode {}; integration {}; merge form {}; requests {}{}",
147        profile.forge.as_deref().unwrap_or("none"),
148        match repo {
149            "" => "none",
150            crate::projection::REPO_PLACEHOLDER => "unresolved",
151            named => named,
152        },
153        git.trunk,
154        git.checkout_mode.as_str(),
155        git.integration.as_str(),
156        git.merge_form.as_str(),
157        if requested.is_empty() {
158            "none".to_owned()
159        } else {
160            requested.join(", ")
161        },
162        scanning.map_or_else(String::new, |provider| format!(
163            ", code_scanning {provider}"
164        ))
165    )
166}
167
168/// Report the target's resolution and selection.
169///
170/// # Errors
171///
172/// Returns [`RkError::Missing`] for a target that is not a directory, and
173/// the resolution's own refusals for a malformed release intent.
174#[allow(
175    clippy::too_many_lines,
176    reason = "one pass reports every domain, source, capability, and omission, and splitting it would separate a value from the report line that states it"
177)]
178pub fn run(args: &ProfileArgs) -> Result<(), RkError> {
179    let out = Output::new(args.json);
180    if !args.target.is_dir() {
181        return Err(RkError::missing(
182            Diagnostic::new(
183                Reason::TargetNotFound,
184                format!("target {} is not a directory", args.target),
185            )
186            .expected("an existing repository to resolve for"),
187        ));
188    }
189    let config = crate::config::load(args.target.as_std_path())?;
190    let record = manifest::load(&args.target)?;
191    let resolved = profile::resolve(
192        &args.target,
193        &landing::Inputs {
194            nix: args.nix_packaging.then_some(true),
195            reporting_policy: args.reporting_policy.then_some(true),
196            scorecard: args.scorecard.then_some(true),
197            workflow_guards: args.workflow_guards.then_some(true),
198            code_scanning: args
199                .code_scanning
200                .as_deref()
201                .map(Provider::parse)
202                .transpose()?,
203            ..args.profile.inputs()?
204        },
205        config.as_ref(),
206        record.as_ref(),
207        landing::Purpose::Preview,
208    )?;
209    let params: &Params = &resolved.params;
210    let evidence = TargetEvidence::gather(&args.target, record.as_ref())?;
211    let projection = Projection::compute(&ProjectionInput {
212        params: params.clone(),
213        evidence,
214    })?;
215    // The landing declaration is a committed answer of class N: only the
216    // configuration states it, and it must name what this landing selects.
217    let accepted_drift = config
218        .as_ref()
219        .map(|config| config.landing.accepted_drift.clone())
220        .unwrap_or_default();
221    landing::accepted_drift_faults(&projection, record.as_ref(), config.as_ref())?;
222    let mut sources = resolved.sources.clone();
223    sources.insert(
224        "landing.accepted_drift",
225        if accepted_drift.is_empty() {
226            profile::Source::Default
227        } else {
228            profile::Source::Config
229        },
230    );
231    for (destination, entry) in &accepted_drift {
232        out.result_line(format!(
233            "landing declaration: accepted drift on {destination} ({}), from config",
234            entry.reason
235        ));
236    }
237
238    out.result_line(format!(
239        "profile: {}",
240        describe(
241            params.profile(),
242            params.git(),
243            params.capabilities(),
244            params.repo()
245        )
246    ));
247    // Where an automatic rust release publishes, stated beside its source
248    // so an unanswered target reads crates.io as the default it is.
249    if let Some(answer) = params.registry() {
250        out.result_line(format!(
251            "registry: {} ({})",
252            answer.as_str(),
253            resolved
254                .sources
255                .get("profile.release.registry")
256                .map_or("default", |source| source.as_str())
257        ));
258    }
259    for (key, source) in &resolved.sources {
260        out.result_line(format!("source {key}: {}", source.as_str()));
261    }
262    for name in &resolved.unknown {
263        out.result_line(format!("unknown {name}: preserved; no adapter drives it"));
264    }
265    if let Some(proposal) = &resolved.proposal {
266        out.result_line(format!(
267            "proposal: {}",
268            match proposal {
269                Proposal::None => "no release-bearing technology, so the release mode is none".to_owned(),
270                Proposal::Automatic { driver } => format!("an automatic release driven by {driver}"),
271                Proposal::Ambiguous { drivers } => format!(
272                    "ambiguous: {} are release-bearing; an apply refuses until --release-driver names one",
273                    drivers.join(" and ")
274                ),
275            }
276        ));
277    }
278    let selection: Vec<CapabilityNote> = projection
279        .capabilities
280        .iter()
281        .map(|selection| CapabilityNote::of(selection, &projection))
282        .collect();
283    for note in &selection {
284        let mut line = format!("{} {}", note.status, note.id);
285        if !note.destinations.is_empty() {
286            line.push_str(": ");
287            line.push_str(&note.destinations.join(", "));
288        }
289        if let Some(reason) = &note.reason {
290            line.push_str(" (");
291            line.push_str(reason);
292            line.push(')');
293        }
294        out.result_line(line);
295        if let Some(action) = &note.action {
296            out.result_line(format!("  action: {action}"));
297        }
298    }
299    let omissions: Vec<Note> = projection
300        .omissions
301        .iter()
302        .map(|omission| Note {
303            destination: omission.destination.clone(),
304            reason: omission.reason.clone(),
305            action: omission.action.clone(),
306        })
307        .collect();
308    let collisions: Vec<Note> = projection
309        .collisions
310        .iter()
311        .map(|collision| Note {
312            destination: collision.destination.clone(),
313            reason: collision.reason.clone(),
314            action: None,
315        })
316        .collect();
317    for note in &omissions {
318        out.result_line(format!("withheld {}: {}", note.destination, note.reason));
319    }
320    for note in &collisions {
321        out.result_line(format!("collision {}: {}", note.destination, note.reason));
322    }
323    // `rk upgrade` takes each capability as `on|off`, and `rk init` takes
324    // the boolean ones as bare flags. The follow-up command must parse, so
325    // it renders the verb's own spelling rather than one of them twice.
326    let (verb, capabilities) = if record.is_some() {
327        ("upgrade", params.capability_toggles())
328    } else {
329        ("init", params.capability_flags())
330    };
331    let mut next = vec![format!(
332        "rk {verb}{}{capabilities} --target {} previews the landing under these answers",
333        params.canonical_flags(),
334        args.target
335    )];
336    if let Some(Proposal::Ambiguous { drivers }) = &resolved.proposal {
337        next.insert(
338            0,
339            format!(
340                "name the driver before an apply: --release-driver <{}>",
341                drivers.join("|")
342            ),
343        );
344    }
345    if let Some(reason) = projection.release_unavailable() {
346        next.insert(
347            0,
348            format!("an apply refuses until the release automation resolves: {reason}"),
349        );
350    }
351    next.push(format!(
352        "rk stage --target {} stages the complete candidate for a byte comparison",
353        args.target
354    ));
355    out.next(&next);
356    out.emit(&Report {
357        schema: "rk.profile/5",
358        target: args.target.to_string(),
359        profile: params.profile().clone(),
360        git: params.git().clone(),
361        capabilities: params.capabilities().clone(),
362        repo: params.repo().to_owned(),
363        security: Security {
364            contact: params.security_contact().to_owned(),
365            response: params.security_response().to_owned(),
366            scope: params.security_scope().to_owned(),
367        },
368        setup: Setup {
369            required_check: params.required_check().to_owned(),
370            required_workflow: params.required_workflow().to_owned(),
371            bot_name: resolved.bot_name.clone(),
372        },
373        landing: LandingDeclaration { accepted_drift },
374        sources,
375        unknown: resolved.unknown,
376        proposal: resolved.proposal,
377        selection,
378        omissions,
379        collisions,
380        next,
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    use super::{Report, Security, Setup};
387    use crate::landing::{CheckoutMode, Integration};
388    use crate::profile::{
389        CapabilityRequests, GitWorkflow, ProfileSnapshot, Proposal, ReleaseIntent, ReleaseMode,
390        Source,
391    };
392    use crate::stage::{CapabilityNote, Note};
393
394    /// The complete `rk.profile/5` shape, held by snapshot.
395    #[test]
396    fn the_profile_report_schema_snapshot_holds() {
397        let report = Report {
398            schema: "rk.profile/5",
399            target: "/tmp/t".into(),
400            profile: ProfileSnapshot {
401                technologies: vec!["python".into(), "rust".into()],
402                forge: Some("github".into()),
403                release: ReleaseIntent {
404                    mode: ReleaseMode::Automatic,
405                    driver: Some("rust".into()),
406                    style: Some(crate::landing::Style::Trunk),
407                    line_prefix: Some("release/".into()),
408                    registry: None,
409                    build: None,
410                },
411            },
412            git: GitWorkflow {
413                trunk: "master".into(),
414                checkout_mode: CheckoutMode::LinkedWorktree,
415                integration: Integration::Local,
416                merge_form: crate::landing::MergeForm::Squash,
417            },
418            capabilities: CapabilityRequests {
419                nix_packaging: false,
420                reporting_policy: true,
421                scorecard: false,
422                code_scanning: None,
423                workflow_guards: Some(false),
424            },
425            repo: "acme/widget".into(),
426            security: Security {
427                contact: String::new(),
428                response: "best-effort".into(),
429                scope: String::new(),
430            },
431            setup: Setup {
432                required_check: String::new(),
433                required_workflow: String::new(),
434                bot_name: String::new(),
435            },
436            landing: super::LandingDeclaration {
437                accepted_drift: std::collections::BTreeMap::new(),
438            },
439            sources: std::iter::once(("profile.technologies", Source::Observation)).collect(),
440            unknown: vec![],
441            proposal: Some(Proposal::Ambiguous {
442                drivers: vec!["python".into(), "rust".into()],
443            }),
444            selection: vec![CapabilityNote {
445                id: "git.guards".into(),
446                status: "selected".into(),
447                reason: None,
448                action: None,
449                destinations: vec!["AGENTS.md".into()],
450            }],
451            omissions: vec![Note {
452                destination: ".gitlab-ci.yml".into(),
453                reason: "the target owns it".into(),
454                action: Some("add the include".into()),
455            }],
456            collisions: vec![],
457            next: vec!["rk init --target /tmp/t previews the landing".into()],
458        };
459        assert_eq!(
460            serde_json::to_string(&report).expect("a report serializes"),
461            r#"{"schema":"rk.profile/5","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","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"workflow_guards":false},"repo":"acme/widget","security":{"contact":"","response":"best-effort","scope":""},"setup":{"required_check":"","required_workflow":"","bot_name":""},"landing":{"accepted_drift":{}},"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"]}"#
462        );
463    }
464}