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 fix_command_block(&self, block: &[&str]) -> String {
126        block
127            .iter()
128            .map(|line| {
129                let trimmed = line.trim_start();
130                if self.is_command_line(line) {
131                    let spaces = line.len() - line.trim_start().len();
132                    let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
133                    format!("{}{}", " ".repeat(spaces), cmd)
134                } else {
135                    line.to_string()
136                }
137            })
138            .collect::<Vec<_>>()
139            .join("\n")
140    }
141
142    fn get_code_block_language(block_start: &str) -> String {
143        block_start
144            .trim_start()
145            .trim_start_matches("```")
146            .split_whitespace()
147            .next()
148            .unwrap_or("")
149            .to_string()
150    }
151
152    /// Find all command lines in the block that should produce output.
153    /// Skips no-output commands (cd, mkdir, etc.).
154    fn find_all_command_lines<'a>(&self, block: &[&'a str]) -> Vec<(usize, &'a str)> {
155        let mut results = Vec::new();
156        for (i, line) in block.iter().enumerate() {
157            if self.is_command_line(line) {
158                let cmd = line.trim()[1..].trim();
159                if !self.is_no_output_command(cmd) {
160                    results.push((i, *line));
161                }
162            }
163        }
164        results
165    }
166}
167
168impl Rule for MD014CommandsShowOutput {
169    fn name(&self) -> &'static str {
170        "MD014"
171    }
172
173    fn description(&self) -> &'static str {
174        "Commands in code blocks should show output"
175    }
176
177    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
178        let content = ctx.content;
179        let _line_index = &ctx.line_index;
180
181        let mut warnings = Vec::new();
182
183        let mut current_block = Vec::new();
184
185        let mut in_code_block = false;
186
187        let mut block_start_line = 0;
188
189        let mut current_lang = String::new();
190
191        for (line_num, line) in content.lines().enumerate() {
192            if line.trim_start().starts_with("```") {
193                if in_code_block {
194                    // End of code block
195                    if self.is_command_without_output(&current_block, &current_lang) {
196                        // Find all command lines that should produce output
197                        let command_lines = self.find_all_command_lines(&current_block);
198                        let fix = Fix {
199                            range: {
200                                // Replace the content line(s) between the fences
201                                let content_start_line = block_start_line + 1; // Line after opening fence (0-indexed)
202                                let content_end_line = line_num - 1; // Line before closing fence (0-indexed)
203
204                                // Calculate byte range for the content lines including their newlines
205                                let start_byte = _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); // +1 for 1-indexed
206                                let end_byte = _line_index
207                                    .get_line_start_byte(content_end_line + 2)
208                                    .unwrap_or(start_byte); // +2 to include newline after last content line
209                                start_byte..end_byte
210                            },
211                            replacement: format!("{}\n", self.fix_command_block(&current_block)),
212                        };
213
214                        for (cmd_line_idx, cmd_line) in &command_lines {
215                            let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; // +1 for fence, +1 for 1-indexed
216
217                            // Find and highlight the dollar sign or prompt
218                            if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
219                                && let Some(cap) = re.captures(cmd_line)
220                            {
221                                let match_obj = cap.get(1).unwrap(); // The $ or > character
222                                let (start_line, start_col, end_line, end_col) =
223                                    calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
224
225                                // Extract command text from this specific line
226                                let cmd_text = cmd_line.trim()[1..].trim().to_string();
227                                let message = if cmd_text.is_empty() {
228                                    "Command should show output (add example output or remove $ prompt)".to_string()
229                                } else {
230                                    format!(
231                                        "Command '{cmd_text}' should show output (add example output or remove $ prompt)"
232                                    )
233                                };
234
235                                warnings.push(LintWarning {
236                                    rule_name: Some(self.name().to_string()),
237                                    line: start_line,
238                                    column: start_col,
239                                    end_line,
240                                    end_column: end_col,
241                                    message,
242                                    severity: Severity::Warning,
243                                    fix: Some(fix.clone()),
244                                });
245                            }
246                        }
247                    }
248                    current_block.clear();
249                } else {
250                    // Start of code block
251                    block_start_line = line_num;
252                    current_lang = Self::get_code_block_language(line);
253                }
254                in_code_block = !in_code_block;
255            } else if in_code_block {
256                current_block.push(line);
257            }
258        }
259
260        Ok(warnings)
261    }
262
263    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
264        let content = ctx.content;
265        let _line_index = &ctx.line_index;
266
267        let mut result = String::new();
268
269        let mut current_block = Vec::new();
270
271        let mut in_code_block = false;
272
273        let mut current_lang = String::new();
274
275        let mut block_start_line_num = 0usize;
276
277        for (line_num_0, line) in content.lines().enumerate() {
278            let line_num = line_num_0 + 1;
279            if line.trim_start().starts_with("```") {
280                if in_code_block {
281                    // End of code block
282                    // Check if any line in the block is disabled
283                    let block_disabled = (0..current_block.len()).any(|j| {
284                        let block_line_num = block_start_line_num + 1 + j;
285                        ctx.inline_config().is_rule_disabled(self.name(), block_line_num)
286                    });
287                    if !block_disabled && self.is_command_without_output(&current_block, &current_lang) {
288                        result.push_str(&self.fix_command_block(&current_block));
289                        result.push('\n');
290                    } else {
291                        for block_line in &current_block {
292                            result.push_str(block_line);
293                            result.push('\n');
294                        }
295                    }
296                    current_block.clear();
297                } else {
298                    current_lang = Self::get_code_block_language(line);
299                    block_start_line_num = line_num;
300                }
301                in_code_block = !in_code_block;
302                result.push_str(line);
303                result.push('\n');
304            } else if in_code_block {
305                current_block.push(line);
306            } else {
307                result.push_str(line);
308                result.push('\n');
309            }
310        }
311
312        // Remove trailing newline if original didn't have one
313        if !content.ends_with('\n') && result.ends_with('\n') {
314            result.pop();
315        }
316
317        Ok(result)
318    }
319
320    fn as_any(&self) -> &dyn std::any::Any {
321        self
322    }
323
324    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
325        // Skip if content is empty or has no code blocks
326        ctx.content.is_empty() || !ctx.likely_has_code()
327    }
328
329    fn default_config_section(&self) -> Option<(String, toml::Value)> {
330        let default_config = MD014Config::default();
331        let json_value = serde_json::to_value(&default_config).ok()?;
332        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
333
334        if let toml::Value::Table(table) = toml_value {
335            if !table.is_empty() {
336                Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
337            } else {
338                None
339            }
340        } else {
341            None
342        }
343    }
344
345    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
346    where
347        Self: Sized,
348    {
349        let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
350        Box::new(Self::from_config_struct(rule_config))
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::lint_context::LintContext;
358
359    #[test]
360    fn test_is_command_line() {
361        let rule = MD014CommandsShowOutput::new();
362        assert!(rule.is_command_line("$ echo test"));
363        assert!(rule.is_command_line("  $ ls -la"));
364        assert!(rule.is_command_line("> pwd"));
365        assert!(rule.is_command_line("   > cd /home"));
366        assert!(!rule.is_command_line("echo test"));
367        assert!(!rule.is_command_line("# comment"));
368        assert!(!rule.is_command_line("output line"));
369    }
370
371    #[test]
372    fn test_is_shell_language() {
373        let rule = MD014CommandsShowOutput::new();
374        assert!(rule.is_shell_language("bash"));
375        assert!(rule.is_shell_language("BASH"));
376        assert!(rule.is_shell_language("sh"));
377        assert!(rule.is_shell_language("shell"));
378        assert!(rule.is_shell_language("Shell"));
379        assert!(rule.is_shell_language("console"));
380        assert!(rule.is_shell_language("CONSOLE"));
381        assert!(rule.is_shell_language("terminal"));
382        assert!(rule.is_shell_language("Terminal"));
383        assert!(!rule.is_shell_language("python"));
384        assert!(!rule.is_shell_language("javascript"));
385        assert!(!rule.is_shell_language(""));
386    }
387
388    #[test]
389    fn test_is_output_line() {
390        let rule = MD014CommandsShowOutput::new();
391        assert!(rule.is_output_line("output text"));
392        assert!(rule.is_output_line("   some output"));
393        assert!(rule.is_output_line("file1 file2"));
394        assert!(!rule.is_output_line(""));
395        assert!(!rule.is_output_line("   "));
396        assert!(!rule.is_output_line("$ command"));
397        assert!(!rule.is_output_line("> prompt"));
398        assert!(!rule.is_output_line("# comment"));
399    }
400
401    #[test]
402    fn test_is_no_output_command() {
403        let rule = MD014CommandsShowOutput::new();
404
405        // Shell built-ins that produce no output
406        assert!(rule.is_no_output_command("cd /home"));
407        assert!(rule.is_no_output_command("cd"));
408        assert!(rule.is_no_output_command("mkdir test"));
409        assert!(rule.is_no_output_command("touch file.txt"));
410        assert!(rule.is_no_output_command("rm -rf dir"));
411        assert!(rule.is_no_output_command("mv old new"));
412        assert!(rule.is_no_output_command("cp src dst"));
413        assert!(rule.is_no_output_command("export VAR=value"));
414        assert!(rule.is_no_output_command("set -e"));
415        assert!(rule.is_no_output_command("source ~/.bashrc"));
416        assert!(rule.is_no_output_command(". ~/.profile"));
417        assert!(rule.is_no_output_command("alias ll='ls -la'"));
418        assert!(rule.is_no_output_command("unset VAR"));
419        assert!(rule.is_no_output_command("true"));
420        assert!(rule.is_no_output_command("false"));
421        assert!(rule.is_no_output_command("sleep 5"));
422        assert!(rule.is_no_output_command("pushd /tmp"));
423        assert!(rule.is_no_output_command("popd"));
424
425        // Case insensitive (lowercased internally)
426        assert!(rule.is_no_output_command("CD /HOME"));
427        assert!(rule.is_no_output_command("MKDIR TEST"));
428
429        // Shell redirects (output goes to file)
430        assert!(rule.is_no_output_command("echo 'test' > file.txt"));
431        assert!(rule.is_no_output_command("cat input.txt > output.txt"));
432        assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
433
434        // Git commands that produce no output on success
435        assert!(rule.is_no_output_command("git add ."));
436        assert!(rule.is_no_output_command("git checkout main"));
437        assert!(rule.is_no_output_command("git stash"));
438        assert!(rule.is_no_output_command("git reset HEAD~1"));
439
440        // Commands that PRODUCE output (should NOT be skipped)
441        assert!(!rule.is_no_output_command("ls -la"));
442        assert!(!rule.is_no_output_command("echo test")); // echo without redirect
443        assert!(!rule.is_no_output_command("pwd"));
444        assert!(!rule.is_no_output_command("cat file.txt")); // cat without redirect
445        assert!(!rule.is_no_output_command("grep pattern file"));
446
447        // Installation commands PRODUCE output (should NOT be skipped)
448        assert!(!rule.is_no_output_command("pip install requests"));
449        assert!(!rule.is_no_output_command("npm install express"));
450        assert!(!rule.is_no_output_command("cargo install ripgrep"));
451        assert!(!rule.is_no_output_command("brew install git"));
452
453        // Build commands PRODUCE output (should NOT be skipped)
454        assert!(!rule.is_no_output_command("cargo build"));
455        assert!(!rule.is_no_output_command("npm run build"));
456        assert!(!rule.is_no_output_command("make"));
457
458        // Docker commands PRODUCE output (should NOT be skipped)
459        assert!(!rule.is_no_output_command("docker ps"));
460        assert!(!rule.is_no_output_command("docker compose up"));
461        assert!(!rule.is_no_output_command("docker run myimage"));
462
463        // Git commands that PRODUCE output (should NOT be skipped)
464        assert!(!rule.is_no_output_command("git status"));
465        assert!(!rule.is_no_output_command("git log"));
466        assert!(!rule.is_no_output_command("git diff"));
467    }
468
469    #[test]
470    fn test_fix_command_block() {
471        let rule = MD014CommandsShowOutput::new();
472        let block = vec!["$ echo test", "$ ls -la"];
473        assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
474
475        let indented = vec!["    $ echo test", "  $ pwd"];
476        assert_eq!(rule.fix_command_block(&indented), "    echo test\n  pwd");
477
478        let mixed = vec!["> cd /home", "$ mkdir test"];
479        assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
480    }
481
482    #[test]
483    fn test_get_code_block_language() {
484        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
485        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
486        assert_eq!(
487            MD014CommandsShowOutput::get_code_block_language("   ```console"),
488            "console"
489        );
490        assert_eq!(
491            MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
492            "bash"
493        );
494        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
495    }
496
497    #[test]
498    fn test_find_all_command_lines() {
499        let rule = MD014CommandsShowOutput::new();
500        let block = vec!["# comment", "$ echo test", "output"];
501        let result = rule.find_all_command_lines(&block);
502        assert_eq!(result, vec![(1, "$ echo test")]);
503
504        let no_commands = vec!["output1", "output2"];
505        assert!(rule.find_all_command_lines(&no_commands).is_empty());
506
507        let multiple = vec!["$ echo one", "$ echo two", "$ cd /tmp"];
508        let result = rule.find_all_command_lines(&multiple);
509        // cd is a no-output command, so only echo commands are returned
510        assert_eq!(result, vec![(0, "$ echo one"), (1, "$ echo two")]);
511    }
512
513    #[test]
514    fn test_is_command_without_output() {
515        let rule = MD014CommandsShowOutput::with_show_output(true);
516
517        // Commands without output should be flagged
518        let block1 = vec!["$ echo test"];
519        assert!(rule.is_command_without_output(&block1, "bash"));
520
521        // Commands with output should not be flagged
522        let block2 = vec!["$ echo test", "test"];
523        assert!(!rule.is_command_without_output(&block2, "bash"));
524
525        // No-output commands should not be flagged
526        let block3 = vec!["$ cd /home"];
527        assert!(!rule.is_command_without_output(&block3, "bash"));
528
529        // Disabled rule should not flag
530        let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
531        assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
532
533        // Non-shell language should not be flagged
534        assert!(!rule.is_command_without_output(&block1, "python"));
535    }
536
537    #[test]
538    fn test_edge_cases() {
539        let rule = MD014CommandsShowOutput::new();
540        // Bare $ doesn't match command pattern (needs a command after $)
541        let content = "```bash\n$ \n```";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.check(&ctx).unwrap();
544        assert!(
545            result.is_empty(),
546            "Bare $ with only space doesn't match command pattern"
547        );
548
549        // Test empty code block
550        let empty_content = "```bash\n```";
551        let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
552        let result2 = rule.check(&ctx2).unwrap();
553        assert!(result2.is_empty(), "Empty code block should not be flagged");
554
555        // Test minimal command
556        let minimal = "```bash\n$ a\n```";
557        let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
558        let result3 = rule.check(&ctx3).unwrap();
559        assert_eq!(result3.len(), 1, "Minimal command should be flagged");
560    }
561
562    #[test]
563    fn test_mixed_silent_and_output_commands() {
564        let rule = MD014CommandsShowOutput::new();
565
566        // Block with only silent commands should NOT be flagged
567        let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
568        let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
569        let result1 = rule.check(&ctx1).unwrap();
570        assert!(
571            result1.is_empty(),
572            "Block with only silent commands should not be flagged"
573        );
574
575        // Block with silent commands followed by output-producing command
576        // should flag the output-producing command only
577        let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
578        let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
579        let result2 = rule.check(&ctx2).unwrap();
580        assert_eq!(result2.len(), 1, "Only output-producing commands should be flagged");
581        assert!(
582            result2[0].message.contains("ls -la"),
583            "Message should mention 'ls -la', not 'cd /home'. Got: {}",
584            result2[0].message
585        );
586
587        // Block with mkdir followed by cat (which produces output)
588        let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
589        let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
590        let result3 = rule.check(&ctx3).unwrap();
591        assert_eq!(result3.len(), 1, "Only output-producing commands should be flagged");
592        assert!(
593            result3[0].message.contains("cat file.txt"),
594            "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
595            result3[0].message
596        );
597
598        // Block with silent command followed by pip install (which produces output)
599        let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
600        let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
601        let result3b = rule.check(&ctx3b).unwrap();
602        assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
603        assert!(
604            result3b[0].message.contains("pip install"),
605            "Message should mention 'pip install'. Got: {}",
606            result3b[0].message
607        );
608
609        // Block with output-producing command followed by silent command
610        let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
611        let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
612        let result4 = rule.check(&ctx4).unwrap();
613        assert_eq!(result4.len(), 1, "Only output-producing commands should be flagged");
614        assert!(
615            result4[0].message.contains("echo hello"),
616            "Message should mention 'echo hello'. Got: {}",
617            result4[0].message
618        );
619    }
620
621    #[test]
622    fn test_multiple_commands_without_output_all_flagged() {
623        let rule = MD014CommandsShowOutput::new();
624
625        // Two identical commands without output should produce two warnings
626        let content = "```shell\n# First invocation\n$ my_command\n\n# Second invocation\n$ my_command\n```";
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628        let result = rule.check(&ctx).unwrap();
629        assert_eq!(result.len(), 2, "Both commands should be flagged. Got: {result:?}");
630        assert!(result[0].message.contains("my_command"));
631        assert!(result[1].message.contains("my_command"));
632        // Verify they point to different lines
633        assert_ne!(result[0].line, result[1].line, "Warnings should be on different lines");
634
635        // Three different commands without output
636        let content2 = "```bash\n$ echo hello\n$ ls -la\n$ pwd\n```";
637        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
638        let result2 = rule.check(&ctx2).unwrap();
639        assert_eq!(
640            result2.len(),
641            3,
642            "All three commands should be flagged. Got: {result2:?}"
643        );
644        assert!(result2[0].message.contains("echo hello"));
645        assert!(result2[1].message.contains("ls -la"));
646        assert!(result2[2].message.contains("pwd"));
647
648        // Two output-producing commands mixed with one silent command
649        let content3 = "```bash\n$ echo hello\n$ cd /tmp\n$ ls -la\n```";
650        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
651        let result3 = rule.check(&ctx3).unwrap();
652        assert_eq!(
653            result3.len(),
654            2,
655            "Only output-producing commands should be flagged. Got: {result3:?}"
656        );
657        assert!(result3[0].message.contains("echo hello"));
658        assert!(result3[1].message.contains("ls -la"));
659    }
660
661    #[test]
662    fn test_issue_516_exact_case() {
663        let rule = MD014CommandsShowOutput::new();
664
665        // Exact test case from GitHub issue #516
666        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";
667        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668        let result = rule.check(&ctx).unwrap();
669        assert_eq!(
670            result.len(),
671            2,
672            "Both $ my_command lines should be flagged. Got: {result:?}"
673        );
674        assert_eq!(result[0].line, 9, "First warning should be on line 9");
675        assert_eq!(result[1].line, 12, "Second warning should be on line 12");
676    }
677
678    #[test]
679    fn test_default_config_section() {
680        let rule = MD014CommandsShowOutput::new();
681        let config_section = rule.default_config_section();
682        assert!(config_section.is_some());
683        let (name, _value) = config_section.unwrap();
684        assert_eq!(name, "MD014");
685    }
686}