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 bot_app_id: Option<String>,
97 trunk_ruleset: String,
99 tag_ruleset: String,
101 lines_ruleset: String,
103 title_check: String,
105 protection: crate::config::Protection,
109}
110
111impl Ctx {
112 pub fn resolve(
121 target: &Utf8PathBuf,
122 repo_flag: Option<&str>,
123 forge_flag: Option<&str>,
124 required_check: Option<&str>,
125 ) -> Result<Self, RkError> {
126 if !target.is_dir() {
127 return Err(RkError::missing(
128 Diagnostic::new(
129 Reason::TargetNotFound,
130 format!("target {target} is not a directory; nothing was run"),
131 )
132 .expected("an existing repository to set up"),
133 ));
134 }
135 let forge_flag = forge_flag
136 .map(|name| {
137 detect::Forge::parse(name).ok_or_else(|| {
138 RkError::Usage(format!(
139 "unknown forge '{name}'; the forges are: github, gitlab"
140 ))
141 })
142 })
143 .transpose()?;
144 let detected = detect::detect(target.as_std_path());
145 let Some(forge) = forge_flag.or(detected.forge) else {
146 let diagnostic = detected.host.as_ref().map_or_else(
147 || {
148 Diagnostic::new(
149 Reason::ForgeUndetected,
150 "no forge detected: the target has no origin remote",
151 )
152 },
153 |host| {
154 Diagnostic::new(
155 Reason::ForgeUndetected,
156 format!("no forge detected: the host {host} is not recognized"),
157 )
158 },
159 );
160 let diagnostic = diagnostic
161 .expected("a github.com or gitlab remote, or an override")
162 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
163 return Err(if detected.host.is_some() {
166 RkError::refusal(diagnostic)
167 } else {
168 RkError::missing(diagnostic)
169 });
170 };
171
172 let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
173 return Err(RkError::missing(
174 Diagnostic::new(
175 Reason::ForgeUndetected,
176 "no repository detected: the target has no origin remote",
177 )
178 .expected("an origin remote naming the project")
179 .action("pass --repo <owner/name>"),
180 ));
181 };
182 let cli = resolve_cli(forge)?;
183 let config = crate::config::load(target.as_std_path())?;
184 let required_check = required_check.map(str::to_owned).or_else(|| {
188 (forge == Forge::Github)
189 .then(|| {
190 config
191 .as_ref()
192 .map(|held| held.setup.required_check.clone())
193 .filter(|name| !name.is_empty())
194 })
195 .flatten()
196 });
197 let retired_branches = config.as_ref().map_or_else(
198 || crate::config::Setup::default().retired_branches,
199 |held| held.setup.retired_branches.clone(),
200 );
201 let release_lines = config.as_ref().is_some_and(|held| held.setup.release_lines);
202 let bot_app_id = config
203 .as_ref()
204 .map(|held| held.setup.bot.app_id.clone())
205 .filter(|id| !id.is_empty());
206 let trunk = crate::config::trunk_of(target.as_std_path())?;
207 let protection = config
208 .as_ref()
209 .map_or_else(crate::config::Protection::default, |held| {
210 held.protection.clone()
211 });
212 Ok(Self {
213 target: target.clone(),
214 repo,
215 forge,
216 host: detected.host,
217 required_check,
218 cli,
219 tech: detect::tech_of(target.as_std_path()),
220 trunk_ruleset: protection.trunk_ruleset(&trunk),
221 tag_ruleset: protection.tag_ruleset.clone(),
222 lines_ruleset: protection.lines_ruleset.clone(),
223 title_check: protection.title_check.clone(),
224 protection,
225 trunk,
226 line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
227 retired_branches,
228 release_lines,
229 bot_app_id,
230 })
231 }
232
233 #[doc(hidden)]
238 #[must_use]
239 pub fn for_tests(
240 target: Utf8PathBuf,
241 repo: String,
242 forge: Forge,
243 cli: PathBuf,
244 tech: Option<&'static str>,
245 ) -> Self {
246 let defaults = crate::config::Protection::default();
247 Self {
248 target,
249 repo,
250 forge,
251 host: None,
252 required_check: None,
253 cli,
254 tech,
255 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
256 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
257 retired_branches: crate::config::Setup::default().retired_branches,
258 release_lines: false,
259 bot_app_id: None,
260 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
261 tag_ruleset: defaults.tag_ruleset.clone(),
262 lines_ruleset: defaults.lines_ruleset.clone(),
263 title_check: defaults.title_check.clone(),
264 protection: defaults,
265 }
266 }
267
268 #[must_use]
270 pub fn trunk(&self) -> &str {
271 &self.trunk
272 }
273
274 #[must_use]
276 pub fn line_prefix(&self) -> &str {
277 &self.line_prefix
278 }
279
280 #[must_use]
282 pub fn retired_branches(&self) -> &[String] {
283 &self.retired_branches
284 }
285
286 #[must_use]
288 pub const fn release_lines(&self) -> bool {
289 self.release_lines
290 }
291
292 #[must_use]
294 pub fn bot_app_id(&self) -> Option<&str> {
295 self.bot_app_id.as_deref()
296 }
297
298 #[must_use]
300 pub fn trunk_ruleset(&self) -> &str {
301 &self.trunk_ruleset
302 }
303
304 #[must_use]
306 pub fn tag_ruleset(&self) -> &str {
307 &self.tag_ruleset
308 }
309
310 #[must_use]
312 pub fn lines_ruleset(&self) -> &str {
313 &self.lines_ruleset
314 }
315
316 #[must_use]
318 pub fn title_check(&self) -> &str {
319 &self.title_check
320 }
321
322 #[must_use]
324 pub const fn protection(&self) -> &crate::config::Protection {
325 &self.protection
326 }
327
328 #[must_use]
331 pub fn self_hosted_gitlab(&self) -> bool {
332 self.forge == Forge::Gitlab
333 && self
334 .host
335 .as_deref()
336 .is_some_and(|host| host != "gitlab.com")
337 }
338
339 #[must_use]
342 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
343 let mut env: Vec<(OsString, OsString)> = vec![
344 ("RK_FORGE".into(), self.forge.as_str().into()),
345 ("RK_REPO".into(), self.repo.clone().into()),
346 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
347 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
348 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
349 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
350 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
351 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
352 (
356 "RK_TAG_PATTERN".into(),
357 self.protection.tag_pattern.clone().into(),
358 ),
359 (
360 "RK_REVIEW_COUNT".into(),
361 self.protection
362 .required_approving_review_count
363 .to_string()
364 .into(),
365 ),
366 (
367 "RK_DISMISS_STALE_REVIEWS".into(),
368 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
369 ),
370 (
371 "RK_CODE_OWNER_REVIEW".into(),
372 bool_word(self.protection.require_code_owner_review).into(),
373 ),
374 (
375 "RK_LAST_PUSH_APPROVAL".into(),
376 bool_word(self.protection.require_last_push_approval).into(),
377 ),
378 (
379 "RK_MERGE_METHODS".into(),
380 json_list(&self.protection.allowed_merge_methods).into(),
381 ),
382 (
383 "RK_STRICT_CHECKS".into(),
384 bool_word(self.protection.strict_required_status_checks).into(),
385 ),
386 (
387 "RK_SQUASH_TITLE_SOURCE".into(),
388 self.protection.github.squash_title_source.clone().into(),
389 ),
390 (
391 "RK_SQUASH_BODY_SOURCE".into(),
392 self.protection.github.squash_body_source.clone().into(),
393 ),
394 (
395 "RK_GITLAB_MERGE_METHOD".into(),
396 self.protection.gitlab.merge_method.clone().into(),
397 ),
398 (
399 "RK_GITLAB_SQUASH_OPTION".into(),
400 self.protection.gitlab.squash_option.clone().into(),
401 ),
402 (
403 "RK_GITLAB_SQUASH_TEMPLATE".into(),
404 self.protection.gitlab.squash_commit_template.clone().into(),
405 ),
406 (
407 "RK_GITLAB_PUSH_LEVEL".into(),
408 self.protection.gitlab.push_access_level.to_string().into(),
409 ),
410 (
411 "RK_GITLAB_MERGE_LEVEL".into(),
412 self.protection.gitlab.merge_access_level.to_string().into(),
413 ),
414 ("GH_PAGER".into(), "".into()),
415 ("GLAB_PAGER".into(), "".into()),
416 ];
417 if let Some(check) = &self.required_check {
418 if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
419 {
420 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
421 }
422 }
423 for name in PASSTHROUGH {
424 if let Some(value) = std::env::var_os(name) {
425 env.push((name.into(), value));
426 }
427 }
428 if let Some(dir) = self.cli_override_dir() {
432 let mut paths: Vec<PathBuf> = vec![dir];
433 if let Some(existing) = std::env::var_os("PATH") {
434 paths.extend(std::env::split_paths(&existing));
435 }
436 if let Ok(joined) = std::env::join_paths(paths) {
437 env.retain(|(name, _)| name != "PATH");
438 env.push(("PATH".into(), joined));
439 }
440 }
441 if step == "bot-secrets" {
442 for name in SECRET_VARS {
443 if let Some(value) = secrets::value_of(name) {
444 env.push((name.into(), value));
445 }
446 }
447 }
448 env
449 }
450
451 fn cli_override_dir(&self) -> Option<PathBuf> {
453 let overridden = std::env::var_os(match self.forge {
454 Forge::Github => "RK_GH_BIN",
455 Forge::Gitlab => "RK_GLAB_BIN",
456 })?;
457 Path::new(&overridden).parent().map(Path::to_path_buf)
458 }
459
460 #[must_use]
468 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
469 SECRET_VARS
470 .iter()
471 .filter_map(|name| secrets::value_of(name))
472 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
473 .collect()
474 }
475}
476
477pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
487 let override_var = match forge {
488 Forge::Github => "RK_GH_BIN",
489 Forge::Gitlab => "RK_GLAB_BIN",
490 };
491 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
492 let path = PathBuf::from(&overridden);
493 if !path.is_file() {
494 return Err(RkError::refusal(
495 Diagnostic::new(
496 Reason::PrerequisiteUnmet,
497 format!(
498 "{override_var} names {}, which does not exist",
499 path.display()
500 ),
501 )
502 .expected("the override to name the forge CLI binary"),
503 ));
504 }
505 if path.file_name().is_none_or(|name| name != forge.cli()) {
510 return Err(RkError::refusal(
511 Diagnostic::new(
512 Reason::PrerequisiteUnmet,
513 format!(
514 "{override_var} must name a binary called {}, and {} is not one",
515 forge.cli(),
516 path.display()
517 ),
518 )
519 .expected(format!(
520 "an override whose file name is {}, so scripts and observations run one binary",
521 forge.cli()
522 )),
523 ));
524 }
525 return Ok(path);
526 }
527 let name = forge.cli();
528 let found = std::env::var_os("PATH").and_then(|path| {
529 std::env::split_paths(&path)
530 .map(|dir| dir.join(name))
531 .find(|candidate| candidate.is_file())
532 });
533 found.ok_or_else(|| {
534 RkError::refusal(
535 Diagnostic::new(
536 Reason::PrerequisiteUnmet,
537 format!(
538 "{name} is not on PATH, and every {} step calls it",
539 forge.as_str()
540 ),
541 )
542 .expected(format!("the {name} CLI installed and authenticated"))
543 .action(format!("install {name}, then run {name} auth login")),
544 )
545 })
546}