use std::fs::read_to_string;
use std::path::PathBuf;
use std::{collections::BTreeMap, path::Path};
use anyhow::{Error, Result};
use serde::Deserialize;
pub const FILENAME: &str = "treefmt.toml";
pub const FILENAMEALT: &str = ".treefmt.toml";
#[derive(Debug, Deserialize, Clone, Eq, PartialEq)]
pub struct Root {
pub global: Option<GlobalConfig>,
pub formatter: BTreeMap<String, FmtConfig>,
}
#[derive(Debug, Deserialize, Clone, Eq, PartialEq)]
pub struct GlobalConfig {
#[serde(default)]
pub excludes: Vec<String>,
}
#[derive(Debug, Deserialize, Clone, Eq, PartialEq)]
pub struct FmtConfig {
pub command: String,
#[serde(default = "cwd")]
pub work_dir: PathBuf,
#[serde(default)]
pub options: Vec<String>,
#[serde(default)]
pub includes: Vec<String>,
#[serde(default)]
pub excludes: Vec<String>,
}
fn cwd() -> PathBuf {
".".into()
}
#[must_use]
pub fn lookup(dir: &Path) -> Option<PathBuf> {
let mut cwd = dir.to_path_buf();
loop {
let config_file = cwd.join(FILENAME);
let config_file_alt = cwd.join(FILENAMEALT);
if config_file.exists() {
return Some(config_file);
} else if config_file_alt.exists() {
return Some(config_file_alt);
}
cwd = match cwd.parent() {
Some(x) => x.to_path_buf(),
None => return None,
};
}
}
pub fn from_path(file_path: &Path) -> Result<Root> {
read_to_string(file_path)
.map_err(Error::msg)
.and_then(|content| from_string(&content))
}
pub fn from_string(file_contents: &str) -> Result<Root> {
toml::from_str::<Root>(file_contents).map_err(Error::msg)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::fs::File;
use super::*;
fn read_toml<T: for<'a> Deserialize<'a>>(toml: &str) -> Result<T, String> {
toml::from_str::<T>(toml).map_err(|x| x.to_string())
}
fn read_root(toml: &str) -> Result<Root, String> {
from_string(toml).map_err(|x| x.to_string())
}
fn default_root(command: &str) -> Root {
Root {
global: None,
formatter: BTreeMap::from([(command.to_string(), default_fmt_config("sh"))]),
}
}
fn default_global_config() -> GlobalConfig {
GlobalConfig { excludes: vec![] }
}
fn default_fmt_config(command: &str) -> FmtConfig {
FmtConfig {
command: command.to_string(),
work_dir: PathBuf::from("."),
options: vec![],
includes: vec![],
excludes: vec![],
}
}
#[test]
fn test_cwd() {
assert_eq!(true, cwd().is_relative(),);
assert_eq!(Some("."), cwd().to_str());
}
#[test]
fn test_global_config() {
let toml = "";
assert_eq!(Ok(default_global_config()), read_toml::<GlobalConfig>(toml));
let toml = r#"
excludes = ["foo", "bar", "baz"]
"#;
let excludes: Vec<String> = ["foo", "bar", "baz"].map(String::from).to_vec();
let expected = GlobalConfig { excludes };
assert_eq!(Ok(expected), read_toml::<GlobalConfig>(toml))
}
#[test]
fn test_fmt_command() {
assert_eq!(
Err("missing field `command`".to_string()),
read_toml::<FmtConfig>("")
);
assert_eq!(
Ok(default_fmt_config("sh")),
read_toml::<FmtConfig>(r#"command="sh""#)
);
}
#[test]
fn test_fmt_work_dir() {
let toml = r#"
command="sh"
"#;
let fmt_config = read_toml::<FmtConfig>(toml).unwrap();
assert_eq!(PathBuf::from("."), fmt_config.work_dir);
let spec = r#"
command="sh"
work_dir="/foo/bar"
"#;
let fmt_config = read_toml::<FmtConfig>(spec).unwrap();
assert_eq!(PathBuf::from("/foo/bar"), fmt_config.work_dir);
}
#[test]
fn test_fmt_options() {
let toml = r#"
command="sh"
"#;
let fmt_config = read_toml::<FmtConfig>(toml).unwrap();
let default_options: Vec<String> = Vec::new();
assert_eq!(default_options, fmt_config.options);
let spec = r#"
command="sh"
options=["foo", "bar", "baz"]
"#;
let fmt_config = read_toml::<FmtConfig>(spec).unwrap();
assert_eq!(vec!["foo", "bar", "baz"], fmt_config.options);
}
#[test]
fn test_fmt_includes() {
let toml = r#"
command="sh"
"#;
let fmt_config = read_toml::<FmtConfig>(toml).unwrap();
let default_includes: Vec<String> = Vec::new();
assert_eq!(default_includes, fmt_config.options);
let spec = r#"
command="sh"
includes=["foo", "bar", "baz"]
"#;
let fmt_config = read_toml::<FmtConfig>(spec).unwrap();
assert_eq!(vec!["foo", "bar", "baz"], fmt_config.includes);
}
#[test]
fn test_fmt_excludes() {
let toml = r#"
command="sh"
"#;
let fmt_config = read_toml::<FmtConfig>(toml).unwrap();
let default_excludes: Vec<String> = Vec::new();
assert_eq!(default_excludes, fmt_config.options);
let spec = r#"
command="sh"
excludes=["foo", "bar", "baz"]
"#;
let fmt_config = read_toml::<FmtConfig>(spec).unwrap();
assert_eq!(vec!["foo", "bar", "baz"], fmt_config.excludes);
}
#[test]
fn test_root() {
let toml = "";
assert_eq!(
Err("missing field `formatter`".to_string()),
read_toml::<Root>(toml)
);
let toml = r#"
[formatter.sh]
command = "sh"
"#;
let expected = default_root("sh");
assert_eq!(Ok(expected), read_root(toml));
let toml = r#"
[global]
excludes = ["foo", "bar", "baz"]
[formatter.sh]
command = "sh"
"#;
let mut expected = default_root("sh");
expected.global = Some(GlobalConfig {
excludes: ["foo", "bar", "baz"].map(String::from).to_vec(),
});
assert_eq!(Ok(expected), read_root(toml));
let toml = r#"
[formatter.sh]
command = "sh"
[formatter.rust]
command = "rustfmt"
[formatter.haskell]
command = "cabal-fmt"
"#;
let mut expected = default_root("sh");
expected.formatter = BTreeMap::from([
("sh".to_string(), default_fmt_config("sh")),
("rust".to_string(), default_fmt_config("rustfmt")),
("haskell".to_string(), default_fmt_config("cabal-fmt")),
]);
assert_eq!(Ok(expected), read_root(toml));
}
#[test]
fn test_from_path() {
let toml = r#"
[global]
excludes = ["foo", "bar", "baz"]
[formatter.sh]
command = "sh"
"#;
let root = tempfile::tempdir().unwrap();
let file_path = root.as_ref().join(PathBuf::from("treefmt.toml"));
fs::write(&file_path, toml).expect("failed to write toml to temp file");
let read_from_path =
|path: &Path| -> Result<Root, String> { from_path(path).map_err(|x| x.to_string()) };
assert_eq!(
Err("No such file or directory (os error 2)".to_string()),
read_from_path(Path::new("foo/bar/baz.toml"))
);
let mut expected = default_root("sh");
expected.global = Some(GlobalConfig {
excludes: ["foo", "bar", "baz"].map(String::from).to_vec(),
});
assert_eq!(Ok(expected), read_from_path(&file_path));
}
#[test]
fn test_lookup() {
let root = tempfile::tempdir().unwrap();
let level_one = tempfile::tempdir_in(&root).unwrap();
let level_two = tempfile::tempdir_in(&level_one).unwrap();
let level_three = tempfile::tempdir_in(&level_two).unwrap();
let level_four = tempfile::tempdir_in(&level_three).unwrap();
let level_five = tempfile::tempdir_in(&level_four).unwrap();
File::create(level_one.as_ref().join(PathBuf::from("treefmt.toml"))).unwrap();
File::create(level_three.as_ref().join(PathBuf::from(".treefmt.toml"))).unwrap();
let standard_path = level_one.as_ref().join(PathBuf::from("treefmt.toml"));
let alternative_path = level_three.as_ref().join(PathBuf::from(".treefmt.toml"));
assert_eq!(Some(alternative_path.clone()), lookup(level_five.as_ref()));
assert_eq!(Some(alternative_path.clone()), lookup(level_four.as_ref()));
assert_eq!(Some(standard_path.clone()), lookup(level_two.as_ref()));
assert_eq!(Some(standard_path.clone()), lookup(level_one.as_ref()));
assert_eq!(None, lookup(root.as_ref()));
}
}