1#[derive(Debug, Clone)]
13pub struct ParsedLine {
14 pub line_number: u32,
16 pub content: CodeownersLine,
18 pub pattern_start: u32,
20 pub pattern_end: u32,
22 pub owners_start: u32,
24 pub comment_start: Option<u32>,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum CodeownersLine {
31 Comment(String),
33 Empty,
35 Rule {
37 pattern: String,
39 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
56pub 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 let parts: Vec<&str> = line
84 .split_whitespace()
85 .take_while(|part| !part.starts_with('#'))
86 .collect();
87
88 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 let pattern_start = line.find(parts[0]).unwrap_or(0) as u32;
103 let pattern_end = pattern_start + parts[0].len() as u32;
104 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
133pub 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#[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#[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#[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#[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 let pattern_dir = get_directory_prefix(pattern);
188
189 let mut best_path_match: Option<(usize, usize)> = None; let mut owner_lines: Vec<usize> = Vec::new();
194
195 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 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 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 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 if let Some((idx, _)) = best_path_match {
237 return idx + 1;
239 }
240
241 if let Some(&last_owner_idx) = owner_lines.last() {
243 return last_owner_idx + 1;
244 }
245
246 if let Some(idx) = catch_all_idx {
248 return idx;
249 }
250
251 lines.len()
253}
254
255fn get_directory_prefix(pattern: &str) -> String {
260 let p = pattern.strip_prefix('/').unwrap_or(pattern);
262
263 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 if let Some(idx) = p.rfind('/') {
279 format!("/{}", &p[..idx])
280 } else {
281 String::new()
282 }
283 }
284 }
285}
286
287fn 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#[allow(dead_code)] pub fn find_owner_at_position(line: &str, char_idx: usize) -> Option<String> {
312 if line.trim_start().starts_with('#') {
314 return None;
315 }
316
317 let scan_end = find_inline_comment_start(line).unwrap_or(line.chars().count());
319
320 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 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 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
354pub 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 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 if trimmed.starts_with('#') {
374 result.push(line.to_string());
375 continue;
376 }
377
378 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 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 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 let idx = find_insertion_point_with_owner(&lines, "/src/api/users.rs", None);
540 assert_eq!(idx, 1); }
542
543 #[test]
544 fn test_insertion_point_before_catch_all() {
545 let lines = parse_codeowners_file("/src/ @team\n* @default");
546 let idx = find_insertion_point_with_owner(&lines, "/lib/foo.rs", None);
548 assert_eq!(idx, 1); }
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 let idx = find_insertion_point_with_owner(&lines, "/bin/tool.rs", Some("@alice"));
557 assert_eq!(idx, 3); }
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 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 assert_eq!(find_owner_at_position(line, 0), None);
607 assert_eq!(find_owner_at_position(line, 3), None);
608 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 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 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 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 let idx = find_insertion_point(&lines, "/src/api/v2/users.rs");
745 assert_eq!(idx, 3); }
747
748 #[test]
749 fn test_insertion_point_catch_all_variants() {
750 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 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 let idx = find_insertion_point(&lines, "/bin/tool.rs");
773 assert_eq!(idx, 2);
775 }
776
777 #[test]
778 fn test_get_directory_prefix_file_with_slash_no_wildcard() {
779 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 let input = "*.rs @owner\n\n/src/ @team";
791 let formatted = format_codeowners(input);
792 assert!(formatted.contains("*.rs @owner"));
794 assert!(formatted.contains("/src/ @team"));
795 }
796
797 #[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 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 assert_eq!(find_owner_at_position(line, 5), Some("@owner".to_string()));
842 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 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}