cargo-gears-lints 0.0.1

Dylint lint collection for cargo-gears architectural rules
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
extern crate rustc_ast;
extern crate rustc_lint;
extern crate rustc_span;

use rustc_lint::LintContext;

use rustc_ast::{UseTree, UseTreeKind};

use rustc_span::source_map::SourceMap;
use rustc_span::{FileName, RemapPathScopeComponents, Span};
use std::collections::HashSet;

const ALLOWED_FLAGS: &[&str] = &["request", "response"];

pub fn is_in_domain_path(source_map: &SourceMap, span: Span) -> bool {
    check_span_path(source_map, span, "/domain/")
}

pub fn is_in_infra_path(source_map: &SourceMap, span: Span) -> bool {
    check_span_path(source_map, span, "/infra/")
}

pub fn is_in_contract_path(source_map: &SourceMap, span: Span) -> bool {
    check_span_path(source_map, span, "/contract/")
}

/// AST-based helper to check if an item is in a contract module.
/// This works with EarlyLintPass and checks both file paths and simulated_dir comments.
pub fn is_in_contract_module_ast(
    cx: &rustc_lint::EarlyContext<'_>,
    item: &rustc_ast::Item,
) -> bool {
    is_in_contract_path(cx.sess().source_map(), item.span)
}

/// AST-based helper to check if an item is in a domain module.
/// This works with EarlyLintPass and checks both file paths and simulated_dir comments.
pub fn is_in_domain_module_ast(cx: &rustc_lint::EarlyContext<'_>, item: &rustc_ast::Item) -> bool {
    is_in_domain_path(cx.sess().source_map(), item.span)
}

pub fn is_in_api_rest_folder(source_map: &SourceMap, span: Span) -> bool {
    check_span_path(source_map, span, "/api/rest/")
}

pub fn is_in_module_folder(source_map: &SourceMap, span: Span) -> bool {
    check_span_path(source_map, span, "/modules/")
}

/// Extract the filename string from a span.
/// Handles local paths and remapped paths with virtual name fallback.
pub fn filename_str(source_map: &SourceMap, span: Span) -> Option<String> {
    let file_name = source_map.span_to_filename(span);
    match &file_name {
        FileName::Real(real) => {
            if let Some(local) = real.local_path() {
                Some(local.to_string_lossy().to_string())
            } else {
                Some(
                    real.path(RemapPathScopeComponents::DIAGNOSTICS)
                        .to_string_lossy()
                        .to_string(),
                )
            }
        }
        _ => None,
    }
}

/// Check if a file path is in a temporary directory (used by test infrastructure).
pub fn is_temp_path(path: &str) -> bool {
    // Primary check: compare against the actual system temp directory
    let temp_dir = std::env::temp_dir();
    if let Some(temp_str) = temp_dir.to_str()
        && path.starts_with(temp_str)
    {
        return true;
    }
    // Fallback patterns for known temp directory locations
    path.contains("/tmp/") || path.contains("/var/folders/") || path.contains("\\Temp\\")
}

/// Result of parsing a version suffix from a name like `FooClientV1` or `FooClient2`.
pub struct VersionParts<'a> {
    /// Base name without version suffix or trailing digits (e.g., `FooClient`)
    pub base: &'a str,
    /// Valid version suffix like `V1`, `V2`, or empty string if none
    pub version_suffix: &'a str,
    /// Trailing digits without V prefix (e.g., `2` from `FooClient2`), or empty string
    pub malformed_digits: &'a str,
}

impl VersionParts<'_> {
    /// Returns true if a valid version suffix (V + digits) was found.
    pub fn has_valid_version(&self) -> bool {
        !self.version_suffix.is_empty()
    }

    /// Returns true if there are trailing digits but no V prefix (malformed version).
    pub fn has_malformed_version(&self) -> bool {
        !self.malformed_digits.is_empty() && self.version_suffix.is_empty()
    }
}

