diff --git a/src/filesystem.rs b/src/filesystem.rs index e1ee1113..f9abc2d8 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -24,6 +24,35 @@ impl File { .map_err(|e| format!("Failed to write {}: {}", path.display(), e)) } + pub(crate) fn replace_lines( + &mut self, + line_nums: std::ops::Range, + text: &str, + ) -> Result<(), String> { + let mut output_lines = String::new(); + + let s = self + .as_str() + .ok_or("Binary file can't have lines replaced")?; + for (line_num, line) in crate::lines::LinesWithTerminator::new(s) + .enumerate() + .map(|(i, l)| (i + 1, l)) + { + if line_num == line_nums.start { + output_lines.push_str(text); + if !text.is_empty() && !text.ends_with('\n') { + output_lines.push('\n'); + } + } + if !line_nums.contains(&line_num) { + output_lines.push_str(line); + } + } + + *self = Self::Text(output_lines); + Ok(()) + } + pub(crate) fn map_text(self, op: impl FnOnce(&str) -> String) -> Self { match self { Self::Binary(data) => Self::Binary(data), @@ -70,6 +99,13 @@ impl File { } } + pub(crate) fn as_str(&self) -> Option<&str> { + match self { + Self::Binary(_) => None, + Self::Text(data) => Some(data.as_str()), + } + } + pub(crate) fn as_bytes(&self) -> &[u8] { match self { Self::Binary(data) => data, @@ -249,3 +285,68 @@ fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<( fn symlink_to_file(link: &std::path::Path, target: &std::path::Path) -> Result<(), std::io::Error> { std::os::unix::fs::symlink(target, link) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn replace_lines_same_line_count() { + let input = "One\nTwo\nThree"; + let line_nums = 2..3; + let replacement = "World\n"; + let expected = File::Text("One\nWorld\nThree".into()); + + let mut actual = File::Text(input.into()); + actual.replace_lines(line_nums, replacement).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn replace_lines_grow() { + let input = "One\nTwo\nThree"; + let line_nums = 2..3; + let replacement = "World\nTrees\n"; + let expected = File::Text("One\nWorld\nTrees\nThree".into()); + + let mut actual = File::Text(input.into()); + actual.replace_lines(line_nums, replacement).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn replace_lines_shrink() { + let input = "One\nTwo\nThree"; + let line_nums = 2..3; + let replacement = ""; + let expected = File::Text("One\nThree".into()); + + let mut actual = File::Text(input.into()); + actual.replace_lines(line_nums, replacement).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn replace_lines_no_trailing() { + let input = "One\nTwo\nThree"; + let line_nums = 2..3; + let replacement = "World"; + let expected = File::Text("One\nWorld\nThree".into()); + + let mut actual = File::Text(input.into()); + actual.replace_lines(line_nums, replacement).unwrap(); + assert_eq!(expected, actual); + } + + #[test] + fn replace_lines_empty_range() { + let input = "One\nTwo\nThree"; + let line_nums = 2..2; + let replacement = "World\n"; + let expected = File::Text("One\nWorld\nTwo\nThree".into()); + + let mut actual = File::Text(input.into()); + actual.replace_lines(line_nums, replacement).unwrap(); + assert_eq!(expected, actual); + } +} diff --git a/src/runner.rs b/src/runner.rs index cc2eea2d..0f67b781 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -185,12 +185,46 @@ impl Case { step.expected_status = Some(crate::schema::CommandStatus::Skipped); } - let step_status = self.run_step(step, cwd.as_deref(), mode, bins); - if step_status.is_err() { + let step_status = self.run_step(step, cwd.as_deref(), bins); + if step_status.is_err() && *mode == Mode::Fail { prior_step_failed = true; } outputs.push(step_status); } + match mode { + Mode::Dump(root) => { + for output in &mut outputs { + let output = match output { + Ok(output) => output, + Err(output) => output, + }; + output.stdout = + match self.dump_stream(root, output.id.as_deref(), output.stdout.take()) { + Ok(stream) => stream, + Err(stream) => stream, + }; + output.stderr = + match self.dump_stream(root, output.id.as_deref(), output.stderr.take()) { + Ok(stream) => stream, + Err(stream) => stream, + }; + } + } + Mode::Overwrite => { + // `rev()` to ensure we don't mess up our line number info + for output in outputs.iter().rev() { + if let Err(output) = output { + let _ = sequence.overwrite( + &self.path, + output.id.as_deref(), + output.stdout.as_ref().map(|s| &s.content), + output.stderr.as_ref().map(|s| &s.content), + ); + } + } + } + Mode::Fail => {} + } if sequence.fs.sandbox() { let mut ok = true; @@ -232,7 +266,6 @@ impl Case { &self, step: &mut crate::schema::Step, cwd: Option<&std::path::Path>, - mode: &Mode, bins: &crate::BinRegistry, ) -> Result { let output = if let Some(id) = step.id.clone() { @@ -266,58 +299,18 @@ impl Case { let cmd_output = step.to_output(cwd).map_err(|e| output.clone().error(e))?; let output = output.output(cmd_output); - // For dump mode's sake, allow running all - let mut ok = output.is_ok(); - let mut output = match self.validate_spawn(output, step.expected_status()) { - Ok(output) => output, - Err(output) => { - ok = false; - output - } - }; - if let Some(mut stdout) = output.stdout { - if !step.binary { - stdout = stdout.utf8(); - } - if stdout.is_ok() { - stdout = match self.validate_stream(stdout, step.expected_stdout.as_ref(), mode) { - Ok(stdout) => stdout, - Err(stdout) => { - ok = false; - stdout - } - }; - } - output.stdout = Some(stdout); - } - if let Some(mut stderr) = output.stderr { - if !step.binary { - stderr = stderr.utf8(); - } - if stderr.is_ok() { - stderr = match self.validate_stream(stderr, step.expected_stderr.as_ref(), mode) { - Ok(stderr) => stderr, - Err(stderr) => { - ok = false; - stderr - } - }; - } - output.stderr = Some(stderr); - } + // For Mode::Dump's sake, allow running all + let output = self.validate_spawn(output, step.expected_status()); + let output = self.validate_streams(output, step); - if ok { + if output.is_ok() { Ok(output) } else { Err(output) } } - fn validate_spawn( - &self, - mut output: Output, - expected: crate::schema::CommandStatus, - ) -> Result { + fn validate_spawn(&self, mut output: Output, expected: crate::schema::CommandStatus) -> Output { let status = output.spawn.exit.expect("bale out before now"); match expected { crate::schema::CommandStatus::Success => { @@ -343,59 +336,82 @@ impl Case { } } - Ok(output) + output + } + + fn validate_streams(&self, mut output: Output, step: &crate::schema::Step) -> Output { + output.stdout = + self.validate_stream(output.stdout, step.expected_stdout.as_ref(), step.binary); + output.stderr = + self.validate_stream(output.stderr, step.expected_stderr.as_ref(), step.binary); + + output } fn validate_stream( &self, - mut stream: Stream, + stream: Option, expected_content: Option<&crate::File>, - mode: &Mode, - ) -> Result { - if let Mode::Dump(root) = mode { - let stdout_path = root.join( - self.path - .with_extension(stream.stream.as_str()) - .file_name() - .unwrap(), - ); - stream.content.write_to(&stdout_path).map_err(|e| { - let mut stream = stream.clone(); - stream.status = StreamStatus::Failure(e); - stream - })?; - } else if let Some(expected_content) = expected_content { + binary: bool, + ) -> Option { + let mut stream = stream?; + + if !binary { + stream = stream.utf8(); + if !stream.is_ok() { + return Some(stream); + } + } + + if let Some(expected_content) = expected_content { if let crate::File::Text(e) = &expected_content { stream.content = stream.content.map_text(|t| crate::elide::normalize(t, e)); } if stream.content != *expected_content { - match mode { - Mode::Fail => { - stream.status = StreamStatus::Expected(expected_content.clone()); - return Err(stream); - } - Mode::Overwrite => { - let stdout_path = self.path.with_extension(stream.stream.as_str()); - if stdout_path.exists() { - stream.content.write_to(&stdout_path).map_err(|e| { - let mut stream = stream.clone(); - stream.status = StreamStatus::Failure(e); - stream - })?; - stream.status = StreamStatus::Expected(expected_content.clone()); - return Ok(stream); - } else { - // `.trycmd` files do not support overwrite, see issue #23 - stream.status = StreamStatus::Expected(expected_content.clone()); - return Err(stream); - } - } - Mode::Dump(_) => unreachable!("handled earlier"), - } + stream.status = StreamStatus::Expected(expected_content.clone()); + return Some(stream); } } - Ok(stream) + Some(stream) + } + + fn dump_stream( + &self, + root: &std::path::Path, + id: Option<&str>, + stream: Option, + ) -> Result, Option> { + if let Some(stream) = stream { + let file_name = match id { + Some(id) => { + format!( + "{}-{}.{}", + self.path.file_stem().unwrap().to_string_lossy(), + id, + stream.stream.as_str(), + ) + } + None => { + format!( + "{}.{}", + self.path.file_stem().unwrap().to_string_lossy(), + stream.stream.as_str(), + ) + } + }; + let stream_path = root.join(file_name); + stream.content.write_to(&stream_path).map_err(|e| { + let mut stream = stream.clone(); + if stream.is_ok() { + stream.status = StreamStatus::Failure(e); + } + stream + })?; + Ok(Some(stream)) + } else { + Ok(None) + } } fn validate_fs( diff --git a/src/schema.rs b/src/schema.rs index 08fafda7..ae3b49b0 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -81,6 +81,53 @@ impl TryCmd { Ok(sequence) } + pub(crate) fn overwrite( + &self, + path: &std::path::Path, + id: Option<&str>, + stdout: Option<&crate::File>, + stderr: Option<&crate::File>, + ) -> Result<(), String> { + if let Some(ext) = path.extension() { + if ext == std::ffi::OsStr::new("toml") { + assert_eq!(id, None); + + if let Some(stdout) = stdout { + let stdout_path = path.with_extension("stdout"); + stdout.write_to(&stdout_path)?; + } + + if let Some(stderr) = stderr { + let stderr_path = path.with_extension("stderr"); + stderr.write_to(&stderr_path)?; + } + } else if ext == std::ffi::OsStr::new("trycmd") || ext == std::ffi::OsStr::new("md") { + assert_eq!(stderr, Some(&crate::File::Text("".into()))); + if let (Some(id), Some(stdout)) = (id, stdout) { + let step = self + .steps + .iter() + .find(|s| s.id.as_deref() == Some(id)) + .expect("id is valid"); + let line_nums = step + .expected_stdout_source + .clone() + .expect("always present for .trycmd"); + let stdout = stdout.as_str().expect("already converted to Text"); + let mut raw = crate::File::read_from(path, false)?; + raw.replace_lines(line_nums, stdout)?; + raw.write_to(path)?; + } + } else { + return Err(format!("Unsupported extension: {}", ext.to_string_lossy())); + } + } else { + return Err("No extension".into()); + } + + Ok(()) + } + fn parse_trycmd(s: &str) -> Result { let mut steps = Vec::new(); @@ -121,11 +168,13 @@ impl TryCmd { let mut expected_status = Some(CommandStatus::Success); let mut stdout = String::new(); let cmd_start; + let mut stdout_start; if let Some((line_num, line)) = lines.pop_front() { if let Some(raw) = line.strip_prefix("$ ") { cmdline.extend(shlex::Shlex::new(raw.trim())); cmd_start = line_num; + stdout_start = line_num + 1; } else { return Err(format!("Expected `$` on line {}, got `{}`", line_num, line)); } @@ -135,6 +184,7 @@ impl TryCmd { while let Some((line_num, line)) = lines.pop_front() { if let Some(raw) = line.strip_prefix("> ") { cmdline.extend(shlex::Shlex::new(raw.trim())); + stdout_start = line_num + 1; } else { lines.push_front((line_num, line)); break; @@ -143,20 +193,25 @@ impl TryCmd { if let Some((line_num, line)) = lines.pop_front() { if let Some(raw) = line.strip_prefix("? ") { expected_status = Some(raw.trim().parse::()?); + stdout_start = line_num + 1; } else { lines.push_front((line_num, line)); } } + let mut post_stdout_start = stdout_start; let mut block_done = false; while let Some((line_num, line)) = lines.pop_front() { if line.starts_with("$ ") { lines.push_front((line_num, line)); + post_stdout_start = line_num; break; } else if line.starts_with("```") { block_done = true; + post_stdout_start = line_num; break; } else { stdout.push_str(line); + post_stdout_start = line_num + 1; } } @@ -178,10 +233,15 @@ impl TryCmd { bin: Some(Bin::Name(bin)), args: cmdline, env, - expected_status, + stdin: None, stderr_to_stdout: true, + expected_status, + expected_stdout_source: Some(stdout_start..post_stdout_start), expected_stdout: Some(crate::File::Text(stdout)), - ..Default::default() + expected_stderr_source: None, + expected_stderr: None, + binary: false, + timeout: None, }; steps.push(step); if block_done { @@ -226,7 +286,9 @@ impl From for TryCmd { stdin: None, stderr_to_stdout, expected_status: status, + expected_stdout_source: None, expected_stdout: None, + expected_stderr_source: None, expected_stderr: None, binary, timeout, @@ -245,7 +307,9 @@ pub(crate) struct Step { pub(crate) stdin: Option, pub(crate) stderr_to_stdout: bool, pub(crate) expected_status: Option, + pub(crate) expected_stdout_source: Option>, pub(crate) expected_stdout: Option, + pub(crate) expected_stderr_source: Option>, pub(crate) expected_stderr: Option, pub(crate) binary: bool, pub(crate) timeout: Option, @@ -606,6 +670,7 @@ mod test { bin: Some(Bin::Name("cmd".into())), expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(4..4), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -632,6 +697,7 @@ $ cmd args: vec!["arg1".into(), "arg with space".into()], expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(4..4), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -658,6 +724,7 @@ $ cmd arg1 'arg with space' args: vec!["arg1".into(), "arg with space".into()], expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(5..5), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -692,6 +759,7 @@ $ cmd arg1 }, expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(4..4), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -717,6 +785,7 @@ $ KEY1=VALUE1 KEY2='VALUE2 with space' cmd bin: Some(Bin::Name("cmd".into())), expected_status: Some(CommandStatus::Skipped), stderr_to_stdout: true, + expected_stdout_source: Some(5..5), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -743,6 +812,7 @@ $ cmd bin: Some(Bin::Name("cmd".into())), expected_status: Some(CommandStatus::Code(-1)), stderr_to_stdout: true, + expected_stdout_source: Some(5..5), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -769,6 +839,7 @@ $ cmd bin: Some(Bin::Name("cmd".into())), expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(4..5), expected_stdout: Some(crate::File::Text("Hello World\n".into())), expected_stderr: None, ..Default::default() @@ -795,6 +866,7 @@ Hello World bin: Some(Bin::Name("cmd1".into())), expected_status: Some(CommandStatus::Code(1)), stderr_to_stdout: true, + expected_stdout_source: Some(5..5), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -804,6 +876,7 @@ Hello World bin: Some(Bin::Name("cmd2".into())), expected_status: Some(CommandStatus::Success), stderr_to_stdout: true, + expected_stdout_source: Some(6..6), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -833,6 +906,7 @@ $ cmd2 bin: Some(Bin::Name("bare-cmd".into())), expected_status: Some(CommandStatus::Code(1)), stderr_to_stdout: true, + expected_stdout_source: Some(5..5), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -842,6 +916,7 @@ $ cmd2 bin: Some(Bin::Name("trycmd-cmd".into())), expected_status: Some(CommandStatus::Code(1)), stderr_to_stdout: true, + expected_stdout_source: Some(10..10), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -851,6 +926,7 @@ $ cmd2 bin: Some(Bin::Name("sh-cmd".into())), expected_status: Some(CommandStatus::Code(1)), stderr_to_stdout: true, + expected_stdout_source: Some(15..15), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default() @@ -860,6 +936,7 @@ $ cmd2 bin: Some(Bin::Name("bash-cmd".into())), expected_status: Some(CommandStatus::Code(1)), stderr_to_stdout: true, + expected_stdout_source: Some(20..20), expected_stdout: Some(crate::File::Text("".into())), expected_stderr: None, ..Default::default()