rumdl 0.1.88

A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
use std::env;
use std::fs;
use std::process::Command;

fn setup_test_files() -> tempfile::TempDir {
    let temp_dir = tempfile::tempdir().unwrap();
    let base_path = temp_dir.path();

    // Create test files with known issues
    fs::write(
        base_path.join("single_file.md"),
        "# Test Heading!\nThis line has trailing spaces.  \nNo newline at end",
    )
    .unwrap();

    fs::write(
        base_path.join("second_file.md"),
        "# Another Heading\n\n## Missing space after ##heading\n\nSome content.",
    )
    .unwrap();

    // Create a file with multiple different issue types for more thorough testing
    fs::write(
        base_path.join("multi_issue.md"),
        "# Heading 1\n# Heading 1 duplicate\nThis line has trailing spaces.  \n\n\nMultiple blank lines above.\n```\nCode block with no language\n```\nNo newline at end",
    )
    .unwrap();

    // Create a file with an unfixable issue - MD013 (line length)
    fs::write(
        base_path.join("unfixable_issue.md"),
        "# Unfixable Issue\n\nThis is a very long line that exceeds the default line length limit of 80 characters which cannot be automatically fixed by the linter.\n",
    )
    .unwrap();

    // Create a file with both fixable and unfixable issues
    fs::write(
        base_path.join("mixed_issues.md"),
        "# Mixed Issues\nThis line has trailing spaces.  \n\nThis paragraph contains * spaced emphasis * that should be fixable.\nThis line should have a newline at the end but doesn't",
    )
    .unwrap();

    temp_dir
}