/// Parse version suffix from a trait/type name.
///
/// - `FooClientV1`  -> base=`FooClient`, version_suffix=`V1`, malformed_digits=``
/// - `FooClientV10` -> base=`FooClient`, version_suffix=`V10`, malformed_digits=``
/// - `FooClient2`   -> base=`FooClient`, version_suffix=``, malformed_digits=`2`
/// - `FooClient`    -> base=`FooClient`, version_suffix=``, malformed_digits=``
/// - `FooClientV`   -> base=`FooClient`, version_suffix=``, malformed_digits=`` (bare V stripped)
/// - `FooClientV0`  -> base=`FooClient`, version_suffix=``, malformed_digits=`` (V0 rejected)
/// - `FooClientV01` -> base=`FooClient`, version_suffix=``, malformed_digits=`` (leading zero rejected)
pub fn parse_version_suffix(name: &str) -> VersionParts<'_> {
    if name.is_empty() {
        return VersionParts {
            base: name,
            version_suffix: "",
            malformed_digits: "",
        };
    }

    let bytes = name.as_bytes();
    let len = bytes.len();

    let mut digit_count = 0;
    for &b in bytes.iter().rev() {
        if b.is_ascii_digit() {
            digit_count += 1;
        } else {
            break;
        }
    }

    if digit_count == 0 {
        // No trailing digits — check for bare trailing V (e.g., `FooClientV`)
        if len > 1 && bytes[len - 1] == b'V' {
            return VersionParts {
                base: &name[..len - 1],
                version_suffix: "",
                malformed_digits: "",
            };
        }
        return VersionParts {
            base: name,
            version_suffix: "",
            malformed_digits: "",
        };
    }

    let digits_start = len - digit_count;

    if digits_start > 0 && bytes[digits_start - 1] == b'V' {
        let v_pos = digits_start - 1;
        let digit_str = &name[digits_start..];

        // Valid version: V followed by non-zero number without leading zeros (V1, V2, V10)
        // Invalid: V0, V00, V01 (leading zeros or zero version)
        if !digit_str.starts_with('0') {
            VersionParts {
                base: &name[..v_pos],
                version_suffix: &name[v_pos..],
                malformed_digits: "",
            }
        } else {
            // V0, V00, V01 — strip the invalid V-prefix version from base
            VersionParts {
                base: &name[..v_pos],
                version_suffix: "",
                malformed_digits: "",
            }
        }
    } else {
        VersionParts {
            base: &name[..digits_start],
            version_suffix: "",
            malformed_digits: &name[digits_start..],
        }
    }
}

/// Check if the current compilation target is an SDK crate (by crate name or file path).
///
/// Also returns true for files in temporary directories — this is required because
/// `dylint_testing::ui_test_examples()` compiles UI test files from temp dirs without
/// passing `--crate-name`, so the crate name check alone doesn't work for UI tests.
pub fn is_in_sdk_crate(cx: &rustc_lint::EarlyContext<'_>, span: Span) -> bool {
    if let Some(crate_name) = cx.sess().opts.crate_name.as_deref()
        // Cargo normalizes dashes to underscores for `--crate-name`.
        && (crate_name.ends_with("-sdk") || crate_name.ends_with("_sdk"))
    {
        return true;
    }

    let Some(file_path) = filename_str(cx.sess().source_map(), span) else {
        return false;
    };

    file_path.contains("-sdk/") || file_path.contains("-sdk\\") || is_temp_path(&file_path)
}

/// Check if span is within libs/modkit-db/ - the internal sqlx wrapper library
/// This path is excluded from sqlx restrictions as it provides the abstraction layer
pub fn is_in_modkit_db_path(source_map: &SourceMap, span: Span) -> bool {
    // Multiple checks handle different path contexts:
    // - "/libs/modkit-db/" - absolute path from workspace root
    // - "libs/modkit-db/" - relative path in some contexts
    // - "modkit-db/src/" - simulated_dir paths in tests
    check_span_path(source_map, span, "/libs/modkit-db/")
        || check_span_path(source_map, span, "libs/modkit-db/")
        || check_span_path(source_map, span, "modkit-db/src/")
}

