1use crate::cli::guide::GuideArgs;
11use crate::commands::walk;
12use crate::detect;
13use crate::embedded;
14use crate::error::RkError;
15use crate::landing::manifest::{self, 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
75 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
76 let detected = detect::detect(&cwd);
77 let forge = forge.or_else(|| detected.forge.map(detect::Forge::as_str));
78 let tech = tech.or_else(|| detect::tech_of(&cwd).map(str::to_owned));
79 let repo = args.repo.clone().or(detected.repo);
80 let workflow = workflow.or_else(|| {
84 camino::Utf8Path::from_path(&cwd)
85 .and_then(|path| manifest::load(path).ok().flatten())
86 .map(|record| record.parameters.workflow)
87 });
88
89 let rendered = render(
90 &text,
91 forge,
92 tech.as_deref(),
93 repo.as_deref(),
94 workflow.map(Workflow::as_str),
95 );
96 let unresolved = repo.is_none() && rendered.contains("<repo>");
97 out.result_raw(&rendered);
98 if unresolved {
99 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
100 }
101 Ok(())
102}
103
104fn axis_of(selector: &str) -> Option<&'static str> {
108 if let Some((tech, forge)) = selector.split_once('/') {
109 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
110 .then_some("pair");
111 }
112 match selector {
113 "github" | "gitlab" => Some("forge"),
114 "rust" | "python" | "bash" => Some("tech"),
115 "worktree" | "branches" => Some("workflow"),
116 _ => None,
117 }
118}
119
120fn label_of(line: &str) -> Option<&str> {
122 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
123 axis_of(selector).map(|_| selector)
124}
125
126fn render(
130 text: &str,
131 forge: Option<&str>,
132 tech: Option<&str>,
133 repo: Option<&str>,
134 workflow: Option<&str>,
135) -> String {
136 let lines: Vec<&str> = text.split('\n').collect();
137 let mut out: Vec<String> = Vec::with_capacity(lines.len());
138 let mut idx = 0;
139 while idx < lines.len() {
140 let line = lines[idx];
141 let Some(selector) = label_of(line) else {
142 out.push(substitute(line, repo, tech));
143 idx += 1;
144 continue;
145 };
146 let resolved = match axis_of(selector) {
147 Some("forge") => forge.map(str::to_owned),
148 Some("tech") => tech.map(str::to_owned),
149 Some("workflow") => workflow.map(str::to_owned),
150 Some("pair") => match (tech, forge) {
153 (Some(tech), Some(forge)) => Some(format!("{tech}/{forge}")),
154 _ => None,
155 },
156 _ => None,
157 };
158 let Some(resolved) = resolved else {
159 out.push(substitute(line, repo, tech));
160 idx += 1;
161 continue;
162 };
163 let body_start = idx + 2;
166 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
167 lines[body_start + 1..]
168 .iter()
169 .position(|l| l.starts_with("```"))
170 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
171 } else {
172 lines[body_start..]
173 .iter()
174 .position(|l| l.trim().is_empty())
175 .map_or(lines.len(), |offset| body_start + offset)
176 };
177 if selector == resolved {
178 for kept in lines.iter().take(body_end).skip(body_start) {
179 out.push(substitute(kept, repo, tech));
180 }
181 idx = body_end;
182 } else {
183 idx = body_end;
184 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
187 idx += 1;
188 }
189 }
190 }
191 out.join("\n")
192}
193
194fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
197 let mut line = line.to_owned();
198 if let Some(slug) = repo {
199 line = line.replace("<repo>", slug);
200 }
201 if let Some(tech) = tech {
202 line = line.replace("<tech>", tech);
203 }
204 line
205}
206
207#[cfg(test)]
208mod tests {
209 use super::render;
210
211 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";
212
213 #[test]
215 fn an_unresolved_render_is_byte_identical() {
216 assert_eq!(render(DOC, None, None, None, None), DOC);
217 }
218
219 #[test]
223 fn a_resolved_render_selects_and_substitutes() {
224 let rendered = render(DOC, Some("github"), None, Some("acme/widget"), None);
225 assert!(rendered.contains("gh pr list --repo acme/widget"));
226 assert!(!rendered.contains("glab"));
227 assert!(!rendered.contains("On github:"));
228 assert!(!rendered.contains("<repo>"));
229 assert!(rendered.contains("<release pr>"));
230 let gitlab = render(DOC, Some("gitlab"), None, None, None);
231 assert!(gitlab.contains("glab mr list"));
232 assert!(!gitlab.contains("gh pr list"));
233 }
234
235 #[test]
237 fn a_paragraph_variant_renders() {
238 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
239 let rendered = render(doc, Some("gitlab"), None, None, None);
240 assert_eq!(rendered, "the request is replaced.\n\nend\n");
241 }
242
243 #[test]
248 fn a_pair_variant_selects_on_both_axes() {
249 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
250 let matched = render(doc, Some("gitlab"), Some("bash"), None, None);
251 assert!(matched.contains("cosign verify-blob-attestation"));
252 assert!(!matched.contains("no provenance surface"));
253 assert!(!matched.contains("On bash/gitlab:"));
254 let sibling = render(doc, Some("gitlab"), Some("rust"), None, None);
255 assert!(!sibling.contains("cosign"));
256 assert!(sibling.contains("no provenance surface."));
257 let open_axis = render(doc, Some("gitlab"), None, None, None);
258 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
259 }
260
261 #[test]
265 fn a_workflow_variant_selects_on_the_mode() {
266 let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
267 let worktree = render(doc, None, None, None, Some("worktree"));
268 assert!(worktree.contains("rk worktree add"));
269 assert!(!worktree.contains("gh pr checkout"));
270 let branches = render(doc, None, None, None, Some("branches"));
271 assert!(branches.contains("gh pr checkout"));
272 assert!(!branches.contains("rk worktree add"));
273 assert_eq!(
274 render(doc, None, None, None, None),
275 doc,
276 "an unresolved mode keeps every variant, label and all"
277 );
278 }
279
280 #[test]
282 fn a_resolved_tech_fills_the_placeholder() {
283 let doc = "rk init --tech <tech> --target .\n";
284 assert_eq!(
285 render(doc, None, Some("rust"), None, None),
286 "rk init --tech rust --target .\n"
287 );
288 assert_eq!(render(doc, None, None, None, None), doc);
289 }
290}