Skip to main content

usage/docs/markdown/
config.rs

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