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 PASSTHROUGH: [&str; 11] = [
33 "PATH",
34 "HOME",
35 "XDG_CONFIG_HOME",
36 "GH_TOKEN",
37 "GITHUB_TOKEN",
38 "GH_HOST",
39 "GH_CONFIG_DIR",
40 "GLAB_TOKEN",
41 "GITLAB_TOKEN",
42 "GITLAB_HOST",
43 "GLAB_CONFIG_DIR",
44];
45
46pub use super::secrets::VALUE_VARS as SECRET_VARS;
52
53#[derive(Debug, Clone)]
55pub struct Ctx {
56 pub target: Utf8PathBuf,
58 pub repo: String,
60 pub forge: Forge,
62 pub host: Option<String>,
64 pub required_check: Option<String>,
66 pub cli: PathBuf,
68 pub tech: Option<&'static str>,
70 trunk: String,
73 line_prefix: String,
76 retired_branches: Vec<String>,
79 release_lines: bool,
82 bot_app_id: Option<String>,
85 trunk_ruleset: String,
87 tag_ruleset: String,
89 lines_ruleset: String,
91 title_check: String,
93}
94
95impl Ctx {
96 pub fn resolve(
105 target: &Utf8PathBuf,
106 repo_flag: Option<&str>,
107 forge_flag: Option<&str>,
108 required_check: Option<&str>,
109 ) -> Result<Self, RkError> {
110 if !target.is_dir() {
111 return Err(RkError::missing(
112 Diagnostic::new(
113 Reason::TargetNotFound,
114 format!("target {target} is not a directory; nothing was run"),
115 )
116 .expected("an existing repository to set up"),
117 ));
118 }
119 let forge_flag = forge_flag
120 .map(|name| {
121 detect::Forge::parse(name).ok_or_else(|| {
122 RkError::Usage(format!(
123 "unknown forge '{name}'; the forges are: github, gitlab"
124 ))
125 })
126 })
127 .transpose()?;
128 let detected = detect::detect(target.as_std_path());
129 let Some(forge) = forge_flag.or(detected.forge) else {
130 let diagnostic = detected.host.as_ref().map_or_else(
131 || {
132 Diagnostic::new(
133 Reason::ForgeUndetected,
134 "no forge detected: the target has no origin remote",
135 )
136 },
137 |host| {
138 Diagnostic::new(
139 Reason::ForgeUndetected,
140 format!("no forge detected: the host {host} is not recognized"),
141 )
142 },
143 );
144 let diagnostic = diagnostic
145 .expected("a github.com or gitlab remote, or an override")
146 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
147 return Err(if detected.host.is_some() {
150 RkError::refusal(diagnostic)
151 } else {
152 RkError::missing(diagnostic)
153 });
154 };
155
156 let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
157 return Err(RkError::missing(
158 Diagnostic::new(
159 Reason::ForgeUndetected,
160 "no repository detected: the target has no origin remote",
161 )
162 .expected("an origin remote naming the project")
163 .action("pass --repo <owner/name>"),
164 ));
165 };
166 let cli = resolve_cli(forge)?;
167 let config = crate::config::load(target.as_std_path())?;
168 let required_check = required_check.map(str::to_owned).or_else(|| {
172 (forge == Forge::Github)
173 .then(|| {
174 config
175 .as_ref()
176 .map(|held| held.setup.required_check.clone())
177 .filter(|name| !name.is_empty())
178 })
179 .flatten()
180 });
181 let retired_branches = config.as_ref().map_or_else(
182 || crate::config::Setup::default().retired_branches,
183 |held| held.setup.retired_branches.clone(),
184 );
185 let release_lines = config.as_ref().is_some_and(|held| held.setup.release_lines);
186 let bot_app_id = config
187 .as_ref()
188 .map(|held| held.setup.bot.app_id.clone())
189 .filter(|id| !id.is_empty());
190 let trunk = crate::config::trunk_of(target.as_std_path())?;
191 let protection = config
192 .as_ref()
193 .map_or_else(crate::config::Protection::default, |held| {
194 held.protection.clone()
195 });
196 Ok(Self {
197 target: target.clone(),
198 repo,
199 forge,
200 host: detected.host,
201 required_check,
202 cli,
203 tech: detect::tech_of(target.as_std_path()),
204 trunk_ruleset: protection.trunk_ruleset(&trunk),
205 tag_ruleset: protection.tag_ruleset.clone(),
206 lines_ruleset: protection.lines_ruleset.clone(),
207 title_check: protection.title_check,
208 trunk,
209 line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
210 retired_branches,
211 release_lines,
212 bot_app_id,
213 })
214 }
215
216 #[doc(hidden)]
221 #[must_use]
222 pub fn for_tests(
223 target: Utf8PathBuf,
224 repo: String,
225 forge: Forge,
226 cli: PathBuf,
227 tech: Option<&'static str>,
228 ) -> Self {
229 let defaults = crate::config::Protection::default();
230 Self {
231 target,
232 repo,
233 forge,
234 host: None,
235 required_check: None,
236 cli,
237 tech,
238 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
239 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
240 retired_branches: crate::config::Setup::default().retired_branches,
241 release_lines: false,
242 bot_app_id: None,
243 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
244 tag_ruleset: defaults.tag_ruleset,
245 lines_ruleset: defaults.lines_ruleset,
246 title_check: defaults.title_check,
247 }
248 }
249
250 #[must_use]
252 pub fn trunk(&self) -> &str {
253 &self.trunk
254 }
255
256 #[must_use]
258 pub fn line_prefix(&self) -> &str {
259 &self.line_prefix
260 }
261
262 #[must_use]
264 pub fn retired_branches(&self) -> &[String] {
265 &self.retired_branches
266 }
267
268 #[must_use]
270 pub const fn release_lines(&self) -> bool {
271 self.release_lines
272 }
273
274 #[must_use]
276 pub fn bot_app_id(&self) -> Option<&str> {
277 self.bot_app_id.as_deref()
278 }
279
280 #[must_use]
282 pub fn trunk_ruleset(&self) -> &str {
283 &self.trunk_ruleset
284 }
285
286 #[must_use]
288 pub fn tag_ruleset(&self) -> &str {
289 &self.tag_ruleset
290 }
291
292 #[must_use]
294 pub fn lines_ruleset(&self) -> &str {
295 &self.lines_ruleset
296 }
297
298 #[must_use]
300 pub fn title_check(&self) -> &str {
301 &self.title_check
302 }
303
304 #[must_use]
307 pub fn self_hosted_gitlab(&self) -> bool {
308 self.forge == Forge::Gitlab
309 && self
310 .host
311 .as_deref()
312 .is_some_and(|host| host != "gitlab.com")
313 }
314
315 #[must_use]
318 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
319 let mut env: Vec<(OsString, OsString)> = vec![
320 ("RK_FORGE".into(), self.forge.as_str().into()),
321 ("RK_REPO".into(), self.repo.clone().into()),
322 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
323 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
324 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
325 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
326 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
327 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
328 ("GH_PAGER".into(), "".into()),
329 ("GLAB_PAGER".into(), "".into()),
330 ];
331 if let Some(check) = &self.required_check {
332 if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
333 {
334 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
335 }
336 }
337 for name in PASSTHROUGH {
338 if let Some(value) = std::env::var_os(name) {
339 env.push((name.into(), value));
340 }
341 }
342 if let Some(dir) = self.cli_override_dir() {
346 let mut paths: Vec<PathBuf> = vec![dir];
347 if let Some(existing) = std::env::var_os("PATH") {
348 paths.extend(std::env::split_paths(&existing));
349 }
350 if let Ok(joined) = std::env::join_paths(paths) {
351 env.retain(|(name, _)| name != "PATH");
352 env.push(("PATH".into(), joined));
353 }
354 }
355 if step == "bot-secrets" {
356 for name in SECRET_VARS {
357 if let Some(value) = secrets::value_of(name) {
358 env.push((name.into(), value));
359 }
360 }
361 }
362 env
363 }
364
365 fn cli_override_dir(&self) -> Option<PathBuf> {
367 let overridden = std::env::var_os(match self.forge {
368 Forge::Github => "RK_GH_BIN",
369 Forge::Gitlab => "RK_GLAB_BIN",
370 })?;
371 Path::new(&overridden).parent().map(Path::to_path_buf)
372 }
373
374 #[must_use]
382 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
383 SECRET_VARS
384 .iter()
385 .filter_map(|name| secrets::value_of(name))
386 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
387 .collect()
388 }
389}
390
391pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
401 let override_var = match forge {
402 Forge::Github => "RK_GH_BIN",
403 Forge::Gitlab => "RK_GLAB_BIN",
404 };
405 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
406 let path = PathBuf::from(&overridden);
407 if !path.is_file() {
408 return Err(RkError::refusal(
409 Diagnostic::new(
410 Reason::PrerequisiteUnmet,
411 format!(
412 "{override_var} names {}, which does not exist",
413 path.display()
414 ),
415 )
416 .expected("the override to name the forge CLI binary"),
417 ));
418 }
419 if path.file_name().is_none_or(|name| name != forge.cli()) {
424 return Err(RkError::refusal(
425 Diagnostic::new(
426 Reason::PrerequisiteUnmet,
427 format!(
428 "{override_var} must name a binary called {}, and {} is not one",
429 forge.cli(),
430 path.display()
431 ),
432 )
433 .expected(format!(
434 "an override whose file name is {}, so scripts and observations run one binary",
435 forge.cli()
436 )),
437 ));
438 }
439 return Ok(path);
440 }
441 let name = forge.cli();
442 let found = std::env::var_os("PATH").and_then(|path| {
443 std::env::split_paths(&path)
444 .map(|dir| dir.join(name))
445 .find(|candidate| candidate.is_file())
446 });
447 found.ok_or_else(|| {
448 RkError::refusal(
449 Diagnostic::new(
450 Reason::PrerequisiteUnmet,
451 format!(
452 "{name} is not on PATH, and every {} step calls it",
453 forge.as_str()
454 ),
455 )
456 .expected(format!("the {name} CLI installed and authenticated"))
457 .action(format!("install {name}, then run {name} auth login")),
458 )
459 })
460}