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 landing: LandingDeclaration,
51 sources: BTreeMap<&'static str, Source>,
53 unknown: Vec<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
58 proposal: Option<Proposal>,
59 selection: Vec<CapabilityNote>,
62 omissions: Vec<Note>,
64 collisions: Vec<Note>,
66 next: Vec<String>,
68}
69
70#[derive(Debug, Serialize)]
72struct LandingDeclaration {
73 accepted_drift: BTreeMap<String, crate::config::AcceptedDrift>,
75}
76
77#[derive(Debug, Serialize)]
79struct Security {
80 contact: String,
82 response: String,
84 scope: String,
87}
88
89#[derive(Debug, Serialize)]
91struct Setup {
92 required_check: String,
94 required_workflow: String,
97 bot_name: String,
99}
100
101#[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#[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 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 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(¬e.destinations.join(", "));
288 }
289 if let Some(reason) = ¬e.reason {
290 line.push_str(" (");
291 line.push_str(reason);
292 line.push(')');
293 }
294 out.result_line(line);
295 if let Some(action) = ¬e.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 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 #[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}