use crate::{
config::{self, FileChange, InputFile, VersionComponentConfigs},
f_string::{self, PythonFormatString},
version::{self, Version},
};
use indexmap::IndexMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(thiserror::Error, Debug)]
pub enum ReplaceVersionError {
#[error(transparent)]
Io(#[from] IoError),
#[error(transparent)]
Serialize(#[from] version::SerializeError),
#[error(transparent)]
MissingArgument(#[from] f_string::MissingArgumentError),
#[error(transparent)]
InvalidFormatString(#[from] f_string::ParseError),
#[error(transparent)]
RegexTemplate(#[from] config::regex::RegexTemplateError),
#[error(transparent)]
Toml(#[from] toml_edit::TomlError),
}
pub fn replace_version<'a, K, V, S>(
before: String,
changes: &'a [FileChange],
current_version: &'a Version,
new_version: &'a Version,
ctx: &'a HashMap<K, V, S>,
) -> Result<Modification, ReplaceVersionError>
where
K: std::borrow::Borrow<str> + std::hash::Hash + Eq + std::fmt::Debug,
V: AsRef<str> + std::fmt::Debug,
S: std::hash::BuildHasher,
{
let mut after = before.clone();
let mut replacements = vec![];
for change in changes {
tracing::debug!(
search = ?change.search,
replace = ?change.replace,
"update",
);
let current_version_serialized =
current_version.serialize(&change.serialize_version_patterns, ctx)?;
let new_version_serialized =
new_version.serialize(&change.serialize_version_patterns, ctx)?;
let ctx: HashMap<&str, &str> = ctx
.iter()
.map(|(k, v)| (k.borrow(), v.as_ref()))
.chain([
("current_version", current_version_serialized.as_str()),
("new_version", new_version_serialized.as_str()),
])
.collect();
let search_pattern = &change.search;
let search_regex = search_pattern.format(&ctx, true)?;
let replace_pattern = &change.replace;
let replacement = PythonFormatString::parse(replace_pattern)?;
let replacement = replacement.format(&ctx, true)?;
after = search_regex.replace_all(&after, &replacement).to_string();
replacements.push(Replacement {
search_pattern: search_pattern.to_string(),
search: search_regex.as_str().to_string(),
replace_pattern: replace_pattern.clone(),
replace: replacement,
});
}
let modification = Modification {
before,
after,
replacements,
};
Ok(modification)
}
#[derive(Debug)]
pub struct Replacement {
pub search: String,
pub search_pattern: String,
pub replace: String,
pub replace_pattern: String,
}
#[derive(Debug)]
pub struct Modification {
pub before: String,
pub after: String,
pub replacements: Vec<Replacement>,
}
impl Modification {
#[must_use]
pub fn diff(&self, path: Option<&Path>) -> Option<String> {
if self.before == self.after {
None
} else {
let (label_before, label_after) = if let Some(path) = path {
(
format!("{} (before)", path.display()),
format!("{} (after)", path.display()),
)
} else {
("before".to_string(), "after".to_string())
};
let diff = similar_asserts::SimpleDiff::from_str(
&self.before,
&self.after,
&label_before,
&label_after,
);
Some(diff.to_string())
}
}
}
pub async fn replace_version_in_file<K, V, S>(
path: &Path,
changes: &[FileChange],
current_version: &Version,
new_version: &Version,
ctx: &HashMap<K, V, S>,
dry_run: bool,
) -> Result<Option<Modification>, ReplaceVersionError>
where
K: std::borrow::Borrow<str> + std::hash::Hash + Eq + std::fmt::Debug,
V: AsRef<str> + std::fmt::Debug,
S: std::hash::BuildHasher,
{
let as_io_error = |source: std::io::Error| -> IoError { IoError::new(source, path) };
if !path.is_file() {
if changes.iter().all(|change| change.ignore_missing_file) {
tracing::info!(?path, "file not found");
return Ok(None);
}
let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
return Err(ReplaceVersionError::from(as_io_error(not_found)));
}
let before = tokio::fs::read_to_string(path).await.map_err(as_io_error)?;
let modification = replace_version(before, changes, current_version, new_version, ctx)?;
if modification.before == modification.after {
}
if !dry_run {
use tokio::io::AsyncWriteExt;
let file = tokio::fs::OpenOptions::new()
.write(true)
.create(false)
.truncate(true)
.open(path)
.await
.map_err(as_io_error)?;
let mut writer = tokio::io::BufWriter::new(file);
writer
.write_all(modification.after.as_bytes())
.await
.map_err(as_io_error)?;
writer.flush().await.map_err(as_io_error)?;
}
Ok(Some(modification))
}
#[derive(thiserror::Error, Debug)]
pub enum GlobError {
#[error(transparent)]
Pattern(#[from] glob::PatternError),
#[error(transparent)]
Glob(#[from] glob::GlobError),
}
#[derive(thiserror::Error, Debug)]
pub struct IoError {
#[source]
pub source: std::io::Error,
pub path: Option<PathBuf>,
}
impl IoError {
pub fn new(source: impl Into<std::io::Error>, path_or_stream: impl Into<PathBuf>) -> Self {
Self {
source: source.into(),
path: Some(path_or_stream.into()),
}
}
}
impl std::fmt::Display for IoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.path {
Some(path) => write!(f, "io error for {}", path.display()),
None => write!(f, "io error"),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
Glob(#[from] GlobError),
#[error(transparent)]
Io(#[from] IoError),
}
fn resolve_glob_files(
pattern: &str,
exclude_patterns: &[String],
) -> Result<Vec<PathBuf>, GlobError> {
let options = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: false,
require_literal_leading_dot: false,
};
let included: HashSet<PathBuf> =
glob::glob_with(pattern, options)?.collect::<Result<_, _>>()?;
let excluded: HashSet<PathBuf> = exclude_patterns
.iter()
.map(|pattern| glob::glob_with(pattern, options))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flat_map(std::iter::IntoIterator::into_iter)
.collect::<Result<_, _>>()?;
Ok(included.difference(&excluded).cloned().collect())
}
pub type FileMap = IndexMap<PathBuf, Vec<FileChange>>;
pub fn resolve_files_from_config(
config: &mut config::FinalizedConfig,
parts: &VersionComponentConfigs,
base_dir: Option<&Path>,
) -> Result<FileMap, Error> {
let files = config.files.drain(..);
let new_files: Vec<_> = files
.into_iter()
.map(|(file, file_config)| {
let new_files = match file {
InputFile::GlobPattern {
pattern,
exclude_patterns,
} => resolve_glob_files(&pattern, exclude_patterns.as_deref().unwrap_or_default()),
InputFile::Path(path) => Ok(vec![path.clone()]),
}?;
let file_change = FileChange::new(file_config, parts);
Ok(new_files
.into_iter()
.map(|file| {
if file.is_absolute() {
Ok(file)
} else if let Some(base_dir) = base_dir {
let file = base_dir.join(&file);
file.canonicalize()
.map_err(|source| IoError::new(source, file))
} else {
Ok(file)
}
})
.map(move |file| file.map(|file| (file, file_change.clone()))))
})
.collect::<Result<_, Error>>()?;
let new_files = new_files.into_iter().flatten().try_fold(
IndexMap::<PathBuf, Vec<FileChange>>::new(),
|mut acc, res| {
let (file, config) = res?;
acc.entry(file).or_default().push(config);
Ok::<_, Error>(acc)
},
)?;
Ok(new_files)
}
pub fn files_to_modify(
config: &config::FinalizedConfig,
file_map: FileMap,
) -> impl Iterator<Item = (PathBuf, Vec<FileChange>)> + use<'_> {
let excluded_paths_from_config: HashSet<&PathBuf> = config
.global
.excluded_paths
.as_deref()
.unwrap_or_default()
.iter()
.collect();
let included_paths_from_config: HashSet<&PathBuf> = config
.global
.included_paths
.as_deref()
.unwrap_or_default()
.iter()
.collect();
let included_files: HashSet<&PathBuf> = file_map
.keys()
.collect::<HashSet<&PathBuf>>()
.difference(&excluded_paths_from_config)
.copied()
.collect();
let included_files: HashSet<PathBuf> = included_paths_from_config
.union(&included_files)
.copied()
.cloned()
.collect();
file_map
.into_iter()
.filter(move |(file, _)| included_files.contains(file))
}