2121//! callers build the `Vec<CiFile>` from the `CiPipeline` they assembled.
2222//! A `WriteSummary` is returned so the CLI can display a results table.
2323
24+ use std:: io:: BufRead ;
2425use std:: path:: { Path , PathBuf } ;
2526
2627use similar:: { ChangeTag , TextDiff } ;
@@ -61,6 +62,17 @@ impl CiFile {
6162 }
6263}
6364
65+ /// User's chosen resolution when a conflict is detected during interactive mode.
66+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
67+ pub enum ConflictResolution {
68+ /// Replace the existing file with the generated content.
69+ Overwrite ,
70+ /// Write both versions into the file using git-style conflict markers.
71+ Merge ,
72+ /// Leave the existing file unchanged.
73+ Skip ,
74+ }
75+
6476/// Result of writing a single file.
6577#[ derive( Debug , Clone , PartialEq , Eq ) ]
6678pub enum WriteOutcome {
@@ -72,6 +84,8 @@ pub enum WriteOutcome {
7284 Overwritten ,
7385 /// File existed with different content and `force = false` → not written.
7486 Skipped ,
87+ /// File written with git-style conflict markers for manual resolution.
88+ Merged ,
7589 /// Generated content failed the YAML validation round-trip.
7690 InvalidYaml ( String ) ,
7791}
@@ -105,16 +119,20 @@ impl WriteSummary {
105119 pub fn invalid ( & self ) -> usize {
106120 self . results . iter ( ) . filter ( |r| matches ! ( r. outcome, WriteOutcome :: InvalidYaml ( _) ) ) . count ( )
107121 }
122+ pub fn merged ( & self ) -> usize {
123+ self . results . iter ( ) . filter ( |r| r. outcome == WriteOutcome :: Merged ) . count ( )
124+ }
108125 pub fn has_conflicts ( & self ) -> bool {
109126 self . results . iter ( ) . any ( |r| r. outcome == WriteOutcome :: Skipped )
110127 }
111128
112- /// Returns a human-readable summary table line.
129+ /// Returns a human-readable single- line summary .
113130 pub fn display_line ( & self ) -> String {
114131 format ! (
115- "{} created, {} overwritten, {} skipped, {} invalid" ,
132+ "{} created, {} overwritten, {} merged, {} skipped, {} invalid" ,
116133 self . created( ) ,
117134 self . overwritten( ) ,
135+ self . merged( ) ,
118136 self . skipped( ) ,
119137 self . invalid( ) ,
120138 )
@@ -147,6 +165,114 @@ pub fn write_ci_files(
147165 Ok ( summary)
148166}
149167
168+ /// Interactive variant of `write_ci_files`.
169+ ///
170+ /// Runs a first pass with `force = false` to detect conflicts, then for each
171+ /// `Skipped` file reads one line from `reader` to ask the user what to do:
172+ ///
173+ /// - `o` → overwrite (replace existing file with generated content)
174+ /// - `m` → merge (write both versions with git-style conflict markers)
175+ /// - `s` / anything else → skip (keep existing file)
176+ ///
177+ /// `reader` is generic over `BufRead` so tests can inject a cursor instead of
178+ /// reading from real stdin.
179+ pub fn write_ci_files_interactive < R : BufRead > (
180+ files : & [ CiFile ] ,
181+ output_dir : & Path ,
182+ reader : & mut R ,
183+ ) -> crate :: Result < WriteSummary > {
184+ let mut summary = write_ci_files ( files, output_dir, false ) ?;
185+
186+ for ( file, result) in files. iter ( ) . zip ( summary. results . iter_mut ( ) ) {
187+ if result. outcome != WriteOutcome :: Skipped {
188+ continue ;
189+ }
190+ let diff = result. diff . as_deref ( ) . unwrap_or ( "" ) ;
191+ let resolution = prompt_conflict_resolution ( & result. path , diff, reader) ;
192+ match resolution {
193+ ConflictResolution :: Overwrite => {
194+ do_write ( & result. path , & file. content ) ?;
195+ result. outcome = WriteOutcome :: Overwritten ;
196+ }
197+ ConflictResolution :: Merge => {
198+ let existing = std:: fs:: read_to_string ( & result. path ) ?;
199+ let merged = conflict_markers ( & existing, & file. content ) ;
200+ do_write ( & result. path , & merged) ?;
201+ result. outcome = WriteOutcome :: Merged ;
202+ }
203+ ConflictResolution :: Skip => { }
204+ }
205+ }
206+
207+ Ok ( summary)
208+ }
209+
210+ /// Reads a single conflict-resolution choice from `reader`.
211+ ///
212+ /// Prints a prompt line to stderr (non-blocking in tests). Parses:
213+ /// - `"o"` → `Overwrite`
214+ /// - `"m"` → `Merge`
215+ /// - anything else (including `"s"`) → `Skip`
216+ pub fn prompt_conflict_resolution < R : BufRead > (
217+ path : & Path ,
218+ _diff : & str ,
219+ reader : & mut R ,
220+ ) -> ConflictResolution {
221+ eprintln ! (
222+ " conflict: {} [o]verwrite / [m]erge / [s]kip?" ,
223+ path. display( )
224+ ) ;
225+ let mut line = String :: new ( ) ;
226+ let _ = reader. read_line ( & mut line) ;
227+ match line. trim ( ) {
228+ "o" => ConflictResolution :: Overwrite ,
229+ "m" => ConflictResolution :: Merge ,
230+ _ => ConflictResolution :: Skip ,
231+ }
232+ }
233+
234+ /// Renders a formatted table summarising `WriteSummary` results.
235+ ///
236+ /// Uses the box-drawing style consistent with the rest of the codebase.
237+ /// Returns a `String` so the caller decides when/how to print it.
238+ pub fn render_summary_table ( summary : & WriteSummary ) -> String {
239+ const PATH_W : usize = 44 ;
240+ const OUT_W : usize = 12 ;
241+ const LINE_W : usize = PATH_W + OUT_W + 5 ; // borders + padding
242+
243+ let ruler = "─" . repeat ( LINE_W ) ;
244+ let mut out = String :: new ( ) ;
245+
246+ out. push_str ( & format ! ( "┌─ CI Files Written {}┐\n " , "─" . repeat( LINE_W - 20 ) ) ) ;
247+ out. push_str ( & format ! (
248+ "│ {:<PATH_W$} {:<OUT_W$}│\n " ,
249+ "File" ,
250+ "Outcome" ,
251+ PATH_W = PATH_W ,
252+ OUT_W = OUT_W
253+ ) ) ;
254+ out. push_str ( & format ! ( "│ {}│\n " , ruler) ) ;
255+
256+ for r in & summary. results {
257+ let label = outcome_label ( & r. outcome ) ;
258+ // Show only the last two path components for readability
259+ let display = compact_path ( & r. path ) ;
260+ out. push_str ( & format ! (
261+ "│ {:<PATH_W$} {:<OUT_W$}│\n " ,
262+ display,
263+ label,
264+ PATH_W = PATH_W ,
265+ OUT_W = OUT_W
266+ ) ) ;
267+ }
268+
269+ out. push_str ( & format ! ( "├{}┤\n " , ruler) ) ;
270+ out. push_str ( & format ! ( "│ {:<width$}│\n " , summary. display_line( ) , width = LINE_W - 2 ) ) ;
271+ out. push_str ( & format ! ( "└{}┘\n " , ruler) ) ;
272+
273+ out
274+ }
275+
150276/// Resolves the on-disk path for a `CiFileKind` relative to `output_dir`.
151277pub fn resolve_path ( output_dir : & Path , kind : & CiFileKind ) -> PathBuf {
152278 match kind {
@@ -165,7 +291,7 @@ pub fn pipeline_path(format: &CiFormat) -> &'static str {
165291 }
166292}
167293
168- // ── Internal helpers ──────────────────────────────────────────────────────────
294+ // ── Internal helpers ─────────────────────────────────────────────────────────
169295
170296/// Validates, diffs, and conditionally writes a single `CiFile`.
171297fn write_one ( file : & CiFile , path : & Path , force : bool ) -> crate :: Result < FileResult > {
@@ -235,6 +361,37 @@ fn validate_yaml(content: &str) -> Result<(), String> {
235361 . map_err ( |e| e. to_string ( ) )
236362}
237363
364+ /// Writes both `old` and `new` into a single file using git-style conflict markers.
365+ fn conflict_markers ( old : & str , new : & str ) -> String {
366+ format ! ( "<<<<<<< current\n {}=======\n {}>>>>>>> generated\n " , old, new)
367+ }
368+
369+ /// Returns a short human-readable label for a `WriteOutcome`.
370+ fn outcome_label ( outcome : & WriteOutcome ) -> & ' static str {
371+ match outcome {
372+ WriteOutcome :: Created => "created" ,
373+ WriteOutcome :: Unchanged => "unchanged" ,
374+ WriteOutcome :: Overwritten => "overwritten" ,
375+ WriteOutcome :: Skipped => "skipped" ,
376+ WriteOutcome :: Merged => "merged" ,
377+ WriteOutcome :: InvalidYaml ( _) => "invalid yaml" ,
378+ }
379+ }
380+
381+ /// Returns the last two components of `path` joined by `/` for compact display.
382+ fn compact_path ( path : & Path ) -> String {
383+ let parts: Vec < _ > = path. components ( ) . collect ( ) ;
384+ if parts. len ( ) >= 2 {
385+ let n = parts. len ( ) ;
386+ format ! ( "{}/{}" ,
387+ parts[ n - 2 ] . as_os_str( ) . to_string_lossy( ) ,
388+ parts[ n - 1 ] . as_os_str( ) . to_string_lossy( )
389+ )
390+ } else {
391+ path. display ( ) . to_string ( )
392+ }
393+ }
394+
238395/// Builds a compact unified diff for display purposes.
239396fn build_diff ( old : & str , new : & str ) -> String {
240397 let diff = TextDiff :: from_lines ( old, new) ;
@@ -420,4 +577,98 @@ mod tests {
420577 assert ! ( line. contains( "0 skipped" ) ) ;
421578 std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
422579 }
580+
581+ #[ test]
582+ fn test_render_summary_table_contains_headers ( ) {
583+ let dir = tmp_dir ( "table" ) ;
584+ let files = vec ! [ CiFile :: pipeline( VALID_YAML . to_string( ) , CiFormat :: GithubActions ) ] ;
585+ let summary = write_ci_files ( & files, & dir, false ) . unwrap ( ) ;
586+ let table = render_summary_table ( & summary) ;
587+ assert ! ( table. contains( "CI Files Written" ) ) ;
588+ assert ! ( table. contains( "File" ) ) ;
589+ assert ! ( table. contains( "Outcome" ) ) ;
590+ assert ! ( table. contains( "created" ) ) ;
591+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
592+ }
593+
594+ // ── prompt_conflict_resolution ────────────────────────────────────────
595+
596+ #[ test]
597+ fn test_prompt_overwrite ( ) {
598+ let mut reader = std:: io:: Cursor :: new ( "o\n " ) ;
599+ let res = prompt_conflict_resolution ( Path :: new ( "/tmp/ci.yml" ) , "" , & mut reader) ;
600+ assert_eq ! ( res, ConflictResolution :: Overwrite ) ;
601+ }
602+
603+ #[ test]
604+ fn test_prompt_merge ( ) {
605+ let mut reader = std:: io:: Cursor :: new ( "m\n " ) ;
606+ let res = prompt_conflict_resolution ( Path :: new ( "/tmp/ci.yml" ) , "" , & mut reader) ;
607+ assert_eq ! ( res, ConflictResolution :: Merge ) ;
608+ }
609+
610+ #[ test]
611+ fn test_prompt_skip ( ) {
612+ let mut reader = std:: io:: Cursor :: new ( "s\n " ) ;
613+ let res = prompt_conflict_resolution ( Path :: new ( "/tmp/ci.yml" ) , "" , & mut reader) ;
614+ assert_eq ! ( res, ConflictResolution :: Skip ) ;
615+ }
616+
617+ #[ test]
618+ fn test_prompt_unrecognised_defaults_to_skip ( ) {
619+ let mut reader = std:: io:: Cursor :: new ( "x\n " ) ;
620+ let res = prompt_conflict_resolution ( Path :: new ( "/tmp/ci.yml" ) , "" , & mut reader) ;
621+ assert_eq ! ( res, ConflictResolution :: Skip ) ;
622+ }
623+
624+ // ── write_ci_files_interactive ────────────────────────────────────────
625+
626+ #[ test]
627+ fn test_interactive_overwrite_resolves_conflict ( ) {
628+ let dir = tmp_dir ( "interactive_ow" ) ;
629+ let files = vec ! [ CiFile :: pipeline( VALID_YAML . to_string( ) , CiFormat :: GithubActions ) ] ;
630+ write_ci_files ( & files, & dir, false ) . unwrap ( ) ;
631+ let new_content = VALID_YAML . replace ( "CI" , "CI-MODIFIED" ) ;
632+ let files2 = vec ! [ CiFile :: pipeline( new_content. clone( ) , CiFormat :: GithubActions ) ] ;
633+ // Simulate user typing "o" at the prompt
634+ let mut reader = std:: io:: Cursor :: new ( "o\n " ) ;
635+ let summary = write_ci_files_interactive ( & files2, & dir, & mut reader) . unwrap ( ) ;
636+ assert_eq ! ( summary. overwritten( ) , 1 ) ;
637+ let written = std:: fs:: read_to_string ( dir. join ( ".github/workflows/ci.yml" ) ) . unwrap ( ) ;
638+ assert_eq ! ( written, new_content) ;
639+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
640+ }
641+
642+ #[ test]
643+ fn test_interactive_merge_writes_conflict_markers ( ) {
644+ let dir = tmp_dir ( "interactive_merge" ) ;
645+ let files = vec ! [ CiFile :: pipeline( VALID_YAML . to_string( ) , CiFormat :: GithubActions ) ] ;
646+ write_ci_files ( & files, & dir, false ) . unwrap ( ) ;
647+ let new_content = VALID_YAML . replace ( "CI" , "CI-MODIFIED" ) ;
648+ let files2 = vec ! [ CiFile :: pipeline( new_content, CiFormat :: GithubActions ) ] ;
649+ // Simulate user typing "m" at the prompt
650+ let mut reader = std:: io:: Cursor :: new ( "m\n " ) ;
651+ let summary = write_ci_files_interactive ( & files2, & dir, & mut reader) . unwrap ( ) ;
652+ assert_eq ! ( summary. merged( ) , 1 ) ;
653+ let written = std:: fs:: read_to_string ( dir. join ( ".github/workflows/ci.yml" ) ) . unwrap ( ) ;
654+ assert ! ( written. contains( "<<<<<<< current" ) ) ;
655+ assert ! ( written. contains( ">>>>>>> generated" ) ) ;
656+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
657+ }
658+
659+ #[ test]
660+ fn test_interactive_skip_leaves_existing_file ( ) {
661+ let dir = tmp_dir ( "interactive_skip" ) ;
662+ let files = vec ! [ CiFile :: pipeline( VALID_YAML . to_string( ) , CiFormat :: GithubActions ) ] ;
663+ write_ci_files ( & files, & dir, false ) . unwrap ( ) ;
664+ let new_content = VALID_YAML . replace ( "CI" , "CI-MODIFIED" ) ;
665+ let files2 = vec ! [ CiFile :: pipeline( new_content, CiFormat :: GithubActions ) ] ;
666+ let mut reader = std:: io:: Cursor :: new ( "s\n " ) ;
667+ let summary = write_ci_files_interactive ( & files2, & dir, & mut reader) . unwrap ( ) ;
668+ assert_eq ! ( summary. skipped( ) , 1 ) ;
669+ let written = std:: fs:: read_to_string ( dir. join ( ".github/workflows/ci.yml" ) ) . unwrap ( ) ;
670+ // Original content must be intact
671+ assert_eq ! ( written, VALID_YAML ) ;
672+ std:: fs:: remove_dir_all ( & dir) . ok ( ) ;
673+ }
423674}
0 commit comments