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 detected = detect::detect(&cwd);
78 let forge = forge.or_else(|| detected.forge.map(detect::Forge::as_str));
79 let tech = tech.or_else(|| detect::tech_of(&cwd).map(str::to_owned));
80 let repo = args.repo.clone().or(detected.repo);
81 let record =
85 camino::Utf8Path::from_path(&cwd).and_then(|path| manifest::load(path).ok().flatten());
86 let workflow = workflow.or_else(|| record.as_ref().map(|record| record.parameters.workflow));
87 let style = style.or_else(|| record.as_ref().and_then(|record| record.parameters.style));
90
91 let rendered = render(
92 &text,
93 forge,
94 tech.as_deref(),
95 repo.as_deref(),
96 workflow.map(Workflow::as_str),
97 style.map(Style::as_str),
98 );
99 let unresolved = repo.is_none() && rendered.contains("<repo>");
100 out.result_raw(&rendered);
101 if unresolved {
102 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
103 }
104 Ok(())
105}
106
107fn axis_of(selector: &str) -> Option<&'static str> {
111 if let Some((tech, forge)) = selector.split_once('/') {
112 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
113 .then_some("pair");
114 }
115 match selector {
116 "github" | "gitlab" => Some("forge"),
117 "rust" | "python" | "bash" => Some("tech"),
118 "worktree" | "branches" => Some("workflow"),
119 "trunk" | "lines" => Some("style"),
120 _ => None,
121 }
122}
123
124fn label_of(line: &str) -> Option<&str> {
126 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
127 axis_of(selector).map(|_| selector)
128}
129
130fn render(
134 text: &str,
135 forge: Option<&str>,
136 tech: Option<&str>,
137 repo: Option<&str>,
138 workflow: Option<&str>,
139 style: Option<&str>,
140) -> String {
141 let lines: Vec<&str> = text.split('\n').collect();
142 let mut out: Vec<String> = Vec::with_capacity(lines.len());
143 let mut idx = 0;
144 while idx < lines.len() {
145 let line = lines[idx];
146 let Some(selector) = label_of(line) else {
147 out.push(substitute(line, repo, tech));
148 idx += 1;
149 continue;
150 };
151 let resolved = match axis_of(selector) {
152 Some("forge") => forge.map(str::to_owned),
153 Some("tech") => tech.map(str::to_owned),
154 Some("workflow") => workflow.map(str::to_owned),
155 Some("style") => style.map(str::to_owned),
156 Some("pair") => match (tech, forge) {
159 (Some(tech), Some(forge)) => Some(format!("{tech}/{forge}")),
160 _ => None,
161 },
162 _ => None,
163 };
164 let Some(resolved) = resolved else {
165 out.push(substitute(line, repo, tech));
166 idx += 1;
167 continue;
168 };
169 let body_start = idx + 2;
172 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
173 lines[body_start + 1..]
174 .iter()
175 .position(|l| l.starts_with("```"))
176 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
177 } else {
178 lines[body_start..]
179 .iter()
180 .position(|l| l.trim().is_empty())
181 .map_or(lines.len(), |offset| body_start + offset)
182 };
183 if selector == resolved {
184 for kept in lines.iter().take(body_end).skip(body_start) {
185 out.push(substitute(kept, repo, tech));
186 }
187 idx = body_end;
188 } else {
189 idx = body_end;
190 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
193 idx += 1;
194 }
195 }
196 }
197 out.join("\n")
198}
199
200fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
203 let mut line = line.to_owned();
204 if let Some(slug) = repo {
205 line = line.replace("<repo>", slug);
206 }
207 if let Some(tech) = tech {
208 line = line.replace("<tech>", tech);
209 }
210 line
211}
212
213#[cfg(test)]
214mod tests {
215 use super::render;
216
217 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";
218
219 #[test]
221 fn an_unresolved_render_is_byte_identical() {
222 assert_eq!(render(DOC, None, None, None, None, None), DOC);
223 }
224
225 #[test]
229 fn a_resolved_render_selects_and_substitutes() {
230 let rendered = render(DOC, Some("github"), None, Some("acme/widget"), None, None);
231 assert!(rendered.contains("gh pr list --repo acme/widget"));
232 assert!(!rendered.contains("glab"));
233 assert!(!rendered.contains("On github:"));
234 assert!(!rendered.contains("<repo>"));
235 assert!(rendered.contains("<release pr>"));
236 let gitlab = render(DOC, Some("gitlab"), None, None, None, None);
237 assert!(gitlab.contains("glab mr list"));
238 assert!(!gitlab.contains("gh pr list"));
239 }
240
241 #[test]
243 fn a_paragraph_variant_renders() {
244 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
245 let rendered = render(doc, Some("gitlab"), None, None, None, None);
246 assert_eq!(rendered, "the request is replaced.\n\nend\n");
247 }
248
249 #[test]
254 fn a_pair_variant_selects_on_both_axes() {
255 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
256 let matched = render(doc, Some("gitlab"), Some("bash"), None, None, None);
257 assert!(matched.contains("cosign verify-blob-attestation"));
258 assert!(!matched.contains("no provenance surface"));
259 assert!(!matched.contains("On bash/gitlab:"));
260 let sibling = render(doc, Some("gitlab"), Some("rust"), None, None, None);
261 assert!(!sibling.contains("cosign"));
262 assert!(sibling.contains("no provenance surface."));
263 let open_axis = render(doc, Some("gitlab"), None, None, None, None);
264 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
265 }
266
267 #[test]
271 fn a_workflow_variant_selects_on_the_mode() {
272 let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
273 let worktree = render(doc, None, None, None, Some("worktree"), None);
274 assert!(worktree.contains("rk worktree add"));
275 assert!(!worktree.contains("gh pr checkout"));
276 let branches = render(doc, None, None, None, Some("branches"), None);
277 assert!(branches.contains("gh pr checkout"));
278 assert!(!branches.contains("rk worktree add"));
279 assert_eq!(
280 render(doc, None, None, None, None, None),
281 doc,
282 "an unresolved mode keeps every variant, label and all"
283 );
284 }
285
286 #[test]
290 fn a_style_variant_selects_on_the_style() {
291 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";
292 let trunk = render(doc, None, None, None, None, Some("trunk"));
293 assert!(trunk.contains("merges itself"));
294 assert!(!trunk.contains("the merge is yours"));
295 let lines = render(doc, None, None, None, None, Some("lines"));
296 assert!(lines.contains("the merge is yours"));
297 assert!(!lines.contains("merges itself"));
298 assert_eq!(
299 render(doc, None, None, None, None, None),
300 doc,
301 "an unresolved style keeps every variant, label and all"
302 );
303 }
304
305 #[test]
307 fn a_resolved_tech_fills_the_placeholder() {
308 let doc = "rk init --tech <tech> --target .\n";
309 assert_eq!(
310 render(doc, None, Some("rust"), None, None, None),
311 "rk init --tech rust --target .\n"
312 );
313 assert_eq!(render(doc, None, None, None, None, None), doc);
314 }
315}