use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use toml_edit::de;
use crate::Result;
use crate::workspace;
const NAMES: [&str; 2] = ["semver-sync.toml", ".semver-sync.toml"];
const SCAFFOLD: &str = "\
# semver-sync configuration.
#
# The first file that exists is the one read:
# 1. the repo root: semver-sync.toml, then .semver-sync.toml
# 2. $HOME: semver-sync.toml, then .semver-sync.toml
# 3. the user config dir ($XDG_CONFIG_HOME, else $HOME/.config): the same two
# names
#
# So a repo can say something different from the machine it sits on.
# Tag the version as v<VERSION>, then push the commits and the tag with them.
# The tag lands on the commit you are standing on: commit what a bump wrote
# first when the tag has to carry it.
git-tag = false
";
#[derive(Debug, Default)]
pub struct Config {
pub path: PathBuf,
pub git_tag: bool,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
struct Settings {
git_tag: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Scaffold {
Written { path: PathBuf },
Existing { path: PathBuf },
}
impl Config {
pub fn discover() -> Result<Option<Self>> {
find(workspace::repo_root().ok().as_deref())
}
pub fn from_repo(repo: &Path) -> Result<Option<Self>> {
find(Some(repo))
}
}
fn find(repo: Option<&Path>) -> Result<Option<Config>> {
search(&candidates(
repo,
home().as_deref(),
config_dir().as_deref(),
))
}
pub fn scaffold(global: bool) -> Result<Scaffold> {
if global {
let dir = config_dir().ok_or_else(|| {
String::from("cannot find your config directory: set $XDG_CONFIG_HOME or $HOME")
})?;
return write_scaffold(&dir);
}
let root = workspace::repo_root().map_err(|err| {
let way_out = " run it inside a workspace, or pass --global for the user file";
format!("cannot find the repo root: {err}\n{way_out}")
})?;
write_scaffold(&root)
}
fn candidates(repo: Option<&Path>, home: Option<&Path>, config_dir: Option<&Path>) -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(NAMES.len() * 3);
for dir in [repo, home, config_dir] {
let Some(dir) = dir else { continue };
paths.extend(NAMES.iter().map(|name| dir.join(name)));
}
paths
}
fn search(paths: &[PathBuf]) -> Result<Option<Config>> {
for path in paths {
let found = match std::fs::metadata(path) {
Ok(found) => found,
Err(err) if err.kind() == ErrorKind::NotFound => continue,
Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
};
if !found.is_file() {
continue;
}
let text = std::fs::read_to_string(path)
.map_err(|err| format!("cannot read {}: {err}", path.display()))?;
let settings = de::from_str::<Settings>(&text)
.map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
return Ok(Some(Config {
path: path.clone(),
git_tag: settings.git_tag,
}));
}
Ok(None)
}
fn write_scaffold(dir: &Path) -> Result<Scaffold> {
std::fs::create_dir_all(dir)
.map_err(|err| format!("cannot create {}: {err}", dir.display()))?;
for name in NAMES {
let path = dir.join(name);
if path.is_file() {
return Ok(Scaffold::Existing { path });
}
}
let path = dir.join(NAMES[0]);
let mut file = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::AlreadyExists => {
return if path.is_file() {
Ok(Scaffold::Existing { path })
} else {
Err(format!("{} is not a file", path.display()))
};
}
Err(err) => return Err(format!("cannot write {}: {err}", path.display())),
};
file.write_all(SCAFFOLD.as_bytes())
.map_err(|err| format!("cannot write {}: {err}", path.display()))?;
Ok(Scaffold::Written { path })
}
fn home() -> Option<PathBuf> {
absolute("HOME")
}
fn config_dir() -> Option<PathBuf> {
absolute("XDG_CONFIG_HOME").or_else(|| home().map(|home| home.join(".config")))
}
fn absolute(var: &str) -> Option<PathBuf> {
std::env::var_os(var)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::write(path, "# nothing set\n").expect("write config");
}
#[test]
fn the_repo_dir_is_searched_before_the_user_dirs() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
let home = dirs.path().join("home");
let config = dirs.path().join("config");
let found = candidates(Some(&repo), Some(&home), Some(&config));
assert_eq!(
found,
vec![
repo.join("semver-sync.toml"),
repo.join(".semver-sync.toml"),
home.join("semver-sync.toml"),
home.join(".semver-sync.toml"),
config.join("semver-sync.toml"),
config.join(".semver-sync.toml"),
]
);
}
#[test]
fn a_repo_with_no_config_file_falls_through_to_the_user_dir() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
let home = dirs.path().join("home");
std::fs::create_dir_all(&home).expect("create home");
touch(&home.join(NAMES[1]));
let found = search(&candidates(Some(&repo), Some(&home), None)).expect("search");
assert_eq!(found.map(|config| config.path), Some(home.join(NAMES[1])));
}
#[test]
fn nothing_written_anywhere_is_no_configuration() {
let dirs = tempfile::tempdir().expect("temp dir");
let found = search(&candidates(
Some(&dirs.path().join("repo")),
Some(&dirs.path().join("home")),
None,
))
.expect("search");
assert!(found.is_none());
}
#[test]
fn a_file_that_is_not_toml_stops_the_search() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
let home = dirs.path().join("home");
std::fs::create_dir_all(&repo).expect("create repo");
std::fs::create_dir_all(&home).expect("create home");
std::fs::write(repo.join(NAMES[0]), "this is not toml = = 1\n").expect("write config");
touch(&home.join(NAMES[0]));
let err = search(&candidates(Some(&repo), Some(&home), None))
.expect_err("a broken file is not skipped");
assert!(
err.contains(&repo.join(NAMES[0]).display().to_string()),
"{err}"
);
}
#[test]
fn a_comment_only_file_is_configuration() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
std::fs::write(repo.join(NAMES[0]), "# only a note\n").expect("write config");
let found = search(&candidates(Some(&repo), None, None))
.expect("search")
.expect("a file of comments is still the config file");
assert_eq!(found.path, repo.join(NAMES[0]));
assert!(!found.git_tag);
}
#[test]
fn git_tag_comes_from_the_file_that_wins() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
let home = dirs.path().join("home");
std::fs::create_dir_all(&repo).expect("create repo");
std::fs::create_dir_all(&home).expect("create home");
std::fs::write(repo.join(NAMES[1]), "git-tag = true\n").expect("write repo config");
std::fs::write(home.join(NAMES[0]), "git-tag = false\n").expect("write home config");
let found = search(&candidates(Some(&repo), Some(&home), None))
.expect("search")
.expect("the repo has a config file");
assert_eq!(found.path, repo.join(NAMES[1]));
assert!(found.git_tag);
}
#[test]
fn a_setting_of_the_wrong_shape_is_an_error() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
std::fs::write(repo.join(NAMES[0]), "git-tag = \"yes\"\n").expect("write config");
let err = search(&candidates(Some(&repo), None, None)).expect_err("a string is no boolean");
assert!(err.contains("boolean"), "{err}");
}
#[test]
fn a_key_that_names_no_setting_is_an_error() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
std::fs::write(repo.join(NAMES[0]), "git-taggable = true\n").expect("write config");
let err = search(&candidates(Some(&repo), None, None))
.expect_err("a misspelled key is not a key left out");
assert!(err.contains("git-taggable"), "{err}");
}
#[test]
fn scaffolding_writes_the_file_once() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
let first = write_scaffold(&repo).expect("scaffold");
let written = repo.join(NAMES[0]);
let text = std::fs::read_to_string(&written).expect("read config");
let second = write_scaffold(&repo).expect("scaffold again");
assert_eq!(
first,
Scaffold::Written {
path: written.clone()
}
);
assert!(text.starts_with("# semver-sync configuration."));
assert_eq!(
second,
Scaffold::Existing {
path: written.clone()
}
);
assert_eq!(
std::fs::read_to_string(&written).expect("read config again"),
text
);
let settings = search(&candidates(Some(&repo), None, None))
.expect("search")
.expect("the scaffold is a config file");
assert!(!settings.git_tag);
}
#[test]
fn a_dotted_file_there_already_is_the_config() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
std::fs::create_dir_all(&repo).expect("create repo");
touch(&repo.join(NAMES[1]));
let outcome = write_scaffold(&repo).expect("scaffold");
assert_eq!(
outcome,
Scaffold::Existing {
path: repo.join(NAMES[1])
}
);
assert!(!repo.join(NAMES[0]).exists());
}
#[test]
fn scaffolding_a_user_dir_creates_the_dir_too() {
let dirs = tempfile::tempdir().expect("temp dir");
let config = dirs.path().join(".config");
let outcome = write_scaffold(&config.join("nested")).expect("scaffold");
assert!(
matches!(outcome, Scaffold::Written { .. }),
"the config dir is usually not there yet"
);
assert!(config.join("nested").join(NAMES[0]).is_file());
}
#[test]
fn a_directory_in_the_way_is_not_reported_as_the_file() {
let dirs = tempfile::tempdir().expect("temp dir");
let repo = dirs.path().join("repo");
std::fs::create_dir_all(repo.join(NAMES[0])).expect("create dir in the way");
let err = write_scaffold(&repo).expect_err("a dir is not a config file");
assert!(err.contains("is not a file"), "{err}");
}
}