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, Integration, 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 let integration = args
88 .integration
89 .as_deref()
90 .map(Integration::parse)
91 .transpose()?;
92
93 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
94 let config = crate::config::load(&cwd)?;
95 let detected = detect::detect(&cwd);
96 let record =
97 camino::Utf8Path::from_path(&cwd).and_then(|path| manifest::load(path).ok().flatten());
98 let configured_forge = config.as_ref().and_then(|c| c.profile.forge.clone());
103 let recorded_forge = record.as_ref().and_then(|r| r.profile.forge.clone());
104 let forge = match (forge, configured_forge) {
105 (Some(flag), _) => Forge::Value(flag.to_owned()),
106 (None, Some(configured)) if configured.is_empty() => Forge::Absent,
107 (None, Some(configured)) => Forge::Value(configured),
108 (None, None) => recorded_forge
109 .or_else(|| {
110 detected
111 .forge
112 .map(|forge| detect::Forge::as_str(forge).to_owned())
113 })
114 .map_or(Forge::Unresolved, Forge::Value),
115 };
116 let forge = match forge {
117 Forge::Value(named) => detect::Forge::parse(&named)
118 .map(detect::Forge::as_str)
119 .map_or(Forge::Unresolved, |name| Forge::Value(name.to_owned())),
120 other => other,
121 };
122 let tech = tech
125 .or_else(|| {
126 config
127 .as_ref()
128 .and_then(|c| c.profile.release.driver.clone())
129 })
130 .or_else(|| {
131 record
132 .as_ref()
133 .and_then(|r| r.profile.release.driver.clone())
134 })
135 .or_else(|| detect::tech_of(&cwd).map(str::to_owned));
136 let repo = args
137 .repo
138 .clone()
139 .or_else(|| {
140 config
141 .as_ref()
142 .map(|c| c.project.repo.clone())
143 .filter(|v| !v.is_empty())
144 })
145 .or_else(|| {
146 record
147 .as_ref()
148 .map(|r| r.parameters.repo.clone())
149 .filter(|v| !v.is_empty())
150 })
151 .or(detected.repo);
152 let checkout_mode = checkout_mode
156 .or_else(|| config.as_ref().and_then(|c| c.git.checkout_mode))
157 .or_else(|| record.as_ref().map(|record| record.git.checkout_mode));
158 let style = style
161 .or_else(|| config.as_ref().and_then(|c| c.profile.release.style))
162 .or_else(|| {
163 record
164 .as_ref()
165 .and_then(|record| record.profile.release.style)
166 });
167
168 let integration = integration
172 .or_else(|| config.as_ref().and_then(|c| c.git.integration))
173 .or_else(|| record.as_ref().map(|record| record.git.integration));
174
175 let rendered = render(
176 &text,
177 &forge,
178 tech.as_deref(),
179 repo.as_deref(),
180 checkout_mode.map(CheckoutMode::runbook_label),
181 style.map(Style::as_str),
182 integration.map(Integration::as_str),
183 );
184 let unresolved = repo.is_none() && rendered.contains("<repo>");
185 out.result_raw(&rendered);
186 if unresolved {
187 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
188 }
189 Ok(())
190}
191
192fn axis_of(selector: &str) -> Option<&'static str> {
196 if let Some((tech, forge)) = selector.split_once('/') {
197 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
198 .then_some("pair");
199 }
200 match selector {
201 "github" | "gitlab" => Some("forge"),
202 "rust" | "python" | "bash" => Some("tech"),
203 "worktree" | "branches" => Some("workflow"),
204 "local" | "forge" => Some("integration"),
205 "trunk" | "lines" => Some("style"),
206 _ => None,
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
217enum Forge {
218 Unresolved,
220 Absent,
222 Value(String),
224}
225
226impl Forge {
227 fn selector(&self) -> Option<String> {
231 match self {
232 Self::Unresolved => None,
233 Self::Absent => Some(String::new()),
234 Self::Value(named) => Some(named.clone()),
235 }
236 }
237}
238
239fn label_of(line: &str) -> Option<&str> {
241 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
242 axis_of(selector).map(|_| selector)
243}
244
245fn render(
249 text: &str,
250 forge: &Forge,
251 tech: Option<&str>,
252 repo: Option<&str>,
253 workflow: Option<&str>,
254 style: Option<&str>,
255 integration: Option<&str>,
256) -> String {
257 let lines: Vec<&str> = text.split('\n').collect();
258 let mut out: Vec<String> = Vec::with_capacity(lines.len());
259 let mut idx = 0;
260 while idx < lines.len() {
261 let line = lines[idx];
262 let Some(selector) = label_of(line) else {
263 out.push(substitute(line, repo, tech));
264 idx += 1;
265 continue;
266 };
267 let resolved = match axis_of(selector) {
268 Some("forge") => forge.selector(),
269 Some("tech") => tech.map(str::to_owned),
270 Some("workflow") => workflow.map(str::to_owned),
271 Some("style") => style.map(str::to_owned),
272 Some("integration") => integration.map(str::to_owned),
273 Some("pair") => match (tech, forge) {
278 (_, Forge::Absent) => Some(String::new()),
279 (Some(tech), Forge::Value(forge)) => Some(format!("{tech}/{forge}")),
280 _ => None,
281 },
282 _ => None,
283 };
284 let Some(resolved) = resolved else {
285 out.push(substitute(line, repo, tech));
286 idx += 1;
287 continue;
288 };
289 let body_start = idx + 2;
292 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
293 lines[body_start + 1..]
294 .iter()
295 .position(|l| l.starts_with("```"))
296 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
297 } else {
298 lines[body_start..]
299 .iter()
300 .position(|l| l.trim().is_empty())
301 .map_or(lines.len(), |offset| body_start + offset)
302 };
303 if selector == resolved {
304 for kept in lines.iter().take(body_end).skip(body_start) {
305 out.push(substitute(kept, repo, tech));
306 }
307 idx = body_end;
308 } else {
309 idx = body_end;
310 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
313 idx += 1;
314 }
315 }
316 }
317 out.join("\n")
318}
319
320fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
323 let mut line = line.to_owned();
324 if let Some(slug) = repo {
325 line = line.replace("<repo>", slug);
326 }
327 if let Some(tech) = tech {
328 line = line.replace("<tech>", tech);
329 }
330 line
331}
332
333#[cfg(test)]
334mod tests {
335 use super::{Forge, render};
336
337 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";
338
339 #[test]
341 fn an_unresolved_render_is_byte_identical() {
342 assert_eq!(
343 render(DOC, &Forge::Unresolved, None, None, None, None, None),
344 DOC
345 );
346 }
347
348 #[test]
352 fn a_resolved_render_selects_and_substitutes() {
353 let rendered = render(
354 DOC,
355 &Forge::Value("github".to_owned()),
356 None,
357 Some("acme/widget"),
358 None,
359 None,
360 None,
361 );
362 assert!(rendered.contains("gh pr list --repo acme/widget"));
363 assert!(!rendered.contains("glab"));
364 assert!(!rendered.contains("On github:"));
365 assert!(!rendered.contains("<repo>"));
366 assert!(rendered.contains("<release pr>"));
367 let gitlab = render(
368 DOC,
369 &Forge::Value("gitlab".to_owned()),
370 None,
371 None,
372 None,
373 None,
374 None,
375 );
376 assert!(gitlab.contains("glab mr list"));
377 assert!(!gitlab.contains("gh pr list"));
378 }
379
380 #[test]
382 fn a_paragraph_variant_renders() {
383 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
384 let rendered = render(
385 doc,
386 &Forge::Value("gitlab".to_owned()),
387 None,
388 None,
389 None,
390 None,
391 None,
392 );
393 assert_eq!(rendered, "the request is replaced.\n\nend\n");
394 }
395
396 #[test]
401 fn a_pair_variant_selects_on_both_axes() {
402 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
403 let matched = render(
404 doc,
405 &Forge::Value("gitlab".to_owned()),
406 Some("bash"),
407 None,
408 None,
409 None,
410 None,
411 );
412 assert!(matched.contains("cosign verify-blob-attestation"));
413 assert!(!matched.contains("no provenance surface"));
414 assert!(!matched.contains("On bash/gitlab:"));
415 let sibling = render(
416 doc,
417 &Forge::Value("gitlab".to_owned()),
418 Some("rust"),
419 None,
420 None,
421 None,
422 None,
423 );
424 assert!(!sibling.contains("cosign"));
425 assert!(sibling.contains("no provenance surface."));
426 let open_axis = render(
427 doc,
428 &Forge::Value("gitlab".to_owned()),
429 None,
430 None,
431 None,
432 None,
433 None,
434 );
435 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
436 }
437
438 #[test]
442 fn a_workflow_variant_selects_on_the_mode() {
443 let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
444 let worktree = render(
445 doc,
446 &Forge::Unresolved,
447 None,
448 None,
449 Some("worktree"),
450 None,
451 None,
452 );
453 assert!(worktree.contains("rk worktree add"));
454 assert!(!worktree.contains("gh pr checkout"));
455 let branches = render(
456 doc,
457 &Forge::Unresolved,
458 None,
459 None,
460 Some("branches"),
461 None,
462 None,
463 );
464 assert!(branches.contains("gh pr checkout"));
465 assert!(!branches.contains("rk worktree add"));
466 assert_eq!(
467 render(doc, &Forge::Unresolved, None, None, None, None, None),
468 doc,
469 "an unresolved mode keeps every variant, label and all"
470 );
471 }
472
473 #[test]
477 fn a_style_variant_selects_on_the_style() {
478 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";
479 let trunk = render(
480 doc,
481 &Forge::Unresolved,
482 None,
483 None,
484 None,
485 Some("trunk"),
486 None,
487 );
488 assert!(trunk.contains("merges itself"));
489 assert!(!trunk.contains("the merge is yours"));
490 let lines = render(
491 doc,
492 &Forge::Unresolved,
493 None,
494 None,
495 None,
496 Some("lines"),
497 None,
498 );
499 assert!(lines.contains("the merge is yours"));
500 assert!(!lines.contains("merges itself"));
501 assert_eq!(
502 render(doc, &Forge::Unresolved, None, None, None, None, None),
503 doc,
504 "an unresolved style keeps every variant, label and all"
505 );
506 }
507
508 #[test]
510 fn a_resolved_tech_fills_the_placeholder() {
511 let doc = "rk init --tech <tech> --target .\n";
512 assert_eq!(
513 render(
514 doc,
515 &Forge::Unresolved,
516 Some("rust"),
517 None,
518 None,
519 None,
520 None
521 ),
522 "rk init --tech rust --target .\n"
523 );
524 assert_eq!(
525 render(doc, &Forge::Unresolved, None, None, None, None, None),
526 doc
527 );
528 }
529}