/// Check if span is within apps/cyberware-example-server - the main server binary
/// This path is excluded from sqlx restrictions as it needs driver linkage workaround
pub fn is_in_cyberware_server_path(source_map: &SourceMap, span: Span) -> bool {
    // Multiple checks handle different path contexts:
    // - "/apps/cyberware-example-server/" - absolute path from workspace root
    // - "apps/cyberware-example-server/" - relative path in some contexts
    // - "cyberware-example-server/src/" - simulated_dir paths in tests
    check_span_path(source_map, span, "/apps/cyberware-example-server/")
        || check_span_path(source_map, span, "apps/cyberware-example-server/")
        || check_span_path(source_map, span, "cyberware-example-server/src/")
}

pub fn check_derive_attrs<F>(item: &rustc_ast::Item, mut f: F)
where
    F: FnMut(&rustc_ast::MetaItem, &rustc_ast::Attribute),
{
    for attr in &item.attrs {
        if !attr.has_name(rustc_span::symbol::sym::derive) {
            continue;
        }

        // Parse the derive attribute meta list
        if let rustc_ast::AttrKind::Normal(attr_item) = &attr.kind
            && let Some(meta_items) = attr_item.item.meta_item_list()
        {
            for nested_meta in meta_items {
                if let Some(meta_item) = nested_meta.meta_item() {
                    f(meta_item, attr)
                }
            }
        }
    }
}

pub fn get_derive_path_segments(meta_item: &rustc_ast::MetaItem) -> Vec<&str> {
    let path = &meta_item.path;
    path.segments
        .iter()
        .map(|s| s.ident.name.as_str())
        .collect()
}

/// Check if path segments represent a serde trait (Serialize or Deserialize)
///
/// Handles various forms:
/// - Bare: `Serialize`, `Deserialize`
/// - Qualified: `serde::Serialize`, `serde::Deserialize`
/// - Fully qualified: `::serde::Serialize`
/// ```
pub fn is_serde_trait(segments: &[&str], trait_name: &str) -> bool {
    if segments.is_empty() {
        return false;
    }

    if segments.last() != Some(&trait_name) {
        return false;
    }

    // If it's a qualified path, ensure it contains "serde"
    // Accept: serde::Serialize, ::serde::Serialize
    // Reject: other_crate::Serialize
    if segments.len() >= 2 {
        segments.contains(&"serde")
    } else {
        // Bare identifier: Serialize or Deserialize
        // We accept this as it's commonly used with `use serde::{Serialize, Deserialize}`
        true
    }
}

/// Check if an item has the `#[modkit_macros::api_dto(...)]` attribute.
///
/// The `api_dto` macro automatically adds:
/// - `#[derive(serde::Serialize)]` (if `response` is specified)
/// - `#[derive(serde::Deserialize)]` (if `request` is specified)
/// - `#[derive(utoipa::ToSchema)]` (always)
/// - `#[serde(rename_all = "snake_case")]` (if `request` or `response` are specified)
///
/// Lints checking for these derives/attributes should skip items with this attribute.
pub fn has_api_dto_attribute(item: &rustc_ast::Item) -> bool {
    for attr in &item.attrs {
        // Check for modkit_macros::api_dto or just api_dto
        if let rustc_ast::AttrKind::Normal(attr_item) = &attr.kind {
            let path = &attr_item.item.path;
            let segments: Vec<&str> = path
                .segments
                .iter()
                .map(|s| s.ident.name.as_str())
                .collect();

            // Match: api_dto, modkit_macros::api_dto
            if segments.last() == Some(&"api_dto") {
                return true;
            }
        }
    }
    false
}

