Overall reorganization (#265)

* refactor!: overall reorganization

* fmt: tab to spaces

* feat: split out funcs for better error collection

* feat: replace Vec-varianted Source with Vec<Source>

* docs: more generic description for Error::InvalidPath

* fmt: reduce signature verbosity

* internal: decolorize to prepare for proper colorization

* fmt: tab to spaces (again)

* refactor: break up App into main.rs, rm utils.rs

* test: only run cli::in_place_following_symlink on Unix, symlinks are privileged on Windows

* test: update insta snapshots due to Replacer::new()? in main()

* fix: restructure logic, Windows requires closing mmap before write

* test: properly mark no-Windows test

* test: properly mark temp. ignored test

* fix: retain unsafe property of Mmap::map in separate function

* chore: `cargo fmt`

* chore: Resolve lone warning

* test: Test a variety of fs failure cases

* refactor: Add back `try_main()`

* refactor: Rework error handling

* test: Update snapshots

* test: fix path inconsistency

---------

Co-authored-by: Cosmic Horror <CosmicHorrorDev@pm.me>
This commit is contained in:
Blair Noctis 2023-11-19 04:04:31 +08:00 committed by GitHub
parent 4241babe12
commit 2d287b9b1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 379 additions and 312 deletions

View file

@ -1,2 +1,6 @@
[*]
indent_style = space
indent_size = 4
[*.rs] [*.rs]
max_line_length = 80 max_line_length = 80

50
Cargo.lock generated
View file

@ -21,15 +21,6 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.4" version = "0.6.4"
@ -324,12 +315,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
[[package]] [[package]]
name = "insta" name = "insta"
version = "1.34.0" version = "1.34.0"
@ -343,17 +328,6 @@ dependencies = [
"yaml-rust", "yaml-rust",
] ]
[[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]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"
@ -650,14 +624,12 @@ name = "sd"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"ansi-to-html", "ansi-to-html",
"ansi_term",
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
"clap", "clap",
"clap_mangen", "clap_mangen",
"console", "console",
"insta", "insta",
"is-terminal",
"memmap2", "memmap2",
"proptest", "proptest",
"rayon", "rayon",
@ -805,28 +777,6 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"

View file

@ -33,8 +33,6 @@ unescape = "0.1.0"
memmap2 = "0.9.0" memmap2 = "0.9.0"
tempfile = "3.8.0" tempfile = "3.8.0"
thiserror = "1.0.50" thiserror = "1.0.50"
ansi_term = "0.12.1"
is-terminal = "0.4.9"
clap.workspace = true clap.workspace = true
[dev-dependencies] [dev-dependencies]

View file

@ -1,7 +1,4 @@
use std::{ use std::{fmt, path::PathBuf};
fmt::{self, Write},
path::PathBuf,
};
use crate::replacer::InvalidReplaceCapture; use crate::replacer::InvalidReplaceCapture;
@ -13,37 +10,38 @@ pub enum Error {
File(#[from] std::io::Error), File(#[from] std::io::Error),
#[error("failed to move file: {0}")] #[error("failed to move file: {0}")]
TempfilePersist(#[from] tempfile::PersistError), TempfilePersist(#[from] tempfile::PersistError),
#[error("file doesn't have parent path: {0}")] #[error("invalid path: {0}")]
InvalidPath(PathBuf), InvalidPath(PathBuf),
#[error("failed processing files:\n{0}")]
FailedProcessing(FailedJobs),
#[error("{0}")] #[error("{0}")]
InvalidReplaceCapture(#[from] InvalidReplaceCapture), 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)
}
}
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_char(')')
}
} }
// pretty-print the error // pretty-print the error
impl std::fmt::Debug for Error { impl fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self) write!(f, "{}", self)
} }
} }
pub type Result<T, E = Error> = std::result::Result<T, E>; 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("Failed processing some inputs\n")?;
for (source, error) in &self.0 {
writeln!(f, " {}: {}", source.display(), error)?;
}
Ok(())
}
}
impl fmt::Debug for FailedJobs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}

View file

@ -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, PartialEq)]
#[derive(Debug)]
pub(crate) enum Source { pub(crate) enum Source {
Stdin, Stdin,
Files(Vec<PathBuf>), File(PathBuf),
} }
pub(crate) struct App { impl Source {
replacer: Replacer, pub(crate) fn from_paths(paths: Vec<PathBuf>) -> Vec<Self> {
source: Source, paths.into_iter().map(Self::File).collect()
}
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(())
} }
pub(crate) fn new(source: Source, replacer: Replacer) -> Self { pub(crate) fn from_stdin() -> Vec<Self> {
Self { source, replacer } vec![Self::Stdin]
} }
pub(crate) fn run(&self, preview: bool) -> Result<()> {
let is_tty = std::io::stdout().is_terminal();
match (&self.source, preview) { pub(crate) fn display(&self) -> String {
(Source::Stdin, true) => self.stdin_replace(is_tty), match self {
(Source::Stdin, false) => self.stdin_replace(is_tty), Self::Stdin => "STDIN".to_string(),
(Source::Files(paths), false) => { Self::File(path) => format!("FILE {}", path.display()),
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(())
})
}
} }
} }
} }
// 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)
}

