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 {}; 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 if requested.is_empty() {
125 "none".to_owned()
126 } else {
127 requested.join(", ")
128 },
129 scanning.map_or_else(String::new, |provider| format!(
130 ", code_scanning {provider}"
131 ))
132 )
133}
134
135#[allow(
142 clippy::too_many_lines,
143 reason = "one pass reports every domain, source, capability, and omission, and splitting it would separate a value from the report line that states it"
144)]
145pub fn run(args: &ProfileArgs) -> Result<(), RkError> {
146 let out = Output::new(args.json);
147 if !args.target.is_dir() {
148 return Err(RkError::missing(
149 Diagnostic::new(
150 Reason::TargetNotFound,
151 format!("target {} is not a directory", args.target),
152 )
153 .expected("an existing repository to resolve for"),
154 ));
155 }
156 let config = crate::config::load(args.target.as_std_path())?;
157 let record = manifest::load(&args.target)?;
158 let resolved = profile::resolve(
159 &args.target,
160 &landing::Inputs {
161 nix: args.nix_packaging.then_some(true),
162 reporting_policy: args.reporting_policy.then_some(true),
163 scorecard: args.scorecard.then_some(true),
164 code_scanning: args
165 .code_scanning
166 .as_deref()
167 .map(Provider::parse)
168 .transpose()?,
169 ..args.profile.inputs()?
170 },
171 config.as_ref(),
172 record.as_ref(),
173 landing::Purpose::Preview,
174 )?;
175 let params: &Params = &resolved.params;
176 let evidence = TargetEvidence::gather(&args.target, record.as_ref())?;
177 let projection = Projection::compute(&ProjectionInput {
178 params: params.clone(),
179 evidence,
180 })?;
181
182 out.result_line(format!(
183 "profile: {}",
184 describe(
185 params.profile(),
186 params.git(),
187 params.capabilities(),
188 params.repo()
189 )
190 ));
191 for (key, source) in &resolved.sources {
192 out.result_line(format!("source {key}: {}", source.as_str()));
193 }
194 for name in &resolved.unknown {
195 out.result_line(format!("unknown {name}: preserved; no adapter drives it"));
196 }
197 if let Some(proposal) = &resolved.proposal {
198 out.result_line(format!(
199 "proposal: {}",
200 match proposal {
201 Proposal::None => "no release-bearing technology, so the release mode is none".to_owned(),
202 Proposal::Automatic { driver } => format!("an automatic release driven by {driver}"),
203 Proposal::Ambiguous { drivers } => format!(
204 "ambiguous: {} are release-bearing; an apply refuses until --release-driver names one",
205 drivers.join(" and ")
206 ),
207 }
208 ));
209 }
210 let selection: Vec<CapabilityNote> = projection
211 .capabilities
212 .iter()
213 .map(|selection| CapabilityNote::of(selection, &projection))
214 .collect();
215 for note in &selection {
216 let mut line = format!("{} {}", note.status, note.id);
217 if !note.destinations.is_empty() {
218 line.push_str(": ");
219 line.push_str(¬e.destinations.join(", "));
220 }
221 if let Some(reason) = ¬e.reason {
222 line.push_str(" (");
223 line.push_str(reason);
224 line.push(')');
225 }
226 out.result_line(line);
227 if let Some(action) = ¬e.action {
228 out.result_line(format!(" action: {action}"));
229 }
230 }
231 let omissions: Vec<Note> = projection
232 .omissions
233 .iter()
234 .map(|omission| Note {
235 destination: omission.destination.clone(),
236 reason: omission.reason.clone(),
237 action: omission.action.clone(),
238 })
239 .collect();
240 let collisions: Vec<Note> = projection
241 .collisions
242 .iter()
243 .map(|collision| Note {
244 destination: collision.destination.clone(),
245 reason: collision.reason.clone(),
246 action: None,
247 })
248 .collect();
249 for note in &omissions {
250 out.result_line(format!("withheld {}: {}", note.destination, note.reason));
251 }
252 for note in &collisions {
253 out.result_line(format!("collision {}: {}", note.destination, note.reason));
254 }
255 let (verb, capabilities) = if record.is_some() {
259 ("upgrade", params.capability_toggles())
260 } else {
261 ("init", params.capability_flags())
262 };
263 let mut next = vec![format!(
264 "rk {verb}{}{capabilities} --target {} previews the landing under these answers",
265 params.canonical_flags(),
266 args.target
267 )];
268 if let Some(Proposal::Ambiguous { drivers }) = &resolved.proposal {
269 next.insert(
270 0,
271 format!(
272 "name the driver before an apply: --release-driver <{}>",
273 drivers.join("|")
274 ),
275 );
276 }
277 if let Some(reason) = projection.release_unavailable() {
278 next.insert(
279 0,
280 format!("an apply refuses until the release automation resolves: {reason}"),
281 );
282 }
283 next.push(format!(
284 "rk stage --target {} stages the complete candidate for a byte comparison",
285 args.target
286 ));
287 out.next(&next);
288 out.emit(&Report {
289 schema: "rk.profile/1",
290 target: args.target.to_string(),
291 profile: params.profile().clone(),
292 git: params.git().clone(),
293 capabilities: params.capabilities().clone(),
294 repo: params.repo().to_owned(),
295 security: Security {
296 contact: params.security_contact().to_owned(),
297 response: params.security_response().to_owned(),
298 },
299 sources: resolved.sources,
300 unknown: resolved.unknown,
301 proposal: resolved.proposal,
302 selection,
303 omissions,
304 collisions,
305 next,
306 })
307}
308
309#[cfg(test)]
310mod tests {
311 use super::{Report, Security};
312 use crate::landing::CheckoutMode;
313 use crate::profile::{
314 CapabilityRequests, GitWorkflow, ProfileSnapshot, Proposal, ReleaseIntent, ReleaseMode,
315 Source,
316 };
317 use crate::stage::{CapabilityNote, Note};
318
319 #[test]
321 fn the_profile_report_schema_snapshot_holds() {
322 let report = Report {
323 schema: "rk.profile/1",
324 target: "/tmp/t".into(),
325 profile: ProfileSnapshot {
326 technologies: vec!["python".into(), "rust".into()],
327 forge: Some("github".into()),
328 release: ReleaseIntent {
329 mode: ReleaseMode::Automatic,
330 driver: Some("rust".into()),
331 style: Some(crate::landing::Style::Trunk),
332 line_prefix: Some("release/".into()),
333 },
334 },
335 git: GitWorkflow {
336 trunk: "master".into(),
337 checkout_mode: CheckoutMode::LinkedWorktree,
338 },
339 capabilities: CapabilityRequests {
340 nix_packaging: false,
341 reporting_policy: true,
342 scorecard: false,
343 code_scanning: None,
344 },
345 repo: "acme/widget".into(),
346 security: Security {
347 contact: String::new(),
348 response: "best-effort".into(),
349 },
350 sources: std::iter::once(("profile.technologies", Source::Observation)).collect(),
351 unknown: vec![],
352 proposal: Some(Proposal::Ambiguous {
353 drivers: vec!["python".into(), "rust".into()],
354 }),
355 selection: vec![CapabilityNote {
356 id: "git.guards".into(),
357 status: "selected".into(),
358 reason: None,
359 action: None,
360 destinations: vec!["AGENTS.md".into()],
361 }],
362 omissions: vec![Note {
363 destination: ".gitlab-ci.yml".into(),
364 reason: "the target owns it".into(),
365 action: Some("add the include".into()),
366 }],
367 collisions: vec![],
368 next: vec!["rk init --target /tmp/t previews the landing".into()],
369 };
370 assert_eq!(
371 serde_json::to_string(&report).expect("a report serializes"),
372 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"},"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"]}"#
373 );
374 }
375}