/// Returns the api_dto arguments (request, response) if present and valid.
/// Returns None if the attribute is not present OR if it contains invalid flags.
/// Returns Some with flags indicating which modes are enabled.
///
/// # Validation
///
/// This function validates the attribute arguments to match the proc-macro's behavior:
/// - Only "request" and "response" flags are allowed
/// - Duplicate flags are rejected
/// - Unknown flags are rejected
/// - At least one of "request" or "response" must be present
///
/// If any validation fails, this function returns `None`, treating the invalid
/// attribute the same as an absent attribute. This ensures lint behavior stays
/// in sync with the proc-macro, which would reject these attributes at compile time.
pub fn get_api_dto_args(item: &rustc_ast::Item) -> Option<ApiDtoArgs> {
    for attr in &item.attrs {
        if let rustc_ast::AttrKind::Normal(attr_item) = &attr.kind {
            let path = &attr_item.item.path;
            let segments: Vec<&str> = path
                .segments
                .iter()
                .map(|s| s.ident.name.as_str())
                .collect();

            if segments.last() != Some(&"api_dto") {
                continue;
            }

            // Parse and validate the arguments
            let mut has_request = false;
            let mut has_response = false;
            let mut seen_flags = HashSet::new();
            let mut has_invalid = false;

            if let Some(args) = attr_item.item.meta_item_list() {
                for arg in args {
                    if let Some(ident) = arg.ident() {
                        let flag_str = ident.name.as_str();

                        // Check if flag is allowed
                        if !ALLOWED_FLAGS.contains(&flag_str) {
                            has_invalid = true;
                            break;
                        }

                        // Check for duplicates (convert to String for storage)
                        if !seen_flags.insert(flag_str.to_string()) {
                            has_invalid = true;
                            break;
                        }

                        match flag_str {
                            "request" => has_request = true,
                            "response" => has_response = true,
                            _ => unreachable!(),
                        }
                    }
                }
            }

            // Reject invalid attributes by returning None
            if has_invalid {
                return None;
            }

            // Reject empty attributes (no request or response)
            if !has_request && !has_response {
                return None;
            }

            return Some(ApiDtoArgs {
                has_request,
                has_response,
            });
        }
    }
    None
}

/// Arguments parsed from a valid `#[api_dto(request, response)]` attribute.
///
/// # Validity
///
/// This struct is only returned by `get_api_dto_args()` for valid attributes.
/// Invalid attributes (unknown flags, duplicates, or empty) cause `get_api_dto_args()`
/// to return `None` instead.
///
/// A valid `api_dto` attribute has:
/// - At least one of `request` or `response`
/// - Only "request" and "response" flags (no unknown flags)
/// - No duplicate flags
#[derive(Debug, Clone, Copy)]
pub struct ApiDtoArgs {
    pub has_request: bool,
    pub has_response: bool,
}

impl ApiDtoArgs {
    /// Returns true if the macro will add Serialize derive (response mode)
    pub fn adds_serialize(&self) -> bool {
        self.has_response
    }

    /// Returns true if the macro will add Deserialize derive (request mode)
    pub fn adds_deserialize(&self) -> bool {
        self.has_request
    }

    /// Returns true if the macro will add ToSchema derive.
    /// Always returns true because `ApiDtoArgs` only exists for valid attributes.
    pub fn adds_toschema(&self) -> bool {
        true
    }

    /// Returns true if the macro will add serde(rename_all = "snake_case").
    /// This is added when serde derives are present (i.e., at least one mode is enabled).
    /// Always returns true for valid `ApiDtoArgs` since validation requires at least one mode.
    pub fn adds_snake_case_rename(&self) -> bool {
        // Matches proc-macro logic: has_serde = serialize || deserialize
        self.has_request || self.has_response
    }
}

// Check if path segments represent a utoipa trait
// Examples: ["ToSchema"], ["utoipa", "ToSchema"], ["utoipa", "ToSchema"]
pub fn is_utoipa_trait(segments: &[&str], trait_name: &str) -> bool {
    if segments.is_empty() {
        return false;
    }

    if segments.last() != Some(&trait_name) {
        return false;
    }

    // If it's a qualified path, ensure it contains "utoipa"
    // Accept: utoipa::ToSchema, ::utoipa::ToSchema
    // Reject: other_crate::ToSchema
    if segments.len() >= 2 {
        segments.contains(&"utoipa")
    } else {
        // Bare identifier: ToSchema
        // We accept this as it's commonly used with `use utoipa::ToSchema`
        true
    }
}

