1use 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#[derive(Debug, Serialize)]
30struct Report {
31 schema: &'static str,
33 target: String,
35 profile: ProfileSnapshot,
37 git: GitWorkflow,
39 capabilities: CapabilityRequests,
41 repo: String,
43 security: Security,
45 sources: BTreeMap<&'static str, Source>,
47 unknown: Vec<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
52 proposal: Option<Proposal>,
53 selection: Vec<CapabilityNote>,
56 omissions: Vec<Note>,
58 collisions: Vec<Note>,
60 next: Vec<String>,
62}
63
64#[derive(Debug, Serialize)]
66struct Security {
67 contact: String,
69 response: String,
71}
72
73#[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#[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(¬e.destinations.join(", "));
221 }
222 if let Some(reason) = ¬e.reason {
223 line.push_str(" (");
224 line.push_str(reason);
225 line.push(')');
226 }
227 out.result_line(line);
228 if let Some(action) = ¬e.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 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 #[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}