Skip to main content

codeowners_parser/
parser.rs

1//! Parse CODEOWNERS files into structured lines, with or without positions.
2//!
3//! Two parse entry points:
4//! - [`parse_codeowners_file_with_positions`] — retains character offsets
5//!   for every pattern, owner span, and inline comment. Use this for
6//!   editor tooling.
7//! - [`parse_codeowners_file`] — drops positions, returns only the
8//!   logical content. Use this for CLI-style tools that only need to
9//!   inspect or rewrite rules.
10
11/// A parsed CODEOWNERS line with character offsets for editor tooling.
12#[derive(Debug, Clone)]
13pub struct ParsedLine {
14    /// 0-indexed line number in the source file.
15    pub line_number: u32,
16    /// The logical content of the line (rule, comment, or empty).
17    pub content: CodeownersLine,
18    /// Character offset where the pattern starts (0 for non-rule lines).
19    pub pattern_start: u32,
20    /// Character offset one past the end of the pattern.
21    pub pattern_end: u32,
22    /// Character offset where owners start (equals `pattern_end` if no owners).
23    pub owners_start: u32,
24    /// Character offset of the inline comment's `#`, if any.
25    pub comment_start: Option<u32>,
26}
27
28/// The logical content of a CODEOWNERS line.
29#[derive(Debug, Clone, PartialEq)]
30pub enum CodeownersLine {
31    /// A comment line (starts with `#`). The stored string includes the `#`.
32    Comment(String),
33    /// A blank or whitespace-only line.
34    Empty,
35    /// A rule line: a glob pattern followed by zero or more owners.
36    Rule {
37        /// The glob pattern, exactly as written (no normalization).
38        pattern: String,
39        /// Owners assigned to this rule. May be empty.
40        owners: Vec<String>,
41    },
42}
43
44impl std::fmt::Display for CodeownersLine {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            CodeownersLine::Comment(c) => write!(f, "{}", c),
48            CodeownersLine::Empty => Ok(()),
49            CodeownersLine::Rule { pattern, owners } => {
50                write!(f, "{} {}", pattern, owners.join(" "))
51            }
52        }
53    }
54}
55
56/// Parse a CODEOWNERS file into structured lines with position info
57pub fn parse_codeowners_file_with_positions(content: &str) -> Vec<ParsedLine> {
58    content
59        .lines()
60        .enumerate()
61        .map(|(line_num, line)| {
62            let trimmed = line.trim();
63            if trimmed.is_empty() {
64                ParsedLine {
65                    line_number: line_num as u32,
66                    content: CodeownersLine::Empty,
67                    pattern_start: 0,
68                    pattern_end: 0,
69                    owners_start: 0,
70                    comment_start: None,
71                }
72            } else if trimmed.starts_with('#') {
73                ParsedLine {
74                    line_number: line_num as u32,
75                    content: CodeownersLine::Comment(line.to_string()),
76                    pattern_start: 0,
77                    pattern_end: 0,
78                    owners_start: 0,
79                    comment_start: None,
80                }
81            } else {
82                // Split by whitespace, stopping at # (end-of-line comment)
83                let parts: Vec<&str> = line
84                    .split_whitespace()
85                    .take_while(|part| !part.starts_with('#'))
86                    .collect();
87
88                // Find the inline comment position (first # that's a separate whitespace-delimited token)
89                let comment_start = find_inline_comment_start(line).map(|pos| pos as u32);
90
91                if parts.is_empty() {
92                    ParsedLine {
93                        line_number: line_num as u32,
94                        content: CodeownersLine::Empty,
95                        pattern_start: 0,
96                        pattern_end: 0,
97                        owners_start: 0,
98                        comment_start: None,
99                    }
100                } else {
101                    // Find pattern position
102                    let pattern_start = line.find(parts[0]).unwrap_or(0) as u32;
103                    let pattern_end = pattern_start + parts[0].len() as u32;
104                    // Search after the pattern, not from the start of the line —
105                    // an owner that also appears inside its own pattern (say
106                    // `api/src api`) would otherwise report the pattern's offset.
107                    let owners_start = if parts.len() > 1 {
108                        line[pattern_end as usize..]
109                            .find(parts[1])
110                            .map(|offset| pattern_end + offset as u32)
111                            .unwrap_or(pattern_end)
112                    } else {
113                        pattern_end
114                    };
115
116                    ParsedLine {
117                        line_number: line_num as u32,
118                        content: CodeownersLine::Rule {
119                            pattern: parts[0].to_string(),
120                            owners: parts[1..].iter().map(|s| s.to_string()).collect(),
121                        },
122                        pattern_start,
123                        pattern_end,
124                        owners_start,
125                        comment_start,
126                    }
127                }
128            }
129        })
130        .collect()
131}
132
133/// Find the char offset of an inline comment on a rule line.
134/// An inline comment starts with `#` that is preceded by whitespace.
135pub fn find_inline_comment_start(line: &str) -> Option<usize> {
136    let mut in_whitespace = true;
137    for (i, c) in line.chars().enumerate() {
138        if c == '#' && in_whitespace {
139            return Some(i);
140        }
141        in_whitespace = c == ' ' || c == '\t';
142    }
143    None
144}
145
146/// Parse a CODEOWNERS file into structured lines (without positions)
147#[allow(dead_code)]
148pub fn parse_codeowners_file(content: &str) -> Vec<CodeownersLine> {
149    parse_codeowners_file_with_positions(content)
150        .into_iter()
151        .map(|p| p.content)
152        .collect()
153}
154
155/// Write parsed lines back to a string
156#[allow(dead_code)]
157pub fn serialize_codeowners(lines: &[CodeownersLine]) -> String {
158    lines
159        .iter()
160        .map(|l| l.to_string())
161        .collect::<Vec<_>>()
162        .join("\n")
163}
164
165/// Find the best insertion point for a new pattern
166/// Uses heuristics to find a sensible location:
167/// 1. Path similarity: near rules with similar directory prefixes
168/// 2. Before catch-all rules (*, /**)
169/// 3. End of file as fallback
170#[allow(dead_code)]
171pub fn find_insertion_point(lines: &[CodeownersLine], pattern: &str) -> usize {
172    find_insertion_point_with_owner(lines, pattern, None)
173}
174
175/// Find the best insertion point considering both path and owner
176#[allow(dead_code)]
177pub fn find_insertion_point_with_owner(
178    lines: &[CodeownersLine],
179    pattern: &str,
180    owner: Option<&str>,
181) -> usize {
182    if lines.is_empty() {
183        return 0;
184    }
185
186    // Extract directory prefix from the pattern (e.g., "/src/foo/bar.rs" -> "/src/foo")
187    let pattern_dir = get_directory_prefix(pattern);
188
189    // Track best match by path similarity
190    let mut best_path_match: Option<(usize, usize)> = None; // (index, depth)
191
192    // Track owner clusters
193    let mut owner_lines: Vec<usize> = Vec::new();
194
195    // Track catch-all position
196    let mut catch_all_idx: Option<usize> = None;
197
198    for (idx, line) in lines.iter().enumerate() {
199        if let CodeownersLine::Rule {
200            pattern: rule_pattern,
201            owners,
202        } = line
203        {
204            // Check for catch-all patterns
205            if rule_pattern == "*" || rule_pattern == "/*" || rule_pattern == "/**" {
206                if catch_all_idx.is_none() {
207                    catch_all_idx = Some(idx);
208                }
209                continue;
210            }
211
212            // Check path similarity
213            let rule_dir = get_directory_prefix(rule_pattern);
214            let common_depth = common_prefix_depth(&pattern_dir, &rule_dir);
215
216            if common_depth > 0 {
217                if let Some((_, best_depth)) = best_path_match {
218                    if common_depth > best_depth {
219                        best_path_match = Some((idx, common_depth));
220                    }
221                } else {
222                    best_path_match = Some((idx, common_depth));
223                }
224            }
225
226            // Track owner occurrences
227            if let Some(target_owner) = owner {
228                if owners.iter().any(|o| o == target_owner) {
229                    owner_lines.push(idx);
230                }
231            }
232        }
233    }
234
235    // Priority 1: Insert after the best path match (similar directory)
236    if let Some((idx, _)) = best_path_match {
237        // Insert after the matched rule
238        return idx + 1;
239    }
240
241    // Priority 2: Insert near owner cluster (after last occurrence)
242    if let Some(&last_owner_idx) = owner_lines.last() {
243        return last_owner_idx + 1;
244    }
245
246    // Priority 3: Insert before catch-all rules
247    if let Some(idx) = catch_all_idx {
248        return idx;
249    }
250
251    // Fallback: end of file
252    lines.len()
253}
254
255/// Extract directory prefix from a pattern
256/// "/src/foo/bar.rs" -> "/src/foo"
257/// "/src/**/*.rs" -> "/src"
258/// "*.js" -> ""
259fn get_directory_prefix(pattern: &str) -> String {
260    // Remove leading / for comparison
261    let p = pattern.strip_prefix('/').unwrap_or(pattern);
262
263    // Find the last path separator before any wildcard
264    let mut last_slash = None;
265    for (i, c) in p.char_indices() {
266        if c == '*' || c == '?' || c == '[' {
267            break;
268        }
269        if c == '/' {
270            last_slash = Some(i);
271        }
272    }
273
274    match last_slash {
275        Some(idx) => format!("/{}", &p[..idx]),
276        None => {
277            // Check if it's a full path to a file (contains /)
278            if let Some(idx) = p.rfind('/') {
279                format!("/{}", &p[..idx])
280            } else {
281                String::new()
282            }
283        }
284    }
285}
286
287/// Count common directory depth between two paths
288/// "/src/foo" and "/src/foo/bar" -> 2 (src, foo)
289/// "/src/foo" and "/lib/bar" -> 0
290fn common_prefix_depth(a: &str, b: &str) -> usize {
291    if a.is_empty() || b.is_empty() {
292        return 0;
293    }
294
295    let a_parts: Vec<&str> = a.split('/').filter(|s| !s.is_empty()).collect();
296    let b_parts: Vec<&str> = b.split('/').filter(|s| !s.is_empty()).collect();
297
298    let mut depth = 0;
299    for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
300        if ap == bp {
301            depth += 1;
302        } else {
303            break;
304        }
305    }
306    depth
307}
308
309/// Find the @owner at a given character position in a line
310#[allow(dead_code)] // Used by LSP only
311pub fn find_owner_at_position(line: &str, char_idx: usize) -> Option<String> {
312    // Skip comments
313    if line.trim_start().starts_with('#') {
314        return None;
315    }
316
317    // Stop scanning at inline comment boundary
318    let scan_end = find_inline_comment_start(line).unwrap_or(line.chars().count());
319
320    // Find all potential owners in the non-comment portion
321    let mut owners: Vec<(usize, usize, String)> = Vec::new();
322    let mut i = 0;
323    let chars: Vec<char> = line.chars().collect();
324
325    while i < chars.len() && i < scan_end {
326        if chars[i] == '@' {
327            let start = i;
328            i += 1;
329            // Collect owner chars (alphanumeric, -, /)
330            while i < chars.len()
331                && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '/')
332            {
333                i += 1;
334            }
335            if i > start + 1 {
336                let owner: String = chars[start..i].iter().collect();
337                owners.push((start, i, owner));
338            }
339        } else {
340            i += 1;
341        }
342    }
343
344    // Find which owner the cursor is on
345    for (start, end, owner) in owners {
346        if char_idx >= start && char_idx < end {
347            return Some(owner);
348        }
349    }
350
351    None
352}
353
354/// Format a CODEOWNERS file: normalize rule spacing, preserve comments exactly
355pub fn format_codeowners(content: &str) -> String {
356    let mut result = Vec::new();
357    let mut prev_was_empty = false;
358
359    for line in content.lines() {
360        let trimmed = line.trim();
361
362        // Preserve blank lines but collapse multiple
363        if trimmed.is_empty() {
364            if !prev_was_empty && !result.is_empty() {
365                result.push(String::new());
366            }
367            prev_was_empty = true;
368            continue;
369        }
370        prev_was_empty = false;
371
372        // Comments: preserve exactly as-is (people use specific formatting)
373        if trimmed.starts_with('#') {
374            result.push(line.to_string());
375            continue;
376        }
377
378        // Rules: normalize spacing between pattern and owners, preserve inline comments
379        let parts: Vec<&str> = trimmed
380            .split_whitespace()
381            .take_while(|part| !part.starts_with('#'))
382            .collect();
383        if parts.is_empty() {
384            continue;
385        }
386
387        let pattern = parts[0];
388        let owners = &parts[1..];
389
390        // Find inline comment in the original line
391        let inline_comment = find_inline_comment_start(line).map(|char_off| {
392            line.chars()
393                .skip(char_off)
394                .collect::<String>()
395                .trim_end()
396                .to_string()
397        });
398
399        let mut formatted = if owners.is_empty() {
400            pattern.to_string()
401        } else {
402            format!("{} {}", pattern, owners.join(" "))
403        };
404
405        if let Some(comment) = inline_comment {
406            formatted.push(' ');
407            formatted.push_str(&comment);
408        }
409
410        result.push(formatted);
411    }
412
413    // Ensure trailing newline
414    let mut output = result.join("\n");
415    if !output.is_empty() && !output.ends_with('\n') {
416        output.push('\n');
417    }
418    output
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_parse_empty_file() {
427        let lines = parse_codeowners_file("");
428        assert!(lines.is_empty());
429    }
430
431    #[test]
432    fn test_parse_comment() {
433        let lines = parse_codeowners_file("# This is a comment");
434        assert_eq!(lines.len(), 1);
435        assert!(matches!(&lines[0], CodeownersLine::Comment(c) if c == "# This is a comment"));
436    }
437
438    #[test]
439    fn test_parse_empty_line() {
440        let lines = parse_codeowners_file("\n\n");
441        assert_eq!(lines.len(), 2);
442        assert!(matches!(lines[0], CodeownersLine::Empty));
443        assert!(matches!(lines[1], CodeownersLine::Empty));
444    }
445
446    #[test]
447    fn test_parse_rule_single_owner() {
448        let lines = parse_codeowners_file("*.rs @rustacean");
449        assert_eq!(lines.len(), 1);
450        match &lines[0] {
451            CodeownersLine::Rule { pattern, owners } => {
452                assert_eq!(pattern, "*.rs");
453                assert_eq!(owners, &vec!["@rustacean".to_string()]);
454            }
455            _ => panic!("Expected Rule"),
456        }
457    }
458
459    #[test]
460    fn test_parse_rule_single_owner_line_comment() {
461        let lines = parse_codeowners_file("*.rs @rustacean # only rust people should touch rust");
462        assert_eq!(lines.len(), 1);
463        match &lines[0] {
464            CodeownersLine::Rule { pattern, owners } => {
465                assert_eq!(pattern, "*.rs");
466                assert_eq!(owners, &vec!["@rustacean".to_string()]);
467            }
468            _ => panic!("Expected Rule"),
469        }
470    }
471
472    #[test]
473    fn test_parse_rule_multiple_owners() {
474        let lines = parse_codeowners_file("/src/ @user1 @org/team email@test.com");
475        assert_eq!(lines.len(), 1);
476        match &lines[0] {
477            CodeownersLine::Rule { pattern, owners } => {
478                assert_eq!(pattern, "/src/");
479                assert_eq!(
480                    owners,
481                    &vec![
482                        "@user1".to_string(),
483                        "@org/team".to_string(),
484                        "email@test.com".to_string()
485                    ]
486                );
487            }
488            _ => panic!("Expected Rule"),
489        }
490    }
491
492    #[test]
493    fn test_parse_rule_no_owners() {
494        let lines = parse_codeowners_file("/unowned/");
495        assert_eq!(lines.len(), 1);
496        match &lines[0] {
497            CodeownersLine::Rule { pattern, owners } => {
498                assert_eq!(pattern, "/unowned/");
499                assert!(owners.is_empty());
500            }
501            _ => panic!("Expected Rule"),
502        }
503    }
504
505    #[test]
506    fn test_parse_with_positions() {
507        let lines = parse_codeowners_file_with_positions("*.rs @owner");
508        assert_eq!(lines.len(), 1);
509        assert_eq!(lines[0].line_number, 0);
510        assert_eq!(lines[0].pattern_start, 0);
511        assert_eq!(lines[0].pattern_end, 4);
512        assert_eq!(lines[0].owners_start, 5);
513    }
514
515    #[test]
516    fn test_serialize_roundtrip() {
517        let original = "# Comment\n*.rs @owner\n/src/ @team";
518        let lines = parse_codeowners_file(original);
519        let serialized = serialize_codeowners(&lines);
520        assert_eq!(serialized, original);
521    }
522
523    #[test]
524    fn test_serialize_empty_rule() {
525        let lines = vec![CodeownersLine::Rule {
526            pattern: "/path/".to_string(),
527            owners: vec![],
528        }];
529        let serialized = serialize_codeowners(&lines);
530        assert_eq!(serialized, "/path/ ");
531    }
532
533    #[test]
534    fn test_insertion_point_path_similarity() {
535        let lines = parse_codeowners_file(
536            "/src/api/ @api-team\n/src/api/auth/ @auth-team\n/lib/ @lib-team",
537        );
538        // New file in /src/api should go after first best path match (/src/api/)
539        let idx = find_insertion_point_with_owner(&lines, "/src/api/users.rs", None);
540        assert_eq!(idx, 1); // After /src/api/
541    }
542
543    #[test]
544    fn test_insertion_point_before_catch_all() {
545        let lines = parse_codeowners_file("/src/ @team\n* @default");
546        // New pattern should go before catch-all
547        let idx = find_insertion_point_with_owner(&lines, "/lib/foo.rs", None);
548        assert_eq!(idx, 1); // Before *
549    }
550
551    #[test]
552    fn test_insertion_point_owner_cluster() {
553        let lines =
554            parse_codeowners_file("/src/ @alice\n/lib/ @bob\n/tests/ @alice\n/docs/ @carol");
555        // When owner is specified, prefer inserting near their other rules
556        let idx = find_insertion_point_with_owner(&lines, "/bin/tool.rs", Some("@alice"));
557        assert_eq!(idx, 3); // After /tests/ @alice
558    }
559
560    #[test]
561    fn test_insertion_point_empty() {
562        let lines: Vec<CodeownersLine> = vec![];
563        let idx = find_insertion_point_with_owner(&lines, "/foo/bar.rs", None);
564        assert_eq!(idx, 0);
565    }
566
567    #[test]
568    fn test_get_directory_prefix() {
569        assert_eq!(get_directory_prefix("/src/foo/bar.rs"), "/src/foo");
570        assert_eq!(get_directory_prefix("/src/**/*.rs"), "/src");
571        assert_eq!(get_directory_prefix("*.js"), "");
572        assert_eq!(get_directory_prefix("/single.rs"), "");
573        assert_eq!(get_directory_prefix("/a/b/c/d.txt"), "/a/b/c");
574        // Directory patterns (trailing slash)
575        assert_eq!(get_directory_prefix("/src/api/"), "/src/api");
576        assert_eq!(get_directory_prefix("/src/api/auth/"), "/src/api/auth");
577    }
578
579    #[test]
580    fn test_common_prefix_depth() {
581        assert_eq!(common_prefix_depth("/src/foo", "/src/foo/bar"), 2);
582        assert_eq!(common_prefix_depth("/src/foo", "/src/bar"), 1);
583        assert_eq!(common_prefix_depth("/src/foo", "/lib/bar"), 0);
584        assert_eq!(common_prefix_depth("", "/src"), 0);
585    }
586
587    #[test]
588    fn test_find_owner_at_position_basic() {
589        let line = "*.rs @owner1 @org/team";
590        assert_eq!(find_owner_at_position(line, 5), Some("@owner1".to_string()));
591        assert_eq!(find_owner_at_position(line, 6), Some("@owner1".to_string()));
592        assert_eq!(
593            find_owner_at_position(line, 11),
594            Some("@owner1".to_string())
595        );
596        assert_eq!(
597            find_owner_at_position(line, 13),
598            Some("@org/team".to_string())
599        );
600    }
601
602    #[test]
603    fn test_find_owner_at_position_outside_owner() {
604        let line = "*.rs @owner";
605        // On pattern, not owner
606        assert_eq!(find_owner_at_position(line, 0), None);
607        assert_eq!(find_owner_at_position(line, 3), None);
608        // Past end
609        assert_eq!(find_owner_at_position(line, 100), None);
610    }
611
612    #[test]
613    fn test_find_owner_at_position_comment() {
614        let line = "# @mention in comment";
615        assert_eq!(find_owner_at_position(line, 2), None);
616    }
617
618    #[test]
619    fn test_find_owner_at_position_indented_comment() {
620        let line = "   # @mention in comment";
621        assert_eq!(find_owner_at_position(line, 5), None);
622    }
623
624    #[test]
625    fn test_find_owner_at_position_no_owners() {
626        let line = "/unowned/path/";
627        assert_eq!(find_owner_at_position(line, 0), None);
628        assert_eq!(find_owner_at_position(line, 5), None);
629    }
630
631    #[test]
632    fn test_find_owner_at_position_just_at_symbol() {
633        let line = "*.rs @ @";
634        // @ alone (no following chars) shouldn't match
635        assert_eq!(find_owner_at_position(line, 5), None);
636        assert_eq!(find_owner_at_position(line, 7), None);
637    }
638
639    #[test]
640    fn test_find_owner_at_position_special_chars() {
641        let line = "*.rs @user-name @org/team";
642        assert_eq!(
643            find_owner_at_position(line, 5),
644            Some("@user-name".to_string())
645        );
646        assert_eq!(
647            find_owner_at_position(line, 16),
648            Some("@org/team".to_string())
649        );
650    }
651
652    #[test]
653    fn test_format_codeowners_basic() {
654        let input = "# Comment\n*.rs    @owner1   @owner2\n/src/   @team";
655        let formatted = format_codeowners(input);
656        assert_eq!(formatted, "# Comment\n*.rs @owner1 @owner2\n/src/ @team\n");
657    }
658
659    #[test]
660    fn test_format_codeowners_collapse_empty_lines() {
661        let input = "*.rs @owner\n\n\n\n/src/ @team";
662        let formatted = format_codeowners(input);
663        assert_eq!(formatted, "*.rs @owner\n\n/src/ @team\n");
664    }
665
666    #[test]
667    fn test_format_codeowners_preserves_comments() {
668        let input = "# This is a special comment    with    spacing\n*.rs @owner";
669        let formatted = format_codeowners(input);
670        assert!(formatted.contains("# This is a special comment    with    spacing"));
671    }
672
673    #[test]
674    fn test_format_codeowners_no_owner() {
675        let input = "/unowned/";
676        let formatted = format_codeowners(input);
677        assert_eq!(formatted, "/unowned/\n");
678    }
679
680    #[test]
681    fn test_format_codeowners_empty() {
682        let input = "";
683        let formatted = format_codeowners(input);
684        assert_eq!(formatted, "");
685    }
686
687    #[test]
688    fn test_format_codeowners_leading_empty_lines() {
689        let input = "\n\n# Header\n*.rs @owner";
690        let formatted = format_codeowners(input);
691        // Leading blank lines should not produce output before first content
692        assert_eq!(formatted, "# Header\n*.rs @owner\n");
693    }
694
695    #[test]
696    fn test_codeowners_line_display_comment() {
697        let line = CodeownersLine::Comment("# test comment".to_string());
698        assert_eq!(format!("{}", line), "# test comment");
699    }
700
701    #[test]
702    fn test_codeowners_line_display_empty() {
703        let line = CodeownersLine::Empty;
704        assert_eq!(format!("{}", line), "");
705    }
706
707    #[test]
708    fn test_codeowners_line_display_rule() {
709        let line = CodeownersLine::Rule {
710            pattern: "*.rs".to_string(),
711            owners: vec!["@alice".to_string(), "@bob".to_string()],
712        };
713        assert_eq!(format!("{}", line), "*.rs @alice @bob");
714    }
715
716    #[test]
717    fn test_parse_positions_with_leading_whitespace() {
718        let lines = parse_codeowners_file_with_positions("  *.rs @owner");
719        assert_eq!(lines.len(), 1);
720        assert_eq!(lines[0].pattern_start, 2);
721        assert_eq!(lines[0].pattern_end, 6);
722        assert_eq!(lines[0].owners_start, 7);
723    }
724
725    #[test]
726    fn test_parse_whitespace_only_line() {
727        let lines = parse_codeowners_file("   \t   ");
728        assert_eq!(lines.len(), 1);
729        assert!(matches!(lines[0], CodeownersLine::Empty));
730    }
731
732    #[test]
733    fn test_insertion_point_only_comments() {
734        let lines = parse_codeowners_file("# Header\n# Another comment");
735        let idx = find_insertion_point(&lines, "/foo.rs");
736        // Should insert at end when no rules
737        assert_eq!(idx, 2);
738    }
739
740    #[test]
741    fn test_insertion_point_prefers_deeper_path_match() {
742        let lines = parse_codeowners_file("/src/ @team1\n/src/api/ @team2\n/src/api/v2/ @team3");
743        // Should prefer /src/api/v2 for a file in /src/api/v2/
744        let idx = find_insertion_point(&lines, "/src/api/v2/users.rs");
745        assert_eq!(idx, 3); // After /src/api/v2/
746    }
747
748    #[test]
749    fn test_insertion_point_catch_all_variants() {
750        // Test with /* catch-all
751        let lines = parse_codeowners_file("/src/ @team\n/* @default");
752        let idx = find_insertion_point(&lines, "/lib/foo.rs");
753        assert_eq!(idx, 1);
754
755        // Test with /** catch-all
756        let lines2 = parse_codeowners_file("/src/ @team\n/** @default");
757        let idx2 = find_insertion_point(&lines2, "/lib/foo.rs");
758        assert_eq!(idx2, 1);
759    }
760
761    #[test]
762    fn test_directory_prefix_wildcard_variants() {
763        assert_eq!(get_directory_prefix("/src/?oo/"), "/src");
764        assert_eq!(get_directory_prefix("/src/[abc]/"), "/src");
765        assert_eq!(get_directory_prefix("/a/b/*.txt"), "/a/b");
766    }
767
768    #[test]
769    fn test_find_insertion_point_without_owner() {
770        let lines = parse_codeowners_file("/src/ @team\n/lib/ @team");
771        // Uses find_insertion_point_with_owner internally
772        let idx = find_insertion_point(&lines, "/bin/tool.rs");
773        // No path match, no catch-all, so end of file
774        assert_eq!(idx, 2);
775    }
776
777    #[test]
778    fn test_get_directory_prefix_file_with_slash_no_wildcard() {
779        // File path with / but no wildcard before it (line 232)
780        // This is like "src/foo/bar.txt" where there's no wildcard
781        assert_eq!(get_directory_prefix("src/foo/bar.txt"), "/src/foo");
782        assert_eq!(get_directory_prefix("a/b/c/file.rs"), "/a/b/c");
783    }
784
785    #[test]
786    fn test_format_codeowners_whitespace_only_after_trim() {
787        // Line that becomes empty after parts split (line 334)
788        // This shouldn't really happen since trimmed empty is caught earlier
789        // but the code handles it
790        let input = "*.rs @owner\n\n/src/ @team";
791        let formatted = format_codeowners(input);
792        // Should handle gracefully
793        assert!(formatted.contains("*.rs @owner"));
794        assert!(formatted.contains("/src/ @team"));
795    }
796
797    // --- Inline comment tests ---
798
799    #[test]
800    fn test_find_inline_comment_start() {
801        assert_eq!(find_inline_comment_start("*.rs @owner # comment"), Some(12));
802        assert_eq!(find_inline_comment_start("*.rs @owner"), None);
803        assert_eq!(find_inline_comment_start("# full line comment"), Some(0));
804        assert_eq!(find_inline_comment_start("*.rs @owner #nospace"), Some(12));
805    }
806
807    #[test]
808    fn test_find_inline_comment_start_hash_in_token() {
809        // # attached to a token (no preceding whitespace) is NOT an inline comment
810        assert_eq!(find_inline_comment_start("*.rs @owner#attached"), None);
811    }
812
813    #[test]
814    fn test_parse_inline_comment_stripped_from_owners() {
815        let lines = parse_codeowners_file("*.rs @owner # contact @admin for changes");
816        assert_eq!(lines.len(), 1);
817        match &lines[0] {
818            CodeownersLine::Rule { owners, .. } => {
819                assert_eq!(owners, &vec!["@owner".to_string()]);
820            }
821            _ => panic!("Expected Rule"),
822        }
823    }
824
825    #[test]
826    fn test_parse_inline_comment_position_stored() {
827        let lines = parse_codeowners_file_with_positions("*.rs @owner # comment");
828        assert_eq!(lines[0].comment_start, Some(12));
829    }
830
831    #[test]
832    fn test_parse_no_inline_comment() {
833        let lines = parse_codeowners_file_with_positions("*.rs @owner");
834        assert_eq!(lines[0].comment_start, None);
835    }
836
837    #[test]
838    fn test_find_owner_at_position_inline_comment_ignored() {
839        let line = "*.rs @owner # contact @admin";
840        // @owner should be found
841        assert_eq!(find_owner_at_position(line, 5), Some("@owner".to_string()));
842        // @admin is in the comment, should NOT be found
843        assert_eq!(find_owner_at_position(line, 22), None);
844    }
845
846    #[test]
847    fn test_find_owner_at_position_inline_comment_no_space() {
848        let line = "*.rs @owner #comment @nope";
849        assert_eq!(find_owner_at_position(line, 5), Some("@owner".to_string()));
850        assert_eq!(find_owner_at_position(line, 21), None);
851    }
852
853    #[test]
854    fn test_format_preserves_inline_comments() {
855        let input = "*.rs    @owner1   @owner2   # important note";
856        let formatted = format_codeowners(input);
857        assert_eq!(formatted, "*.rs @owner1 @owner2 # important note\n");
858    }
859
860    #[test]
861    fn test_format_preserves_inline_comment_no_owners() {
862        let input = "/unowned/ # deliberately unowned";
863        let formatted = format_codeowners(input);
864        assert_eq!(formatted, "/unowned/ # deliberately unowned\n");
865    }
866
867    #[test]
868    fn test_format_roundtrip_with_inline_comments() {
869        let input = "# Header\n*.rs @owner # rust files\n/src/ @team # source dir\n";
870        let formatted = format_codeowners(input);
871        assert_eq!(
872            formatted,
873            "# Header\n*.rs @owner # rust files\n/src/ @team # source dir\n"
874        );
875    }
876}
877
878#[cfg(test)]
879mod owner_offset_tests {
880    use super::{parse_codeowners_file_with_positions, CodeownersLine};
881
882    fn owners_start_of(line: &str) -> u32 {
883        let parsed = parse_codeowners_file_with_positions(line);
884        assert!(matches!(parsed[0].content, CodeownersLine::Rule { .. }));
885        parsed[0].owners_start
886    }
887
888    #[test]
889    fn owners_start_skips_matches_inside_the_pattern() {
890        // "api" occurs inside the pattern before it occurs as the owner.
891        assert_eq!(owners_start_of("api/src api"), 8);
892        assert_eq!(owners_start_of("docs/ @docs"), 6);
893        assert_eq!(owners_start_of("src src src"), 4);
894    }
895
896    #[test]
897    fn owners_start_is_unchanged_for_ordinary_rules() {
898        assert_eq!(owners_start_of("*.rs @owner"), 5);
899        assert_eq!(owners_start_of("/src/  @org/team"), 7);
900    }
901}