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}
77
78impl Ctx {
79 pub fn resolve(
88 target: &Utf8PathBuf,
89 repo_flag: Option<&str>,
90 forge_flag: Option<&str>,
91 required_check: Option<&str>,
92 ) -> Result<Self, RkError> {
93 if !target.is_dir() {
94 return Err(RkError::missing(
95 Diagnostic::new(
96 Reason::TargetNotFound,
97 format!("target {target} is not a directory; nothing was run"),
98 )
99 .expected("an existing repository to set up"),
100 ));
101 }
102 let forge_flag = forge_flag
103 .map(|name| {
104 detect::Forge::parse(name).ok_or_else(|| {
105 RkError::Usage(format!(
106 "unknown forge '{name}'; the forges are: github, gitlab"
107 ))
108 })
109 })
110 .transpose()?;
111 let detected = detect::detect(target.as_std_path());
112 let Some(forge) = forge_flag.or(detected.forge) else {
113 let diagnostic = detected.host.as_ref().map_or_else(
114 || {
115 Diagnostic::new(
116 Reason::ForgeUndetected,
117 "no forge detected: the target has no origin remote",
118 )
119 },
120 |host| {
121 Diagnostic::new(
122 Reason::ForgeUndetected,
123 format!("no forge detected: the host {host} is not recognized"),
124 )
125 },
126 );
127 let diagnostic = diagnostic
128 .expected("a github.com or gitlab remote, or an override")
129 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
130 return Err(if detected.host.is_some() {
133 RkError::refusal(diagnostic)
134 } else {
135 RkError::missing(diagnostic)
136 });
137 };
138
139 let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
140 return Err(RkError::missing(
141 Diagnostic::new(
142 Reason::ForgeUndetected,
143 "no repository detected: the target has no origin remote",
144 )
145 .expected("an origin remote naming the project")
146 .action("pass --repo <owner/name>"),
147 ));
148 };
149 let cli = resolve_cli(forge)?;
150 Ok(Self {
151 target: target.clone(),
152 repo,
153 forge,
154 host: detected.host,
155 required_check: required_check.map(str::to_owned),
156 cli,
157 tech: detect::tech_of(target.as_std_path()),
158 trunk: crate::config::trunk_of(target.as_std_path())?,
159 line_prefix: crate::config::line_prefix_of(target.as_std_path())?,
160 })
161 }
162
163 #[doc(hidden)]
168 #[must_use]
169 pub fn for_tests(
170 target: Utf8PathBuf,
171 repo: String,
172 forge: Forge,
173 cli: PathBuf,
174 tech: Option<&'static str>,
175 ) -> Self {
176 Self {
177 target,
178 repo,
179 forge,
180 host: None,
181 required_check: None,
182 cli,
183 tech,
184 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
185 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
186 }
187 }
188
189 #[must_use]
191 pub fn trunk(&self) -> &str {
192 &self.trunk
193 }
194
195 #[must_use]
197 pub fn line_prefix(&self) -> &str {
198 &self.line_prefix
199 }
200
201 #[must_use]
204 pub fn self_hosted_gitlab(&self) -> bool {
205 self.forge == Forge::Gitlab
206 && self
207 .host
208 .as_deref()
209 .is_some_and(|host| host != "gitlab.com")
210 }
211
212 #[must_use]
215 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
216 let mut env: Vec<(OsString, OsString)> = vec![
217 ("RK_FORGE".into(), self.forge.as_str().into()),
218 ("RK_REPO".into(), self.repo.clone().into()),
219 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
220 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
221 ("GH_PAGER".into(), "".into()),
222 ("GLAB_PAGER".into(), "".into()),
223 ];
224 if let Some(check) = &self.required_check {
225 if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
226 {
227 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
228 }
229 }
230 for name in PASSTHROUGH {
231 if let Some(value) = std::env::var_os(name) {
232 env.push((name.into(), value));
233 }
234 }
235 if let Some(dir) = self.cli_override_dir() {
239 let mut paths: Vec<PathBuf> = vec![dir];
240 if let Some(existing) = std::env::var_os("PATH") {
241 paths.extend(std::env::split_paths(&existing));
242 }
243 if let Ok(joined) = std::env::join_paths(paths) {
244 env.retain(|(name, _)| name != "PATH");
245 env.push(("PATH".into(), joined));
246 }
247 }
248 if step == "bot-secrets" {
249 for name in SECRET_VARS {
250 if let Some(value) = secrets::value_of(name) {
251 env.push((name.into(), value));
252 }
253 }
254 }
255 env
256 }
257
258 fn cli_override_dir(&self) -> Option<PathBuf> {
260 let overridden = std::env::var_os(match self.forge {
261 Forge::Github => "RK_GH_BIN",
262 Forge::Gitlab => "RK_GLAB_BIN",
263 })?;
264 Path::new(&overridden).parent().map(Path::to_path_buf)
265 }
266
267 #[must_use]
275 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
276 SECRET_VARS
277 .iter()
278 .filter_map(|name| secrets::value_of(name))
279 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
280 .collect()
281 }
282}
283
284pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
294 let override_var = match forge {
295 Forge::Github => "RK_GH_BIN",
296 Forge::Gitlab => "RK_GLAB_BIN",
297 };
298 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
299 let path = PathBuf::from(&overridden);
300 if !path.is_file() {
301 return Err(RkError::refusal(
302 Diagnostic::new(
303 Reason::PrerequisiteUnmet,
304 format!(
305 "{override_var} names {}, which does not exist",
306 path.display()
307 ),
308 )
309 .expected("the override to name the forge CLI binary"),
310 ));
311 }
312 if path.file_name().is_none_or(|name| name != forge.cli()) {
317 return Err(RkError::refusal(
318 Diagnostic::new(
319 Reason::PrerequisiteUnmet,
320 format!(
321 "{override_var} must name a binary called {}, and {} is not one",
322 forge.cli(),
323 path.display()
324 ),
325 )
326 .expected(format!(
327 "an override whose file name is {}, so scripts and observations run one binary",
328 forge.cli()
329 )),
330 ));
331 }
332 return Ok(path);
333 }
334 let name = forge.cli();
335 let found = std::env::var_os("PATH").and_then(|path| {
336 std::env::split_paths(&path)
337 .map(|dir| dir.join(name))
338 .find(|candidate| candidate.is_file())
339 });
340 found.ok_or_else(|| {
341 RkError::refusal(
342 Diagnostic::new(
343 Reason::PrerequisiteUnmet,
344 format!(
345 "{name} is not on PATH, and every {} step calls it",
346 forge.as_str()
347 ),
348 )
349 .expected(format!("the {name} CLI installed and authenticated"))
350 .action(format!("install {name}, then run {name} auth login")),
351 )
352 })
353}