#[test]
fn test_output_format_singular() {
    let temp_dir = setup_test_files();
    let base_path = temp_dir.path();

    // Run the linter on a single file without fixes
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(base_path)
        .args(["check", "single_file.md"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Single file output:\n{stdout}");

    // Check for singular "file" in the output
    assert!(
        stdout.contains("issues in 1 file"),
        "Expected output to contain 'issues in 1 file', but got:\n{stdout}"
    );

    // Make sure it doesn't use plural for single file
    assert!(
        !stdout.contains("issues in 1 files"),
        "Output should not contain 'issues in 1 files'"
    );
}

#[test]
fn test_output_format_plural() {
    let temp_dir = setup_test_files();
    let base_path = temp_dir.path();

    // Run the linter on multiple files without fixes
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(base_path)
        .args(["check", "single_file.md", "second_file.md"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Multiple files output:\n{stdout}");

    // Check for plural "files" in the output
    assert!(
        stdout.contains("issues in 2 files"),
        "Expected output to contain 'issues in 2 files', but got:\n{stdout}"
    );
}

#[test]
fn test_output_format_fix_mode_singular() {
    let temp_dir = setup_test_files();
    let base_path = temp_dir.path();

    // Run the linter on a single file with fix mode
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(base_path)
        .args(["check", "single_file.md", "--fix"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Fix mode single file output:\n{stdout}");

    // Check for singular "file" in the fixed output
    assert!(
        stdout.contains("issues in 1 file"),
        "Expected output to contain 'issues in 1 file', but got:\n{stdout}"
    );

    // Make sure it doesn't use plural for single file
    assert!(
        !stdout.contains("issues in 1 files"),
        "Output should not contain 'issues in 1 files'"
    );

    // Verify only the Fixed line is shown, not the Issues line
    assert!(stdout.contains("Fixed:"), "Output should contain 'Fixed:' line");
    assert!(
        !stdout.contains("Issues:"),
        "Output should not contain 'Issues:' line when in fix mode"
    );
}

#[test]
fn test_output_format_fix_mode_plural() {
    let temp_dir = setup_test_files();
    let base_path = temp_dir.path();

    // Run the linter on multiple files with fix mode
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .current_dir(base_path)
        .args(["check", "single_file.md", "second_file.md", "--fix"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Fix mode multiple files output:\n{stdout}");

    // Check for plural "files" in the fixed output
    assert!(
        stdout.contains("issues in 2 files"),
        "Expected output to contain 'issues in 2 files', but got:\n{stdout}"
    );

    // Verify only the Fixed line is shown, not the Issues line
    assert!(stdout.contains("Fixed:"), "Output should contain 'Fixed:' line");
    assert!(
        !stdout.contains("Issues:"),
        "Output should not contain 'Issues:' line when in fix mode"
    );
}

#[test]
fn test_output_format_fix_mode_label() {
    // Create temporary markdown files with known issues
    let temp_dir = setup_test_files();

    // Get path to the first test file (use existing single_file.md)
    let test_file = temp_dir.path().join("single_file.md");
    let test_file_path = test_file.to_str().unwrap();

    // Run in normal mode first
    let output_normal = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let stdout_normal = String::from_utf8_lossy(&output_normal.stdout);

    // Check that fixable issues have [*] label
    assert!(
        stdout_normal.contains("[*]"),
        "Normal mode should show [*] for fixable issues"
    );
    assert!(
        !stdout_normal.contains("[fixed]"),
        "Normal mode should not show [fixed] labels"
    );

    // Now run in fix mode
    let output_fix = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix"])
        .output()
        .expect("Failed to execute command");

    let stdout_fix = String::from_utf8_lossy(&output_fix.stdout);

    // Fixed issues should have [fixed] label in text output
    assert!(
        stdout_fix.contains("[fixed]"),
        "Fix mode should show [fixed] for fixed issues. stdout: {stdout_fix}"
    );
    assert!(!stdout_fix.contains("[*]"), "Fix mode should not show [*] labels");
}

#[test]
fn test_multi_issue_output_format() {
    // Create temporary markdown files with different issue types
    let temp_dir = setup_test_files();

    // Get path to the multi-issue test file
    let test_file = temp_dir.path().join("multi_issue.md");
    let test_file_path = test_file.to_str().unwrap();

    // First run in normal mode to check issue labeling
    let output_normal = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let stdout_normal = String::from_utf8_lossy(&output_normal.stdout);
    println!("Multi-issue normal mode output:\n{stdout_normal}");

    // Verify each type of expected issue is detected and marked as fixable
    assert!(
        stdout_normal.contains("[MD022]") && stdout_normal.contains("blank line"),
        "Should detect heading blank line issue"
    );
    assert!(
        stdout_normal.contains("[MD025]") && stdout_normal.contains("Multiple top-level headings"),
        "Should detect duplicate level 1 heading issue"
    );
    assert!(
        stdout_normal.contains("[MD012]") && stdout_normal.contains("Multiple consecutive blank lines"),
        "Should detect multiple blank lines issue"
    );
    assert!(
        stdout_normal.contains("[MD040]") && stdout_normal.contains("Code block (```) missing language"),
        "Should detect code block language issue"
    );
    assert!(
        stdout_normal.contains("[MD047]") && stdout_normal.contains("File should end with a single newline"),
        "Should detect file ending newline issue"
    );

    // Verify each fixable issue is properly labeled in normal mode
    let normal_mode_fixable_issues = stdout_normal.matches("[*]").count();
    assert!(
        normal_mode_fixable_issues >= 5,
        "Expected at least 5 fixable issues marked with [*], found {normal_mode_fixable_issues}"
    );

    // Now run in fix mode to check fixed issue labeling
    let output_fix = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix"])
        .output()
        .expect("Failed to execute command");

    let stdout_fix = String::from_utf8_lossy(&output_fix.stdout);
    println!("Multi-issue fix mode output:\n{stdout_fix}");

    // Fixed issues should have [fixed] label in text output
    let fix_mode_fixed_issues = stdout_fix.matches("[fixed]").count();
    assert!(
        fix_mode_fixed_issues >= 5,
        "Expected at least 5 issues marked as [fixed], found {fix_mode_fixed_issues}. stdout: {stdout_fix}"
    );
    assert!(!stdout_fix.contains("[*]"), "Fix mode should not have any [*] labels");

    // The summary should report fixes were applied
    assert!(
        stdout_fix.contains("Fixed:"),
        "Fix mode summary should report fixes applied. stdout: {stdout_fix}"
    );
}

#[test]
fn test_fixable_issues_labeling() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a file with a fixable issue - MD037 (spaces inside emphasis markers)
    let test_file = temp_dir.path().join("fixable_issue.md");
    fs::write(
        &test_file,
        "# Fixable Issue\n\nThis paragraph contains * spaced emphasis *.\n",
    )
    .unwrap();

    let test_file_path = test_file.to_str().unwrap();

    // Run the linter
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Fixable issue output:\n{stdout}");

    // Verify MD037 emphasis spaces issue is reported
    assert!(stdout.contains("[MD037]"), "Should detect spaces around emphasis issue");

    // Verify issue has a [*] label, indicating it's fixable
    if stdout.contains("[MD037]") {
        let md037_line = stdout.lines().find(|line| line.contains("[MD037]")).unwrap_or("");
        assert!(
            md037_line.contains("[*]"),
            "MD037 should have [*] label indicating it's fixable"
        );
    }

    // Run with fix mode and ensure the issue is fixed
    let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix"])
        .output()
        .expect("Failed to execute command");

    let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
    println!("Fixable issue with --fix output:\n{fix_stdout}");

    // Fixed issues should have [fixed] label in text output
    assert!(
        fix_stdout.contains("[fixed]"),
        "Fixed issue should show [fixed] label. stdout: {fix_stdout}"
    );
    // Summary should show the fix happened
    assert!(fix_stdout.contains("Fixed"), "Summary should confirm fix was applied");

    // Verify the content was actually fixed
    let content = fs::read_to_string(test_file_path).expect("Failed to read file");
    assert!(
        content.contains("*spaced emphasis*"),
        "Spaces inside emphasis should be fixed in the file content"
    );
}

#[test]
fn test_truly_unfixable_issues_labeling() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a file with an unfixable issue - MD013 (line length)
    let test_file = temp_dir.path().join("unfixable_issue.md");
    // Create content with a very long line that exceeds default line length (80 chars)
    fs::write(
        &test_file,
        "# Truly Unfixable Issue\n\nThis paragraph contains a very long line that definitely exceeds the maximum line length limit and cannot be automatically fixed by the linter because line wrapping requires manual intervention.\n",
    ).unwrap();

    // Create a custom config file with a small line_length to ensure MD013 is triggered
    let config_file = temp_dir.path().join("custom_rumdl.toml");
    fs::write(&config_file, "[MD013]\nline_length = 20\n").unwrap();

    let test_file_path = test_file.to_str().unwrap();
    let config_file_path = config_file.to_str().unwrap();

    // Run the linter with custom config
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--config", config_file_path])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Truly unfixable issue output:\n{stdout}");

    // Verify MD013 line length issue is reported
    assert!(
        stdout.contains("[MD013]") && stdout.contains("Line length"),
        "Should detect line length issue"
    );

    // Verify issue does NOT have a [*] label, indicating it's NOT fixable
    if stdout.contains("[MD013]") {
        let md013_line = stdout.lines().find(|line| line.contains("[MD013]")).unwrap_or("");
        assert!(
            !md013_line.contains("[*]"),
            "MD013 should NOT have [*] label since it's not fixable"
        );
    }

    // Run with fix mode and ensure the issue is NOT fixed
    let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix", "--config", config_file_path])
        .output()
        .expect("Failed to execute command");

    let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
    println!("Truly unfixable issue with --fix output:\n{fix_stdout}");

    // Verify the issue is still reported and NOT marked as fixed
    assert!(
        fix_stdout.contains("[MD013]") && fix_stdout.contains("Line length"),
        "Line length issue should still be reported after fix attempt"
    );

    if fix_stdout.contains("[MD013]") {
        let md013_line = fix_stdout.lines().find(|line| line.contains("[MD013]")).unwrap_or("");
        assert!(
            !md013_line.contains("[fixed]"),
            "Unfixable issue (MD013) should NOT have [fixed] label"
        );
    }

    // Verify the content was NOT fixed
    let content = fs::read_to_string(test_file_path).expect("Failed to read file");
    // The long line should still be long
    assert!(
        content.contains("This paragraph contains a very long line"),
        "Line length should NOT be fixed in the file content"
    );
}

#[test]
fn test_mixed_fixable_unfixable_issues() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a file with both fixable and unfixable issues
    let test_file = temp_dir.path().join("mixed_issues.md");
    fs::write(
        &test_file,
        "# Mixed Issues\nThis line has trailing spaces.  \n\nThis paragraph contains * spaced emphasis * that should be fixable.\nThis line is extremely long and exceeds the maximum line length which cannot be automatically fixed because line wrapping requires manual intervention by the user.\nThis line should have a newline at the end but doesn't",
    ).unwrap();

    // Create a custom config file with a small line_length to ensure MD013 is triggered
    let config_file = temp_dir.path().join("custom_rumdl.toml");
    fs::write(&config_file, "[MD013]\nline_length = 20\n").unwrap();

    let test_file_path = test_file.to_str().unwrap();
    let config_file_path = config_file.to_str().unwrap();

    // Run the linter in normal mode with custom config
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--config", config_file_path])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Mixed issues output:\n{stdout}");

    // Check for fixable issues
    assert!(
        stdout.contains("[MD022]"),
        "Should detect heading blank line issue (fixable)"
    );
    assert!(
        stdout.contains("[MD047]"),
        "Should detect missing newline issue (fixable)"
    );
    assert!(
        stdout.contains("[MD037]"),
        "Should detect spaces around emphasis issue (fixable)"
    );

    // Check for unfixable issues
    assert!(
        stdout.contains("[MD013]"),
        "Should detect line length issue (unfixable)"
    );

    // Check that fixable issues have [*] label
    assert!(
        stdout.contains("[*]"),
        "Should detect at least one fixable issue with [*] label"
    );

    // Check that MD013 doesn't have [*] label
    if stdout.contains("[MD013]") {
        let md013_line = stdout.lines().find(|line| line.contains("[MD013]")).unwrap_or("");
        assert!(
            !md013_line.contains("[*]"),
            "MD013 should NOT have [*] label since it's not fixable"
        );
    }

    // Run with fix mode and custom config
    let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix", "--config", config_file_path])
        .output()
        .expect("Failed to execute command");

    let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
    println!("Mixed issues with --fix output:\n{fix_stdout}");

    // Fixable issues should have [fixed] label in text output
    assert!(
        fix_stdout.contains("[fixed]"),
        "Fixable issues should show [fixed] label. stdout: {fix_stdout}"
    );

    // Verify MD037 is marked as fixed
    if fix_stdout.contains("[MD037]") {
        let md037_line = fix_stdout.lines().find(|line| line.contains("[MD037]")).unwrap_or("");
        assert!(
            md037_line.contains("[fixed]"),
            "MD037 should have [fixed] label after applying fixes"
        );
    }

    // Unfixable issues (MD013) should still be present without [fixed]
    assert!(
        fix_stdout.contains("[MD013]"),
        "MD013 (unfixable) should remain in output. stdout: {fix_stdout}"
    );
    if fix_stdout.contains("[MD013]") {
        let md013_line = fix_stdout.lines().find(|line| line.contains("[MD013]")).unwrap_or("");
        assert!(
            !md013_line.contains("[fixed]"),
            "MD013 should NOT have [fixed] label as it cannot be fixed automatically"
        );
    }
    assert!(!fix_stdout.contains("[*]"), "Fix mode should not show [*] labels");

    // Verify the content was actually fixed for fixable issues only
    let content = fs::read_to_string(test_file_path).expect("Failed to read file");

    // Check that the heading has a blank line below it now
    assert!(
        content.contains("# Mixed Issues\n\n"),
        "Heading should have a blank line below it after fixing"
    );

    // Check that emphasis spaces were fixed
    assert!(
        content.contains("*spaced emphasis*"),
        "Spaces inside emphasis should be fixed in the file content"
    );

    // Check that file ends with newline
    assert!(
        content.ends_with('\n'),
        "Missing newline should be fixed in the file content"
    );

    // Check that long line is still long (unfixed)
    assert!(
        content.contains("This line is extremely long"),
        "Long line should remain unfixed in the content"
    );
}

/// Verify that text fix mode shows [fixed] labels while JSON fix mode shows remaining-only.
/// This test catches regressions where [fixed] labels are accidentally removed from text output
/// or accidentally included in structured output.
#[test]
fn test_fix_mode_text_vs_json_output() {
    let temp_dir = setup_test_files();

    // Create a file with both fixable (MD037) and unfixable (MD013) issues
    let test_file = temp_dir.path().join("text_vs_json.md");
    fs::write(
        &test_file,
        "# Test\nThis paragraph contains * spaced emphasis * that is fixable.\nThis line is extremely long and exceeds the maximum line length which cannot be automatically fixed because line wrapping requires manual intervention by the user to decide where to break.\n",
    ).unwrap();

    let config_file = temp_dir.path().join("tvj_config.toml");
    fs::write(&config_file, "[MD013]\nline_length = 20\n").unwrap();

    let test_file_path = test_file.to_str().unwrap();
    let config_file_path = config_file.to_str().unwrap();

    // Run in fix mode with default text output
    let text_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix", "--config", config_file_path])
        .env("NO_COLOR", "1")
        .output()
        .expect("Failed to execute command");

    let text_stdout = String::from_utf8_lossy(&text_output.stdout);

    // Text output MUST show [fixed] labels for fixed issues
    assert!(
        text_stdout.contains("[fixed]"),
        "Text fix mode must show [fixed] labels for fixed issues. stdout: {text_stdout}"
    );

    // Text output MUST show both fixed and unfixed warnings
    assert!(
        text_stdout.contains("[MD037]"),
        "Text fix mode must show MD037 (fixed). stdout: {text_stdout}"
    );
    assert!(
        text_stdout.contains("[MD013]"),
        "Text fix mode must show MD013 (unfixable). stdout: {text_stdout}"
    );

    // MD037 line should have [fixed], MD013 line should NOT
    let md037_line = text_stdout.lines().find(|l| l.contains("[MD037]")).unwrap_or("");
    assert!(
        md037_line.contains("[fixed]"),
        "MD037 must have [fixed] label in text output. line: {md037_line}"
    );
    let md013_line = text_stdout.lines().find(|l| l.contains("[MD013]")).unwrap_or("");
    assert!(
        !md013_line.contains("[fixed]"),
        "MD013 must NOT have [fixed] label. line: {md013_line}"
    );

    // Restore the file for the next run
    fs::write(
        &test_file,
        "# Test\nThis paragraph contains * spaced emphasis * that is fixable.\nThis line is extremely long and exceeds the maximum line length which cannot be automatically fixed because line wrapping requires manual intervention by the user to decide where to break.\n",
    ).unwrap();

    // Run in fix mode with JSON output
    let json_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args([
            "check",
            test_file_path,
            "--fix",
            "--output-format",
            "json",
            "--config",
            config_file_path,
        ])
        .output()
        .expect("Failed to execute command");

    let json_stdout = String::from_utf8_lossy(&json_output.stdout);

    // JSON output should be valid JSON
    let parsed: serde_json::Value = serde_json::from_str(&json_stdout)
        .unwrap_or_else(|e| panic!("Invalid JSON output: {e}. stdout: {json_stdout}"));
    let warnings = parsed.as_array().expect("JSON output should be an array");

    // JSON output should contain ONLY remaining (unfixed) warnings
    for w in warnings {
        let rule = w["rule"].as_str().unwrap_or("");
        assert_ne!(
            rule, "MD037",
            "JSON fix mode must NOT include fixed warnings (MD037). Got: {json_stdout}"
        );
    }

    // JSON output should still contain unfixable warnings
    let has_md013 = warnings.iter().any(|w| w["rule"].as_str() == Some("MD013"));
    assert!(
        has_md013,
        "JSON fix mode must include remaining unfixable warnings (MD013). Got: {json_stdout}"
    );

    // JSON output must NOT have a "fixed" field
    for w in warnings {
        assert!(
            w.get("fixed").is_none(),
            "JSON output must not contain 'fixed' field. Got: {w}"
        );
    }
}

#[test]
fn test_color_output_disabled() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();
    let test_file = temp_dir.path().join("single_file.md");
    let test_file_path = test_file.to_str().unwrap();

    // Run with NO_COLOR environment variable to disable colored output
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .env("NO_COLOR", "1")
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("No color output:\n{stdout}");

    // ANSI color codes start with ESC character (27) followed by [
    // In Rust strings, this looks like \x1b[
    assert!(
        !stdout.contains("\x1b["),
        "Output should not contain ANSI color codes when NO_COLOR is set"
    );
}

#[test]
fn test_quiet_mode_output() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();
    let test_file = temp_dir.path().join("single_file.md");
    let test_file_path = test_file.to_str().unwrap();

    // Run with --silent flag
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--silent"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Silent mode output:\n{stdout}");

    // Verify output is suppressed
    assert!(
        stdout.is_empty(),
        "Silent mode should suppress standard output, got: {stdout}"
    );

    // Run with --silent and --fix to ensure it still fixes issues but doesn't output
    let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--silent", "--fix"])
        .output()
        .expect("Failed to execute command");

    let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
    assert!(
        fix_stdout.is_empty(),
        "Quiet mode with fix should suppress standard output, got: {fix_stdout}"
    );

    // Verify that fix was still applied by checking the content of the fixed file
    let content = fs::read_to_string(test_file_path).expect("Failed to read file");
    assert!(
        content.ends_with('\n'),
        "File should have been fixed (newline added) even in quiet mode"
    );
}

