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::output::Output;
16
17/// Print one runbook, or list them.
18///
19/// # Errors
20///
21/// Returns [`RkError::NotFound`] for an unknown runbook and
22/// [`RkError::Usage`] when neither a name nor `--list` is given, or a flag
23/// value is not one of the known axes.
24pub 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
88/// Which axis a variant label selects on. A `tech/forge` pair selects on
89/// both at once, for the steps whose answer differs per pair rather than
90/// per axis — the provenance verifier is one.
91fn 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
103/// The selector of a variant label line, `On <selector>:`.
104fn label_of(line: &str) -> Option<&str> {
105    let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
106    axis_of(selector).map(|_| selector)
107}
108
109/// Render one runbook: keep the matching variant of every resolved axis and
110/// drop its siblings, substitute `<repo>` and `<tech>` where they are known,
111/// and leave everything else byte-identical.
112fn 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            // A pair resolves only once both halves have: with either axis
127            // open, every pair variant stays visible, label and all.
128            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        // The variant grammar: the label line, one blank line, then one
140        // fenced block or one paragraph.
141        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            // Swallow one following blank line, so a dropped variant does
161            // not leave a double gap.
162            if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
163                idx += 1;
164            }
165        }
166    }
167    out.join("\n")
168}
169
170/// Fill `<repo>` and `<tech>` where detection or a flag resolved them;
171/// everything else stays a placeholder.
172fn 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    /// Nothing resolved: the output is byte-identical to the source.
190    #[test]
191    fn an_unresolved_render_is_byte_identical() {
192        assert_eq!(render(DOC, None, None, None), DOC);
193    }
194
195    /// A resolved forge keeps its variant, drops the sibling and both
196    /// labels, and a resolved repo fills `<repo>` while `<release pr>`
197    /// stays a placeholder.
198    #[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    /// A paragraph variant is selected the same way a fenced one is.
212    #[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    /// A pair variant renders only for its exact pair, drops for every
220    /// other resolved pair, and stays visible — label and all — while
221    /// either axis is open, so an unresolved render still shows every
222    /// pair's answer.
223    #[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    /// A resolved tech fills `<tech>` everywhere; unresolved it stays.
238    #[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}