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