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