1+ use crate :: utils:: error:: { Error , Result } ;
2+ use std:: path:: Path ;
3+ use std:: io:: { BufRead , BufReader } ;
4+ use std:: fs:: File ;
5+
6+ use super :: types:: { InlineCodeownersEntry , Owner , Tag } ;
7+ use super :: parser:: parse_owner;
8+
9+ /// Detects inline CODEOWNERS declaration in the first 50 lines of a file
10+ pub fn detect_inline_codeowners ( file_path : & Path ) -> Result < Option < InlineCodeownersEntry > > {
11+ let file = match File :: open ( file_path) {
12+ Ok ( f) => f,
13+ Err ( _) => return Ok ( None ) , // File doesn't exist or can't be read
14+ } ;
15+
16+ let reader = BufReader :: new ( file) ;
17+ let lines = reader. lines ( ) . take ( 50 ) ;
18+
19+ for ( line_num, line_result) in lines. enumerate ( ) {
20+ let line = match line_result {
21+ Ok ( l) => l,
22+ Err ( _) => continue , // Skip lines that can't be read
23+ } ;
24+
25+ if let Some ( entry) = parse_inline_codeowners_line ( & line, line_num + 1 , file_path) ? {
26+ return Ok ( Some ( entry) ) ;
27+ }
28+ }
29+
30+ Ok ( None )
31+ }
32+
33+ /// Parse a single line for inline CODEOWNERS declaration
34+ fn parse_inline_codeowners_line (
35+ line : & str ,
36+ line_number : usize ,
37+ file_path : & Path ,
38+ ) -> Result < Option < InlineCodeownersEntry > > {
39+ // Look for !!!CODEOWNERS marker
40+ if let Some ( marker_pos) = line. find ( "!!!CODEOWNERS" ) {
41+ // Extract everything after the marker
42+ let after_marker = & line[ marker_pos + "!!!CODEOWNERS" . len ( ) ..] ;
43+
44+ // Split by whitespace to get tokens
45+ let tokens: Vec < & str > = after_marker. split_whitespace ( ) . collect ( ) ;
46+
47+ if tokens. is_empty ( ) {
48+ return Ok ( None ) ;
49+ }
50+
51+ let mut owners: Vec < Owner > = Vec :: new ( ) ;
52+ let mut tags: Vec < Tag > = Vec :: new ( ) ;
53+ let mut i = 0 ;
54+
55+ // Collect owners until a token starts with '#'
56+ while i < tokens. len ( ) && !tokens[ i] . starts_with ( '#' ) {
57+ owners. push ( parse_owner ( tokens[ i] ) ?) ;
58+ i += 1 ;
59+ }
60+
61+ // Collect tags
62+ while i < tokens. len ( ) {
63+ let token = tokens[ i] ;
64+ if token. starts_with ( '#' ) {
65+ if token == "#" {
66+ // Standalone # means comment starts, break
67+ break ;
68+ } else {
69+ // Extract tag name, but check if this might be a comment
70+ let tag_part = & token[ 1 ..] ;
71+
72+ // If the tag part is empty, it's probably a comment marker
73+ if tag_part. is_empty ( ) {
74+ break ;
75+ }
76+
77+ // Special handling for common comment patterns
78+ // If the next token looks like end of comment (like "-->"), still treat as tag
79+ let next_token = if i + 1 < tokens. len ( ) { Some ( tokens[ i + 1 ] ) } else { None } ;
80+
81+ match next_token {
82+ Some ( "-->" ) | Some ( "*/" ) => {
83+ // This is likely the end of a comment block, so the tag is valid
84+ tags. push ( Tag ( tag_part. to_string ( ) ) ) ;
85+ i += 1 ;
86+ break ; // Stop after this tag since we hit comment end
87+ }
88+ Some ( next) if next. starts_with ( '#' ) => {
89+ // Next token is also a tag, so this is definitely a tag
90+ tags. push ( Tag ( tag_part. to_string ( ) ) ) ;
91+ i += 1 ;
92+ }
93+ Some ( _) => {
94+ // Next token doesn't start with # and isn't a comment ender
95+ // This could be a comment, but we'll be conservative and treat as tag
96+ // if it looks like a valid tag name (alphanumeric + common chars)
97+ if tag_part. chars ( ) . all ( |c| c. is_alphanumeric ( ) || c == '-' || c == '_' ) {
98+ tags. push ( Tag ( tag_part. to_string ( ) ) ) ;
99+ i += 1 ;
100+ break ; // Stop here as next token is likely a comment
101+ } else {
102+ break ; // This is probably a comment
103+ }
104+ }
105+ None => {
106+ // This is the last token, treat as tag
107+ tags. push ( Tag ( tag_part. to_string ( ) ) ) ;
108+ i += 1 ;
109+ }
110+ }
111+ }
112+ } else {
113+ // Non-# token, this is part of a comment
114+ break ;
115+ }
116+ }
117+
118+ // Only return an entry if we have at least one owner
119+ if !owners. is_empty ( ) {
120+ return Ok ( Some ( InlineCodeownersEntry {
121+ file_path : file_path. to_path_buf ( ) ,
122+ line_number,
123+ owners,
124+ tags,
125+ } ) ) ;
126+ }
127+ }
128+
129+ Ok ( None )
130+ }
131+
132+ #[ cfg( test) ]
133+ mod tests {
134+ use super :: * ;
135+ use crate :: core:: types:: { Owner , OwnerType , Tag } ;
136+ use std:: fs;
137+ use tempfile:: TempDir ;
138+
139+ #[ test]
140+ fn test_detect_inline_codeowners_rust_comment ( ) -> Result < ( ) > {
141+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
142+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
143+
144+ let content = r#"// This is a Rust file
145+ // !!!CODEOWNERS @user1 @org/team2 #tag1 #tag2
146+ fn main() {
147+ println!("Hello world");
148+ }
149+ "# ;
150+ fs:: write ( & file_path, content) . unwrap ( ) ;
151+
152+ let result = detect_inline_codeowners ( & file_path) ?;
153+ assert ! ( result. is_some( ) ) ;
154+
155+ let entry = result. unwrap ( ) ;
156+ assert_eq ! ( entry. file_path, file_path) ;
157+ assert_eq ! ( entry. line_number, 2 ) ;
158+ assert_eq ! ( entry. owners. len( ) , 2 ) ;
159+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@user1" ) ;
160+ assert_eq ! ( entry. owners[ 1 ] . identifier, "@org/team2" ) ;
161+ assert_eq ! ( entry. tags. len( ) , 2 ) ;
162+ assert_eq ! ( entry. tags[ 0 ] . 0 , "tag1" ) ;
163+ assert_eq ! ( entry. tags[ 1 ] . 0 , "tag2" ) ;
164+
165+ Ok ( ( ) )
166+ }
167+
168+ #[ test]
169+ fn test_detect_inline_codeowners_javascript_comment ( ) -> Result < ( ) > {
170+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
171+ let file_path = temp_dir. path ( ) . join ( "test.js" ) ;
172+
173+ let content = r#"/*
174+ * !!!CODEOWNERS @frontend-team #javascript
175+ */
176+ function hello() {
177+ console.log("Hello");
178+ }
179+ "# ;
180+ fs:: write ( & file_path, content) . unwrap ( ) ;
181+
182+ let result = detect_inline_codeowners ( & file_path) ?;
183+ assert ! ( result. is_some( ) ) ;
184+
185+ let entry = result. unwrap ( ) ;
186+ assert_eq ! ( entry. owners. len( ) , 1 ) ;
187+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@frontend-team" ) ;
188+ assert_eq ! ( entry. tags. len( ) , 1 ) ;
189+ assert_eq ! ( entry. tags[ 0 ] . 0 , "javascript" ) ;
190+
191+ Ok ( ( ) )
192+ }
193+
194+ #[ test]
195+ fn test_detect_inline_codeowners_python_comment ( ) -> Result < ( ) > {
196+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
197+ let file_path = temp_dir. path ( ) . join ( "test.py" ) ;
198+
199+ let content = r#"#!/usr/bin/env python3
200+ # !!!CODEOWNERS @python-team @user1 #backend #critical
201+ """
202+ This is a Python module
203+ """
204+
205+ def main():
206+ pass
207+ "# ;
208+ fs:: write ( & file_path, content) . unwrap ( ) ;
209+
210+ let result = detect_inline_codeowners ( & file_path) ?;
211+ assert ! ( result. is_some( ) ) ;
212+
213+ let entry = result. unwrap ( ) ;
214+ assert_eq ! ( entry. line_number, 2 ) ;
215+ assert_eq ! ( entry. owners. len( ) , 2 ) ;
216+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@python-team" ) ;
217+ assert_eq ! ( entry. owners[ 1 ] . identifier, "@user1" ) ;
218+ assert_eq ! ( entry. tags. len( ) , 2 ) ;
219+ assert_eq ! ( entry. tags[ 0 ] . 0 , "backend" ) ;
220+ assert_eq ! ( entry. tags[ 1 ] . 0 , "critical" ) ;
221+
222+ Ok ( ( ) )
223+ }
224+
225+ #[ test]
226+ fn test_detect_inline_codeowners_html_comment ( ) -> Result < ( ) > {
227+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
228+ let file_path = temp_dir. path ( ) . join ( "test.html" ) ;
229+
230+ let content = r#"<!DOCTYPE html>
231+ <html>
232+ <!-- !!!CODEOWNERS @web-team #frontend -->
233+ <head>
234+ <title>Test</title>
235+ </head>
236+ </html>
237+ "# ;
238+ fs:: write ( & file_path, content) . unwrap ( ) ;
239+
240+ let result = detect_inline_codeowners ( & file_path) ?;
241+ assert ! ( result. is_some( ) ) ;
242+
243+ let entry = result. unwrap ( ) ;
244+ assert_eq ! ( entry. owners. len( ) , 1 ) ;
245+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@web-team" ) ;
246+ assert_eq ! ( entry. tags. len( ) , 1 ) ;
247+ assert_eq ! ( entry. tags[ 0 ] . 0 , "frontend" ) ;
248+
249+ Ok ( ( ) )
250+ }
251+
252+ #[ test]
253+ fn test_detect_inline_codeowners_no_marker ( ) -> Result < ( ) > {
254+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
255+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
256+
257+ let content = r#"// This is a regular file
258+ fn main() {
259+ println!("No CODEOWNERS marker here");
260+ }
261+ "# ;
262+ fs:: write ( & file_path, content) . unwrap ( ) ;
263+
264+ let result = detect_inline_codeowners ( & file_path) ?;
265+ assert ! ( result. is_none( ) ) ;
266+
267+ Ok ( ( ) )
268+ }
269+
270+ #[ test]
271+ fn test_detect_inline_codeowners_no_owners ( ) -> Result < ( ) > {
272+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
273+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
274+
275+ let content = r#"// !!!CODEOWNERS #just-tags
276+ fn main() {
277+ println!("Only tags, no owners");
278+ }
279+ "# ;
280+ fs:: write ( & file_path, content) . unwrap ( ) ;
281+
282+ let result = detect_inline_codeowners ( & file_path) ?;
283+ assert ! ( result. is_none( ) ) ;
284+
285+ Ok ( ( ) )
286+ }
287+
288+ #[ test]
289+ fn test_detect_inline_codeowners_first_occurrence_only ( ) -> Result < ( ) > {
290+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
291+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
292+
293+ let content = r#"// !!!CODEOWNERS @first-owner #first-tag
294+ fn main() {
295+ // !!!CODEOWNERS @second-owner #second-tag
296+ println!("Should only detect first occurrence");
297+ }
298+ "# ;
299+ fs:: write ( & file_path, content) . unwrap ( ) ;
300+
301+ let result = detect_inline_codeowners ( & file_path) ?;
302+ assert ! ( result. is_some( ) ) ;
303+
304+ let entry = result. unwrap ( ) ;
305+ assert_eq ! ( entry. line_number, 1 ) ;
306+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@first-owner" ) ;
307+ assert_eq ! ( entry. tags[ 0 ] . 0 , "first-tag" ) ;
308+
309+ Ok ( ( ) )
310+ }
311+
312+ #[ test]
313+ fn test_detect_inline_codeowners_beyond_50_lines ( ) -> Result < ( ) > {
314+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
315+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
316+
317+ let mut content = String :: new ( ) ;
318+ // Add 51 lines, with the marker on line 51
319+ for i in 1 ..=50 {
320+ content. push_str ( & format ! ( "// Line {}\n " , i) ) ;
321+ }
322+ content. push_str ( "// !!!CODEOWNERS @should-not-be-found #beyond-limit\n " ) ;
323+ content. push_str ( "fn main() {}\n " ) ;
324+
325+ fs:: write ( & file_path, content) . unwrap ( ) ;
326+
327+ let result = detect_inline_codeowners ( & file_path) ?;
328+ assert ! ( result. is_none( ) ) ;
329+
330+ Ok ( ( ) )
331+ }
332+
333+ #[ test]
334+ fn test_detect_inline_codeowners_with_comment_after ( ) -> Result < ( ) > {
335+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
336+ let file_path = temp_dir. path ( ) . join ( "test.rs" ) ;
337+
338+ let content = r#"// !!!CODEOWNERS @user1 #tag1 # this is a comment after
339+ fn main() {}
340+ "# ;
341+ fs:: write ( & file_path, content) . unwrap ( ) ;
342+
343+ let result = detect_inline_codeowners ( & file_path) ?;
344+ assert ! ( result. is_some( ) ) ;
345+
346+ let entry = result. unwrap ( ) ;
347+ assert_eq ! ( entry. owners. len( ) , 1 ) ;
348+ assert_eq ! ( entry. owners[ 0 ] . identifier, "@user1" ) ;
349+ assert_eq ! ( entry. tags. len( ) , 1 ) ;
350+ assert_eq ! ( entry. tags[ 0 ] . 0 , "tag1" ) ;
351+
352+ Ok ( ( ) )
353+ }
354+
355+ #[ test]
356+ fn test_detect_inline_codeowners_nonexistent_file ( ) -> Result < ( ) > {
357+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
358+ let file_path = temp_dir. path ( ) . join ( "nonexistent.rs" ) ;
359+
360+ let result = detect_inline_codeowners ( & file_path) ?;
361+ assert ! ( result. is_none( ) ) ;
362+
363+ Ok ( ( ) )
364+ }
365+
366+ }
0 commit comments