View file

@ -3,20 +3,25 @@ mod error;
mod input; mod input;
pub(crate) mod replacer; 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 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() { fn main() {
if let Err(e) = try_main() { if let Err(e) = try_main() {
eprintln!("{}: {}", Style::from(Color::Red).bold().paint("error"), e); eprintln!("error: {e}");
process::exit(1); process::exit(1);
} }
} }
@ -24,22 +29,104 @@ fn main() {
fn try_main() -> Result<()> { fn try_main() -> Result<()> {
let options = cli::Options::parse(); let options = cli::Options::parse();
let source = if !options.files.is_empty() { let replacer = Replacer::new(
Source::Files(options.files) 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 { } else {
Source::Stdin Source::from_stdin()
}; };
App::new( let mut mmaps = Vec::new();
source, for source in sources.iter() {
Replacer::new( let mmap = match source {
options.find, Source::File(path) => {
options.replace_with, if path.exists() {
options.literal_mode, unsafe { make_mmap(&path)? }
options.flags, } else {
options.replacements, return Err(Error::InvalidPath(path.to_owned()));
)?, }
) }
.run(options.preview)?; 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(()) Ok(())
} }

View file

@ -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; use regex::bytes::Regex;
@ -32,7 +32,7 @@ impl Replacer {
( (
look_for, look_for,
utils::unescape(&replace_with) unescape::unescape(&replace_with)
.unwrap_or(replace_with) .unwrap_or(replace_with)
.into_bytes(), .into_bytes(),
) )
@ -74,20 +74,7 @@ impl Replacer {
}) })
} }
pub(crate) fn has_matches(&self, content: &[u8]) -> bool { pub(crate) fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> {
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]> {
let regex = &self.regex; let regex = &self.regex;
let limit = self.replacements; let limit = self.replacements;
let use_color = false; let use_color = false;
@ -116,7 +103,7 @@ impl Replacer {
regex: &regex::bytes::Regex, regex: &regex::bytes::Regex,
limit: usize, limit: usize,
haystack: &'haystack [u8], haystack: &'haystack [u8],
use_color: bool, _use_color: bool,
mut rep: R, mut rep: R,
) -> Cow<'haystack, [u8]> { ) -> Cow<'haystack, [u8]> {
let mut it = regex.captures_iter(haystack).enumerate().peekable(); 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 // unwrap on 0 is OK because captures only reports matches
let m = cap.get(0).unwrap(); let m = cap.get(0).unwrap();
new.extend_from_slice(&haystack[last_match..m.start()]); 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); 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(); last_match = m.end();
if limit > 0 && i >= limit - 1 { if limit > 0 && i >= limit - 1 {
break; break;
@ -148,65 +125,4 @@ impl Replacer {
new.extend_from_slice(&haystack[last_match..]); new.extend_from_slice(&haystack[last_match..]);
Cow::Owned(new) 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(())
}
} }

View file

@ -1,7 +1,5 @@
use std::{error::Error, fmt, str::CharIndices}; use std::{error::Error, fmt, str::CharIndices};
use ansi_term::{Color, Style};
#[derive(Debug)] #[derive(Debug)]
pub struct InvalidReplaceCapture { pub struct InvalidReplaceCapture {
original_replace: String, original_replace: String,
@ -53,21 +51,23 @@ impl fmt::Display for InvalidReplaceCapture {
// Build up the error to show the user // Build up the error to show the user
let mut formatted = String::new(); let mut formatted = String::new();
let mut arrows_start = Span::start_at(0); 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() { for (byte_index, c) in original_replace.char_indices() {
let (prefix, suffix, text) = match SpecialChar::new(c) { let (prefix, suffix, text) = match SpecialChar::new(c) {
Some(c) => { Some(c) => {
(Some(special.prefix()), Some(special.suffix()), c.render()) (
Some("" /* special prefix */),
Some("" /* special suffix */),
c.render(),
)
} }
None => { None => {
let (prefix, suffix) = if byte_index == invalid_ident.start let (prefix, suffix) = if byte_index == invalid_ident.start
{ {
(Some(error.prefix()), None) (Some("" /* error prefix */), None)
} else if byte_index } else if byte_index
== invalid_ident.end.checked_sub(1).unwrap() == invalid_ident.end.checked_sub(1).unwrap()
{ {
(None, Some(error.suffix())) (None, Some("" /* error suffix */))
} else { } else {
(None, None) (None, None)
}; };
@ -97,22 +97,18 @@ impl fmt::Display for InvalidReplaceCapture {
// This relies on all non-curly-braced capture chars being 1 byte // This relies on all non-curly-braced capture chars being 1 byte
let arrows_span = arrows_start.end_offset(invalid_ident.len()); let arrows_span = arrows_start.end_offset(invalid_ident.len());
let mut arrows = " ".repeat(arrows_span.start); let mut arrows = " ".repeat(arrows_span.start);
arrows.push_str(&format!( arrows.push_str(&format!("{}", "^".repeat(arrows_span.len())));
"{}",
Style::new().bold().paint("^".repeat(arrows_span.len()))
));
let ident = invalid_ident.slice(original_replace); let ident = invalid_ident.slice(original_replace);
let (number, the_rest) = ident.split_at(*num_leading_digits); let (number, the_rest) = ident.split_at(*num_leading_digits);
let disambiguous = format!("${{{number}}}{the_rest}"); let disambiguous = format!("${{{number}}}{the_rest}");
let error_message = format!( let error_message = format!(
"The numbered capture group `{}` in the replacement text is ambiguous.", "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!( let hint_message = format!(
"{}: Use curly braces to disambiguate it `{}`.", "{}: Use curly braces to disambiguate it `{}`.",
Style::from(Color::Blue).bold().paint("hint"), "hint", disambiguous
Style::new().bold().paint(disambiguous)
); );
writeln!(f, "{}", error_message)?; writeln!(f, "{}", error_message)?;

View file

@ -1,3 +0,0 @@
pub(crate) fn unescape(s: &str) -> Option<String> {
unescape::unescape(s)
}

View file

@ -3,7 +3,7 @@
mod cli { mod cli {
use anyhow::Result; use anyhow::Result;
use assert_cmd::Command; use assert_cmd::Command;
use std::io::prelude::*; use std::{fs, io::prelude::*, path::Path};
fn sd() -> Command { fn sd() -> Command {
Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("Error invoking sd") 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()); 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>>( fn create_soft_link<P: AsRef<std::path::Path>>(
src: &P, src: &P,
dst: &P, dst: &P,
) -> Result<()> { ) -> Result<()> {
#[cfg(target_family = "unix")] #[cfg(target_family = "unix")]
std::os::unix::fs::symlink(src, dst)?; std::os::unix::fs::symlink(src, dst)?;
#[cfg(target_family = "windows")]
std::os::windows::fs::symlink_file(src, dst)?;
Ok(()) Ok(())
} }
@ -53,6 +57,10 @@ mod cli {
Ok(()) Ok(())
} }
#[cfg_attr(
target_family = "windows",
ignore = "Windows symlinks are privileged"
)]
#[test] #[test]
fn in_place_following_symlink() -> Result<()> { fn in_place_following_symlink() -> Result<()> {
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
@ -81,11 +89,7 @@ mod cli {
sd().args(["-p", "abc\\d+", "", file.path().to_str().unwrap()]) sd().args(["-p", "abc\\d+", "", file.path().to_str().unwrap()])
.assert() .assert()
.success() .success()
.stdout(format!( .stdout("def");
"{}{}def\n",
ansi_term::Color::Blue.prefix(),
ansi_term::Color::Blue.suffix()
));
assert_file(file.path(), "abc123def"); assert_file(file.path(), "abc123def");
@ -113,13 +117,7 @@ mod cli {
fn bad_replace_helper_plain(replace: &str) -> String { fn bad_replace_helper_plain(replace: &str) -> String {
let stderr = bad_replace_helper_styled(replace); let stderr = bad_replace_helper_styled(replace);
stderr
// 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()
} }
#[test] #[test]
@ -182,6 +180,7 @@ mod cli {
// NOTE: styled terminal output is platform dependent, so convert to a // NOTE: styled terminal output is platform dependent, so convert to a
// common format, in this case HTML, to check // common format, in this case HTML, to check
#[ignore = "TODO: wait for proper colorization"]
#[test] #[test]
fn ambiguous_replace_ensure_styling() { fn ambiguous_replace_ensure_styling() {
let styled_stderr = bad_replace_helper_styled("\t$1bad after"); let styled_stderr = bad_replace_helper_styled("\t$1bad after");
@ -225,10 +224,7 @@ mod cli {
]) ])
.assert() .assert()
.success() .success()
.stdout(format!( .stdout("bar\nfoo\nfoo");
"{}\nfoo\nfoo\n",
ansi_term::Color::Blue.paint("bar")
));
Ok(()) Ok(())
} }
@ -250,4 +246,158 @@ mod cli {
.success() .success()
.stdout("bar\nfoo\nfoo"); .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(())
}
}
} }

View file

@ -0,0 +1,6 @@
---
source: tests/cli.rs
expression: stderr_norm
---
error: invalid path: <test_home>/missing

View file

@ -0,0 +1,6 @@
---
source: tests/cli.rs
expression: stderr_norm
---
error: Permission denied (os error 13)

View file

@ -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>"