Skip to main content

usage/docs/markdown/
config.rs

1use crate::docs::markdown::renderer::MarkdownRenderer;
2use crate::docs::models::SpecConfig;
3use crate::error::UsageErr;
4
5impl MarkdownRenderer {
6    /// The settings reference for a spec's `config` block.
7    ///
8    /// Empty string when there is nothing to say, so a caller can concatenate it without
9    /// checking — a CLI with no settings should not grow a blank section.
10    pub fn render_config(&self) -> Result<String, UsageErr> {
11        // From the raw block, not from `self.spec.config`: that one was rendered by `new`
12        // before the builder options were set, and rendering is once-only, so taking it would
13        // silently ignore `replace_pre_with_code_fences`. Same reason `render_cmd` converts
14        // from the raw command it is given.
15        let mut config = SpecConfig::from(&self.raw_config);
16        if config.is_empty() {
17            return Ok(String::new());
18        }
19        config.render_md(self);
20        self.render_with("config_template.md.tera", |ctx| {
21            ctx.insert("config", &config)
22        })
23    }
24}
25
26#[cfg(test)]
27mod tests {
28    use crate::docs::markdown::renderer::MarkdownRenderer;
29    use insta::assert_snapshot;
30
31    fn rendered(src: &str) -> String {
32        let spec: crate::Spec = src.parse().unwrap();
33        MarkdownRenderer::new(spec)
34            .with_replace_pre_with_code_fences(true)
35            .render_config()
36            .unwrap()
37    }
38
39    #[test]
40    fn a_cli_with_no_settings_renders_nothing() {
41        assert_eq!(rendered("name \"ex\"\nbin \"ex\"\n"), "");
42    }
43
44    #[test]
45    fn explicit_optionality_and_aliases_are_documented() {
46        let page = rendered(
47            r#"
48name "ex"
49bin "ex"
50config {
51    prop "jobs" type="uint" optional=#false {
52        alias "parallelism" "threads"
53    }
54}
55"#,
56        );
57        assert!(
58            page.contains("**aliases**: `parallelism`, `threads`"),
59            "{page}"
60        );
61        assert!(page.contains("**optional**: false"), "{page}");
62    }
63
64    #[test]
65    fn the_facts_list_starts_on_its_own_line_whatever_its_first_item_is() {
66        // The blank line that opens the list was emitted inside the `type_` branch, so a prop
67        // with a default and no declared type put its first list item straight against the
68        // heading — and a deprecated one put it against the admonition's closing `:::`, where
69        // some renderers read it as part of the admonition rather than as a list.
70        let page = rendered(
71            r##"
72name "ex"
73bin "ex"
74config {
75    prop "untyped" default=1 help="No declared type"
76    prop "gone" default=2 deprecated="Use untyped." help="Also no declared type"
77}
78"##,
79        );
80        assert!(
81            page.contains("## `untyped`\n\n- **default**: `1`"),
82            "the list item is against the heading:\n{page:?}"
83        );
84        assert!(
85            page.contains(":::\n\n- **default**: `2`"),
86            "the list item is against the admonition:\n{page:?}"
87        );
88    }
89
90    #[test]
91    fn the_settings_section_sits_under_the_title_on_the_single_file_page() {
92        // Two things were wrong here at once, and the second hid the first: `{%- include %}`
93        // stripped the blank line before the section, so its heading was glued onto the last
94        // line of the command above it — `Run# Configuration` — and a `header_level` decrement
95        // put that heading at level 1, beside the document's own title.
96        let spec: crate::Spec = r##"
97name "ex"
98bin "ex"
99config {
100    prop "jobs" type="uint" help="How many"
101}
102cmd "run" help="Run"
103"##
104        .parse()
105        .unwrap();
106        let page = MarkdownRenderer::new(spec).render_spec().unwrap();
107        assert!(
108            page.contains("\n\n## Configuration\n"),
109            "the heading is glued to the line above it, or at the wrong level:\n{page}"
110        );
111        // And the settings sit below it, a level deeper than the commands' own level.
112        assert!(page.contains("### `jobs`"), "{page}");
113    }
114
115    #[test]
116    fn a_setting_s_long_help_gets_the_rendering_options_it_was_asked_for() {
117        // `MarkdownRenderer::new` renders the whole docs model eagerly — before the builder
118        // methods that set the options — and rendering marks each item done, so a second pass
119        // no-ops. Taking the already-rendered config meant `replace_pre_with_code_fences` did
120        // nothing at all to a setting's long help, silently, while doing its job everywhere
121        // else on the same page.
122        let src = r##"
123name "ex"
124bin "ex"
125config {
126    prop "shell" help="Which shell" {
127        long_help "Run it like this:\n\n    ex --shell bash\n"
128    }
129}
130"##;
131        let spec: crate::Spec = src.parse().unwrap();
132        let with_fences = MarkdownRenderer::new(spec.clone())
133            .with_replace_pre_with_code_fences(true)
134            .render_config()
135            .unwrap();
136        assert!(
137            with_fences.contains("```"),
138            "the option did not reach the setting's help:\n{with_fences}"
139        );
140        // And without it the block stays indented, so the assertion above is about the option
141        // rather than about something else in the pipeline.
142        let without = MarkdownRenderer::new(spec.clone()).render_config().unwrap();
143        assert!(!without.contains("```"), "{without}");
144        assert!(without.contains("    ex --shell bash"), "{without}");
145
146        // The single-file page renders the same model by its own path, and had the same bug.
147        let whole = MarkdownRenderer::new(spec)
148            .with_replace_pre_with_code_fences(true)
149            .render_spec()
150            .unwrap();
151        assert!(
152            whole.contains("```"),
153            "the option did not reach the settings section of the whole-spec page:\n{whole}"
154        );
155    }
156
157    #[test]
158    fn the_index_links_the_settings_page_beside_it() {
159        // `--multi` writes settings.md next to index.md, and a reader who starts at the
160        // index — which is what an index is for — has to be able to get there.
161        let with_settings: crate::Spec = r##"
162name "ex"
163bin "ex"
164config {
165    prop "jobs" type="uint"
166}
167cmd "run" help="Run"
168"##
169        .parse()
170        .unwrap();
171        let renderer = MarkdownRenderer::new(with_settings);
172        let index = renderer.render_index().unwrap();
173        assert!(
174            index.contains(&format!("[Settings](/{})", renderer.config_page())),
175            "{index}"
176        );
177
178        // The page name does not move when a `settings` command appears — mise has exactly that
179        // command, and `settings.md` would have been its page. A name that switched would leave
180        // the abandoned one behind as a stale page on the next run.
181        let collides: crate::Spec = r##"
182name "ex"
183bin "ex"
184config {
185    prop "jobs" type="uint"
186}
187cmd "settings" help="Manage settings"
188"##
189        .parse()
190        .unwrap();
191        let renderer = MarkdownRenderer::new(collides);
192        assert_eq!(renderer.config_page(), "configuration.md");
193        assert_eq!(renderer.config_page_collision(), None);
194
195        // And the one collision left is reported rather than silent.
196        let clash: crate::Spec = r##"
197name "ex"
198bin "ex"
199config {
200    prop "jobs" type="uint"
201}
202cmd "configuration" help="Somebody really did this"
203"##
204        .parse()
205        .unwrap();
206        assert_eq!(
207            MarkdownRenderer::new(clash).config_page_collision(),
208            Some("configuration")
209        );
210
211        // And a CLI with no settings gets no link, because there is no page: the two are
212        // gated on the same condition so the index cannot point at a file nothing wrote.
213        let without: crate::Spec = "name \"ex\"\nbin \"ex\"\ncmd \"run\" help=\"Run\"\n"
214            .parse()
215            .unwrap();
216        let renderer = MarkdownRenderer::new(without);
217        let index = renderer.render_index().unwrap();
218        // Against the name the page is actually written under, not a name nothing uses any
219        // more: asserting the absence of `settings.md` passed happily while a broken gate
220        // emitted a link to `configuration.md`.
221        assert!(
222            !index.contains(&format!("[Settings](/{}", renderer.config_page())),
223            "{index}"
224        );
225    }
226
227    #[test]
228    fn a_block_of_only_files_reaches_every_output() {
229        // Where the config files live is the part a reader cannot guess, and a CLI may
230        // describe the chain before it declares its first setting. Three output paths render
231        // this model — its own page, the single-file page, and the manpage — and each had its
232        // own idea of when there was something to render: two gated on props, so the same
233        // spec documented its files in one place and not the others.
234        let src = r##"
235name "ex"
236bin "ex"
237config {
238    file "/etc/ex/config.toml" scope="system"
239    file "ex.toml" findup=#true
240}
241"##;
242        let spec: crate::Spec = src.parse().unwrap();
243        let renderer = MarkdownRenderer::new(spec);
244        let page = renderer.render_config().unwrap();
245        assert!(page.contains("ex.toml"), "{page}");
246        let whole = renderer.render_spec().unwrap();
247        assert!(
248            whole.contains("ex.toml"),
249            "the single-file page dropped the file chain:\n{whole}"
250        );
251    }
252
253    #[test]
254    fn every_part_of_a_prop_reaches_the_page() {
255        assert_snapshot!(rendered(
256            r##"
257name "hk"
258bin "hk"
259config {
260    source "git" name="git config" doc_hint="git config `{key}`"
261    file "~/.config/hk/config.toml" scope="global"
262    file "hk.toml" findup=#true
263    prop "jobs" type="uint" default=0 default_note="0 = auto-detect" \
264        help="Number of parallel jobs" since="1.0.0" help_heading="Performance" {
265        cli "--jobs" "-j"
266        env "HK_JOBS" "HK_JOB"
267        source "git" "hk.jobs"
268        example "hk check --jobs 4"
269    }
270    prop "exclude" type="list<string>" merge="union" help="Patterns to skip" {
271        default "target" "node_modules"
272        env "HK_EXCLUDE"
273    }
274    prop "stash" type="string" help="How to stash" {
275        choices {
276            choice "git" help="Use `git stash`"
277            choice "none" help="No stashing"
278        }
279    }
280    prop "trusted" type="bool" scope="global" help="Trust the config"
281    prop "old" deprecated="Use jobs instead." deprecated_remove_at="2027.12.0" help="Old"
282    prop "secret" hide=#true help="Not for the page"
283}
284"##
285        ));
286    }
287}