@@ -120,6 +120,8 @@ pub struct FilePatch {
120120 filename : String ,
121121 /// The header row (optional). If `None`, the header is not checked against base files.
122122 header_row : Option < Vec < String > > ,
123+ /// Full replacement content for this file (optional)
124+ replacement_content : Option < String > ,
123125 /// Rows to delete (each row is a vector of fields)
124126 to_delete : CSVTable ,
125127 /// Rows to add (each row is a vector of fields)
@@ -132,13 +134,18 @@ impl FilePatch {
132134 FilePatch {
133135 filename : filename. into ( ) ,
134136 header_row : None ,
137+ replacement_content : None ,
135138 to_delete : IndexSet :: new ( ) ,
136139 to_add : IndexSet :: new ( ) ,
137140 }
138141 }
139142
140143 /// Set the header row for this patch (header should be a comma-joined string, e.g. "a,b,c").
141144 pub fn with_header ( mut self , header : impl Into < String > ) -> Self {
145+ assert ! (
146+ self . replacement_content. is_none( ) ,
147+ "Cannot set header when replacement content is set for this FilePatch" ,
148+ ) ;
142149 assert ! (
143150 self . header_row. is_none( ) ,
144151 "Header already set for this FilePatch" ,
@@ -149,8 +156,48 @@ impl FilePatch {
149156 self
150157 }
151158
159+ /// Set full replacement content for this file from a slice of lines.
160+ ///
161+ /// Each line is joined with newlines, and a trailing newline is added.
162+ /// All lines must have the same number of columns (commas).
163+ /// Example: `with_replacement(&["header1,header2", "value1,value2"])`
164+ pub fn with_replacement ( mut self , lines : & [ & str ] ) -> Self {
165+ assert ! (
166+ self . header_row. is_none( ) ,
167+ "Cannot set replacement content when header is set for this FilePatch" ,
168+ ) ;
169+ assert ! (
170+ self . to_delete. is_empty( ) && self . to_add. is_empty( ) ,
171+ "Cannot set replacement content when additions/deletions are set for this FilePatch" ,
172+ ) ;
173+ assert ! (
174+ self . replacement_content. is_none( ) ,
175+ "Replacement content already set for this FilePatch" ,
176+ ) ;
177+
178+ // Validate that all lines have the same number of columns
179+ if !lines. is_empty ( ) {
180+ let first_col_count = lines[ 0 ] . matches ( ',' ) . count ( ) + 1 ;
181+ for ( idx, line) in lines. iter ( ) . enumerate ( ) {
182+ let col_count = line. matches ( ',' ) . count ( ) + 1 ;
183+ assert_eq ! (
184+ col_count, first_col_count,
185+ "Line {idx} has {col_count} columns but line 0 has {first_col_count}: {line:?}"
186+ ) ;
187+ }
188+ }
189+
190+ let content = lines. join ( "\n " ) + "\n " ;
191+ self . replacement_content = Some ( content) ;
192+ self
193+ }
194+
152195 /// Add a row to the patch (row should be a comma-joined string, e.g. "a,b,c").
153196 pub fn with_addition ( mut self , row : impl Into < String > ) -> Self {
197+ assert ! (
198+ self . replacement_content. is_none( ) ,
199+ "Cannot add rows when replacement content is set for this FilePatch" ,
200+ ) ;
154201 let s = row. into ( ) ;
155202 let v = s. split ( ',' ) . map ( |s| s. trim ( ) . to_string ( ) ) . collect ( ) ;
156203 self . to_add . insert ( v) ;
@@ -159,6 +206,10 @@ impl FilePatch {
159206
160207 /// Mark a row for deletion from the base (row should be a comma-joined string, e.g. "a,b,c").
161208 pub fn with_deletion ( mut self , row : impl Into < String > ) -> Self {
209+ assert ! (
210+ self . replacement_content. is_none( ) ,
211+ "Cannot delete rows when replacement content is set for this FilePatch" ,
212+ ) ;
162213 let s = row. into ( ) ;
163214 let v = s. split ( ',' ) . map ( |s| s. trim ( ) . to_string ( ) ) . collect ( ) ;
164215 self . to_delete . insert ( v) ;
@@ -167,13 +218,21 @@ impl FilePatch {
167218
168219 /// Apply this patch to a base model and return the modified CSV as a string.
169220 fn apply ( & self , base_model_dir : & Path ) -> Result < String > {
170- // Read the base file to string
221+ // Read and validate the base file path
171222 let base_path = base_model_dir. join ( & self . filename ) ;
172223 ensure ! (
173224 base_path. exists( ) && base_path. is_file( ) ,
174225 "Base file for patching does not exist: {}" ,
175226 base_path. display( )
176227 ) ;
228+
229+ // If this patch is a full replacement, validate the base file exists
230+ // (checked above) and return the replacement content
231+ if let Some ( content) = & self . replacement_content {
232+ return Ok ( content. clone ( ) ) ;
233+ }
234+
235+ // Read the base file to string
177236 let base = fs:: read_to_string ( & base_path) ?;
178237
179238 // Apply the patch
@@ -232,7 +291,6 @@ fn modify_base_with_patch(base: &str, patch: &FilePatch) -> Result<String> {
232291 header_row_vec. join( ", " )
233292 ) ;
234293 }
235-
236294 // Read all rows from the base, preserving order and checking for duplicates
237295 let mut base_rows: CSVTable = CSVTable :: new ( ) ;
238296 for result in reader. records ( ) {
@@ -278,6 +336,16 @@ fn modify_base_with_patch(base: &str, patch: &FilePatch) -> Result<String> {
278336 ) ;
279337 }
280338
339+ // Check all rows match base header length
340+ let expected_len = base_header_vec. len ( ) ;
341+ for row in & base_rows {
342+ ensure ! (
343+ row. len( ) == expected_len,
344+ "Row has {} columns but header has {expected_len}: {row:?}" ,
345+ row. len( ) ,
346+ ) ;
347+ }
348+
281349 // Serialize CSV output using csv::Writer
282350 let mut wtr = Writer :: from_writer ( vec ! [ ] ) ;
283351 wtr. write_record ( base_header_vec. iter ( ) ) ?;
@@ -379,6 +447,73 @@ mod tests {
379447 assert ! ( assets_content. contains( "GASDRV,GBR,A0_GEX,4003.26,2020" ) ) ;
380448 }
381449
450+ #[ test]
451+ fn file_patch_with_replacement ( ) {
452+ let expected = "col1,col2\n new1,new2\n " ;
453+
454+ let model_dir = ModelPatch :: from_example ( "simple" )
455+ . with_file_patch (
456+ FilePatch :: new ( "assets.csv" ) . with_replacement ( & [ "col1,col2" , "new1,new2" ] ) ,
457+ )
458+ . build_to_tempdir ( )
459+ . unwrap ( ) ;
460+
461+ let assets_path = model_dir. path ( ) . join ( "assets.csv" ) ;
462+ let assets_content = std:: fs:: read_to_string ( assets_path) . unwrap ( ) ;
463+ assert_eq ! ( assets_content, expected) ;
464+ }
465+
466+ #[ test]
467+ #[ should_panic(
468+ expected = "Cannot set replacement content when header is set for this FilePatch"
469+ ) ]
470+ fn file_patch_replacement_after_header_panics ( ) {
471+ let _ = FilePatch :: new ( "assets.csv" )
472+ . with_header ( "col1,col2" )
473+ . with_replacement ( & [ "col1,col2" , "a,b" ] ) ;
474+ }
475+
476+ #[ test]
477+ #[ should_panic(
478+ expected = "Cannot set replacement content when additions/deletions are set for this FilePatch"
479+ ) ]
480+ fn file_patch_replacement_after_addition_panics ( ) {
481+ let _ = FilePatch :: new ( "assets.csv" )
482+ . with_addition ( "a,b" )
483+ . with_replacement ( & [ "col1,col2" , "a,b" ] ) ;
484+ }
485+
486+ #[ test]
487+ #[ should_panic( expected = "Cannot add rows when replacement content is set for this FilePatch" ) ]
488+ fn file_patch_addition_after_replacement_panics ( ) {
489+ let _ = FilePatch :: new ( "assets.csv" )
490+ . with_replacement ( & [ "col1,col2" , "a,b" ] )
491+ . with_addition ( "c,d" ) ;
492+ }
493+
494+ #[ test]
495+ fn file_patch_with_replacement_missing_base_file_fails ( ) {
496+ let model_patch = ModelPatch :: from_example ( "simple" ) . with_file_patch (
497+ FilePatch :: new ( "not_a_real_file.csv" ) . with_replacement ( & [ "x,y" , "1,2" ] ) ,
498+ ) ;
499+
500+ let expected = format ! (
501+ "Base file for patching does not exist: {}" ,
502+ std:: path:: PathBuf :: from( "examples" )
503+ . join( "simple" )
504+ . join( "not_a_real_file.csv" )
505+ . display( )
506+ ) ;
507+
508+ assert_error ! ( model_patch. build_to_tempdir( ) , expected) ;
509+ }
510+
511+ #[ test]
512+ #[ should_panic( expected = "Line 1 has 2 columns but line 0 has 3" ) ]
513+ fn file_patch_replacement_column_count_mismatch_panics ( ) {
514+ let _ = FilePatch :: new ( "test.csv" ) . with_replacement ( & [ "col1,col2,col3" , "a,b" ] ) ;
515+ }
516+
382517 #[ test]
383518 fn toml_patch ( ) {
384519 // Patch to add an extra milestone year (2050)
0 commit comments