mod cereal;
#[cfg(feature = "tui")]
mod tui;
#[cfg(feature = "tui")]
pub use tui::*;
use editor_command::{Editor, EditorBuilder, EditorBuilderError};
use serde::Serialize;
use slumber_util::{
ResultTraced, doc_link, git_link,
paths::{self, create_parent, expand_home},
yaml::{self, YamlError},
};
use std::{
env,
error::Error,
fs::{self, File},
io::{self, Write},
path::{Path, PathBuf},
};
use thiserror::Error;
use tracing::{error, info, warn};
const PATH_ENV_VAR: &str = "SLUMBER_CONFIG_PATH";
const FILE: &str = "config.yml";
const DEFAULT_OLD: &str = include_str!("default_old.yml");
#[derive(Debug, Default, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(
default,
// Allow any top-level property beginning with .
extend("patternProperties" = {
"^\\.": { "description": "Ignore any property beginning with `.`" }
}),
example = Config::default(),
)
)]
pub struct Config {
pub editor: Option<String>,
#[serde(flatten)]
pub http: HttpEngineConfig,
#[cfg(feature = "tui")]
#[serde(flatten)]
pub tui: tui::TuiConfig,
}
impl Config {
pub fn path() -> PathBuf {
if let Ok(path) = env::var(PATH_ENV_VAR) {
return expand_home(PathBuf::from(path)).into_owned();
}
let legacy_path = paths::data_directory().join(FILE);
if legacy_path.is_file() {
return legacy_path;
}
paths::config_directory().join(FILE)
}
pub fn load() -> Result<Self, ConfigError> {
let path = Self::path();
info!(?path, "Loading configuration file");
Self::replace_default(&path);
match yaml::deserialize_file::<Config>(&path) {
Ok(config) => Ok(config),
Err(error) => {
if let yaml::YamlErrorKind::Io { error, .. } = &error.kind {
error!(
error = error as &dyn Error,
"Error opening config file {path:?}"
);
if error.kind() == io::ErrorKind::NotFound {
let _ = Self::create_new(&path).traced();
}
Ok(Self::default())
} else {
Err(error.into())
}
}
}
}
pub fn editor(&self) -> Result<Editor, EditorError> {
EditorBuilder::new()
.string(self.editor.as_deref())
.environment()
.string(Some("vim"))
.build()
.map_err(EditorError)
}
fn create_new(path: &Path) -> Result<(), ConfigError> {
create_parent(path)
.and_then(|()| {
let mut file = File::create_new(path)?;
file.write_all(Self::default_content().as_bytes())?;
Ok(())
})
.map_err(|error| ConfigError::Create {
path: path.to_owned(),
error,
})
}
pub fn default_content() -> String {
const SOURCE: &str = include_str!("default.yml");
const SCHEMA_REPLACEMENT: &str = "{{#schema}}";
SOURCE.replace(SCHEMA_REPLACEMENT, &git_link("schemas/config.json"))
}
fn replace_default(path: &Path) {
if fs::read_to_string(path).is_ok_and(|content| content == DEFAULT_OLD)
{
warn!(
"Config file contains old default content. \
Replacing with new defaults."
);
let _ = fs::write(path, Self::default_content()).traced();
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(default))]
pub struct HttpEngineConfig {
pub ignore_certificate_hosts: Vec<String>,
pub large_body_size: usize,
pub follow_redirects: bool,
}
impl HttpEngineConfig {
pub fn is_large(&self, size: usize) -> bool {
size > self.large_body_size
}
}
impl Default for HttpEngineConfig {
fn default() -> Self {
Self {
ignore_certificate_hosts: Default::default(),
large_body_size: 1000 * 1000, follow_redirects: true,
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Error creating config file {}", path.display())]
Create { path: PathBuf, error: io::Error },
#[error(transparent)]
Yaml(#[from] YamlError),
}
#[derive(Debug, Error)]
#[error("Error opening editor; see {}", doc_link("user_guide/tui/editor"))]
pub struct EditorError(#[source] EditorBuilderError);
#[cfg(test)]
mod tests {
use super::*;
use env_lock::EnvGuard;
use pretty_assertions::assert_eq;
use rstest::{fixture, rstest};
use slumber_util::{TempDir, temp_dir};
use std::fs;
struct ConfigPath {
path: PathBuf,
dir: TempDir,
_guard: EnvGuard<'static>,
}
#[fixture]
fn config_path(temp_dir: TempDir) -> ConfigPath {
let path = temp_dir.join("config.yml");
let guard =
env_lock::lock_env([(PATH_ENV_VAR, Some(path.to_str().unwrap()))]);
ConfigPath {
path,
dir: temp_dir,
_guard: guard,
}
}
#[test]
fn test_custom_config_path() {
let _guard = env_lock::lock_env([(
PATH_ENV_VAR,
Some("~/dotfiles/slumber.yml"),
)]);
assert_eq!(
Config::path(),
dirs::home_dir().unwrap().join("dotfiles/slumber.yml")
);
}
#[rstest]
fn test_load_file_empty(config_path: ConfigPath) {
fs::write(&config_path.path, "").unwrap();
let config = Config::load().unwrap();
assert_eq!(config, Config::default());
}
#[rstest]
fn test_load_file_exists_readonly(config_path: ConfigPath) {
fs::write(&config_path.path, "large_body_size: 1000\n").unwrap();
let mut permissions =
fs::metadata(&config_path.path).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&config_path.path, permissions).unwrap();
let config = Config::load().unwrap();
assert_eq!(
config,
Config {
editor: None,
http: HttpEngineConfig {
large_body_size: 1000,
..Default::default()
},
#[cfg(feature = "tui")]
tui: TuiConfig::default(),
}
);
}
#[rstest]
fn test_load_file_does_not_exist_can_create(config_path: ConfigPath) {
let path = &config_path.path;
assert!(!path.exists());
let config = Config::load().unwrap();
let content = Config::default_content();
assert_eq!(fs::read_to_string(path).unwrap(), content);
assert_eq!(config, Config::default());
assert!(
content.starts_with(
"# yaml-language-server: $schema=\
https://raw.githubusercontent.com/LucasPickering/slumber/"
),
"Default content missing schema link"
);
}
#[rstest]
#[cfg(unix)]
fn test_load_file_does_not_exist_cannot_create(config_path: ConfigPath) {
let mut permissions =
fs::metadata(&*config_path.dir).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&*config_path.dir, permissions).unwrap();
let config = Config::load().unwrap();
assert_eq!(config, Config::default());
assert!(!config_path.path.exists());
}
#[cfg(feature = "tui")] #[rstest]
fn test_load_file_invalid(config_path: ConfigPath) {
fs::write(&config_path.path, "fake_field: true\n").unwrap();
slumber_util::assert_err(
Config::load(),
"Unexpected field `fake_field`",
);
}
#[rstest]
fn test_replace_old_default_file(config_path: ConfigPath) {
let path = &config_path.path;
assert!(DEFAULT_OLD.len() > 1000);
fs::write(path, DEFAULT_OLD).unwrap();
Config::load().unwrap();
assert_eq!(
fs::read_to_string(path).unwrap(),
Config::default_content()
);
}
}