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;
23
24const fn bool_word(value: bool) -> &'static str {
31 if value { "true" } else { "false" }
32}
33
34fn json_list(values: &[String]) -> String {
37 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
38 format!("[{}]", inner.join(", "))
39}
40
41const PASSTHROUGH: [&str; 11] = [
45 "PATH",
46 "HOME",
47 "XDG_CONFIG_HOME",
48 "GH_TOKEN",
49 "GITHUB_TOKEN",
50 "GH_HOST",
51 "GH_CONFIG_DIR",
52 "GLAB_TOKEN",
53 "GITLAB_TOKEN",
54 "GITLAB_HOST",
55 "GLAB_CONFIG_DIR",
56];
57
58pub use super::secrets::VALUE_VARS as SECRET_VARS;
64
65#[derive(Debug, Clone)]
67pub struct Ctx {
68 pub target: Utf8PathBuf,
70 pub repo: String,
72 pub forge: Forge,
74 pub host: Option<String>,
76 pub required_check: Option<String>,
78 pub cli: PathBuf,
80 pub tech: Option<&'static str>,
82 trunk: String,
85 line_prefix: String,
88 retired_branches: Vec<String>,
91 release_lines: bool,
94 excluded_steps: std::collections::BTreeMap<String, String>,
97 bot_app_id: Option<String>,
100 trunk_ruleset: String,
102 tag_ruleset: String,
104 lines_ruleset: String,
106 title_check: String,
108 protection: crate::config::Protection,
112}
113
114impl Ctx {
115 pub fn resolve(
124 target: &Utf8PathBuf,
125 repo_flag: Option<&str>,
126 forge_flag: Option<&str>,
127 required_check: Option<&str>,
128 ) -> Result<Self, RkError> {
129 if !target.is_dir() {
130 return Err(RkError::missing(
131 Diagnostic::new(
132 Reason::TargetNotFound,
133 format!("target {target} is not a directory; nothing was run"),
134 )
135 .expected("an existing repository to set up"),
136 ));
137 }
138 let forge_flag = forge_flag
139 .map(|name| {
140 detect::Forge::parse(name).ok_or_else(|| {
141 RkError::Usage(format!(
142 "unknown forge '{name}'; the forges are: github, gitlab"
143 ))
144 })
145 })
146 .transpose()?;
147 let detected = detect::detect(target.as_std_path());
148 let Some(forge) = forge_flag.or(detected.forge) else {
149 let diagnostic = detected.host.as_ref().map_or_else(
150 || {
151 Diagnostic::new(
152 Reason::ForgeUndetected,
153 "no forge detected: the target has no origin remote",
154 )
155 },
156 |host| {
157 Diagnostic::new(
158 Reason::ForgeUndetected,
159 format!("no forge detected: the host {host} is not recognized"),
160 )
161 },
162 );
163 let diagnostic = diagnostic
164 .expected("a github.com or gitlab remote, or an override")
165 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
166 return Err(if detected.host.is_some() {
169 RkError::refusal(diagnostic)
170 } else {
171 RkError::missing(diagnostic)
172 });
173 };
174
175 let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
176 return Err(RkError::missing(
177 Diagnostic::new(
178 Reason::ForgeUndetected,
179 "no repository detected: the target has no origin remote",
180 )
181 .expected("an origin remote naming the project")
182 .action("pass --repo <owner/name>"),
183 ));
184 };
185 let cli = resolve_cli(forge)?;
186 let config = crate::config::load(target.as_std_path())?;
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 == Forge::Github)
196 });
197 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
198 let trunk = crate::config::trunk_of(target.as_std_path())?;
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: detect::tech_of(target.as_std_path()),
212 trunk_ruleset: protection.trunk_ruleset(&trunk),
213 tag_ruleset: protection.tag_ruleset.clone(),
214 lines_ruleset: protection.lines_ruleset.clone(),
215 title_check: protection.title_check.clone(),
216 protection,
217 trunk,
218 line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
219 retired_branches: answers.retired_branches,
220 release_lines: answers.release_lines,
221 excluded_steps: answers.excluded_steps,
222 bot_app_id,
223 })
224 }
225
226 #[doc(hidden)]
231 #[must_use]
232 pub fn for_tests(
233 target: Utf8PathBuf,
234 repo: String,
235 forge: Forge,
236 cli: PathBuf,
237 tech: Option<&'static str>,
238 ) -> Self {
239 let defaults = crate::config::Protection::default();
240 Self {
241 target,
242 repo,
243 forge,
244 host: None,
245 required_check: None,
246 cli,
247 tech,
248 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
249 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
250 retired_branches: crate::config::Setup::default().retired_branches,
251 release_lines: false,
252 excluded_steps: std::collections::BTreeMap::new(),
253 bot_app_id: None,
254 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
255 tag_ruleset: defaults.tag_ruleset.clone(),
256 lines_ruleset: defaults.lines_ruleset.clone(),
257 title_check: defaults.title_check.clone(),
258 protection: defaults,
259 }
260 }
261
262 #[must_use]
264 pub fn trunk(&self) -> &str {
265 &self.trunk
266 }
267
268 #[must_use]
270 pub fn line_prefix(&self) -> &str {
271 &self.line_prefix
272 }
273
274 #[must_use]
276 pub fn retired_branches(&self) -> &[String] {
277 &self.retired_branches
278 }
279
280 #[must_use]
282 pub const fn release_lines(&self) -> bool {
283 self.release_lines
284 }
285
286 #[must_use]
290 pub fn excluded(&self, step: &str) -> Option<&str> {
291 self.excluded_steps.get(step).map(String::as_str)
292 }
293
294 #[must_use]
296 pub fn excluded_count(&self) -> usize {
297 self.excluded_steps.len()
298 }
299
300 #[must_use]
302 pub fn bot_app_id(&self) -> Option<&str> {
303 self.bot_app_id.as_deref()
304 }
305
306 #[must_use]
308 pub fn trunk_ruleset(&self) -> &str {
309 &self.trunk_ruleset
310 }
311
312 #[must_use]
314 pub fn tag_ruleset(&self) -> &str {
315 &self.tag_ruleset
316 }
317
318 #[must_use]
320 pub fn lines_ruleset(&self) -> &str {
321 &self.lines_ruleset
322 }
323
324 #[must_use]
326 pub fn title_check(&self) -> &str {
327 &self.title_check
328 }
329
330 #[must_use]
332 pub const fn protection(&self) -> &crate::config::Protection {
333 &self.protection
334 }
335
336 #[must_use]
339 pub fn self_hosted_gitlab(&self) -> bool {
340 self.forge == Forge::Gitlab
341 && self
342 .host
343 .as_deref()
344 .is_some_and(|host| host != "gitlab.com")
345 }
346
347 #[must_use]
350 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
351 let mut env: Vec<(OsString, OsString)> = vec![
352 ("RK_FORGE".into(), self.forge.as_str().into()),
353 ("RK_REPO".into(), self.repo.clone().into()),
354 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
355 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
356 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
357 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
358 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
359 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
360 (
364 "RK_TAG_PATTERN".into(),
365 self.protection.tag_pattern.clone().into(),
366 ),
367 (
368 "RK_REVIEW_COUNT".into(),
369 self.protection
370 .required_approving_review_count
371 .to_string()
372 .into(),
373 ),
374 (
375 "RK_DISMISS_STALE_REVIEWS".into(),
376 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
377 ),
378 (
379 "RK_CODE_OWNER_REVIEW".into(),
380 bool_word(self.protection.require_code_owner_review).into(),
381 ),
382 (
383 "RK_LAST_PUSH_APPROVAL".into(),
384 bool_word(self.protection.require_last_push_approval).into(),
385 ),
386 (
387 "RK_MERGE_METHODS".into(),
388 json_list(&self.protection.allowed_merge_methods).into(),
389 ),
390 (
391 "RK_STRICT_CHECKS".into(),
392 bool_word(self.protection.strict_required_status_checks).into(),
393 ),
394 (
395 "RK_SQUASH_TITLE_SOURCE".into(),
396 self.protection.github.squash_title_source.clone().into(),
397 ),
398 (
399 "RK_SQUASH_BODY_SOURCE".into(),
400 self.protection.github.squash_body_source.clone().into(),
401 ),
402 (
403 "RK_GITLAB_MERGE_METHOD".into(),
404 self.protection.gitlab.merge_method.clone().into(),
405 ),
406 (
407 "RK_GITLAB_SQUASH_OPTION".into(),
408 self.protection.gitlab.squash_option.clone().into(),
409 ),
410 (
411 "RK_GITLAB_SQUASH_TEMPLATE".into(),
412 self.protection.gitlab.squash_commit_template.clone().into(),
413 ),
414 (
415 "RK_GITLAB_PUSH_LEVEL".into(),
416 self.protection.gitlab.push_access_level.to_string().into(),
417 ),
418 (
419 "RK_GITLAB_MERGE_LEVEL".into(),
420 self.protection.gitlab.merge_access_level.to_string().into(),
421 ),
422 ("GH_PAGER".into(), "".into()),
423 ("GLAB_PAGER".into(), "".into()),
424 ];
425 if let Some(check) = &self.required_check {
426 if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
427 {
428 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
429 }
430 }
431 for name in PASSTHROUGH {
432 if let Some(value) = std::env::var_os(name) {
433 env.push((name.into(), value));
434 }
435 }
436 if let Some(dir) = self.cli_override_dir() {
440 let mut paths: Vec<PathBuf> = vec![dir];
441 if let Some(existing) = std::env::var_os("PATH") {
442 paths.extend(std::env::split_paths(&existing));
443 }
444 if let Ok(joined) = std::env::join_paths(paths) {
445 env.retain(|(name, _)| name != "PATH");
446 env.push(("PATH".into(), joined));
447 }
448 }
449 if step == "bot-secrets" {
450 for name in SECRET_VARS {
451 if let Some(value) = secrets::value_of(name) {
452 env.push((name.into(), value));
453 }
454 }
455 }
456 env
457 }
458
459 fn cli_override_dir(&self) -> Option<PathBuf> {
461 let overridden = std::env::var_os(match self.forge {
462 Forge::Github => "RK_GH_BIN",
463 Forge::Gitlab => "RK_GLAB_BIN",
464 })?;
465 Path::new(&overridden).parent().map(Path::to_path_buf)
466 }
467
468 #[must_use]
476 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
477 SECRET_VARS
478 .iter()
479 .filter_map(|name| secrets::value_of(name))
480 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
481 .collect()
482 }
483}
484
485pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
495 let override_var = match forge {
496 Forge::Github => "RK_GH_BIN",
497 Forge::Gitlab => "RK_GLAB_BIN",
498 };
499 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
500 let path = PathBuf::from(&overridden);
501 if !path.is_file() {
502 return Err(RkError::refusal(
503 Diagnostic::new(
504 Reason::PrerequisiteUnmet,
505 format!(
506 "{override_var} names {}, which does not exist",
507 path.display()
508 ),
509 )
510 .expected("the override to name the forge CLI binary"),
511 ));
512 }
513 if path.file_name().is_none_or(|name| name != forge.cli()) {
518 return Err(RkError::refusal(
519 Diagnostic::new(
520 Reason::PrerequisiteUnmet,
521 format!(
522 "{override_var} must name a binary called {}, and {} is not one",
523 forge.cli(),
524 path.display()
525 ),
526 )
527 .expected(format!(
528 "an override whose file name is {}, so scripts and observations run one binary",
529 forge.cli()
530 )),
531 ));
532 }
533 return Ok(path);
534 }
535 let name = forge.cli();
536 let found = std::env::var_os("PATH").and_then(|path| {
537 std::env::split_paths(&path)
538 .map(|dir| dir.join(name))
539 .find(|candidate| candidate.is_file())
540 });
541 found.ok_or_else(|| {
542 RkError::refusal(
543 Diagnostic::new(
544 Reason::PrerequisiteUnmet,
545 format!(
546 "{name} is not on PATH, and every {} step calls it",
547 forge.as_str()
548 ),
549 )
550 .expected(format!("the {name} CLI installed and authenticated"))
551 .action(format!("install {name}, then run {name} auth login")),
552 )
553 })
554}