/// Converts a UseTree to a vector of fully qualified path strings.
/// Handles Simple, Glob, and Nested use tree kinds.
///
/// Examples:
/// - `use foo::bar` -> `["foo::bar"]`
/// - `use foo::{bar, baz}` -> `["foo::bar", "foo::baz"]`
/// - `use foo::*` -> `["foo"]`
pub fn use_tree_to_strings(tree: &UseTree) -> Vec<String> {
    match &tree.kind {
        UseTreeKind::Simple(..) | UseTreeKind::Glob => {
            vec![
                tree.prefix
                    .segments
                    .iter()
                    .map(|seg| seg.ident.name.as_str())
                    .collect::<Vec<_>>()
                    .join("::"),
            ]
        }
        UseTreeKind::Nested { items, .. } => {
            let prefix = tree
                .prefix
                .segments
                .iter()
                .map(|seg| seg.ident.name.as_str())
                .collect::<Vec<_>>()
                .join("::");

            let mut paths = Vec::new();
            for (nested_tree, _) in items {
                for nested_str in use_tree_to_strings(nested_tree) {
                    if nested_str.is_empty() {
                        paths.push(prefix.clone());
                    } else if prefix.is_empty() {
                        paths.push(nested_str);
                    } else {
                        paths.push(format!("{}::{}", prefix, nested_str));
                    }
                }
            }
            if paths.is_empty() {
                vec![prefix]
            } else {
                paths
            }
        }
    }
}

fn check_span_path(source_map: &SourceMap, span: Span, pattern: &str) -> bool {
    let pattern_windows = pattern.replace('/', "\\");
    let Some(path_str) = get_path_str_from_session(source_map, span) else {
        // If we can't get the path (e.g., synthetic/virtual files), assume not matching
        return false;
    };

    // Check for simulated directory in test files first
    if let Some(simulated) = extract_simulated_dir(&path_str) {
        return simulated.contains(pattern) || simulated.contains(&pattern_windows);
    }

    path_str.contains(pattern) || path_str.contains(&pattern_windows)
}

fn get_path_str_from_session(source_map: &SourceMap, span: Span) -> Option<String> {
    let file_name = source_map.span_to_filename(span);

    match file_name {
        FileName::Real(ref real_name) => real_name
            .local_path()
            .map(|local| local.to_string_lossy().to_string()),
        _ => None,
    }
}

/// Extract simulated directory path from a comment at the start of a file.
/// Looks for a comment like: `// simulated_dir=/cyberfabric/modules/some_module/contract/`
/// Returns None if no such comment is found.
///
/// Only checks files in temporary directories to avoid unnecessary file I/O in production.
fn extract_simulated_dir(path_str: &str) -> Option<String> {
    // Only check for simulated_dir in temporary paths (tests run in temp directories)
    let is_temp = path_str.contains("/tmp/")
        || path_str.contains("/var/folders/")  // macOS temp
        || path_str.contains("\\Temp\\")        // Windows temp
        || path_str.contains(".tmp"); // dylint test temp dirs

    if !is_temp {
        return None;
    }

    // Read the first few lines of the file to check for simulated_dir comment
    let contents = std::fs::read_to_string(std::path::PathBuf::from(path_str)).ok()?;

    for line in contents.lines().take(1) {
        let trimmed = line.trim();
        if trimmed.starts_with("// simulated_dir=") {
            return Some(trimmed.trim_start_matches("// simulated_dir=").to_string());
        }
        if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("#!") {
            break;
        }
    }

    None
}

