Skip to content

Commit fe18804

Browse files
committed
feat(ci): CI-20 close gaps — interactive prompt, merge, summary table
1 parent 08263d6 commit fe18804

1 file changed

Lines changed: 254 additions & 3 deletions

File tree

‎src/generator/ci_generation/writer.rs‎

Lines changed: 254 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
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;
2425
use std::path::{Path, PathBuf};
2526

2627
use 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)]
6678
pub 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`.
151277
pub 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`.
171297
fn 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.
239396
fn 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

Comments
 (0)