@@ -481,15 +481,49 @@ impl<'c> GoalPreprocessorWithContext<'c> {
481481 /// that is enforced during goal parsing.
482482 fn replace_metadata_placeholders ( & mut self , chapter : & mut Chapter ) -> anyhow:: Result < ( ) > {
483483 // Auto-inject teams and task owners directly into metadata table instead of using placeholders
484- self . inject_teams_into_metadata_table ( chapter) ?;
485- self . inject_task_owners_into_metadata_table ( chapter) ?;
484+ self . inject_metadata_rows ( chapter) ?;
486485
487486 Ok ( ( ) )
488487 }
489488
490- /// Automatically inject team names into the metadata table's Teams row.
491- /// This replaces the need for manual `<!-- TEAMS WITH ASKS -->` placeholders.
492- fn inject_teams_into_metadata_table ( & mut self , chapter : & mut Chapter ) -> anyhow:: Result < ( ) > {
489+ /// Find the end of a markdown table (first line that doesn't start with |).
490+ /// Returns the byte offset where new rows should be inserted.
491+ fn find_markdown_table_end ( content : & str ) -> Option < usize > {
492+ let lines: Vec < & str > = content. lines ( ) . collect ( ) ;
493+
494+ // Find first line starting with |
495+ let table_start = lines. iter ( ) . position ( |line| line. trim_start ( ) . starts_with ( '|' ) ) ?;
496+
497+ // Find first line after table_start that doesn't start with |
498+ let table_end = lines[ table_start..]
499+ . iter ( )
500+ . position ( |line| !line. trim_start ( ) . starts_with ( '|' ) )
501+ . map ( |pos| table_start + pos)
502+ . unwrap_or ( lines. len ( ) ) ;
503+
504+ // Calculate byte offset to the start of the table_end line
505+ let mut offset = 0 ;
506+ for i in 0 ..table_end {
507+
508+ if i > 0 {
509+ offset += 1 ; // newline
510+ }
511+ offset += lines[ i] . len ( ) ;
512+ }
513+
514+ if table_end < lines. len ( ) {
515+ // There's a line after the table, insert before it
516+ Some ( offset + 1 ) // +1 for the newline after the last table row
517+ } else {
518+ // Table goes to end of file, append at the end
519+ Some ( content. len ( ) )
520+ }
521+ }
522+
523+ /// Automatically inject team names and task owners into the metadata table.
524+ /// This replaces the need for manual placeholders and combines the logic
525+ /// to avoid duplicate table parsing.
526+ fn inject_metadata_rows ( & mut self , chapter : & mut Chapter ) -> anyhow:: Result < ( ) > {
493527 let Some ( chapter_path) = chapter. path . as_ref ( ) else {
494528 return Ok ( ( ) ) ; // No path, nothing to inject
495529 } ;
@@ -500,7 +534,6 @@ impl<'c> GoalPreprocessorWithContext<'c> {
500534 }
501535
502536 // Only process files in milestone directories (like 2024h2, 2025h1, etc.)
503- // These directories contain actual goal documents
504537 let Some ( parent_dir) = chapter_path. parent ( ) else {
505538 return Ok ( ( ) ) ;
506539 } ;
@@ -537,77 +570,6 @@ impl<'c> GoalPreprocessorWithContext<'c> {
537570 team_names. join ( ", " )
538571 } ;
539572
540- // First, check if there's already a Teams row and replace it
541- let teams_row_regex = regex:: Regex :: new ( r"(?m)^\|\s*Teams\s*\|\s*([^|]*)\s*\|" ) . unwrap ( ) ;
542-
543- if teams_row_regex. is_match ( & chapter. content ) {
544- // Replace existing Teams row
545- chapter. content = teams_row_regex
546- . replace ( & chapter. content , |_caps : & regex:: Captures | {
547- format ! ( "| Teams | {} |" , teams_text)
548- } )
549- . to_string ( ) ;
550- } else {
551- // No Teams row exists, add one after Point of contact
552- let metadata_table_regex =
553- regex:: Regex :: new ( r"(?m)^(\| Point of contact \| [^|]+ \|)" ) . unwrap ( ) ;
554-
555- if metadata_table_regex. is_match ( & chapter. content ) {
556- chapter. content = metadata_table_regex
557- . replace ( & chapter. content , |caps : & regex:: Captures | {
558- format ! (
559- "{}\n | Teams | {} |" ,
560- caps. get( 1 ) . unwrap( ) . as_str( ) ,
561- teams_text
562- )
563- } )
564- . to_string ( ) ;
565- }
566- }
567-
568- Ok ( ( ) )
569- }
570-
571- /// Automatically inject task owner names into the metadata table's Task owners row.
572- /// This replaces the need for manual `<!-- TASK OWNERS -->` placeholders.
573- fn inject_task_owners_into_metadata_table (
574- & mut self ,
575- chapter : & mut Chapter ,
576- ) -> anyhow:: Result < ( ) > {
577- let Some ( chapter_path) = chapter. path . as_ref ( ) else {
578- return Ok ( ( ) ) ; // No path, nothing to inject
579- } ;
580-
581- // Skip template files
582- if chapter_path. file_name ( ) . and_then ( |n| n. to_str ( ) ) == Some ( "TEMPLATE.md" ) {
583- return Ok ( ( ) ) ;
584- }
585-
586- // Only process files in milestone directories (like 2024h2, 2025h1, etc.)
587- // These directories contain actual goal documents
588- let Some ( parent_dir) = chapter_path. parent ( ) else {
589- return Ok ( ( ) ) ;
590- } ;
591-
592- let Some ( parent_name) = parent_dir. file_name ( ) . and_then ( |n| n. to_str ( ) ) else {
593- return Ok ( ( ) ) ;
594- } ;
595-
596- if !parent_name
597- . chars ( )
598- . next ( )
599- . map_or ( false , |c| c. is_ascii_digit ( ) )
600- {
601- return Ok ( ( ) ) ; // Not a milestone directory, skip
602- }
603-
604- // Find the goal document for this chapter
605- let goals = self . goal_documents ( & chapter_path) ?;
606- let chapter_in_context = self . ctx . config . book . src . join ( chapter_path) ;
607- let Some ( goal) = goals. iter ( ) . find ( |gd| gd. path == chapter_in_context) else {
608- return Ok ( ( ) ) ; // No goal document found, nothing to inject
609- } ;
610-
611573 // Compute the task owner names
612574 let task_owners: Vec < String > = goal. task_owners . iter ( ) . cloned ( ) . collect ( ) ;
613575
@@ -617,48 +579,40 @@ impl<'c> GoalPreprocessorWithContext<'c> {
617579 task_owners. join ( ", " )
618580 } ;
619581
620- // First, check if there's already a Task owners row and replace it
621- let task_owners_row_regex =
622- regex:: Regex :: new ( r"(?m)^\|\s*Task owners\s*\|\s*([^|]*)\s*\|" ) . unwrap ( ) ;
623-
624- if task_owners_row_regex. is_match ( & chapter. content ) {
625- // Replace existing Task owners row
626- chapter. content = task_owners_row_regex
627- . replace ( & chapter. content , |_caps : & regex:: Captures | {
628- format ! ( "| Task owners | {} |" , task_owners_text)
629- } )
630- . to_string ( ) ;
631- } else {
632- // No Task owners row exists, add one after Teams row (or Point of contact if no Teams row)
633- let after_teams_regex = regex:: Regex :: new ( r"(?m)^(\| Teams\s*\| [^|]+ \|)" ) . unwrap ( ) ;
634- let after_contact_regex =
635- regex:: Regex :: new ( r"(?m)^(\| Point of contact \| [^|]+ \|)" ) . unwrap ( ) ;
636-
637- if after_teams_regex. is_match ( & chapter. content ) {
638- // Add after Teams row
639- chapter. content = after_teams_regex
640- . replace ( & chapter. content , |caps : & regex:: Captures | {
641- format ! (
642- "{}\n | Task owners | {} |" ,
643- caps. get( 1 ) . unwrap( ) . as_str( ) ,
644- task_owners_text
645- )
646- } )
647- . to_string ( ) ;
648- } else if after_contact_regex. is_match ( & chapter. content ) {
649- // Add after Point of contact row
650- chapter. content = after_contact_regex
651- . replace ( & chapter. content , |caps : & regex:: Captures | {
652- format ! (
653- "{}\n | Task owners | {} |" ,
654- caps. get( 1 ) . unwrap( ) . as_str( ) ,
655- task_owners_text
656- )
657- } )
658- . to_string ( ) ;
659- }
582+ // Find the table end and insert both rows
583+ if let Some ( table_end) = Self :: find_markdown_table_end ( & chapter. content ) {
584+ let insertion_text = format ! (
585+ "| Teams | {} |\n | Task owners | {} |\n " ,
586+ teams_text, task_owners_text
587+ ) ;
588+ chapter. content . insert_str ( table_end, & insertion_text) ;
660589 }
661590
662591 Ok ( ( ) )
663592 }
664593}
594+
595+ #[ cfg( test) ]
596+ mod tests {
597+ use super :: * ;
598+
599+ #[ test]
600+ fn test_find_markdown_table_end ( ) {
601+ let content = "Some text before\n \n | Metadata | Value |\n |----------|-------|\n | Point of contact | @nikomatsakis |\n | Teams | (none) |\n \n Some text after" ;
602+
603+ let result = GoalPreprocessorWithContext :: find_markdown_table_end ( content) ;
604+ assert ! ( result. is_some( ) ) ;
605+
606+ let offset = result. unwrap ( ) ;
607+ let ( before, after) = content. split_at ( offset) ;
608+
609+ // Should split right before the blank line after the table
610+ assert ! ( before. ends_with( "| Teams | (none) |\n " ) ) ;
611+ assert ! ( after. starts_with( "\n Some text after" ) ) ;
612+
613+ // Test that inserting at this offset works correctly
614+ let mut test_content = content. to_string ( ) ;
615+ test_content. insert_str ( offset, "| New row | value |\n " ) ;
616+ assert ! ( test_content. contains( "| Teams | (none) |\n | New row | value |\n \n Some text after" ) ) ;
617+ }
618+ }
0 commit comments