/// Test helper function to validate that comment annotations in UI test files match the stderr outputs.
///
/// This function scans all `.rs` files in the specified UI test directory and verifies that:
/// - Lines with a "Should trigger" comment have corresponding errors in the `.stderr` file
/// - Lines with a "Should not trigger" comment do NOT have errors in the `.stderr` file
/// - All errors in `.stderr` files are properly annotated with "Should trigger" comments
///
/// # Arguments
/// * `ui_dir` - Path to the directory containing UI test files
/// * `lint_code` - The lint code to check for in comments (e.g., "DE0101")
/// * `comment_pattern` - The pattern to match in comments (e.g., "Serde in contract")
pub fn test_comment_annotations_match_stderr(
    ui_dir: &std::path::Path,
    lint_code: &str,
    comment_pattern: &str,
) {
    use std::collections::{HashMap, HashSet};
    use std::fs;

    let trigger_comment = format!("// Should trigger {} - {}", lint_code, comment_pattern);
    let not_trigger_comment = format!("// Should not trigger {} - {}", lint_code, comment_pattern);

    // Find all .rs files in ui directory
    let rs_files: Vec<_> = fs::read_dir(ui_dir)
        .expect("Failed to read ui directory")
        .filter_map(|entry| {
            let entry = entry.ok()?;
            let path = entry.path();
            if path.extension()? == "rs" {
                Some(path)
            } else {
                None
            }
        })
        .collect();

    assert!(
        !rs_files.is_empty(),
        "No .rs test files found in ui directory"
    );

    for rs_file in rs_files {
        let stderr_file = rs_file.with_extension("stderr");

        // Read the .rs file
        let rs_content =
            fs::read_to_string(&rs_file).unwrap_or_else(|_| panic!("Failed to read {:?}", rs_file));

        // Read the .stderr file (if it exists)
        let stderr_content = fs::read_to_string(&stderr_file).unwrap_or_default();

        // Parse lines from .rs file
        let rs_lines: Vec<&str> = rs_content.lines().collect();

        // Find all lines with "Should trigger" or "Should not trigger" comments
        let mut should_trigger_lines = HashMap::new();
        let mut should_not_trigger_lines = HashMap::new();

        for (idx, line) in rs_lines.iter().enumerate() {
            let comment_line_num = idx + 1;
            let expected_error_line_num = idx + 2;

            if line.contains(&trigger_comment) {
                // The next line should have an error (idx + 1 is the next line, +1 again for 1-indexed)
                should_trigger_lines.insert(expected_error_line_num, comment_line_num);
            } else if line.contains(&not_trigger_comment) {
                // The next line should NOT have an error
                should_not_trigger_lines.insert(expected_error_line_num, comment_line_num);
            }
        }

        // Parse stderr file to find which lines have errors
        let mut error_lines = HashSet::new();
        for line in stderr_content.lines() {
            // Look for lines like "  --> $DIR/file.rs:5:1"
            if line.contains("-->")
                && line.contains(".rs:")
                && let Some(pos) = line.rfind(".rs:")
            {
                let rest = &line[pos + 4..];
                if let Some(colon_pos) = rest.find(':')
                    && let Ok(line_num) = rest[..colon_pos].parse::<usize>()
                {
                    error_lines.insert(line_num);
                }
            }
        }

        // Validate that should_trigger_lines match error_lines
        for (line_num, comment_line_num) in &should_trigger_lines {
            assert!(
                error_lines.contains(line_num),
                "In {:?}: Line {} has '{}' comment but no corresponding error in .stderr file",
                rs_file.file_name().unwrap(),
                comment_line_num,
                trigger_comment
            );
        }

        // Validate that should_not_trigger_lines do NOT appear in error_lines
        for (line_num, comment_line_num) in &should_not_trigger_lines {
            assert!(
                !error_lines.contains(line_num),
                "In {:?}: Line {} has '{}' comment but has an error in .stderr file",
                rs_file.file_name().unwrap(),
                comment_line_num,
                not_trigger_comment
            );
        }

        // Also verify that all error_lines are marked with should_trigger comments
        for line_num in &error_lines {
            assert!(
                should_trigger_lines.contains_key(line_num),
                "In {:?}: Line {} has an error in .stderr file but no '{}' comment",
                rs_file.file_name().unwrap(),
                line_num,
                trigger_comment
            );
        }
    }
}