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    /// The setup runbook's protected-path proof follows the integration
381    /// mode alone: a GitHub target that integrates locally reads the local
382    /// push and no request merge.
383    #[test]
384    fn the_setup_proof_on_github_and_local_names_no_request_merge() {
385        let text = std::fs::read_to_string(
386            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("runbooks/setup.md"),
387        )
388        .expect("the setup runbook reads");
389        let render_for = |integration: &str| {
390            render(
391                &text,
392                &Forge::Value("github".to_owned()),
393                None,
394                None,
395                None,
396                None,
397                Some(integration),
398            )
399        };
400        let local = render_for("local");
401        let proof = &local
402            [local.find("### 7a.").expect("step 7a")..local.find("### 7b.").expect("step 7b")];
403        assert!(proof.contains("git push origin master"), "{proof}");
404        assert!(!proof.contains("gh pr merge"), "{proof}");
405        assert!(!proof.contains("--forge"), "{proof}");
406        let forge = render_for("forge");
407        let proof = &forge
408            [forge.find("### 7a.").expect("step 7a")..forge.find("### 7b.").expect("step 7b")];
409        assert!(
410            proof.contains("rk integrate <type>/<slug> --forge --apply"),
411            "{proof}"
412        );
413        assert!(!proof.contains("git push origin master"), "{proof}");
414    }
415
416    /// A paragraph variant is selected the same way a fenced one is.
417    #[test]
418    fn a_paragraph_variant_renders() {
419        let doc = "On github:\n\nthe force-push refresh survives.\n\nOn gitlab:\n\nthe request is replaced.\n\nend\n";
420        let rendered = render(
421            doc,
422            &Forge::Value("gitlab".to_owned()),
423            None,
424            None,
425            None,
426            None,
427            None,
428        );
429        assert_eq!(rendered, "the request is replaced.\n\nend\n");
430    }
431
432    /// A pair variant renders only for its exact pair, drops for every
433    /// other resolved pair, and stays visible — label and all — while
434    /// either axis is open, so an unresolved render still shows every
435    /// pair's answer.
436    #[test]
437    fn a_pair_variant_selects_on_both_axes() {
438        let doc = "On bash/gitlab:\n\n```bash\ncosign verify-blob-attestation\n```\n\nOn rust/gitlab:\n\nno provenance surface.\n\nend\n";
439        let matched = render(
440            doc,
441            &Forge::Value("gitlab".to_owned()),
442            Some("bash"),
443            None,
444            None,
445            None,
446            None,
447        );
448        assert!(matched.contains("cosign verify-blob-attestation"));
449        assert!(!matched.contains("no provenance surface"));
450        assert!(!matched.contains("On bash/gitlab:"));
451        let sibling = render(
452            doc,
453            &Forge::Value("gitlab".to_owned()),
454            Some("rust"),
455            None,
456            None,
457            None,
458            None,
459        );
460        assert!(!sibling.contains("cosign"));
461        assert!(sibling.contains("no provenance surface."));
462        let open_axis = render(
463            doc,
464            &Forge::Value("gitlab".to_owned()),
465            None,
466            None,
467            None,
468            None,
469            None,
470        );
471        assert_eq!(open_axis, doc, "an open axis keeps every pair variant");
472    }
473
474    /// The workflow axis renders like the others: resolved, the matching
475    /// variant is kept and its sibling dropped; open, every variant
476    /// prints with its label.
477    #[test]
478    fn a_workflow_variant_selects_on_the_mode() {
479        let doc = "On worktree:\n\nrk worktree add release-branch --apply\n\nOn branches:\n\ngh pr checkout 7\n\nend\n";
480        let worktree = render(
481            doc,
482            &Forge::Unresolved,
483            None,
484            None,
485            Some("worktree"),
486            None,
487            None,
488        );
489        assert!(worktree.contains("rk worktree add"));
490        assert!(!worktree.contains("gh pr checkout"));
491        let branches = render(
492            doc,
493            &Forge::Unresolved,
494            None,
495            None,
496            Some("branches"),
497            None,
498            None,
499        );
500        assert!(branches.contains("gh pr checkout"));
501        assert!(!branches.contains("rk worktree add"));
502        assert_eq!(
503            render(doc, &Forge::Unresolved, None, None, None, None, None),
504            doc,
505            "an unresolved mode keeps every variant, label and all"
506        );
507    }
508
509    /// The style axis renders like the workflow axis: resolved, the
510    /// matching variant is kept and its sibling dropped; open, every
511    /// variant prints with its label.
512    #[test]
513    fn a_style_variant_selects_on_the_style() {
514        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";
515        let trunk = render(
516            doc,
517            &Forge::Unresolved,
518            None,
519            None,
520            None,
521            Some("trunk"),
522            None,
523        );
524        assert!(trunk.contains("merges itself"));
525        assert!(!trunk.contains("the merge is yours"));
526        let lines = render(
527            doc,
528            &Forge::Unresolved,
529            None,
530            None,
531            None,
532            Some("lines"),
533            None,
534        );
535        assert!(lines.contains("the merge is yours"));
536        assert!(!lines.contains("merges itself"));
537        assert_eq!(
538            render(doc, &Forge::Unresolved, None, None, None, None, None),
539            doc,
540            "an unresolved style keeps every variant, label and all"
541        );
542    }
543
544    /// A resolved tech fills `<tech>` everywhere; unresolved it stays.
545    #[test]
546    fn a_resolved_tech_fills_the_placeholder() {
547        let doc = "rk init --tech <tech> --target .\n";
548        assert_eq!(
549            render(
550                doc,
551                &Forge::Unresolved,
552                Some("rust"),
553                None,
554                None,
555                None,
556                None
557            ),
558            "rk init --tech rust --target .\n"
559        );
560        assert_eq!(
561            render(doc, &Forge::Unresolved, None, None, None, None, None),
562            doc
563        );
564    }
565}