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