1use crate::cli::guide::GuideArgs;
11use crate::commands::walk;
12use crate::detect;
13use crate::embedded;
14use crate::error::RkError;
15use crate::landing::manifest::{self, CheckoutMode, Style};
16use crate::output::Output;
17
18#[allow(
26 clippy::too_many_lines,
27 reason = "one pass resolves every runbook axis from the same three sources, and splitting it would separate an axis from the precedence it shares"
28)]
29pub fn run(args: &GuideArgs) -> Result<(), RkError> {
30 let out = Output::human();
31 let entries = walk(&embedded::RUNBOOKS);
32 if args.list {
33 for (path, _) in &entries {
34 out.result_line(path.trim_end_matches(".md").to_ascii_lowercase());
35 }
36 return Ok(());
37 }
38 let Some(name) = args.name.as_deref() else {
39 return Err(RkError::Usage(
40 "name a runbook, or pass --list to see them".into(),
41 ));
42 };
43 let wanted = name.to_ascii_lowercase();
44 let wanted = wanted.trim_end_matches(".md");
45 let Some((_, contents)) = entries
46 .iter()
47 .find(|(path, _)| path.trim_end_matches(".md").eq_ignore_ascii_case(wanted))
48 else {
49 return Err(RkError::NotFound {
50 kind: "runbook",
51 name: name.to_owned(),
52 });
53 };
54 let text = String::from_utf8_lossy(contents);
55
56 let forge = match args.forge.as_deref() {
57 Some(value) => Some(
58 detect::Forge::parse(value)
59 .ok_or_else(|| {
60 RkError::Usage(format!(
61 "unknown forge '{value}'; the forges are: github, gitlab"
62 ))
63 })?
64 .as_str(),
65 ),
66 None => None,
67 };
68 let tech = match args.technology.as_deref() {
69 Some(value @ ("rust" | "python" | "bash")) => Some(value.to_owned()),
70 Some(other) => {
71 return Err(RkError::Usage(format!(
72 "unknown technology '{other}'; the bindings are: rust, python, bash"
73 )));
74 }
75 None => None,
76 };
77 let checkout_mode = args
78 .checkout_mode
79 .as_deref()
80 .map(CheckoutMode::parse)
81 .transpose()?;
82 let style = args
83 .release_style
84 .as_deref()
85 .map(Style::parse)
86 .transpose()?;
87
88 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
89 let config = crate::config::load(&cwd)?;
90 let detected = detect::detect(&cwd);
91 let record =
92 camino::Utf8Path::from_path(&cwd).and_then(|path| manifest::load(path).ok().flatten());
93 let configured_forge = config.as_ref().and_then(|c| c.profile.forge.clone());
98 let recorded_forge = record.as_ref().and_then(|r| r.profile.forge.clone());
99 let forge = match (forge, configured_forge) {
100 (Some(flag), _) => Forge::Value(flag.to_owned()),
101 (None, Some(configured)) if configured.is_empty() => Forge::Absent,
102 (None, Some(configured)) => Forge::Value(configured),
103 (None, None) => recorded_forge
104 .or_else(|| {
105 detected
106 .forge
107 .map(|forge| detect::Forge::as_str(forge).to_owned())
108 })
109 .map_or(Forge::Unresolved, Forge::Value),
110 };
111 let forge = match forge {
112 Forge::Value(named) => detect::Forge::parse(&named)
113 .map(detect::Forge::as_str)
114 .map_or(Forge::Unresolved, |name| Forge::Value(name.to_owned())),
115 other => other,
116 };
117 let tech = tech
120 .or_else(|| {
121 config
122 .as_ref()
123 .and_then(|c| c.profile.release.driver.clone())
124 })
125 .or_else(|| {
126 record
127 .as_ref()
128 .and_then(|r| r.profile.release.driver.clone())
129 })
130 .or_else(|| detect::tech_of(&cwd).map(str::to_owned));
131 let repo = args
132 .repo
133 .clone()
134 .or_else(|| {
135 config
136 .as_ref()
137 .map(|c| c.project.repo.clone())
138 .filter(|v| !v.is_empty())
139 })
140 .or_else(|| {
141 record
142 .as_ref()
143 .map(|r| r.parameters.repo.clone())
144 .filter(|v| !v.is_empty())
145 })
146 .or(detected.repo);
147 let checkout_mode = checkout_mode
151 .or_else(|| config.as_ref().and_then(|c| c.git.checkout_mode))
152 .or_else(|| record.as_ref().map(|record| record.git.checkout_mode));
153 let style = style
156 .or_else(|| config.as_ref().and_then(|c| c.profile.release.style))
157 .or_else(|| {
158 record
159 .as_ref()
160 .and_then(|record| record.profile.release.style)
161 });
162
163 let rendered = render(
164 &text,
165 &forge,
166 tech.as_deref(),
167 repo.as_deref(),
168 checkout_mode.map(CheckoutMode::runbook_label),
169 style.map(Style::as_str),
170 );
171 let unresolved = repo.is_none() && rendered.contains("<repo>");
172 out.result_raw(&rendered);
173 if unresolved {
174 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
175 }
176 Ok(())
177}
178
179fn axis_of(selector: &str) -> Option<&'static str> {
183 if let Some((tech, forge)) = selector.split_once('/') {
184 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
185 .then_some("pair");
186 }
187 match selector {
188 "github" | "gitlab" => Some("forge"),
189 "rust" | "python" | "bash" => Some("tech"),
190 "worktree" | "branches" => Some("workflow"),
191 "trunk" | "lines" => Some("style"),
192 _ => None,
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
203enum Forge {
204 Unresolved,
206 Absent,
208 Value(String),
210}
211
212impl Forge {
213 fn selector(&self) -> Option<String> {
217 match self {
218 Self::Unresolved => None,
219 Self::Absent => Some(String::new()),
220 Self::Value(named) => Some(named.clone()),
221 }
222 }
223}
224
225fn label_of(line: &str) -> Option<&str> {
227 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
228 axis_of(selector).map(|_| selector)
229}
230
231fn render(
235 text: &str,
236 forge: &Forge,
237 tech: Option<&str>,
238 repo: Option<&str>,
239 workflow: Option<&str>,
240 style: Option<&str>,
241) -> String {
242 let lines: Vec<&str> = text.split('\n').collect();
243 let mut out: Vec<String> = Vec::with_capacity(lines.len());
244 let mut idx = 0;
245 while idx < lines.len() {
246 let line = lines[idx];
247 let Some(selector) = label_of(line) else {
248 out.push(substitute(line, repo, tech));
249 idx += 1;
250 continue;
251 };
252 let resolved = match axis_of(selector) {
253 Some("forge") => forge.selector(),
254 Some("tech") => tech.map(str::to_owned),
255 Some("workflow") => workflow.map(str::to_owned),
256 Some("style") => style.map(str::to_owned),
257 Some("pair") => match (tech, forge) {
262 (_, Forge::Absent) => Some(String::new()),
263 (Some(tech), Forge::Value(forge)) => Some(format!("{tech}/{forge}")),
264 _ => None,
265 },
266 _ => None,
267 };
268 let Some(resolved) = resolved else {
269 out.push(substitute(line, repo, tech));
270 idx += 1;
271 continue;
272 };
273 let body_start = idx + 2;
276 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
277 lines[body_start + 1..]
278 .iter()
279 .position(|l| l.starts_with("```"))
280 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
281 } else {
282 lines[body_start..]
283 .iter()
284 .position(|l| l.trim().is_empty())
285 .map_or(lines.len(), |offset| body_start + offset)
286 };
287 if selector == resolved {
288 for kept in lines.iter().take(body_end).skip(body_start) {
289 out.push(substitute(kept, repo, tech));
290 }
291 idx = body_end;
292 } else {
293 idx = body_end;
294 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
297 idx += 1;
298 }
299 }
300 }
301 out.join("\n")
302}
303
304fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
307 let mut line = line.to_owned();
308 if let Some(slug) = repo {
309 line = line.replace("<repo>", slug);
310 }
311 if let Some(tech) = tech {
312 line = line.replace("<tech>", tech);
313 }
314 line
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{Forge, render};
320
321 const DOC: &str = "# T\n\nOn github:\n\n```bash\ngh pr list --repo <repo>\n```\n\nOn gitlab:\n\n```bash\nglab mr list\n```\n\ntail <release pr>\n";
322
323 #[test]
325 fn an_unresolved_render_is_byte_identical() {
326 assert_eq!(render(DOC, &Forge::Unresolved, None, None, None, None), DOC);
327 }
328
329 #[test]
333 fn a_resolved_render_selects_and_substitutes() {
334 let rendered = render(
335 DOC,
336 &Forge::Value("github".to_owned()),
337 None,
338 Some("acme/widget"),
339 None,
340 None,
341 );
342 assert!(rendered.contains("gh pr list --repo acme/widget"));
343 assert!(!rendered.contains("glab"));
344 assert!(!rendered.contains("On github:"));
345 assert!(!rendered.contains("<repo>"));
346 assert!(rendered.contains("<release pr>"));
347 let gitlab = render(
348 DOC,
349 &Forge::Value("gitlab".to_owned()),
350 None,
351 None,
352 None,
353 None,
354 );
355 assert!(gitlab.contains("glab mr list"));
356 assert!(!gitlab.contains("gh pr list"));
357 }
358
359 #[test]
361 fn a_paragraph_variant_renders() {
362 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
363 let rendered = render(
364 doc,
365 &Forge::Value("gitlab".to_owned()),
366 None,
367 None,
368 None,
369 None,
370 );
371 assert_eq!(rendered, "the request is replaced.\n\nend\n");
372 }
373
374 #[test]
379 fn a_pair_variant_selects_on_both_axes() {
380 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
381 let matched = render(
382 doc,
383 &Forge::Value("gitlab".to_owned()),
384 Some("bash"),
385 None,
386 None,
387 None,
388 );
389 assert!(matched.contains("cosign verify-blob-attestation"));
390 assert!(!matched.contains("no provenance surface"));
391 assert!(!matched.contains("On bash/gitlab:"));
392 let sibling = render(
393 doc,
394 &Forge::Value("gitlab".to_owned()),
395 Some("rust"),
396 None,
397 None,
398 None,
399 );
400 assert!(!sibling.contains("cosign"));
401 assert!(sibling.contains("no provenance surface."));
402 let open_axis = render(
403 doc,
404 &Forge::Value("gitlab".to_owned()),
405 None,
406 None,
407 None,
408 None,
409 );
410 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
411 }
412
413 #[test]
417 fn a_workflow_variant_selects_on_the_mode() {
418 let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
419 let worktree = render(doc, &Forge::Unresolved, None, None, Some("worktree"), None);
420 assert!(worktree.contains("rk worktree add"));
421 assert!(!worktree.contains("gh pr checkout"));
422 let branches = render(doc, &Forge::Unresolved, None, None, Some("branches"), None);
423 assert!(branches.contains("gh pr checkout"));
424 assert!(!branches.contains("rk worktree add"));
425 assert_eq!(
426 render(doc, &Forge::Unresolved, None, None, None, None),
427 doc,
428 "an unresolved mode keeps every variant, label and all"
429 );
430 }
431
432 #[test]
436 fn a_style_variant_selects_on_the_style() {
437 let doc = "On trunk:\n\nthe request merges itself when the last check passes.\n\nOn lines:\n\nthe merge is yours.\n\nend\n";
438 let trunk = render(doc, &Forge::Unresolved, None, None, None, Some("trunk"));
439 assert!(trunk.contains("merges itself"));
440 assert!(!trunk.contains("the merge is yours"));
441 let lines = render(doc, &Forge::Unresolved, None, None, None, Some("lines"));
442 assert!(lines.contains("the merge is yours"));
443 assert!(!lines.contains("merges itself"));
444 assert_eq!(
445 render(doc, &Forge::Unresolved, None, None, None, None),
446 doc,
447 "an unresolved style keeps every variant, label and all"
448 );
449 }
450
451 #[test]
453 fn a_resolved_tech_fills_the_placeholder() {
454 let doc = "rk init --tech <tech> --target .\n";
455 assert_eq!(
456 render(doc, &Forge::Unresolved, Some("rust"), None, None, None),
457 "rk init --tech rust --target .\n"
458 );
459 assert_eq!(render(doc, &Forge::Unresolved, None, None, None, None), doc);
460 }
461}