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