summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.editorconfig4
-rw-r--r--Cargo.lock50
-rw-r--r--Cargo.toml2
-rw-r--r--src/error.rs40
-rw-r--r--src/input.rs120
-rw-r--r--src/main.rs133
-rw-r--r--src/replacer/mod.rs94
-rw-r--r--src/replacer/validate.rs24
-rw-r--r--src/utils.rs3
-rw-r--r--tests/cli.rs188
-rw-r--r--tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap6
-rw-r--r--tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap6
-rw-r--r--tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap9
13 files changed, 373 insertions, 306 deletions
diff --git a/.editorconfig b/.editorconfig
index 649cad9..5a09788 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,2 +1,6 @@
+[*]
+indent_style = space
+indent_size = 4
+
[*.rs]
max_line_length = 80
diff --git a/Cargo.lock b/Cargo.lock
index 5f67cce..2123179 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -22,15 +22,6 @@ dependencies = [
]
[[package]]
-name = "ansi_term"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
-dependencies = [
- "winapi",
-]
-
-[[package]]
name = "anstream"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -325,12 +316,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
-name = "hermit-abi"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
-
-[[package]]
name = "insta"
version = "1.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -344,17 +329,6 @@ dependencies = [
]
[[package]]
-name = "is-terminal"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
-dependencies = [
- "hermit-abi",
- "rustix",
- "windows-sys 0.48.0",
-]
-
-[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -650,14 +624,12 @@ name = "sd"
version = "1.0.0"
dependencies = [
"ansi-to-html",
- "ansi_term",
"anyhow",
"assert_cmd",
"clap",
"clap_mangen",
"console",
"insta",
- "is-terminal",
"memmap2",
"proptest",
"rayon",
@@ -806,28 +778,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 97ffc3b..e44aede 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,8 +33,6 @@ unescape = "0.1.0"
memmap2 = "0.9.0"
tempfile = "3.8.0"
thiserror = "1.0.50"
-ansi_term = "0.12.1"
-is-terminal = "0.4.9"
clap.workspace = true
[dev-dependencies]
diff --git a/src/error.rs b/src/error.rs
index 517defd..3a06758 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,7 +1,4 @@
-use std::{
- fmt::{self, Write},
- path::PathBuf,
-};
+use std::{fmt, path::PathBuf};
use crate::replacer::InvalidReplaceCapture;
@@ -13,37 +10,38 @@ pub enum Error {
File(#[from] std::io::Error),
#[error("failed to move file: {0}")]
TempfilePersist(#[from] tempfile::PersistError),
- #[error("file doesn't have parent path: {0}")]
+ #[error("invalid path: {0}")]
InvalidPath(PathBuf),
- #[error("failed processing files:\n{0}")]
- FailedProcessing(FailedJobs),
#[error("{0}")]
InvalidReplaceCapture(#[from] InvalidReplaceCapture),
+ #[error("{0}")]
+ FailedJobs(FailedJobs),
}
-pub struct FailedJobs(Vec<(PathBuf, Error)>);
-
-impl From<Vec<(PathBuf, Error)>> for FailedJobs {
- fn from(vec: Vec<(PathBuf, Error)>) -> Self {
- Self(vec)
+// pretty-print the error
+impl fmt::Debug for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self)
}
}
+pub type Result<T, E = Error> = std::result::Result<T, E>;
+
+pub struct FailedJobs(pub Vec<(PathBuf, Error)>);
+
impl fmt::Display for FailedJobs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str("\tFailedJobs(\n")?;
- for (path, err) in &self.0 {
- f.write_str(&format!("\t{:?}: {}\n", path, err))?;
+ f.write_str("Failed processing some inputs\n")?;
+ for (source, error) in &self.0 {
+ writeln!(f, " {}: {}", source.display(), error)?;
}
- f.write_char(')')
+
+ Ok(())
}
}
-// pretty-print the error
-impl std::fmt::Debug for Error {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl fmt::Debug for FailedJobs {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
-
-pub type Result<T, E = Error> = std::result::Result<T, E>;
diff --git a/src/input.rs b/src/input.rs
index 79a174d..fb98e62 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -1,98 +1,48 @@
-use std::{fs::File, io::prelude::*, path::PathBuf};
+use memmap2::{Mmap, MmapOptions};
+use std::{
+ fs::File,
+ io::{stdin, Read},
+ path::PathBuf,
+};
-use crate::{Error, Replacer, Result};
+use crate::error::Result;
-use is_terminal::IsTerminal;
-
-#[derive(Debug)]
+#[derive(Debug, PartialEq)]
pub(crate) enum Source {
Stdin,
- Files(Vec<PathBuf>),
-}
-
-pub(crate) struct App {
- replacer: Replacer,
- source: Source,
+ File(PathBuf),
}
-impl App {
- fn stdin_replace(&self, is_tty: bool) -> Result<()> {
- let mut buffer = Vec::with_capacity(256);
- let stdin = std::io::stdin();
- let mut handle = stdin.lock();
- handle.read_to_end(&mut buffer)?;
-
- let stdout = std::io::stdout();
- let mut handle = stdout.lock();
-
- handle.write_all(&if is_tty {
- self.replacer.replace_preview(&buffer)
- } else {
- self.replacer.replace(&buffer)
- })?;
-
- Ok(())
+impl Source {
+ pub(crate) fn from_paths(paths: Vec<PathBuf>) -> Vec<Self> {
+ paths.into_iter().map(Self::File).collect()
}
- pub(crate) fn new(source: Source, replacer: Replacer) -> Self {
- Self { source, replacer }
+ pub(crate) fn from_stdin() -> Vec<Self> {
+ vec![Self::Stdin]
}
- pub(crate) fn run(&self, preview: bool) -> Result<()> {
- let is_tty = std::io::stdout().is_terminal();
-
- match (&self.source, preview) {
- (Source::Stdin, true) => self.stdin_replace(is_tty),
- (Source::Stdin, false) => self.stdin_replace(is_tty),
- (Source::Files(paths), false) => {
- use rayon::prelude::*;
-
- let failed_jobs: Vec<_> = paths
- .par_iter()
- .filter_map(|p| {
- if let Err(e) = self.replacer.replace_file(p) {
- Some((p.to_owned(), e))
- } else {
- None
- }
- })
- .collect();
- if failed_jobs.is_empty() {
- Ok(())
- } else {
- let failed_jobs =
- crate::error::FailedJobs::from(failed_jobs);
- Err(Error::FailedProcessing(failed_jobs))
- }
- }
- (Source::Files(paths), true) => {
- let stdout = std::io::stdout();
- let mut handle = stdout.lock();
- let print_path = paths.len() > 1;
-
- paths.iter().try_for_each(|path| {
- if Replacer::check_not_empty(File::open(path)?).is_err() {
- return Ok(());
- }
- let file =
- unsafe { memmap2::Mmap::map(&File::open(path)?)? };
- if self.replacer.has_matches(&file) {
- if print_path {
- writeln!(
- handle,
- "----- FILE {} -----",
- path.display()
- )?;
- }
-
- handle
- .write_all(&self.replacer.replace_preview(&file))?;
- writeln!(handle)?;
- }
-
- Ok(())
- })
- }
+ pub(crate) fn display(&self) -> String {
+ match self {
+ Self::Stdin => "STDIN".to_string(),
+ Self::File(path) => format!("FILE {}", path.display()),
}
}
}
+
+// TODO: memmap2 docs state that users should implement proper
+// procedures to avoid problems the `unsafe` keyword indicate.
+// This would be in a later PR.
+pub(crate) unsafe fn make_mmap(path: &PathBuf) -> Result<Mmap> {
+ Ok(Mmap::map(&File::open(path)?)?)
+}
+
+pub(crate) fn make_mmap_stdin() -> Result<Mmap> {
+ let mut handle = stdin().lock();
+ let mut buf = Vec::new();
+ handle.read_to_end(&mut buf)?;
+ let mut mmap = MmapOptions::new().len(buf.len()).map_anon()?;
+ mmap.copy_from_slice(&buf);
+ let mmap = mmap.make_read_only()?;
+ Ok(mmap)
+}
diff --git a/src/main.rs b/src/main.rs
index 07c48b8..c346ab6 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,20 +3,25 @@ mod error;
mod input;
pub(crate) mod replacer;
-pub(crate) mod utils;
-
-use std::process;
-
-pub(crate) use self::input::{App, Source};
-use ansi_term::{Color, Style};
-pub(crate) use error::{Error, Result};
-use replacer::Replacer;
use clap::Parser;
+use memmap2::MmapMut;
+use std::{
+ fs,
+ io::{stdout, Write},
+ ops::DerefMut,
+ path::PathBuf,
+ process,
+};
+
+pub(crate) use self::error::{Error, FailedJobs, Result};
+pub(crate) use self::input::Source;
+use self::input::{make_mmap, make_mmap_stdin};
+use self::replacer::Replacer;
fn main() {
if let Err(e) = try_main() {
- eprintln!("{}: {}", Style::from(Color::Red).bold().paint("error"), e);
+ eprintln!("error: {e}");
process::exit(1);
}
}
@@ -24,22 +29,104 @@ fn main() {
fn try_main() -> Result<()> {
let options = cli::Options::parse();
- let source = if !options.files.is_empty() {
- Source::Files(options.files)
+ let replacer = Replacer::new(
+ options.find,
+ options.replace_with,
+ options.literal_mode,
+ options.flags,
+ options.replacements,
+ )?;
+
+ let sources = if !options.files.is_empty() {
+ Source::from_paths(options.files)
} else {
- Source::Stdin
+ Source::from_stdin()
};
- App::new(
- source,
- Replacer::new(
- options.find,
- options.replace_with,
- options.literal_mode,
- options.flags,
- options.replacements,
- )?,
- )
- .run(options.preview)?;
+ let mut mmaps = Vec::new();
+ for source in sources.iter() {
+ let mmap = match source {
+ Source::File(path) => {
+ if path.exists() {
+ unsafe { make_mmap(&path)? }
+ } else {
+ return Err(Error::InvalidPath(path.to_owned()));
+ }
+ }
+ Source::Stdin => make_mmap_stdin()?,
+ };
+
+ mmaps.push(mmap);
+ }
+
+ let needs_separator = sources.len() > 1;
+
+ let replaced: Vec<_> = {
+ use rayon::prelude::*;
+ mmaps
+ .par_iter()
+ .map(|mmap| replacer.replace(&mmap))
+ .collect()
+ };
+
+ if options.preview || sources.first() == Some(&Source::Stdin) {
+ let mut handle = stdout().lock();
+
+ for (source, replaced) in sources.iter().zip(replaced) {
+ if needs_separator {
+ writeln!(handle, "----- {} -----", source.display())?;
+ }
+ handle.write_all(&replaced)?;
+ }
+ } else {
+ // Windows requires closing mmap before writing:
+ // > The requested operation cannot be performed on a file with a user-mapped section open
+ #[cfg(target_family = "windows")]
+ let replaced: Vec<Vec<u8>> =
+ replaced.into_iter().map(|r| r.to_vec()).collect();
+ #[cfg(target_family = "windows")]
+ drop(mmaps);
+
+ let mut failed_jobs = Vec::new();
+ for (source, replaced) in sources.iter().zip(replaced) {
+ match source {
+ Source::File(path) => {
+ if let Err(e) = write_with_temp(path, &replaced) {
+ failed_jobs.push((path.to_owned(), e));
+ }
+ }
+ _ => unreachable!("stdin should go previous branch"),
+ }
+ }
+ if !failed_jobs.is_empty() {
+ return Err(Error::FailedJobs(FailedJobs(failed_jobs)));
+ }
+ }
+
+ Ok(())
+}
+
+fn write_with_temp(path: &PathBuf, data: &[u8]) -> Result<()> {
+ let path = fs::canonicalize(path)?;
+
+ let temp = tempfile::NamedTempFile::new_in(
+ path.parent()
+ .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?,
+ )?;
+
+ let file = temp.as_file();
+ file.set_len(data.len() as u64)?;
+ if let Ok(metadata) = fs::metadata(&path) {
+ file.set_permissions(metadata.permissions()).ok();
+ }
+
+ if !data.is_empty() {
+ let mut mmap_temp = unsafe { MmapMut::map_mut(file)? };
+ mmap_temp.deref_mut().write_all(data)?;
+ mmap_temp.flush_async()?;
+ }
+
+ temp.persist(&path)?;
+
Ok(())
}
diff --git a/src/replacer/mod.rs b/src/replacer/mod.rs
index d871323..8db902e 100644
--- a/src/replacer/mod.rs
+++ b/src/replacer/mod.rs
@@ -1,6 +1,6 @@
-use std::{borrow::Cow, fs, fs::File, io::prelude::*, path::Path};
+use std::borrow::Cow;
-use crate::{utils, Error, Result};
+use crate::Result;
use regex::bytes::Regex;
@@ -32,7 +32,7 @@ impl Replacer {
(
look_for,
- utils::unescape(&replace_with)
+ unescape::unescape(&replace_with)
.unwrap_or(replace_with)
.into_bytes(),
)
@@ -74,20 +74,7 @@ impl Replacer {
})
}
- pub(crate) fn has_matches(&self, content: &[u8]) -> bool {
- self.regex.is_match(content)
- }
-
- pub(crate) fn check_not_empty(mut file: File) -> Result<()> {
- let mut buf: [u8; 1] = Default::default();
- file.read_exact(&mut buf)?;
- Ok(())
- }
-
- pub(crate) fn replace<'a>(
- &'a self,
- content: &'a [u8],
- ) -> std::borrow::Cow<'a, [u8]> {
+ pub(crate) fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> {
let regex = &self.regex;
let limit = self.replacements;
let use_color = false;
@@ -116,7 +103,7 @@ impl Replacer {
regex: &regex::bytes::Regex,
limit: usize,
haystack: &'haystack [u8],
- use_color: bool,
+ _use_color: bool,
mut rep: R,
) -> Cow<'haystack, [u8]> {
let mut it = regex.captures_iter(haystack).enumerate().peekable();
@@ -129,17 +116,7 @@ impl Replacer {
// unwrap on 0 is OK because captures only reports matches
let m = cap.get(0).unwrap();
new.extend_from_slice(&haystack[last_match..m.start()]);
- if use_color {
- new.extend_from_slice(
- ansi_term::Color::Blue.prefix().to_string().as_bytes(),
- );
- }
rep.replace_append(&cap, &mut new);
- if use_color {
- new.extend_from_slice(
- ansi_term::Color::Blue.suffix().to_string().as_bytes(),
- );
- }
last_match = m.end();
if limit > 0 && i >= limit - 1 {
break;
@@ -148,65 +125,4 @@ impl Replacer {
new.extend_from_slice(&haystack[last_match..]);
Cow::Owned(new)
}
-
- pub(crate) fn replace_preview<'a>(
- &self,
- content: &'a [u8],
- ) -> std::borrow::Cow<'a, [u8]> {
- let regex = &self.regex;
- let limit = self.replacements;
- // TODO: refine this condition more
- let use_color = true;
- if self.is_literal {
- Self::replacen(
- regex,
- limit,
- content,
- use_color,
- regex::bytes::NoExpand(&self.replace_with),
- )
- } else {
- Self::replacen(
- regex,
- limit,
- content,
- use_color,
- &*self.replace_with,
- )
- }
- }
-
- pub(crate) fn replace_file(&self, path: &Path) -> Result<()> {
- use memmap2::{Mmap, MmapMut};
- use std::ops::DerefMut;
-
- if Self::check_not_empty(File::open(path)?).is_err() {
- return Ok(());
- }
-
- let source = File::open(path)?;
- let meta = fs::metadata(path)?;
- let mmap_source = unsafe { Mmap::map(&source)? };
- let replaced = self.replace(&mmap_source);
-
- let target = tempfile::NamedTempFile::new_in(
- path.parent()
- .ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?,
- )?;
- let file = target.as_file();
- file.set_len(replaced.len() as u64)?;
- file.set_permissions(meta.permissions())?;
-
- if !replaced.is_empty() {
- let mut mmap_target = unsafe { MmapMut::map_mut(file)? };
- mmap_target.deref_mut().write_all(&replaced)?;
- mmap_target.flush_async()?;
- }
-
- drop(mmap_source);
- drop(source);
-
- target.persist(fs::canonicalize(path)?)?;
- Ok(())
- }
}
diff --git a/src/replacer/validate.rs b/src/replacer/validate.rs
index da5cc71..a7ce765 100644
--- a/src/replacer/validate.rs
+++ b/src/replacer/validate.rs
@@ -1,7 +1,5 @@
use std::{error::Error, fmt, str::CharIndices};
-use ansi_term::{Color, Style};
-
#[derive(Debug)]
pub struct InvalidReplaceCapture {
original_replace: String,
@@ -53,21 +51,23 @@ impl fmt::Display for InvalidReplaceCapture {
// Build up the error to show the user
let mut formatted = String::new();
let mut arrows_start = Span::start_at(0);
- let special = Style::new().bold();
- let error = Style::from(Color::Red).bold();
for (byte_index, c) in original_replace.char_indices() {
let (prefix, suffix, text) = match SpecialChar::new(c) {
Some(c) => {
- (Some(special.prefix()), Some(special.suffix()), c.render())
+ (
+ Some("" /* special prefix */),
+ Some("" /* special suffix */),
+ c.render(),
+ )
}
None => {
let (prefix, suffix) = if byte_index == invalid_ident.start
{
- (Some(error.prefix()), None)
+ (Some("" /* error prefix */), None)
} else if byte_index
== invalid_ident.end.checked_sub(1).unwrap()
{
- (None, Some(error.suffix()))
+ (None, Some("" /* error suffix */))
} else {
(None, None)
};
@@ -97,22 +97,18 @@ impl fmt::Display for InvalidReplaceCapture {
// This relies on all non-curly-braced capture chars being 1 byte
let arrows_span = arrows_start.end_offset(invalid_ident.len());
let mut arrows = " ".repeat(arrows_span.start);
- arrows.push_str(&format!(
- "{}",
- Style::new().bold().paint("^".repeat(arrows_span.len()))
- ));
+ arrows.push_str(&format!("{}", "^".repeat(arrows_span.len())));
let ident = invalid_ident.slice(original_replace);
let (number, the_rest) = ident.split_at(*num_leading_digits);
let disambiguous = format!("${{{number}}}{the_rest}");
let error_message = format!(
"The numbered capture group `{}` in the replacement text is ambiguous.",
- Style::new().bold().paint(format!("${}", number).to_string())
+ format!("${}", number).to_string()
);
let hint_message = format!(
"{}: Use curly braces to disambiguate it `{}`.",
- Style::from(Color::Blue).bold().paint("hint"),
- Style::new().bold().paint(disambiguous)
+ "hint", disambiguous
);
writeln!(f, "{}", error_message)?;
diff --git a/src/utils.rs b/src/utils.rs
deleted file mode 100644
index 72ad146..0000000
--- a/src/utils.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub(crate) fn unescape(s: &str) -> Option<String> {
- unescape::unescape(s)
-}
diff --git a/tests/cli.rs b/tests/cli.rs
index 6bf78cc..1030dc1 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -3,7 +3,7 @@
mod cli {
use anyhow::Result;
use assert_cmd::Command;
- use std::io::prelude::*;
+ use std::{fs, io::prelude::*, path::Path};
fn sd() -> Command {
Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("Error invoking sd")
@@ -13,14 +13,18 @@ mod cli {
assert_eq!(content, std::fs::read_to_string(path).unwrap());
}
+ // This should really be cfg_attr(target_family = "windows"), but wasi impl
+ // is nightly for now, and other impls are not part of std
+ #[cfg_attr(
+ not(target_family = "unix"),
+ ignore = "Windows symlinks are privileged"
+ )]
fn create_soft_link<P: AsRef<std::path::Path>>(
src: &P,
dst: &P,
) -> Result<()> {
#[cfg(target_family = "unix")]
std::os::unix::fs::symlink(src, dst)?;
- #[cfg(target_family = "windows")]
- std::os::windows::fs::symlink_file(src, dst)?;
Ok(())
}
@@ -53,6 +57,10 @@ mod cli {
Ok(())
}
+ #[cfg_attr(
+ target_family = "windows",
+ ignore = "Windows symlinks are privileged"
+ )]
#[test]
fn in_place_following_symlink() -> Result<()> {
let dir = tempfile::tempdir()?;
@@ -81,11 +89,7 @@ mod cli {
sd().args(["-p", "abc\\d+", "", file.path().to_str().unwrap()])
.assert()
.success()
- .stdout(format!(
- "{}{}def\n",
- ansi_term::Color::Blue.prefix(),
- ansi_term::Color::Blue.suffix()
- ));
+ .stdout("def");
assert_file(file.path(), "abc123def");
@@ -113,13 +117,7 @@ mod cli {
fn bad_replace_helper_plain(replace: &str) -> String {
let stderr = bad_replace_helper_styled(replace);
-
- // TODO: no easy way to toggle off styling yet. Add a `--color <when>`
- // flag, and respect things like `$NO_COLOR`. `ansi_term` is
- // unmaintained, so we should migrate off of it anyways
- console::AnsiCodeIterator::new(&stderr)
- .filter_map(|(s, is_ansi)| (!is_ansi).then_some(s))
- .collect()
+ stderr
}
#[test]
@@ -182,6 +180,7 @@ mod cli {
// NOTE: styled terminal output is platform dependent, so convert to a
// common format, in this case HTML, to check
+ #[ignore = "TODO: wait for proper colorization"]
#[test]
fn ambiguous_replace_ensure_styling() {
let styled_stderr = bad_replace_helper_styled("\t$1bad after");
@@ -225,10 +224,7 @@ mod cli {
])
.assert()
.success()
- .stdout(format!(
- "{}\nfoo\nfoo\n",
- ansi_term::Color::Blue.paint("bar")
- ));
+ .stdout("bar\nfoo\nfoo");
Ok(())
}
@@ -250,4 +246,158 @@ mod cli {
.success()
.stdout("bar\nfoo\nfoo");
}
+
+ const UNTOUCHED_CONTENTS: &str = "untouched";
+
+ fn assert_fails_correctly(
+ command: &mut Command,
+ valid: &Path,
+ test_home: &Path,
+ snap_name: &str,
+ ) {
+ let failed_command = command.assert().failure().code(1);
+
+ assert_eq!(fs::read_to_string(&valid).unwrap(), UNTOUCHED_CONTENTS);
+
+ let stderr_orig =
+ std::str::from_utf8(&failed_command.get_output().stderr).unwrap();
+ // Normalize unstable path bits
+ let stderr_norm = stderr_orig
+ .replace(test_home.to_str().unwrap(), "<test_home>")
+ .replace('\\', "/");
+ insta::assert_snapshot!(snap_name, stderr_norm);
+ }
+
+ #[test]
+ fn correctly_fails_on_missing_file() -> Result<()> {
+ let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?;
+ let test_home = test_dir.path();
+
+ let valid = test_home.join("valid");
+ fs::write(&valid, UNTOUCHED_CONTENTS)?;
+ let missing = test_home.join("missing");
+
+ assert_fails_correctly(
+ sd().args([".*", ""]).arg(&valid).arg(&missing),
+ &valid,
+ test_home,
+ "correctly_fails_on_missing_file",
+ );
+
+ Ok(())
+ }
+
+ #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")]
+ #[test]
+ fn correctly_fails_on_unreadable_file() -> Result<()> {
+ #[cfg(not(target_family = "unix"))]
+ {
+ unreachable!("This test should be ignored");
+ }
+ #[cfg(target_family = "unix")]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ let test_dir =
+ tempfile::Builder::new().prefix("sd-test-").tempdir()?;
+ let test_home = test_dir.path();
+
+ let valid = test_home.join("valid");
+ fs::write(&valid, UNTOUCHED_CONTENTS)?;
+ let write_only = {
+ let path = test_home.join("write_only");
+ let mut write_only_file = std::fs::OpenOptions::new()
+ .mode(0o333)
+ .create(true)
+ .write(true)
+ .open(&path)?;
+ write!(write_only_file, "unreadable")?;
+ path
+ };
+
+ assert_fails_correctly(
+ sd().args([".*", ""]).arg(&valid).arg(&write_only),
+ &valid,
+ test_home,
+ "correctly_fails_on_unreadable_file",
+ );
+
+ Ok(())
+ }
+ }
+
+ // Failing to create a temporary file in the same directory as the input is
+ // one of the failure cases that is past the "point of no return" (after we
+ // already start making replacements). This means that any files that could
+ // be modified are, and we report any failure cases
+ #[cfg_attr(not(target_family = "unix"), ignore = "only runs on unix")]
+ #[test]
+ fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> {
+ #[cfg(not(target_family = "unix"))]
+ {
+ unreachable!("This test should be ignored");
+ }
+ #[cfg(target_family = "unix")]
+ {
+ use std::os::unix::fs::PermissionsExt;
+
+ const FIND_REPLACE: [&str; 2] = ["able", "ed"];
+ const ORIG_TEXT: &str = "modifiable";
+ const MODIFIED_TEXT: &str = "modified";
+
+ let test_dir =
+ tempfile::Builder::new().prefix("sd-test-").tempdir()?;
+ let test_home = test_dir.path().canonicalize()?;
+
+ let writable_dir = test_home.join("writable");
+ fs::create_dir(&writable_dir)?;
+ let writable_dir_file = writable_dir.join("foo");
+ fs::write(&writable_dir_file, ORIG_TEXT)?;
+
+ let unwritable_dir = test_home.join("unwritable");
+ fs::create_dir(&unwritable_dir)?;
+ let unwritable_dir_file1 = unwritable_dir.join("bar");
+ fs::write(&unwritable_dir_file1, ORIG_TEXT)?;
+ let unwritable_dir_file2 = unwritable_dir.join("baz");
+ fs::write(&unwritable_dir_file2, ORIG_TEXT)?;
+ let mut perms = fs::metadata(&unwritable_dir)?.permissions();
+ perms.set_mode(0o555);
+ fs::set_permissions(&unwritable_dir, perms)?;
+
+ let failed_command = sd()
+ .args(FIND_REPLACE)
+ .arg(&writable_dir_file)
+ .arg(&unwritable_dir_file1)
+ .arg(&unwritable_dir_file2)
+ .assert()
+ .failure()
+ .code(1);
+
+ // Confirm that we modified the one file that we were able to
+ assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT);
+ assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT);
+ assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT);
+
+ let stderr_orig =
+ std::str::from_utf8(&failed_command.get_output().stderr)
+ .unwrap();
+ // Normalize unstable path bits
+ let stderr_partial_norm = stderr_orig
+ .replace(test_home.to_str().unwrap(), "<test_home>")
+ .replace('\\', "/");
+ let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?;
+ let stderr_norm =
+ tmp_file_rep.replace_all(&stderr_partial_norm, "<tmp_file>");
+ insta::assert_snapshot!(stderr_norm);
+
+ // Make the unwritable dir writable again, so it can be cleaned up
+ // when dropping the temp dir
+ let mut perms = fs::metadata(&unwritable_dir)?.permissions();
+ perms.set_mode(0o777);
+ fs::set_permissions(&unwritable_dir, perms)?;
+ test_dir.close()?;
+
+ Ok(())
+ }
+ }
}
diff --git a/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap b/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap
new file mode 100644
index 0000000..eb2a82e
--- /dev/null
+++ b/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap
@@ -0,0 +1,6 @@
+---
+source: tests/cli.rs
+expression: stderr_norm
+---
+error: invalid path: <test_home>/missing
+
diff --git a/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap b/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap
new file mode 100644
index 0000000..3dcab19
--- /dev/null
+++ b/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap
@@ -0,0 +1,6 @@
+---
+source: tests/cli.rs
+expression: stderr_norm
+---
+error: Permission denied (os error 13)
+
diff --git a/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap b/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap
new file mode 100644
index 0000000..07930dc
--- /dev/null
+++ b/tests/snapshots/cli__cli__reports_errors_on_atomic_file_swap_creation_failure.snap
@@ -0,0 +1,9 @@
+---
+source: tests/cli.rs
+expression: stderr_norm
+---
+error: Failed processing some inputs
+ <test_home>/unwritable/bar: Permission denied (os error 13) at path "<test_home>/unwritable/<tmp_file>"
+ <test_home>/unwritable/baz: Permission denied (os error 13) at path "<test_home>/unwritable/<tmp_file>"
+
+