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, CheckoutMode, Style};
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.
25#[allow(
26    clippy::too_many_lines,
27    reason = "one pass resolves every runbook axis from the same three sources, and splitting it would separate an axis from the precedence it shares"
28)]
29pub fn run(args: &GuideArgs) -> Result<(), RkError> {
30    let out = Output::human();
31    let entries = walk(&embedded::RUNBOOKS);
32    if args.list {
33        for (path, _) in &entries {
34            out.result_line(path.trim_end_matches(".md").to_ascii_lowercase());
35        }
36        return Ok(());
37    }
38    let Some(name) = args.name.as_deref() else {
39        return Err(RkError::Usage(
40            "name a runbook, or pass --list to see them".into(),
41        ));
42    };
43    let wanted = name.to_ascii_lowercase();
44    let wanted = wanted.trim_end_matches(".md");
45    let Some((_, contents)) = entries
46        .iter()
47        .find(|(path, _)| path.trim_end_matches(".md").eq_ignore_ascii_case(wanted))
48    else {
49        return Err(RkError::NotFound {
50            kind: "runbook",
51            name: name.to_owned(),
52        });
53    };
54    let text = String::from_utf8_lossy(contents);
55
56    let forge = match args.forge.as_deref() {
57        Some(value) => Some(
58            detect::Forge::parse(value)
59                .ok_or_else(|| {
60                    RkError::Usage(format!(
61                        "unknown forge '{value}'; the forges are: github, gitlab"
62                    ))
63                })?
64                .as_str(),
65        ),
66        None => None,
67    };
68    let tech = match args.technology.as_deref() {
69        Some(value @ ("rust" | "python" | "bash")) => Some(value.to_owned()),
70        Some(other) => {
71            return Err(RkError::Usage(format!(
72                "unknown technology '{other}'; the bindings are: rust, python, bash"
73            )));
74        }
75        None => None,
76    };
77    let checkout_mode = args
78        .checkout_mode
79        .as_deref()
80        .map(CheckoutMode::parse)
81        .transpose()?;
82    let style = args
83        .release_style
84        .as_deref()
85        .map(Style::parse)
86        .transpose()?;
87
88    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
89    let config = crate::config::load(&cwd)?;
90    let detected = detect::detect(&cwd);
91    let record =
92        camino::Utf8Path::from_path(&cwd).and_then(|path| manifest::load(path).ok().flatten());
93    // An empty `profile.forge` is the committed statement that the project
94    // has no forge, so it answers the axis and no lower tier is consulted.
95    // Dropping it would let a remote or an older record re-introduce a
96    // forge the configuration ruled out.
97    let configured_forge = config.as_ref().and_then(|c| c.profile.forge.clone());
98    let recorded_forge = record.as_ref().and_then(|r| r.profile.forge.clone());
99    let forge = match (forge, configured_forge) {
100        (Some(flag), _) => Forge::Value(flag.to_owned()),
101        (None, Some(configured)) if configured.is_empty() => Forge::Absent,
102        (None, Some(configured)) => Forge::Value(configured),
103        (None, None) => recorded_forge
104            .or_else(|| {
105                detected
106                    .forge
107                    .map(|forge| detect::Forge::as_str(forge).to_owned())
108            })
109            .map_or(Forge::Unresolved, Forge::Value),
110    };
111    let forge = match forge {
112        Forge::Value(named) => detect::Forge::parse(&named)
113            .map(detect::Forge::as_str)
114            .map_or(Forge::Unresolved, |name| Forge::Value(name.to_owned())),
115        other => other,
116    };
117    // The technology axis is the release driver: the configured or
118    // recorded driver, then the first release-bearing version file.
119    let tech = tech
120        .or_else(|| {
121            config
122                .as_ref()
123                .and_then(|c| c.profile.release.driver.clone())
124        })
125        .or_else(|| {
126            record
127                .as_ref()
128                .and_then(|r| r.profile.release.driver.clone())
129        })
130        .or_else(|| detect::tech_of(&cwd).map(str::to_owned));
131    let repo = args
132        .repo
133        .clone()
134        .or_else(|| {
135            config
136                .as_ref()
137                .map(|c| c.project.repo.clone())
138                .filter(|v| !v.is_empty())
139        })
140        .or_else(|| {
141            record
142                .as_ref()
143                .map(|r| r.parameters.repo.clone())
144                .filter(|v| !v.is_empty())
145        })
146        .or(detected.repo);
147    // The checkout mode resolves from the configuration and the landing
148    // record — a committed project decision, not a detection guess — and
149    // stays open where neither exists, the honest pre-landing fallback.
150    let checkout_mode = checkout_mode
151        .or_else(|| config.as_ref().and_then(|c| c.git.checkout_mode))
152        .or_else(|| record.as_ref().map(|record| record.git.checkout_mode));
153    // The style axis resolves the same way: a committed project decision,
154    // open where no record exists.
155    let style = style
156        .or_else(|| config.as_ref().and_then(|c| c.profile.release.style))
157        .or_else(|| {
158            record
159                .as_ref()
160                .and_then(|record| record.profile.release.style)
161        });
162
163    let rendered = render(
164        &text,
165        &forge,
166        tech.as_deref(),
167        repo.as_deref(),
168        checkout_mode.map(CheckoutMode::runbook_label),
169        style.map(Style::as_str),
170    );
171    let unresolved = repo.is_none() && rendered.contains("<repo>");
172    out.result_raw(&rendered);
173    if unresolved {
174        out.frame("note: <repo> is unresolved; pass --repo <owner/name> to fill it");
175    }
176    Ok(())
177}
178
179/// Which axis a variant label selects on. A `tech/forge` pair selects on
180/// both at once, for the steps whose answer differs per pair rather than
181/// per axis — the provenance verifier is one.
182fn axis_of(selector: &str) -> Option<&'static str> {
183    if let Some((tech, forge)) = selector.split_once('/') {
184        return (axis_of(tech) == Some("tech") && axis_of(forge) == Some("forge"))
185            .then_some("pair");
186    }
187    match selector {
188        "github" | "gitlab" => Some("forge"),
189        "rust" | "python" | "bash" => Some("tech"),
190        "worktree" | "branches" => Some("workflow"),
191        "trunk" | "lines" => Some("style"),
192        _ => None,
193    }
194}
195
196/// What the forge axis knows, which is three states rather than two.
197///
198/// A project that states no forge has answered the axis. Collapsing that
199/// into the same value as an unanswered one would print both forges'
200/// instructions to an operator whose configuration ruled both out, which
201/// is the opposite of what the committed answer asked for.
202#[derive(Debug, Clone, PartialEq, Eq)]
203enum Forge {
204    /// Nothing has answered yet: every variant stays, label and all.
205    Unresolved,
206    /// The project has no forge: no forge variant belongs in the text.
207    Absent,
208    /// The named forge.
209    Value(String),
210}
211
212impl Forge {
213    /// The selector a variant must carry to survive, or `None` where the
214    /// axis is open. An absent forge matches no forge this binary knows,
215    /// so every forge and pair variant drops.
216    fn selector(&self) -> Option<String> {
217        match self {
218            Self::Unresolved => None,
219            Self::Absent => Some(String::new()),
220            Self::Value(named) => Some(named.clone()),
221        }
222    }
223}
224
225/// The selector of a variant label line, `On <selector>:`.
226fn label_of(line: &str) -> Option<&str> {
227    let selector = line.strip_prefix("On ")?.strip_suffix(":")?;
228    axis_of(selector).map(|_| selector)
229}
230
231/// Render one runbook: keep the matching variant of every resolved axis and
232/// drop its siblings, substitute `<repo>` and `<tech>` where they are known,
233/// and leave everything else byte-identical.
234fn render(
235    text: &str,
236    forge: &Forge,
237    tech: Option<&str>,
238    repo: Option<&str>,
239    workflow: Option<&str>,
240    style: Option<&str>,
241) -> String {
242    let lines: Vec<&str> = text.split('\n').collect();
243    let mut out: Vec<String> = Vec::with_capacity(lines.len());
244    let mut idx = 0;
245    while idx < lines.len() {
246        let line = lines[idx];
247        let Some(selector) = label_of(line) else {
248            out.push(substitute(line, repo, tech));
249            idx += 1;
250            continue;
251        };
252        let resolved = match axis_of(selector) {
253            Some("forge") => forge.selector(),
254            Some("tech") => tech.map(str::to_owned),
255            Some("workflow") => workflow.map(str::to_owned),
256            Some("style") => style.map(str::to_owned),
257            // A pair resolves only once both halves have: with either axis
258            // open, every pair variant stays visible, label and all. A
259            // forge the project states it does not have is not an open
260            // axis, though, and no pair variant belongs in that text.
261            Some("pair") => match (tech, forge) {
262                (_, Forge::Absent) => Some(String::new()),
263                (Some(tech), Forge::Value(forge)) => Some(format!("{tech}/{forge}")),
264                _ => None,
265            },
266            _ => None,
267        };
268        let Some(resolved) = resolved else {
269            out.push(substitute(line, repo, tech));
270            idx += 1;
271            continue;
272        };
273        // The variant grammar: the label line, one blank line, then one
274        // fenced block or one paragraph.
275        let body_start = idx + 2;
276        let body_end = if lines.get(body_start).is_some_and(|l| l.starts_with("```")) {
277            lines[body_start + 1..]
278                .iter()
279                .position(|l| l.starts_with("```"))
280                .map_or(lines.len(), |offset| body_start + 1 + offset + 1)
281        } else {
282            lines[body_start..]
283                .iter()
284                .position(|l| l.trim().is_empty())
285                .map_or(lines.len(), |offset| body_start + offset)
286        };
287        if selector == resolved {
288            for kept in lines.iter().take(body_end).skip(body_start) {
289                out.push(substitute(kept, repo, tech));
290            }
291            idx = body_end;
292        } else {
293            idx = body_end;
294            // Swallow one following blank line, so a dropped variant does
295            // not leave a double gap.
296            if lines.get(idx).is_some_and(|l| l.trim().is_empty()) {
297                idx += 1;
298            }
299        }
300    }
301    out.join("\n")
302}
303
304/// Fill `<repo>` and `<tech>` where detection or a flag resolved them;
305/// everything else stays a placeholder.
306fn substitute(line: &str, repo: Option<&str>, tech: Option<&str>) -> String {
307    let mut line = line.to_owned();
308    if let Some(slug) = repo {
309        line = line.replace("<repo>", slug);
310    }
311    if let Some(tech) = tech {
312        line = line.replace("<tech>", tech);
313    }
314    line
315}
316
317#[cfg(test)]
318mod tests {
319    use super::{Forge, render};
320
321    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";
322
323    /// Nothing resolved: the output is byte-identical to the source.
324    #[test]
325    fn an_unresolved_render_is_byte_identical() {
326        assert_eq!(render(DOC, &Forge::Unresolved, None, None, None, None), DOC);
327    }
328
329    /// A resolved forge keeps its variant, drops the sibling and both
330    /// labels, and a resolved repo fills `<repo>` while `<release pr>`
331    /// stays a placeholder.
332    #[test]
333    fn a_resolved_render_selects_and_substitutes() {
334        let rendered = render(
335            DOC,
336            &Forge::Value("github".to_owned()),
337            None,
338            Some("acme/widget"),
339            None,
340            None,
341        );
342        assert!(rendered.contains("gh pr list --repo acme/widget"));
343        assert!(!rendered.contains("glab"));
344        assert!(!rendered.contains("On github:"));
345        assert!(!rendered.contains("<repo>"));
346        assert!(rendered.contains("<release pr>"));
347        let gitlab = render(
348            DOC,
349            &Forge::Value("gitlab".to_owned()),
350            None,
351            None,
352            None,
353            None,
354        );
355        assert!(gitlab.contains("glab mr list"));
356        assert!(!gitlab.contains("gh pr list"));
357    }
358
359    /// A paragraph variant is selected the same way a fenced one is.
360    #[test]
361    fn a_paragraph_variant_renders() {
362        let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
363        let rendered = render(
364            doc,
365            &Forge::Value("gitlab".to_owned()),
366            None,
367            None,
368            None,
369            None,
370        );
371        assert_eq!(rendered, "the request is replaced.\n\nend\n");
372    }
373
374    /// A pair variant renders only for its exact pair, drops for every
375    /// other resolved pair, and stays visible — label and all — while
376    /// either axis is open, so an unresolved render still shows every
377    /// pair's answer.
378    #[test]
379    fn a_pair_variant_selects_on_both_axes() {
380        let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
381        let matched = render(
382            doc,
383            &Forge::Value("gitlab".to_owned()),
384            Some("bash"),
385            None,
386            None,
387            None,
388        );
389        assert!(matched.contains("cosign verify-blob-attestation"));
390        assert!(!matched.contains("no provenance surface"));
391        assert!(!matched.contains("On bash/gitlab:"));
392        let sibling = render(
393            doc,
394            &Forge::Value("gitlab".to_owned()),
395            Some("rust"),
396            None,
397            None,
398            None,
399        );
400        assert!(!sibling.contains("cosign"));
401        assert!(sibling.contains("no provenance surface."));
402        let open_axis = render(
403            doc,
404            &Forge::Value("gitlab".to_owned()),
405            None,
406            None,
407            None,
408            None,
409        );
410        assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
411    }
412
413    /// The workflow axis renders like the others: resolved, the matching
414    /// variant is kept and its sibling dropped; open, every variant
415    /// prints with its label.
416    #[test]
417    fn a_workflow_variant_selects_on_the_mode() {
418        let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
419        let worktree = render(doc, &Forge::Unresolved, None, None, Some("worktree"), None);
420        assert!(worktree.contains("rk worktree add"));
421        assert!(!worktree.contains("gh pr checkout"));
422        let branches = render(doc, &Forge::Unresolved, None, None, Some("branches"), None);
423        assert!(branches.contains("gh pr checkout"));
424        assert!(!branches.contains("rk worktree add"));
425        assert_eq!(
426            render(doc, &Forge::Unresolved, None, None, None, None),
427            doc,
428            "an unresolved mode keeps every variant, label and all"
429        );
430    }
431
432    /// The style axis renders like the workflow axis: resolved, the
433    /// matching variant is kept and its sibling dropped; open, every
434    /// variant prints with its label.
435    #[test]
436    fn a_style_variant_selects_on_the_style() {
437        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";
438        let trunk = render(doc, &Forge::Unresolved, None, None, None, Some("trunk"));
439        assert!(trunk.contains("merges itself"));
440        assert!(!trunk.contains("the merge is yours"));
441        let lines = render(doc, &Forge::Unresolved, None, None, None, Some("lines"));
442        assert!(lines.contains("the merge is yours"));
443        assert!(!lines.contains("merges itself"));
444        assert_eq!(
445            render(doc, &Forge::Unresolved, None, None, None, None),
446            doc,
447            "an unresolved style keeps every variant, label and all"
448        );
449    }
450
451    /// A resolved tech fills `<tech>` everywhere; unresolved it stays.
452    #[test]
453    fn a_resolved_tech_fills_the_placeholder() {
454        let doc = "rk init --tech <tech> --target .\n";
455        assert_eq!(
456            render(doc, &Forge::Unresolved, Some("rust"), None, None, None),
457            "rk init --tech rust --target .\n"
458        );
459        assert_eq!(render(doc, &Forge::Unresolved, None, None, None, None), doc);
460    }
461}