#[test]
fn test_verbose_mode_output() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();
    let test_file = temp_dir.path().join("single_file.md");
    let test_file_path = test_file.to_str().unwrap();

    // Run with --verbose flag
    let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--verbose"])
        .output()
        .expect("Failed to execute command");

    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Verbose mode output:\n{stdout}");

    // Verify verbose output includes file processing messages
    assert!(
        stdout.contains("Processing file:"),
        "Verbose mode should include 'Processing file:' messages"
    );

    // Verify verbose output includes list of rules
    assert!(
        stdout.contains("Enabled rules:"),
        "Verbose mode should include list of enabled rules"
    );

    // Verify the basic issues are still present in output
    // The test file should trigger at least some warnings
    assert!(
        stdout.contains("[MD"),
        "Verbose mode should still show lint warnings in the output"
    );
}

#[test]
fn test_exit_code_validation() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a clean file with no issues
    let clean_file = temp_dir.path().join("clean_file.md");
    fs::write(&clean_file, "# Clean File\n\nThis file has no issues.\n").unwrap();

    // Create a file with issues
    let issue_file = temp_dir.path().join("single_file.md");

    // Run linter on file with issues
    let output_with_issues = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", issue_file.to_str().unwrap()])
        .output()
        .expect("Failed to execute command");

    // Run linter on clean file
    let output_no_issues = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", clean_file.to_str().unwrap()])
        .output()
        .expect("Failed to execute command");

    // Verify exit code is non-zero when issues found
    assert_ne!(
        output_with_issues.status.code(),
        Some(0),
        "Exit code should be non-zero when issues are found"
    );

    // Verify exit code is zero when no issues found
    assert_eq!(
        output_no_issues.status.code(),
        Some(0),
        "Exit code should be zero when no issues are found"
    );

    // Verify fix mode results in exit code 0 if all issues fixed
    let output_fix = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", issue_file.to_str().unwrap(), "--fix"])
        .output()
        .expect("Failed to execute command");

    // If exit code is 0, all issues were fixed. Otherwise, some were unfixable.
    let fix_stdout = String::from_utf8_lossy(&output_fix.stdout);
    if !fix_stdout.contains("issues") || fix_stdout.contains("Fixed: 0/") {
        // No issues or no issues fixed
        assert_eq!(
            output_fix.status.code(),
            Some(0),
            "Exit code should be 0 when no issues remain after fix"
        );
    }
}

