release_kit/commands/
guide.rs1use crate::cli::guide::GuideArgs;
11use crate::commands::walk;
12use crate::detect;
13use crate::embedded;
14use crate::error::RkError;
15use crate::output::Output;
16
17pub fn run(args: &GuideArgs) -> Result<(), RkError> {
25 let out = Output::human();
26 let entries = walk(&embedded::RUNBOOKS);
27 if args.list {
28 for (path, _) in &entries {
29 out.result_line(path.trim_end_matches(".md").to_ascii_lowercase());
30 }
31 return Ok(());
32 }
33 let Some(name) = args.name.as_deref() else {
34 return Err(RkError::Usage(
35 "name a runbook, or pass --list to see them".into(),
36 ));
37 };
38 let wanted = name.to_ascii_lowercase();
39 let wanted = wanted.trim_end_matches(".md");
40 let Some((_, contents)) = entries
41 .iter()
42 .find(|(path, _)| path.trim_end_matches(".md").eq_ignore_ascii_case(wanted))
43 else {
44 return Err(RkError::NotFound {
45 kind: "runbook",
46 name: name.to_owned(),
47 });
48 };
49 let text = String::from_utf8_lossy(contents);
50
51 let forge = match args.forge.as_deref() {
52 Some(value) => Some(
53 detect::Forge::parse(value)
54 .ok_or_else(|| {
55 RkError::Usage(format!(
56 "unknown forge '{value}'; the forges are: github, gitlab"
57 ))
58 })?
59 .as_str(),
60 ),
61 None => None,
62 };
63 let tech = match args.tech.as_deref() {
64 Some(value @ ("rust" | "python" | "bash")) => Some(value.to_owned()),
65 Some(other) => {
66 return Err(RkError::Usage(format!(
67 "unknown tech '{other}'; the bindings are: rust, python, bash"
68 )));
69 }
70 None => None,
71 };
72
73 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
74 let detected = detect::detect(&cwd);
75 let forge = forge.or_else(|| detected.forge.map(detect::Forge::as_str));
76 let tech = tech.or_else(|| detect::tech_of(&cwd).map(str::to_owned));
77 let repo = args.repo.clone().or(detected.repo);
78
79 let rendered = render(&text, forge, tech.as_deref(), repo.as_deref());
80 let unresolved = repo.is_none() && rendered.contains("<repo>");
81 out.result_raw(&rendered);
82 if unresolved {
83 out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
84 }
85 Ok(())
86}
87
88fn axis_of(selector: &str) -> Option<&'static str> {
92 if let Some((tech, forge)) = selector.split_once('/') {
93 return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
94 .then_some("pair");
95 }
96 match selector {
97 "github" | "gitlab" => Some("forge"),
98 "rust" | "python" | "bash" => Some("tech"),
99 _ => None,
100 }
101}
102
103fn label_of(line: &str) -> Option<&str> {
105 let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
106 axis_of(selector).map(|_| selector)
107}
108
109fn render(text: &str, forge: Option<&str>, tech: Option<&str>, repo: Option<&str>) -> String {
113 let lines: Vec<&str> = text.split('\n').collect();
114 let mut out: Vec<String> = Vec::with_capacity(lines.len());
115 let mut idx = 0;
116 while idx < lines.len() {
117 let line = lines[idx];
118 let Some(selector) = label_of(line) else {
119 out.push(substitute(line, repo, tech));
120 idx += 1;
121 continue;
122 };
123 let resolved = match axis_of(selector) {
124 Some("forge") => forge.map(str::to_owned),
125 Some("tech") => tech.map(str::to_owned),
126 Some("pair") => match (tech, forge) {
129 (Some(tech), Some(forge)) => Some(format!("{tech}/{forge}")),
130 _ => None,
131 },
132 _ => None,
133 };
134 let Some(resolved) = resolved else {
135 out.push(substitute(line, repo, tech));
136 idx += 1;
137 continue;
138 };
139 let body_start = idx + 2;
142 let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
143 lines[body_start + 1..]
144 .iter()
145 .position(|l| l.starts_with("```"))
146 .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
147 } else {
148 lines[body_start..]
149 .iter()
150 .position(|l| l.trim().is_empty())
151 .map_or(lines.len(), |offset| body_start + offset)
152 };
153 if selector == resolved {
154 for kept in lines.iter().take(body_end).skip(body_start) {
155 out.push(substitute(kept, repo, tech));
156 }
157 idx = body_end;
158 } else {
159 idx = body_end;
160 if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
163 idx += 1;
164 }
165 }
166 }
167 out.join("\n")
168}
169
170fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
173 let mut line = line.to_owned();
174 if let Some(slug) = repo {
175 line = line.replace("<repo>", slug);
176 }
177 if let Some(tech) = tech {
178 line = line.replace("<tech>", tech);
179 }
180 line
181}
182
183#[cfg(test)]
184mod tests {
185 use super::render;
186
187 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";
188
189 #[test]
191 fn an_unresolved_render_is_byte_identical() {
192 assert_eq!(render(DOC, None, None, None), DOC);
193 }
194
195 #[test]
199 fn a_resolved_render_selects_and_substitutes() {
200 let rendered = render(DOC, Some("github"), None, Some("acme/widget"));
201 assert!(rendered.contains("gh pr list --repo acme/widget"));
202 assert!(!rendered.contains("glab"));
203 assert!(!rendered.contains("On github:"));
204 assert!(!rendered.contains("<repo>"));
205 assert!(rendered.contains("<release pr>"));
206 let gitlab = render(DOC, Some("gitlab"), None, None);
207 assert!(gitlab.contains("glab mr list"));
208 assert!(!gitlab.contains("gh pr list"));
209 }
210
211 #[test]
213 fn a_paragraph_variant_renders() {
214 let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
215 let rendered = render(doc, Some("gitlab"), None, None);
216 assert_eq!(rendered, "the request is replaced.\n\nend\n");
217 }
218
219 #[test]
224 fn a_pair_variant_selects_on_both_axes() {
225 let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
226 let matched = render(doc, Some("gitlab"), Some("bash"), None);
227 assert!(matched.contains("cosign verify-blob-attestation"));
228 assert!(!matched.contains("no provenance surface"));
229 assert!(!matched.contains("On bash/gitlab:"));
230 let sibling = render(doc, Some("gitlab"), Some("rust"), None);
231 assert!(!sibling.contains("cosign"));
232 assert!(sibling.contains("no provenance surface."));
233 let open_axis = render(doc, Some("gitlab"), None, None);
234 assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
235 }
236
237 #[test]
239 fn a_resolved_tech_fills_the_placeholder() {
240 let doc = "rk init --tech <tech> --target .\n";
241 assert_eq!(
242 render(doc, None, Some("rust"), None),
243 "rk init --tech rust --target .\n"
244 );
245 assert_eq!(render(doc, None, None, None), doc);
246 }
247}