Skip to main content

rumdl_lib/rules/
md014_commands_show_output.rs

1//!
2//! Rule MD014: Commands should show output
3//!
4//! See [docs/md014.md](../../docs/md014.md) for full documentation, configuration, and examples.
5
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::utils::range_utils::calculate_match_range;
9use crate::utils::regex_cache::get_cached_regex;
10use toml;
11
12mod md014_config;
13use md014_config::MD014Config;
14
15// Command detection patterns
16const COMMAND_PATTERN: &str = r"^\s*[$>]\s+\S+";
17const SHELL_LANG_PATTERN: &str = r"^(?i)(bash|sh|shell|console|terminal)";
18const DOLLAR_PROMPT_PATTERN: &str = r"^\s*([$>])";
19
20#[derive(Clone, Default)]
21pub struct MD014CommandsShowOutput {
22    config: MD014Config,
23}
24
25impl MD014CommandsShowOutput {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    pub fn with_show_output(show_output: bool) -> Self {
31        Self {
32            config: MD014Config { show_output },
33        }
34    }
35
36    pub fn from_config_struct(config: MD014Config) -> Self {
37        Self { config }
38    }
39
40    fn is_command_line(&self, line: &str) -> bool {
41        get_cached_regex(COMMAND_PATTERN)
42            .map(|re| re.is_match(line))
43            .unwrap_or(false)
44    }
45
46    fn is_shell_language(&self, lang: &str) -> bool {
47        get_cached_regex(SHELL_LANG_PATTERN)
48            .map(|re| re.is_match(lang))
49            .unwrap_or(false)
50    }
51
52    fn is_output_line(&self, line: &str) -> bool {
53        let trimmed = line.trim();
54        !trimmed.is_empty() && !trimmed.starts_with('$') && !trimmed.starts_with('>') && !trimmed.starts_with('#')
55    }
56
57    fn is_no_output_command(&self, cmd: &str) -> bool {
58        let cmd = cmd.trim().to_lowercase();
59
60        // Only skip commands that produce NO output by design.
61        // Commands that produce output (even if verbose) should NOT be skipped -
62        // the rule's intent is to encourage showing output when using $ prompts.
63
64        // Shell built-ins and commands that produce no terminal output
65        cmd.starts_with("cd ")
66            || cmd == "cd"
67            || cmd.starts_with("mkdir ")
68            || cmd.starts_with("touch ")
69            || cmd.starts_with("rm ")
70            || cmd.starts_with("mv ")
71            || cmd.starts_with("cp ")
72            || cmd.starts_with("export ")
73            || cmd.starts_with("set ")
74            || cmd.starts_with("alias ")
75            || cmd.starts_with("unset ")
76            || cmd.starts_with("source ")
77            || cmd.starts_with(". ")
78            || cmd == "true"
79            || cmd == "false"
80            || cmd.starts_with("sleep ")
81            || cmd.starts_with("wait ")
82            || cmd.starts_with("pushd ")
83            || cmd.starts_with("popd")
84
85            // Shell redirects (output goes to file, not terminal)
86            || cmd.contains(" > ")
87            || cmd.contains(" >> ")
88
89            // Git commands that produce no output on success
90            || cmd.starts_with("git add ")
91            || cmd.starts_with("git checkout ")
92            || cmd.starts_with("git stash")
93            || cmd.starts_with("git reset ")
94    }
95
96    fn is_command_without_output(&self, block: &[&str], lang: &str) -> bool {
97        if !self.config.show_output || !self.is_shell_language(lang) {
98            return false;
99        }
100
101        // Check if block has any output
102        let has_output = block.iter().any(|line| self.is_output_line(line));
103        if has_output {
104            return false; // Has output, don't flag
105        }
106
107        // Flag if there's at least one command that should produce output
108        self.get_first_output_command(block).is_some()
109    }
110
111    /// Returns the first command in the block that should produce output.
112    /// Skips no-output commands like cd, mkdir, etc.
113    fn get_first_output_command(&self, block: &[&str]) -> Option<(usize, String)> {
114        for (i, line) in block.iter().enumerate() {
115            if self.is_command_line(line) {
116                let cmd = line.trim()[1..].trim().to_string();
117                if !self.is_no_output_command(&cmd) {
118                    return Some((i, cmd));
119                }
120            }
121        }
122        None // All commands are no-output commands
123    }
124
125    fn get_command_from_block(&self, block: &[&str]) -> String {
126        // Return the first command that should produce output
127        if let Some((_, cmd)) = self.get_first_output_command(block) {
128            return cmd;
129        }
130        // Fallback to first command (for backwards compatibility)
131        for line in block {
132            let trimmed = line.trim();
133            if self.is_command_line(line) {
134                return trimmed[1..].trim().to_string();
135            }
136        }
137        String::new()
138    }
139
140    fn fix_command_block(&self, block: &[&str]) -> String {
141        block
142            .iter()
143            .map(|line| {
144                let trimmed = line.trim_start();
145                if self.is_command_line(line) {
146                    let spaces = line.len() - line.trim_start().len();
147                    let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
148                    format!("{}{}", " ".repeat(spaces), cmd)
149                } else {
150                    line.to_string()
151                }
152            })
153            .collect::<Vec<_>>()
154            .join("\n")
155    }
156
157    fn get_code_block_language(block_start: &str) -> String {
158        block_start
159            .trim_start()
160            .trim_start_matches("```")
161            .split_whitespace()
162            .next()
163            .unwrap_or("")
164            .to_string()
165    }
166
167    /// Find the first command line that should produce output.
168    /// Skips no-output commands (cd, mkdir, etc.) to report the correct position.
169    fn find_first_command_line<'a>(&self, block: &[&'a str]) -> Option<(usize, &'a str)> {
170        for (i, line) in block.iter().enumerate() {
171            if self.is_command_line(line) {
172                let cmd = line.trim()[1..].trim();
173                if !self.is_no_output_command(cmd) {
174                    return Some((i, line));
175                }
176            }
177        }
178        None
179    }
180}
181
182impl Rule for MD014CommandsShowOutput {
183    fn name(&self) -> &'static str {
184        "MD014"
185    }
186
187    fn description(&self) -> &'static str {
188        "Commands in code blocks should show output"
189    }
190
191    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
192        let content = ctx.content;
193        let _line_index = &ctx.line_index;
194
195        let mut warnings = Vec::new();
196
197        let mut current_block = Vec::new();
198
199        let mut in_code_block = false;
200
201        let mut block_start_line = 0;
202
203        let mut current_lang = String::new();
204
205        for (line_num, line) in content.lines().enumerate() {
206            if line.trim_start().starts_with("```") {
207                if in_code_block {
208                    // End of code block
209                    if self.is_command_without_output(&current_block, &current_lang) {
210                        // Find the first command line to highlight the dollar sign
211                        if let Some((cmd_line_idx, cmd_line)) = self.find_first_command_line(&current_block) {
212                            let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; // +1 for fence, +1 for 1-indexed
213
214                            // Find and highlight the dollar sign or prompt
215                            if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
216                                && let Some(cap) = re.captures(cmd_line)
217                            {
218                                let match_obj = cap.get(1).unwrap(); // The $ or > character
219                                let (start_line, start_col, end_line, end_col) =
220                                    calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
221
222                                // Get the command for a more helpful message
223                                let command = self.get_command_from_block(&current_block);
224                                let message = if command.is_empty() {
225                                    "Command should show output (add example output or remove $ prompt)".to_string()
226                                } else {
227                                    format!(
228                                        "Command '{command}' should show output (add example output or remove $ prompt)"
229                                    )
230                                };
231
232                                warnings.push(LintWarning {
233                                    rule_name: Some(self.name().to_string()),
234                                    line: start_line,
235                                    column: start_col,
236                                    end_line,
237                                    end_column: end_col,
238                                    message,
239                                    severity: Severity::Warning,
240                                    fix: Some(Fix {
241                                        range: {
242                                            // Replace the content line(s) between the fences
243                                            let content_start_line = block_start_line + 1; // Line after opening fence (0-indexed)
244                                            let content_end_line = line_num - 1; // Line before closing fence (0-indexed)
245
246                                            // Calculate byte range for the content lines including their newlines
247                                            let start_byte =
248                                                _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); // +1 for 1-indexed
249                                            let end_byte = _line_index
250                                                .get_line_start_byte(content_end_line + 2)
251                                                .unwrap_or(start_byte); // +2 to include newline after last content line
252                                            start_byte..end_byte
253                                        },
254                                        replacement: format!("{}\n", self.fix_command_block(&current_block)),
255                                    }),
256                                });
257                            }
258                        }
259                    }
260                    current_block.clear();
261                } else {
262                    // Start of code block
263                    block_start_line = line_num;
264                    current_lang = Self::get_code_block_language(line);
265                }
266                in_code_block = !in_code_block;
267            } else if in_code_block {
268                current_block.push(line);
269            }
270        }
271
272        Ok(warnings)
273    }
274
275    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
276        let content = ctx.content;
277        let _line_index = &ctx.line_index;
278
279        let mut result = String::new();
280
281        let mut current_block = Vec::new();
282
283        let mut in_code_block = false;
284
285        let mut current_lang = String::new();
286
287        let mut block_start_line_num = 0usize;
288
289        for (line_num_0, line) in content.lines().enumerate() {
290            let line_num = line_num_0 + 1;
291            if line.trim_start().starts_with("```") {
292                if in_code_block {
293                    // End of code block
294                    // Check if any line in the block is disabled
295                    let block_disabled = (0..current_block.len()).any(|j| {
296                        let block_line_num = block_start_line_num + 1 + j;
297                        ctx.inline_config().is_rule_disabled(self.name(), block_line_num)
298                    });
299                    if !block_disabled && self.is_command_without_output(&current_block, &current_lang) {
300                        result.push_str(&self.fix_command_block(&current_block));
301                        result.push('\n');
302                    } else {
303                        for block_line in &current_block {
304                            result.push_str(block_line);
305                            result.push('\n');
306                        }
307                    }
308                    current_block.clear();
309                } else {
310                    current_lang = Self::get_code_block_language(line);
311                    block_start_line_num = line_num;
312                }
313                in_code_block = !in_code_block;
314                result.push_str(line);
315                result.push('\n');
316            } else if in_code_block {
317                current_block.push(line);
318            } else {
319                result.push_str(line);
320                result.push('\n');
321            }
322        }
323
324        // Remove trailing newline if original didn't have one
325        if !content.ends_with('\n') && result.ends_with('\n') {
326            result.pop();
327        }
328
329        Ok(result)
330    }
331
332    fn as_any(&self) -> &dyn std::any::Any {
333        self
334    }
335
336    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
337        // Skip if content is empty or has no code blocks
338        ctx.content.is_empty() || !ctx.likely_has_code()
339    }
340
341    fn default_config_section(&self) -> Option<(String, toml::Value)> {
342        let default_config = MD014Config::default();
343        let json_value = serde_json::to_value(&default_config).ok()?;
344        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
345
346        if let toml::Value::Table(table) = toml_value {
347            if !table.is_empty() {
348                Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
349            } else {
350                None
351            }
352        } else {
353            None
354        }
355    }
356
357    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
358    where
359        Self: Sized,
360    {
361        let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
362        Box::new(Self::from_config_struct(rule_config))
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::lint_context::LintContext;
370
371    #[test]
372    fn test_is_command_line() {
373        let rule = MD014CommandsShowOutput::new();
374        assert!(rule.is_command_line("$ echo test"));
375        assert!(rule.is_command_line("  $ ls -la"));
376        assert!(rule.is_command_line("> pwd"));
377        assert!(rule.is_command_line("   > cd /home"));
378        assert!(!rule.is_command_line("echo test"));
379        assert!(!rule.is_command_line("# comment"));
380        assert!(!rule.is_command_line("output line"));
381    }
382
383    #[test]
384    fn test_is_shell_language() {
385        let rule = MD014CommandsShowOutput::new();
386        assert!(rule.is_shell_language("bash"));
387        assert!(rule.is_shell_language("BASH"));
388        assert!(rule.is_shell_language("sh"));
389        assert!(rule.is_shell_language("shell"));
390        assert!(rule.is_shell_language("Shell"));
391        assert!(rule.is_shell_language("console"));
392        assert!(rule.is_shell_language("CONSOLE"));
393        assert!(rule.is_shell_language("terminal"));
394        assert!(rule.is_shell_language("Terminal"));
395        assert!(!rule.is_shell_language("python"));
396        assert!(!rule.is_shell_language("javascript"));
397        assert!(!rule.is_shell_language(""));
398    }
399
400    #[test]
401    fn test_is_output_line() {
402        let rule = MD014CommandsShowOutput::new();
403        assert!(rule.is_output_line("output text"));
404        assert!(rule.is_output_line("   some output"));
405        assert!(rule.is_output_line("file1 file2"));
406        assert!(!rule.is_output_line(""));
407        assert!(!rule.is_output_line("   "));
408        assert!(!rule.is_output_line("$ command"));
409        assert!(!rule.is_output_line("> prompt"));
410        assert!(!rule.is_output_line("# comment"));
411    }
412
413    #[test]
414    fn test_is_no_output_command() {
415        let rule = MD014CommandsShowOutput::new();
416
417        // Shell built-ins that produce no output
418        assert!(rule.is_no_output_command("cd /home"));
419        assert!(rule.is_no_output_command("cd"));
420        assert!(rule.is_no_output_command("mkdir test"));
421        assert!(rule.is_no_output_command("touch file.txt"));
422        assert!(rule.is_no_output_command("rm -rf dir"));
423        assert!(rule.is_no_output_command("mv old new"));
424        assert!(rule.is_no_output_command("cp src dst"));
425        assert!(rule.is_no_output_command("export VAR=value"));
426        assert!(rule.is_no_output_command("set -e"));
427        assert!(rule.is_no_output_command("source ~/.bashrc"));
428        assert!(rule.is_no_output_command(". ~/.profile"));
429        assert!(rule.is_no_output_command("alias ll='ls -la'"));
430        assert!(rule.is_no_output_command("unset VAR"));
431        assert!(rule.is_no_output_command("true"));
432        assert!(rule.is_no_output_command("false"));
433        assert!(rule.is_no_output_command("sleep 5"));
434        assert!(rule.is_no_output_command("pushd /tmp"));
435        assert!(rule.is_no_output_command("popd"));
436
437        // Case insensitive (lowercased internally)
438        assert!(rule.is_no_output_command("CD /HOME"));
439        assert!(rule.is_no_output_command("MKDIR TEST"));
440
441        // Shell redirects (output goes to file)
442        assert!(rule.is_no_output_command("echo 'test' > file.txt"));
443        assert!(rule.is_no_output_command("cat input.txt > output.txt"));
444        assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
445
446        // Git commands that produce no output on success
447        assert!(rule.is_no_output_command("git add ."));
448        assert!(rule.is_no_output_command("git checkout main"));
449        assert!(rule.is_no_output_command("git stash"));
450        assert!(rule.is_no_output_command("git reset HEAD~1"));
451
452        // Commands that PRODUCE output (should NOT be skipped)
453        assert!(!rule.is_no_output_command("ls -la"));
454        assert!(!rule.is_no_output_command("echo test")); // echo without redirect
455        assert!(!rule.is_no_output_command("pwd"));
456        assert!(!rule.is_no_output_command("cat file.txt")); // cat without redirect
457        assert!(!rule.is_no_output_command("grep pattern file"));
458
459        // Installation commands PRODUCE output (should NOT be skipped)
460        assert!(!rule.is_no_output_command("pip install requests"));
461        assert!(!rule.is_no_output_command("npm install express"));
462        assert!(!rule.is_no_output_command("cargo install ripgrep"));
463        assert!(!rule.is_no_output_command("brew install git"));
464
465        // Build commands PRODUCE output (should NOT be skipped)
466        assert!(!rule.is_no_output_command("cargo build"));
467        assert!(!rule.is_no_output_command("npm run build"));
468        assert!(!rule.is_no_output_command("make"));
469
470        // Docker commands PRODUCE output (should NOT be skipped)
471        assert!(!rule.is_no_output_command("docker ps"));
472        assert!(!rule.is_no_output_command("docker compose up"));
473        assert!(!rule.is_no_output_command("docker run myimage"));
474
475        // Git commands that PRODUCE output (should NOT be skipped)
476        assert!(!rule.is_no_output_command("git status"));
477        assert!(!rule.is_no_output_command("git log"));
478        assert!(!rule.is_no_output_command("git diff"));
479    }
480
481    #[test]
482    fn test_get_command_from_block() {
483        let rule = MD014CommandsShowOutput::new();
484        let block = vec!["$ echo test", "output"];
485        assert_eq!(rule.get_command_from_block(&block), "echo test");
486
487        let block2 = vec!["  $ ls -la", "file1 file2"];
488        assert_eq!(rule.get_command_from_block(&block2), "ls -la");
489
490        let block3 = vec!["> pwd", "/home"];
491        assert_eq!(rule.get_command_from_block(&block3), "pwd");
492
493        let empty_block: Vec<&str> = vec![];
494        assert_eq!(rule.get_command_from_block(&empty_block), "");
495    }
496
497    #[test]
498    fn test_fix_command_block() {
499        let rule = MD014CommandsShowOutput::new();
500        let block = vec!["$ echo test", "$ ls -la"];
501        assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
502
503        let indented = vec!["    $ echo test", "  $ pwd"];
504        assert_eq!(rule.fix_command_block(&indented), "    echo test\n  pwd");
505
506        let mixed = vec!["> cd /home", "$ mkdir test"];
507        assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
508    }
509
510    #[test]
511    fn test_get_code_block_language() {
512        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
513        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
514        assert_eq!(
515            MD014CommandsShowOutput::get_code_block_language("   ```console"),
516            "console"
517        );
518        assert_eq!(
519            MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
520            "bash"
521        );
522        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
523    }
524
525    #[test]
526    fn test_find_first_command_line() {
527        let rule = MD014CommandsShowOutput::new();
528        let block = vec!["# comment", "$ echo test", "output"];
529        let result = rule.find_first_command_line(&block);
530        assert_eq!(result, Some((1, "$ echo test")));
531
532        let no_commands = vec!["output1", "output2"];
533        assert_eq!(rule.find_first_command_line(&no_commands), None);
534    }
535
536    #[test]
537    fn test_is_command_without_output() {
538        let rule = MD014CommandsShowOutput::with_show_output(true);
539
540        // Commands without output should be flagged
541        let block1 = vec!["$ echo test"];
542        assert!(rule.is_command_without_output(&block1, "bash"));
543
544        // Commands with output should not be flagged
545        let block2 = vec!["$ echo test", "test"];
546        assert!(!rule.is_command_without_output(&block2, "bash"));
547
548        // No-output commands should not be flagged
549        let block3 = vec!["$ cd /home"];
550        assert!(!rule.is_command_without_output(&block3, "bash"));
551
552        // Disabled rule should not flag
553        let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
554        assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
555
556        // Non-shell language should not be flagged
557        assert!(!rule.is_command_without_output(&block1, "python"));
558    }
559
560    #[test]
561    fn test_edge_cases() {
562        let rule = MD014CommandsShowOutput::new();
563        // Bare $ doesn't match command pattern (needs a command after $)
564        let content = "```bash\n$ \n```";
565        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566        let result = rule.check(&ctx).unwrap();
567        assert!(
568            result.is_empty(),
569            "Bare $ with only space doesn't match command pattern"
570        );
571
572        // Test empty code block
573        let empty_content = "```bash\n```";
574        let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
575        let result2 = rule.check(&ctx2).unwrap();
576        assert!(result2.is_empty(), "Empty code block should not be flagged");
577
578        // Test minimal command
579        let minimal = "```bash\n$ a\n```";
580        let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
581        let result3 = rule.check(&ctx3).unwrap();
582        assert_eq!(result3.len(), 1, "Minimal command should be flagged");
583    }
584
585    #[test]
586    fn test_mixed_silent_and_output_commands() {
587        let rule = MD014CommandsShowOutput::new();
588
589        // Block with only silent commands should NOT be flagged
590        let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
591        let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
592        let result1 = rule.check(&ctx1).unwrap();
593        assert!(
594            result1.is_empty(),
595            "Block with only silent commands should not be flagged"
596        );
597
598        // Block with silent commands followed by output-producing command
599        // should flag with the OUTPUT-PRODUCING command in the message
600        let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
601        let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
602        let result2 = rule.check(&ctx2).unwrap();
603        assert_eq!(result2.len(), 1, "Mixed block should be flagged once");
604        assert!(
605            result2[0].message.contains("ls -la"),
606            "Message should mention 'ls -la', not 'cd /home'. Got: {}",
607            result2[0].message
608        );
609
610        // Block with mkdir followed by cat (which produces output)
611        let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
612        let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
613        let result3 = rule.check(&ctx3).unwrap();
614        assert_eq!(result3.len(), 1, "Mixed block should be flagged once");
615        assert!(
616            result3[0].message.contains("cat file.txt"),
617            "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
618            result3[0].message
619        );
620
621        // Block with silent command followed by pip install (which produces output)
622        // pip install is NOT a silent command - it produces verbose output
623        let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
624        let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
625        let result3b = rule.check(&ctx3b).unwrap();
626        assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
627        assert!(
628            result3b[0].message.contains("pip install"),
629            "Message should mention 'pip install'. Got: {}",
630            result3b[0].message
631        );
632
633        // Block with output-producing command followed by silent command
634        // should still flag with the FIRST output-producing command
635        let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
636        let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
637        let result4 = rule.check(&ctx4).unwrap();
638        assert_eq!(result4.len(), 1, "Mixed block should be flagged once");
639        assert!(
640            result4[0].message.contains("echo hello"),
641            "Message should mention 'echo hello'. Got: {}",
642            result4[0].message
643        );
644    }
645
646    #[test]
647    fn test_default_config_section() {
648        let rule = MD014CommandsShowOutput::new();
649        let config_section = rule.default_config_section();
650        assert!(config_section.is_some());
651        let (name, _value) = config_section.unwrap();
652        assert_eq!(name, "MD014");
653    }
654}