#[test]
fn test_rule_with_configuration() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a file with customizable issues (line length can be configured)
    let test_file = temp_dir.path().join("configurable_issue.md");
    fs::write(
        &test_file,
        "# Configurable Issue\n\nThis line is exactly 70 characters long which is within default limits.\nThis line is a bit longer and has exactly 75 characters which exceeds 70 chars.\nThis line is much longer and definitely exceeds the default limit of 80 characters by a substantial margin including many extra words that ensure it is well over the limit of 80 characters making it extremely long and definitely triggering the MD013 rule with its default configuration.\n",
    ).unwrap();

    let test_file_path = test_file.to_str().unwrap();

    // First run with default configuration (line length 80)
    let default_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let default_stdout = String::from_utf8_lossy(&default_output.stdout);
    println!("Default configuration output:\n{default_stdout}");

    // Check that line length issues are reported with default config (limit 80)
    assert!(
        default_stdout.contains("[MD013]"),
        "Should detect line length issues over 80 characters with default configuration"
    );

    // Now create a custom config file with lower limit
    let config_file = temp_dir.path().join(".rumdl.toml");
    fs::write(
        &config_file,
        r#"
[MD013]
line_length = 70
"#,
    )
    .unwrap();

    // Run with custom configuration (line length 70)
    let custom_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .current_dir(temp_dir.path()) // Important: set working directory to where config file is
        .output()
        .expect("Failed to execute command");

    let custom_stdout = String::from_utf8_lossy(&custom_output.stdout);
    println!("Custom configuration output:\n{custom_stdout}");

    // Custom config should detect more issues (since limit is lower)
    assert!(
        custom_stdout.contains("[MD013]"),
        "Should detect line length issues over 70 characters with custom configuration"
    );

    // The custom configuration should detect the second line as an issue (over 70 chars)
    // which the default configuration wouldn't detect (since it's under 80)
    let default_issue_count = default_stdout.matches("[MD013]").count();
    let custom_issue_count = custom_stdout.matches("[MD013]").count();

    assert!(
        custom_issue_count > default_issue_count,
        "Custom configuration should detect more issues than default configuration"
    );
}

