Skip to main content

release_kit/commands/
guide.rs

1//! `rk guide`: print a runbook with what detection knows filled in.
2//!
3//! The line between substituted and not is honesty, not convenience: a
4//! value detection resolved — the project path, the forge, the technology —
5//! is filled in, and a value `rk` would have to guess stays a placeholder.
6//! `<release pr>` and its siblings exist only once a bot has opened them; a
7//! substituted-but-stale number merges someone else's work, where a visible
8//! placeholder fails loudly.
9
10use 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
18/// Print one runbook, or list them.
19///
20/// # Errors
21///
22/// Returns [`RkError::NotFound`] for an unknown runbook and
23/// [`RkError::Usage`] when neither a name nor `--list` is given, or a flag
24/// value is not one of the known axes.
25pub 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    // The workflow axis resolves from the landing record — the mode is a
106    // committed project decision, not a detection guess — and stays open
107    // where no record exists, the honest pre-landing fallback.
108    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    // The style axis resolves the same way: a committed project decision,
114    // open where no record exists.
115    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
135/// Which axis a variant label selects on. A `tech/forge` pair selects on
136/// both at once, for the steps whose answer differs per pair rather than
137/// per axis — the provenance verifier is one.
138fn 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
152/// The selector of a variant label line, `On <selector>:`.
153fn label_of(line: &str) -> Option<&str> {
154    let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
155    axis_of(selector).map(|_| selector)
156}
157
158/// Render one runbook: keep the matching variant of every resolved axis and
159/// drop its siblings, substitute `<repo>` and `<tech>` where they are known,
160/// and leave everything else byte-identical.
161fn 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            // A pair resolves only once both halves have: with either axis
185            // open, every pair variant stays visible, label and all.
186            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        // The variant grammar: the label line, one blank line, then one
198        // fenced block or one paragraph.
199        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            // Swallow one following blank line, so a dropped variant does
219            // not leave a double gap.
220            if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
221                idx += 1;
222            }
223        }
224    }
225    out.join("\n")
226}
227
228/// Fill `<repo>` and `<tech>` where detection or a flag resolved them;
229/// everything else stays a placeholder.
230fn 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    /// Nothing resolved: the output is byte-identical to the source.
248    #[test]
249    fn an_unresolved_render_is_byte_identical() {
250        assert_eq!(render(DOC, None, None, None, None, None), DOC);
251    }
252
253    /// A resolved forge keeps its variant, drops the sibling and both
254    /// labels, and a resolved repo fills `<repo>` while `<release pr>`
255    /// stays a placeholder.
256    #[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    /// A paragraph variant is selected the same way a fenced one is.
270    #[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    /// A pair variant renders only for its exact pair, drops for every
278    /// other resolved pair, and stays visible — label and all — while
279    /// either axis is open, so an unresolved render still shows every
280    /// pair's answer.
281    #[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    /// The workflow axis renders like the others: resolved, the matching
296    /// variant is kept and its sibling dropped; open, every variant
297    /// prints with its label.
298    #[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    /// The style axis renders like the workflow axis: resolved, the
315    /// matching variant is kept and its sibling dropped; open, every
316    /// variant prints with its label.
317    #[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    /// A resolved tech fills `<tech>` everywhere; unresolved it stays.
334    #[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}