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
24pub const TRUNK_BRANCH: &str = "master";
27
28const PASSTHROUGH: [&str; 11] = [
32 "PATH",
33 "HOME",
34 "XDG_CONFIG_HOME",
35 "GH_TOKEN",
36 "GITHUB_TOKEN",
37 "GH_HOST",
38 "GH_CONFIG_DIR",
39 "GLAB_TOKEN",
40 "GITLAB_TOKEN",
41 "GITLAB_HOST",
42 "GLAB_CONFIG_DIR",
43];
44
45pub use super::secrets::VALUE_VARS as SECRET_VARS;
51
52#[derive(Debug)]
54pub struct Ctx {
55 pub target: Utf8PathBuf,
57 pub repo: String,
59 pub forge: Forge,
61 pub host: Option<String>,
63 pub required_check: Option<String>,
65 pub cli: PathBuf,
67 pub tech: Option<&'static str>,
69}
70
71impl Ctx {
72 pub fn resolve(
81 target: &Utf8PathBuf,
82 repo_flag: Option<&str>,
83 forge_flag: Option<&str>,
84 required_check: Option<&str>,
85 ) -> Result<Self, RkError> {
86 if !target.is_dir() {
87 return Err(RkError::missing(
88 Diagnostic::new(
89 Reason::TargetNotFound,
90 format!("target {target} is not a directory; nothing was run"),
91 )
92 .expected("an existing repository to set up"),
93 ));
94 }
95 let forge_flag = forge_flag
96 .map(|name| {
97 detect::Forge::parse(name).ok_or_else(|| {
98 RkError::Usage(format!(
99 "unknown forge '{name}'; the forges are: github, gitlab"
100 ))
101 })
102 })
103 .transpose()?;
104 let detected = detect::detect(target.as_std_path());
105 let Some(forge) = forge_flag.or(detected.forge) else {
106 let diagnostic = detected.host.as_ref().map_or_else(
107 || {
108 Diagnostic::new(
109 Reason::ForgeUndetected,
110 "no forge detected: the target has no origin remote",
111 )
112 },
113 |host| {
114 Diagnostic::new(
115 Reason::ForgeUndetected,
116 format!("no forge detected: the host {host} is not recognized"),
117 )
118 },
119 );
120 let diagnostic = diagnostic
121 .expected("a github.com or gitlab remote, or an override")
122 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
123 return Err(if detected.host.is_some() {
126 RkError::refusal(diagnostic)
127 } else {
128 RkError::missing(diagnostic)
129 });
130 };
131
132 let Some(repo) = repo_flag.map(str::to_owned).or(detected.repo) else {
133 return Err(RkError::missing(
134 Diagnostic::new(
135 Reason::ForgeUndetected,
136 "no repository detected: the target has no origin remote",
137 )
138 .expected("an origin remote naming the project")
139 .action("pass --repo <owner/name>"),
140 ));
141 };
142 let cli = resolve_cli(forge)?;
143 Ok(Self {
144 target: target.clone(),
145 repo,
146 forge,
147 host: detected.host,
148 required_check: required_check.map(str::to_owned),
149 cli,
150 tech: detect::tech_of(target.as_std_path()),
151 })
152 }
153
154 #[must_use]
157 pub fn self_hosted_gitlab(&self) -> bool {
158 self.forge == Forge::Gitlab
159 && self
160 .host
161 .as_deref()
162 .is_some_and(|host| host != "gitlab.com")
163 }
164
165 #[must_use]
168 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
169 let mut env: Vec<(OsString, OsString)> = vec![
170 ("RK_FORGE".into(), self.forge.as_str().into()),
171 ("RK_REPO".into(), self.repo.clone().into()),
172 ("RK_TRUNK_BRANCH".into(), TRUNK_BRANCH.into()),
173 ("GH_PAGER".into(), "".into()),
174 ("GLAB_PAGER".into(), "".into()),
175 ];
176 if let Some(check) = &self.required_check {
177 if self.forge == Forge::Github && matches!(step, "protect-trunk" | "protections-check")
178 {
179 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
180 }
181 }
182 for name in PASSTHROUGH {
183 if let Some(value) = std::env::var_os(name) {
184 env.push((name.into(), value));
185 }
186 }
187 if let Some(dir) = self.cli_override_dir() {
191 let mut paths: Vec<PathBuf> = vec![dir];
192 if let Some(existing) = std::env::var_os("PATH") {
193 paths.extend(std::env::split_paths(&existing));
194 }
195 if let Ok(joined) = std::env::join_paths(paths) {
196 env.retain(|(name, _)| name != "PATH");
197 env.push(("PATH".into(), joined));
198 }
199 }
200 if step == "bot-secrets" {
201 for name in SECRET_VARS {
202 if let Some(value) = secrets::value_of(name) {
203 env.push((name.into(), value));
204 }
205 }
206 }
207 env
208 }
209
210 fn cli_override_dir(&self) -> Option<PathBuf> {
212 let overridden = std::env::var_os(match self.forge {
213 Forge::Github => "RK_GH_BIN",
214 Forge::Gitlab => "RK_GLAB_BIN",
215 })?;
216 Path::new(&overridden).parent().map(Path::to_path_buf)
217 }
218
219 #[must_use]
227 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
228 SECRET_VARS
229 .iter()
230 .filter_map(|name| secrets::value_of(name))
231 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
232 .collect()
233 }
234}
235
236fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
240 let override_var = match forge {
241 Forge::Github => "RK_GH_BIN",
242 Forge::Gitlab => "RK_GLAB_BIN",
243 };
244 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
245 let path = PathBuf::from(&overridden);
246 if !path.is_file() {
247 return Err(RkError::refusal(
248 Diagnostic::new(
249 Reason::PrerequisiteUnmet,
250 format!(
251 "{override_var} names {}, which does not exist",
252 path.display()
253 ),
254 )
255 .expected("the override to name the forge CLI binary"),
256 ));
257 }
258 if path.file_name().is_none_or(|name| name != forge.cli()) {
263 return Err(RkError::refusal(
264 Diagnostic::new(
265 Reason::PrerequisiteUnmet,
266 format!(
267 "{override_var} must name a binary called {}, and {} is not one",
268 forge.cli(),
269 path.display()
270 ),
271 )
272 .expected(format!(
273 "an override whose file name is {}, so scripts and observations run one binary",
274 forge.cli()
275 )),
276 ));
277 }
278 return Ok(path);
279 }
280 let name = forge.cli();
281 let found = std::env::var_os("PATH").and_then(|path| {
282 std::env::split_paths(&path)
283 .map(|dir| dir.join(name))
284 .find(|candidate| candidate.is_file())
285 });
286 found.ok_or_else(|| {
287 RkError::refusal(
288 Diagnostic::new(
289 Reason::PrerequisiteUnmet,
290 format!(
291 "{name} is not on PATH, and every {} step calls it",
292 forge.as_str()
293 ),
294 )
295 .expected(format!("the {name} CLI installed and authenticated"))
296 .action(format!("install {name}, then run {name} auth login")),
297 )
298 })
299}