#[test]
fn test_fixed_content_validation() {
    // Create temporary markdown files
    let temp_dir = setup_test_files();

    // Create a simple test file with specific fixable issues
    let test_file = temp_dir.path().join("fixable_issues.md");
    fs::write(&test_file, "# Missing Newline\nThis line does not end with a newline").unwrap();

    let test_file_path = test_file.to_str().unwrap();

    // Save original content for comparison
    let original_content = fs::read_to_string(&test_file).unwrap();

    // Run the linter to verify the issue exists
    let check_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let check_stdout = String::from_utf8_lossy(&check_output.stdout);
    println!("Pre-fix output:\n{check_stdout}");

    // Verify the file has the MD047 issue (missing newline)
    assert!(
        check_stdout.contains("[MD047]") && check_stdout.contains("newline"),
        "Should detect missing newline issue"
    );

    // Fix the file
    let fix_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path, "--fix"])
        .output()
        .expect("Failed to execute command");

    let fix_stdout = String::from_utf8_lossy(&fix_output.stdout);
    println!("Fix output:\n{fix_stdout}");

    // Check that content was actually modified
    let fixed_content = fs::read_to_string(&test_file).unwrap();
    assert_ne!(
        original_content, fixed_content,
        "Content should be modified after fixing"
    );

    // Missing newline should be added
    assert!(fixed_content.ends_with('\n'), "Fixed content should end with a newline");

    // Run linter again to verify no issues remain
    let recheck_output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
        .args(["check", test_file_path])
        .output()
        .expect("Failed to execute command");

    let recheck_stdout = String::from_utf8_lossy(&recheck_output.stdout);
    println!("Post-fix check output:\n{recheck_stdout}");

    // The specific fixed issue should not be reported again
    assert!(!recheck_stdout.contains("[MD047]"), "MD047 issue should be fixed");
}