1use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8PathBuf;
17use zeroize::Zeroizing;
18
19use super::secrets;
20use crate::detect::{self, Forge};
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25const fn bool_word(value: bool) -> &'static str {
32 if value { "true" } else { "false" }
33}
34
35fn json_list(values: &[String]) -> String {
38 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
39 format!("[{}]", inner.join(", "))
40}
41
42const PASSTHROUGH: [&str; 11] = [
46 "PATH",
47 "HOME",
48 "XDG_CONFIG_HOME",
49 "GH_TOKEN",
50 "GITHUB_TOKEN",
51 "GH_HOST",
52 "GH_CONFIG_DIR",
53 "GLAB_TOKEN",
54 "GITLAB_TOKEN",
55 "GITLAB_HOST",
56 "GLAB_CONFIG_DIR",
57];
58
59pub use super::secrets::VALUE_VARS as SECRET_VARS;
65
66#[derive(Debug, Clone)]
68pub struct Ctx {
69 pub target: Utf8PathBuf,
71 pub repo: String,
73 pub forge: Option<Forge>,
78 pub declared_forge: Option<String>,
81 pub profile: ProfileSnapshot,
83 pub capabilities: CapabilityRequests,
85 pub host: Option<String>,
87 pub required_check: Option<String>,
89 pub cli: PathBuf,
91 pub tech: Option<&'static str>,
93 trunk: String,
96 line_prefix: String,
99 retired_branches: Vec<String>,
102 release_lines: bool,
105 excluded_steps: std::collections::BTreeMap<String, String>,
108 bot_app_id: Option<String>,
111 trunk_ruleset: String,
113 tag_ruleset: String,
115 lines_ruleset: String,
117 title_check: String,
119 protection: crate::config::Protection,
123}
124
125impl Ctx {
126 pub fn resolve(
135 target: &Utf8PathBuf,
136 repo_flag: Option<&str>,
137 forge_flag: Option<&str>,
138 required_check: Option<&str>,
139 ) -> Result<Self, RkError> {
140 if !target.is_dir() {
141 return Err(RkError::missing(
142 Diagnostic::new(
143 Reason::TargetNotFound,
144 format!("target {target} is not a directory; nothing was run"),
145 )
146 .expected("an existing repository to set up"),
147 ));
148 }
149 let forge_flag = forge_flag
150 .map(|name| {
151 detect::Forge::parse(name).ok_or_else(|| {
152 RkError::Usage(format!(
153 "unknown forge '{name}'; the forges are: github, gitlab"
154 ))
155 })
156 })
157 .transpose()?;
158 let detected = detect::detect(target.as_std_path());
159 let config = crate::config::load(target.as_std_path())?;
160 let record = crate::landing::manifest::load(target)?;
161 let resolved = crate::profile::Params::resolve(
165 target,
166 &crate::profile::Inputs {
167 forge: forge_flag.map(Forge::as_str),
168 repo: repo_flag,
169 ..crate::profile::Inputs::default()
170 },
171 config.as_ref(),
172 record.as_ref(),
173 crate::profile::Purpose::Preview,
174 )?;
175 let declared_forge = resolved.forge().map(str::to_owned);
176 let forge = declared_forge.as_deref().and_then(Forge::parse);
177 let repo = resolved.repo().to_owned();
178 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
179 String::new()
180 } else {
181 repo
182 };
183 let cli = PathBuf::new();
187 let answers = config
188 .as_ref()
189 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
190 let required_check = required_check.map(str::to_owned).or_else(|| {
194 Some(answers.required_check.clone())
195 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
196 });
197 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
198 let trunk = resolved.trunk().to_owned();
199 let protection = config
200 .as_ref()
201 .map_or_else(crate::config::Protection::default, |held| {
202 held.protection.clone()
203 });
204 Ok(Self {
205 target: target.clone(),
206 repo,
207 forge,
208 host: detected.host,
209 required_check,
210 cli,
211 tech: resolved.driver().and_then(|driver| {
212 ["rust", "python", "bash"]
213 .into_iter()
214 .find(|known| *known == driver)
215 }),
216 trunk_ruleset: protection.trunk_ruleset(&trunk),
217 tag_ruleset: protection.tag_ruleset.clone(),
218 lines_ruleset: protection.lines_ruleset.clone(),
219 title_check: protection.title_check.clone(),
220 protection,
221 trunk,
222 line_prefix: resolved.line_prefix().to_owned(),
223 profile: resolved.profile().clone(),
224 capabilities: resolved.capabilities().clone(),
225 declared_forge,
226 retired_branches: answers.retired_branches,
227 release_lines: answers.release_lines,
228 excluded_steps: answers.excluded_steps,
229 bot_app_id,
230 })
231 }
232
233 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
248 let Some(forge) = self.forge else {
249 return Ok(());
250 };
251 let calls = steps.iter().any(|step| {
252 step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
253 });
254 if calls && self.cli.as_os_str().is_empty() {
255 self.cli = resolve_cli(forge)?;
256 }
257 Ok(())
258 }
259
260 #[doc(hidden)]
265 #[must_use]
266 pub fn for_tests(
267 target: Utf8PathBuf,
268 repo: String,
269 forge: Forge,
270 cli: PathBuf,
271 tech: Option<&'static str>,
272 ) -> Self {
273 let defaults = crate::config::Protection::default();
274 Self {
275 target,
276 repo,
277 forge: Some(forge),
278 declared_forge: Some(forge.as_str().to_owned()),
279 profile: ProfileSnapshot {
280 technologies: tech.into_iter().map(str::to_owned).collect(),
281 forge: Some(forge.as_str().to_owned()),
282 release: crate::profile::ReleaseIntent {
283 mode: ReleaseMode::Automatic,
284 driver: tech.map(str::to_owned),
285 style: Some(crate::landing::Style::Trunk),
286 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
287 },
288 },
289 capabilities: CapabilityRequests {
290 nix_packaging: false,
291 reporting_policy: true,
292 scorecard: false,
293 code_scanning: None,
294 },
295 host: None,
296 required_check: None,
297 cli,
298 tech,
299 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
300 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
301 retired_branches: crate::config::Setup::default().retired_branches,
302 release_lines: false,
303 excluded_steps: std::collections::BTreeMap::new(),
304 bot_app_id: None,
305 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
306 tag_ruleset: defaults.tag_ruleset.clone(),
307 lines_ruleset: defaults.lines_ruleset.clone(),
308 title_check: defaults.title_check.clone(),
309 protection: defaults,
310 }
311 }
312
313 #[must_use]
315 pub const fn has_adapter(&self) -> bool {
316 self.forge.is_some()
317 }
318
319 pub fn adapter(&self) -> Result<Forge, RkError> {
327 self.forge.ok_or_else(|| {
328 let named = self.declared_forge.as_deref();
329 let message = named.map_or_else(
330 || "the profile names no forge, and this operation acts on one".to_owned(),
331 |name| {
332 format!(
333 "the profile names the forge {name}, which this release has no adapter for"
334 )
335 },
336 );
337 RkError::refusal(
338 Diagnostic::new(Reason::PrerequisiteUnmet, message)
339 .expected("a profile naming github or gitlab")
340 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
341 .target_state("unchanged"),
342 )
343 })
344 }
345
346 #[must_use]
348 pub const fn automatic_release(&self) -> bool {
349 matches!(self.profile.release.mode, ReleaseMode::Automatic)
350 }
351
352 #[must_use]
354 pub fn driver(&self) -> Option<&str> {
355 self.profile.release.driver.as_deref()
356 }
357
358 #[must_use]
360 pub fn declared_forge(&self) -> Option<&str> {
361 self.declared_forge.as_deref()
362 }
363
364 #[must_use]
366 pub const fn reporting_policy(&self) -> bool {
367 self.capabilities.reporting_policy
368 }
369
370 #[must_use]
372 pub fn trunk(&self) -> &str {
373 &self.trunk
374 }
375
376 #[must_use]
378 pub fn line_prefix(&self) -> &str {
379 &self.line_prefix
380 }
381
382 #[must_use]
384 pub fn retired_branches(&self) -> &[String] {
385 &self.retired_branches
386 }
387
388 #[must_use]
390 pub const fn release_lines(&self) -> bool {
391 self.release_lines
392 }
393
394 #[must_use]
398 pub fn excluded(&self, step: &str) -> Option<&str> {
399 self.excluded_steps.get(step).map(String::as_str)
400 }
401
402 #[must_use]
404 pub fn excluded_count(&self) -> usize {
405 self.excluded_steps.len()
406 }
407
408 #[must_use]
410 pub fn bot_app_id(&self) -> Option<&str> {
411 self.bot_app_id.as_deref()
412 }
413
414 #[must_use]
416 pub fn trunk_ruleset(&self) -> &str {
417 &self.trunk_ruleset
418 }
419
420 #[must_use]
422 pub fn tag_ruleset(&self) -> &str {
423 &self.tag_ruleset
424 }
425
426 #[must_use]
428 pub fn lines_ruleset(&self) -> &str {
429 &self.lines_ruleset
430 }
431
432 #[must_use]
434 pub fn title_check(&self) -> &str {
435 &self.title_check
436 }
437
438 #[must_use]
440 pub const fn protection(&self) -> &crate::config::Protection {
441 &self.protection
442 }
443
444 #[must_use]
447 pub fn self_hosted_gitlab(&self) -> bool {
448 self.forge == Some(Forge::Gitlab)
449 && self
450 .host
451 .as_deref()
452 .is_some_and(|host| host != "gitlab.com")
453 }
454
455 #[must_use]
458 #[allow(
459 clippy::too_many_lines,
460 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
461 )]
462 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
463 let mut env: Vec<(OsString, OsString)> = vec![
464 (
465 "RK_FORGE".into(),
466 self.forge.map_or("", Forge::as_str).into(),
467 ),
468 ("RK_REPO".into(), self.repo.clone().into()),
469 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
470 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
471 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
472 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
473 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
474 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
475 (
479 "RK_TAG_PATTERN".into(),
480 self.protection.tag_pattern.clone().into(),
481 ),
482 (
483 "RK_REVIEW_COUNT".into(),
484 self.protection
485 .required_approving_review_count
486 .to_string()
487 .into(),
488 ),
489 (
490 "RK_DISMISS_STALE_REVIEWS".into(),
491 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
492 ),
493 (
494 "RK_CODE_OWNER_REVIEW".into(),
495 bool_word(self.protection.require_code_owner_review).into(),
496 ),
497 (
498 "RK_LAST_PUSH_APPROVAL".into(),
499 bool_word(self.protection.require_last_push_approval).into(),
500 ),
501 (
502 "RK_MERGE_METHODS".into(),
503 json_list(&self.protection.allowed_merge_methods).into(),
504 ),
505 (
506 "RK_STRICT_CHECKS".into(),
507 bool_word(self.protection.strict_required_status_checks).into(),
508 ),
509 (
510 "RK_SQUASH_TITLE_SOURCE".into(),
511 self.protection.github.squash_title_source.clone().into(),
512 ),
513 (
514 "RK_SQUASH_BODY_SOURCE".into(),
515 self.protection.github.squash_body_source.clone().into(),
516 ),
517 (
518 "RK_GITLAB_MERGE_METHOD".into(),
519 self.protection.gitlab.merge_method.clone().into(),
520 ),
521 (
522 "RK_GITLAB_SQUASH_OPTION".into(),
523 self.protection.gitlab.squash_option.clone().into(),
524 ),
525 (
526 "RK_GITLAB_SQUASH_TEMPLATE".into(),
527 self.protection.gitlab.squash_commit_template.clone().into(),
528 ),
529 (
530 "RK_GITLAB_PUSH_LEVEL".into(),
531 self.protection.gitlab.push_access_level.to_string().into(),
532 ),
533 (
534 "RK_GITLAB_MERGE_LEVEL".into(),
535 self.protection.gitlab.merge_access_level.to_string().into(),
536 ),
537 ("GH_PAGER".into(), "".into()),
538 ("GLAB_PAGER".into(), "".into()),
539 ];
540 if let Some(check) = &self.required_check
541 && self.forge == Some(Forge::Github)
542 && matches!(step, "protect-trunk" | "protections-check")
543 {
544 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
545 }
546 for name in PASSTHROUGH {
547 if let Some(value) = std::env::var_os(name) {
548 env.push((name.into(), value));
549 }
550 }
551 if let Some(dir) = self.cli_override_dir() {
555 let mut paths: Vec<PathBuf> = vec![dir];
556 if let Some(existing) = std::env::var_os("PATH") {
557 paths.extend(std::env::split_paths(&existing));
558 }
559 if let Ok(joined) = std::env::join_paths(paths) {
560 env.retain(|(name, _)| name != "PATH");
561 env.push(("PATH".into(), joined));
562 }
563 }
564 if step == "bot-secrets" {
565 for name in SECRET_VARS {
566 if let Some(value) = secrets::value_of(name) {
567 env.push((name.into(), value));
568 }
569 }
570 }
571 env
572 }
573
574 fn cli_override_dir(&self) -> Option<PathBuf> {
576 let overridden = std::env::var_os(match self.forge? {
577 Forge::Github => "RK_GH_BIN",
578 Forge::Gitlab => "RK_GLAB_BIN",
579 })?;
580 Path::new(&overridden).parent().map(Path::to_path_buf)
581 }
582
583 #[must_use]
591 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
592 SECRET_VARS
593 .iter()
594 .filter_map(|name| secrets::value_of(name))
595 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
596 .collect()
597 }
598}
599
600pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
610 let override_var = match forge {
611 Forge::Github => "RK_GH_BIN",
612 Forge::Gitlab => "RK_GLAB_BIN",
613 };
614 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
615 let path = PathBuf::from(&overridden);
616 if !path.is_file() {
617 return Err(RkError::refusal(
618 Diagnostic::new(
619 Reason::PrerequisiteUnmet,
620 format!(
621 "{override_var} names {}, which does not exist",
622 path.display()
623 ),
624 )
625 .expected("the override to name the forge CLI binary"),
626 ));
627 }
628 if path.file_name().is_none_or(|name| name != forge.cli()) {
633 return Err(RkError::refusal(
634 Diagnostic::new(
635 Reason::PrerequisiteUnmet,
636 format!(
637 "{override_var} must name a binary called {}, and {} is not one",
638 forge.cli(),
639 path.display()
640 ),
641 )
642 .expected(format!(
643 "an override whose file name is {}, so scripts and observations run one binary",
644 forge.cli()
645 )),
646 ));
647 }
648 return Ok(path);
649 }
650 let name = forge.cli();
651 let found = std::env::var_os("PATH").and_then(|path| {
652 std::env::split_paths(&path)
653 .map(|dir| dir.join(name))
654 .find(|candidate| candidate.is_file())
655 });
656 found.ok_or_else(|| {
657 RkError::refusal(
658 Diagnostic::new(
659 Reason::PrerequisiteUnmet,
660 format!(
661 "{name} is not on PATH, and a step this run acts on calls it on {}",
662 forge.as_str()
663 ),
664 )
665 .expected(format!("the {name} CLI installed and authenticated"))
666 .action(format!("install {name}, then run {name} auth login")),
667 )
668 })
669}