1use crate::cli::guide::GuideArgs;
11use crate::commands::walk;
12use crate::detect;
13use crate::embedded;
14use crate::error::RkError;
15use crate::landing::manifest::{self, Style, Workflow};
16use crate::output::Output;
17
18pub fn run(args: &GuideArgs) -> Result<(), RkError> {
26 let out = Output::human();
27 let entries = walk(&embedded::RUNBOOKS);
28 if args.list {
29 for (path, _) in &entries {
30 out.result_line(path.trim_end_matches(".md").to_ascii_lowercase());
31 }
32 return Ok(());
33 }
34 let Some(name) = args.name.as_deref() else {
35 return Err(RkError::Usage(
36 "name a runbook, or pass --list to see them".into(),
37 ));
38 };
39 let wanted = name.to_ascii_lowercase();
40 let wanted = wanted.trim_end_matches(".md");
41 let Some((_, contents)) = entries
42 .iter()
43 .find(|(path, _)| path.trim_end_matches(".md").eq_ignore_ascii_case(wanted))
44 else {
45 return Err(RkError::NotFound {
46 kind: "runbook",
47 name: name.to_owned(),
48 });
49 };
50 let text = String::from_utf8_lossy(contents);
51
52 let forge = match args.forge.as_deref() {
53 Some(value) => Some(
54 detect::Forge::parse(value)
55 .ok_or_else(|| {
56 RkError::Usage(format!(
57 "unknown forge '{value}'; the forges are: github, gitlab"
58 ))
59 })?
60 .as_str(),
61 ),
62 None => None,
63 };
64 let tech = match args.tech.as_deref() {
65 Some(value @ ("rust" | "python" | "bash")) => Some(value.to_owned()),
66 Some(other) => {
67 return Err(RkError::Usage(format!(
68 "unknown tech '{other}'; the bindings are: rust, python, bash"
69 )));
70 }
71 None => None,
72 };
73 let workflow = args.workflow.as_deref().map(Workflow::parse).transpose()?;
74 let style = args.style.as_deref().map(Style::parse).transpose()?;
75
76 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
77 let config = crate::config::load(&cwd)?;
78 let detected = detect::detect(&cwd);
79 let forge = forge
80 .or_else(|| {
81 config
82 .as_ref()
83 .map(|c| c.project.forge.as_str())
84 .filter(|v| !v.is_empty())
85 })
86 .or_else(|| detected.forge.map(detect::Forge::as_str));
87 let tech = tech
88 .or_else(|| {
89 config
90 .as_ref()
91 .map(|c| c.project.tech.clone())
92 .filter(|v| !v.is_empty())
93 })
94 .or_else(|| detect::tech_of(&cwd).map(str::to_owned));
95 let repo = args
96 .repo
97 .clone()
98 .or_else(|| {
99 config
100 .as_ref()
101 .map(|c| c.project.repo.clone())
102 .filter(|v| !v.is_empty())
103 })
104 .or(detected.repo);
105 let record =
109 camino::Utf8Path::from_path(&cwd).and_then(|path| manifest::load(path).ok().flatten());
110 let workflow = workflow
111 .or_else(|| config.as_ref().and_then(|c| c.landing.workflow))
112 .or_else(|| record.as_ref().map(|record| record.parameters.workflow));
113 let style = style
116 .or_else(|| config.as_ref().and_then(|c| c.landing.style))
117 .or_else(|| record.as_ref().and_then(|record| record.parameters.style));
118
119 let rendered = render(
120 &text,
121 forge,
122 tech.as_deref(),
123 repo.as_deref(),
124 workflow.map(Workflow::as_str),
125 style.map(Style::as_str),
126 );
127 let unresolved = repo.is_none() && rendered.contains("<repo>");
128 out.result_raw(&rendered);
129 if unresolved {
130 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
131 }
132 Ok(())
133}
134
135fn axis_of(selector: &str) -> Option<&'static str> {
139 if let Some((tech, forge)) = selector.split_once('/') {
140 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
141 .then_some("pair");
142 }
143 match selector {
144 "github" | "gitlab" => Some("forge"),
145 "rust" | "python" | "bash" => Some("tech"),
146 "worktree" | "branches" => Some("workflow"),
147 "trunk" | "lines" => Some("style"),
148 _ => None,
149 }
150}
151
152fn label_of(line: &str) -> Option<&str> {
154 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
155 axis_of(selector).map(|_| selector)
156}
157
158fn render(
162 text: &str,
163 forge: Option<&str>,
164 tech: Option<&str>,
165 repo: Option<&str>,
166 workflow: Option<&str>,
167 style: Option<&str>,
168) -> String {
169 let lines: Vec<&str> = text.split('\n').collect();
170 let mut out: Vec<String> = Vec::with_capacity(lines.len());
171 let mut idx = 0;
172 while idx < lines.len() {
173 let line = lines[idx];
174 let Some(selector) = label_of(line) else {
175 out.push(substitute(line, repo, tech));
176 idx += 1;
177 continue;
178 };
179 let resolved = match axis_of(selector) {
180 Some("forge") => forge.map(str::to_owned),
181 Some("tech") => tech.map(str::to_owned),
182 Some("workflow") => workflow.map(str::to_owned),
183 Some("style") => style.map(str::to_owned),
184 Some("pair") => match (tech, forge) {
187 (Some(tech), Some(forge)) => Some(format!("{tech}/{forge}")),
188 _ => None,
189 },
190 _ => None,
191 };
192 let Some(resolved) = resolved else {
193 out.push(substitute(line, repo, tech));
194 idx += 1;
195 continue;
196 };
197 let body_start = idx + 2;
200 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
201 lines[body_start + 1..]
202 .iter()
203 .position(|l| l.starts_with("```"))
204 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
205 } else {
206 lines[body_start..]
207 .iter()
208 .position(|l| l.trim().is_empty())
209 .map_or(lines.len(), |offset| body_start + offset)
210 };
211 if selector == resolved {
212 for kept in lines.iter().take(body_end).skip(body_start) {
213 out.push(substitute(kept, repo, tech));
214 }
215 idx = body_end;
216 } else {
217 idx = body_end;
218 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
221 idx += 1;
222 }
223 }
224 }
225 out.join("\n")
226}
227
228fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
231 let mut line = line.to_owned();
232 if let Some(slug) = repo {
233 line = line.replace("<repo>", slug);
234 }
235 if let Some(tech) = tech {
236 line = line.replace("<tech>", tech);
237 }
238 line
239}
240
241#[cfg(test)]
242mod tests {
243 use super::render;
244
245 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";
246
247 #[test]
249 fn an_unresolved_render_is_byte_identical() {
250 assert_eq!(render(DOC, None, None, None, None, None), DOC);
251 }
252
253 #[test]
257 fn a_resolved_render_selects_and_substitutes() {
258 let rendered = render(DOC, Some("github"), None, Some("acme/widget"), None, None);
259 assert!(rendered.contains("gh pr list --repo acme/widget"));
260 assert!(!rendered.contains("glab"));
261 assert!(!rendered.contains("On github:"));
262 assert!(!rendered.contains("<repo>"));
263 assert!(rendered.contains("<release pr>"));
264 let gitlab = render(DOC, Some("gitlab"), None, None, None, None);
265 assert!(gitlab.contains("glab mr list"));
266 assert!(!gitlab.contains("gh pr list"));
267 }
268
269 #[test]
271 fn a_paragraph_variant_renders() {
272 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
273 let rendered = render(doc, Some("gitlab"), None, None, None, None);
274 assert_eq!(rendered, "the request is replaced.\n\nend\n");
275 }
276
277 #[test]
282 fn a_pair_variant_selects_on_both_axes() {
283 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
284 let matched = render(doc, Some("gitlab"), Some("bash"), None, None, None);
285 assert!(matched.contains("cosign verify-blob-attestation"));
286 assert!(!matched.contains("no provenance surface"));
287 assert!(!matched.contains("On bash/gitlab:"));
288 let sibling = render(doc, Some("gitlab"), Some("rust"), None, None, None);
289 assert!(!sibling.contains("cosign"));
290 assert!(sibling.contains("no provenance surface."));
291 let open_axis = render(doc, Some("gitlab"), None, None, None, None);
292 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
293 }
294
295 #[test]
299 fn a_workflow_variant_selects_on_the_mode() {
300 let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
301 let worktree = render(doc, None, None, None, Some("worktree"), None);
302 assert!(worktree.contains("rk worktree add"));
303 assert!(!worktree.contains("gh pr checkout"));
304 let branches = render(doc, None, None, None, Some("branches"), None);
305 assert!(branches.contains("gh pr checkout"));
306 assert!(!branches.contains("rk worktree add"));
307 assert_eq!(
308 render(doc, None, None, None, None, None),
309 doc,
310 "an unresolved mode keeps every variant, label and all"
311 );
312 }
313
314 #[test]
318 fn a_style_variant_selects_on_the_style() {
319 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";
320 let trunk = render(doc, None, None, None, None, Some("trunk"));
321 assert!(trunk.contains("merges itself"));
322 assert!(!trunk.contains("the merge is yours"));
323 let lines = render(doc, None, None, None, None, Some("lines"));
324 assert!(lines.contains("the merge is yours"));
325 assert!(!lines.contains("merges itself"));
326 assert_eq!(
327 render(doc, None, None, None, None, None),
328 doc,
329 "an unresolved style keeps every variant, label and all"
330 );
331 }
332
333 #[test]
335 fn a_resolved_tech_fills_the_placeholder() {
336 let doc = "rk init --tech <tech> --target .\n";
337 assert_eq!(
338 render(doc, None, Some("rust"), None, None, None),
339 "rk init --tech rust --target .\n"
340 );
341 assert_eq!(render(doc, None, None, None, None, None), doc);
342 }
343}