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