use dirs::config_dir;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum VerbosityLevel {
Quiet,
#[default]
Verbose,
Debug,
}
impl VerbosityLevel {
fn is_default_verbosity(&self) -> bool {
matches!(self, VerbosityLevel::Verbose)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OriginPreference {
Github,
Tangled,
}
impl std::fmt::Display for OriginPreference {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OriginPreference::Github => write!(f, "github"),
OriginPreference::Tangled => write!(f, "tangled"),
}
}
}
impl OriginPreference {
pub fn from_alias(s: &str) -> Option<Self> {
match s {
"github" | "gh" => Some(OriginPreference::Github),
"tangled" | "tngl" => Some(OriginPreference::Tangled),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
pub github_username: String,
pub tangled_username: String,
pub origin_preference: OriginPreference,
#[serde(default, skip_serializing_if = "VerbosityLevel::is_default_verbosity")]
pub verbosity_preference: VerbosityLevel,
}
impl Config {
pub fn effective_verbosity(&self, quiet: bool, debug: bool) -> VerbosityLevel {
if quiet {
VerbosityLevel::Quiet
} else if debug {
VerbosityLevel::Debug
} else {
self.verbosity_preference
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartialConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub github_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tangled_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_preference: Option<OriginPreference>,
}
impl PartialConfig {
pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(PartialConfig::default());
}
Err(e) => return Err(ConfigError::Unreadable(e)),
};
if content.trim().is_empty() {
return Ok(PartialConfig::default());
}
serde_json::from_str(&content).map_err(|e| ConfigError::Corrupted(e.to_string()))
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(ConfigError::CannotCreateDir)?;
}
let json = serde_json::to_string_pretty(self)
.expect("PartialConfig serialization should never fail");
atomic_write_config(path, &json)?;
Ok(())
}
}
fn atomic_write_config(path: &Path, content: &str) -> Result<(), ConfigError> {
use std::io::Write as _;
let dir = path.parent().ok_or_else(|| {
ConfigError::CannotWriteFile(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config path has no parent directory",
))
})?;
let mut tmp = tempfile::Builder::new()
.suffix(".lock")
.tempfile_in(dir)
.map_err(ConfigError::CannotWriteFile)?;
tmp.write_all(content.as_bytes())
.map_err(ConfigError::CannotWriteFile)?;
tmp.persist(path)
.map_err(|e| ConfigError::CannotWriteFile(e.error))?;
Ok(())
}
#[derive(Debug)]
pub enum ConfigError {
NoPlatformConfigDir,
NotFound,
Unreadable(std::io::Error),
Empty,
MissingGithubUsername,
MissingTangledUsername,
MissingOriginPreference,
Corrupted(String),
CannotCreateDir(std::io::Error),
CannotWriteFile(std::io::Error),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::NoPlatformConfigDir => write!(
f,
"Could not determine a config directory for this platform."
),
ConfigError::NotFound => write!(
f,
"No config file found. Run `entangle setup` to create one."
),
ConfigError::Unreadable(e) => write!(f, "Config file exists but couldn't be read: {e}"),
ConfigError::Empty => write!(
f,
"Config file is empty. Run `entangle setup` to fill it in."
),
ConfigError::MissingGithubUsername => write!(
f,
"Config is missing `github_username`. Run `entangle set gh-user <username>`."
),
ConfigError::MissingTangledUsername => write!(
f,
"Config is missing `tangled_username`. Run `entangle set tngl-user <username>`."
),
ConfigError::MissingOriginPreference => write!(
f,
"Config is missing `origin_preference`. Run `entangle set origin <github|tangled>`."
),
ConfigError::Corrupted(msg) => write!(
f,
"Config file is unreadable ({msg}). Re-run `entangle setup` to overwrite it."
),
ConfigError::CannotCreateDir(e) => {
write!(f, "Couldn't create config directory: {e}")
}
ConfigError::CannotWriteFile(e) => write!(f, "Couldn't write config file: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
pub fn config_path() -> Result<PathBuf, ConfigError> {
if let Ok(override_path) = std::env::var("ENTANGLE_CONFIG_PATH") {
return Ok(PathBuf::from(override_path));
}
let base = config_dir().ok_or(ConfigError::NoPlatformConfigDir)?;
Ok(base.join("entangle").join("config.json"))
}
impl Config {
pub fn load() -> Result<Config, ConfigError> {
let path = config_path()?;
Config::load_from_path(&path)
}
pub fn load_from_path(path: &Path) -> Result<Config, ConfigError> {
let content = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::NotFound
} else {
ConfigError::Unreadable(e)
}
})?;
if content.trim().is_empty() {
return Err(ConfigError::Empty);
}
let value: serde_json::Value =
serde_json::from_str(&content).map_err(|e| ConfigError::Corrupted(e.to_string()))?;
let obj = value.as_object().ok_or(ConfigError::Corrupted(
"Expected a JSON object at the top level".to_string(),
))?;
if obj.is_empty() {
return Err(ConfigError::Empty);
}
if !obj.contains_key("github_username") {
return Err(ConfigError::MissingGithubUsername);
}
if !obj.contains_key("tangled_username") {
return Err(ConfigError::MissingTangledUsername);
}
if !obj.contains_key("origin_preference") {
return Err(ConfigError::MissingOriginPreference);
}
let cfg: Config =
serde_json::from_value(value).map_err(|e| ConfigError::Corrupted(e.to_string()))?;
if cfg.github_username.contains(['\n', '\r']) {
return Err(ConfigError::Corrupted(
"github_username contains a newline character. \
Edit config.json or re-run `entangle set gh-user <username>`."
.to_string(),
));
}
if cfg.tangled_username.contains(['\n', '\r']) {
return Err(ConfigError::Corrupted(
"tangled_username contains a newline character. \
Edit config.json or re-run `entangle set tngl-user <handle>`."
.to_string(),
));
}
Ok(cfg)
}
pub fn save(&self) -> Result<(), ConfigError> {
let path = config_path()?;
self.save_to_path(&path)
}
pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(ConfigError::CannotCreateDir)?;
}
let json =
serde_json::to_string_pretty(self).expect("Config serialization should never fail");
atomic_write_config(path, &json)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn valid_config() -> Config {
Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: Default::default(),
}
}
fn valid_json() -> &'static str {
r#"{"github_username":"cyrusae","tangled_username":"atdot.fyi","origin_preference":"github"}"#
}
fn temp_with(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
write!(f, "{content}").unwrap();
f
}
#[test]
fn config_path_ends_with_entangle_config_json() {
let Ok(path) = config_path() else { return };
let mut components: Vec<_> = path.components().collect();
let file = components.pop().unwrap();
let dir = components.pop().unwrap();
assert_eq!(
file.as_os_str(),
"config.json",
"last component should be config.json"
);
assert_eq!(
dir.as_os_str(),
"entangle",
"second-to-last component should be entangle"
);
}
#[test]
fn serialize_deserialize_roundtrip() {
let original = valid_config();
let json = serde_json::to_string(&original).unwrap();
let restored: Config = serde_json::from_str(&json).unwrap();
assert_eq!(original, restored);
}
#[test]
fn origin_preference_tangled_roundtrip() {
let cfg = Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Tangled,
verbosity_preference: Default::default(),
};
let json = serde_json::to_string(&cfg).unwrap();
let restored: Config = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, restored);
assert_eq!(restored.origin_preference, OriginPreference::Tangled);
}
#[test]
fn load_not_found_for_nonexistent_path() {
let path = PathBuf::from("/tmp/entangle-test-definitely-does-not-exist-abc123/config.json");
let err = Config::load_from_path(&path).unwrap_err();
assert!(
matches!(err, ConfigError::NotFound),
"expected NotFound, got: {err}"
);
}
#[test]
fn load_empty_for_zero_byte_file() {
let f = temp_with("");
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Empty),
"expected Empty, got: {err}"
);
}
#[test]
fn load_empty_for_whitespace_only_file() {
let f = temp_with(" \n\t ");
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Empty),
"expected Empty, got: {err}"
);
}
#[test]
fn load_empty_for_empty_json_object() {
let f = temp_with("{}");
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Empty),
"expected Empty, got: {err}"
);
}
#[test]
fn load_corrupted_for_truncated_json() {
let f = temp_with(r#"{"github_username": "cyrusae""#); let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Corrupted(_)),
"expected Corrupted, got: {err}"
);
}
#[test]
fn load_corrupted_for_invalid_utf8_like_content() {
let f = temp_with("not json at all !!!!");
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Corrupted(_)),
"expected Corrupted, got: {err}"
);
}
#[test]
fn load_corrupted_for_newline_in_github_username() {
let f = temp_with(
"{\"github_username\":\"cyrus\\nae\",\"tangled_username\":\"atdot.fyi\",\"origin_preference\":\"github\"}",
);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Corrupted(_)),
"expected Corrupted for newline in github_username, got: {err}"
);
}
#[test]
fn load_corrupted_for_newline_in_tangled_username() {
let f = temp_with(
"{\"github_username\":\"cyrusae\",\"tangled_username\":\"atdot\\nfyi\",\"origin_preference\":\"github\"}",
);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Corrupted(_)),
"expected Corrupted for newline in tangled_username, got: {err}"
);
}
#[test]
fn load_corrupted_for_invalid_origin_preference_value() {
let f = temp_with(
r#"{"github_username":"cyrusae","tangled_username":"atdot.fyi","origin_preference":"gitlab"}"#,
);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::Corrupted(_)),
"expected Corrupted for unknown origin variant, got: {err}"
);
}
#[test]
fn load_missing_github_username() {
let f = temp_with(r#"{"tangled_username":"atdot.fyi","origin_preference":"github"}"#);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::MissingGithubUsername),
"expected MissingGithubUsername, got: {err}"
);
}
#[test]
fn load_missing_tangled_username() {
let f = temp_with(r#"{"github_username":"cyrusae","origin_preference":"github"}"#);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::MissingTangledUsername),
"expected MissingTangledUsername, got: {err}"
);
}
#[test]
fn load_missing_origin_preference() {
let f = temp_with(r#"{"github_username":"cyrusae","tangled_username":"atdot.fyi"}"#);
let err = Config::load_from_path(f.path()).unwrap_err();
assert!(
matches!(err, ConfigError::MissingOriginPreference),
"expected MissingOriginPreference, got: {err}"
);
}
#[test]
fn load_success_for_valid_json() {
let f = temp_with(valid_json());
let cfg = Config::load_from_path(f.path()).unwrap();
assert_eq!(cfg, valid_config());
}
#[test]
fn load_ignores_unknown_fields_in_json() {
let json = r#"{
"github_username": "cyrusae",
"tangled_username": "atdot.fyi",
"origin_preference": "github",
"unknown_future_field": 42,
"another_unknown": "hello"
}"#;
let f = temp_with(json);
let cfg = Config::load_from_path(f.path()).unwrap();
assert_eq!(cfg, valid_config());
}
#[test]
fn save_leaves_no_lock_file_behind() {
let f = NamedTempFile::new().unwrap();
let path = f.path();
let lock_path = path.with_extension("lock");
valid_config().save_to_path(path).unwrap();
assert!(
!lock_path.exists(),
"no .lock file should remain after a successful save"
);
}
#[test]
fn save_creates_parent_directory_if_missing() {
let base = tempfile::tempdir().unwrap();
let path = base.path().join("nested").join("dir").join("config.json");
valid_config().save_to_path(&path).unwrap();
assert!(path.exists(), "config.json should have been created");
}
#[test]
fn save_writes_parseable_json() {
let f = NamedTempFile::new().unwrap();
valid_config().save_to_path(f.path()).unwrap();
let content = std::fs::read_to_string(f.path()).unwrap();
let _: serde_json::Value = serde_json::from_str(&content).unwrap();
}
#[test]
fn save_then_load_roundtrip() {
let f = NamedTempFile::new().unwrap();
let original = valid_config();
original.save_to_path(f.path()).unwrap();
let restored = Config::load_from_path(f.path()).unwrap();
assert_eq!(original, restored);
}
#[test]
fn save_then_load_tangled_origin_roundtrip() {
let f = NamedTempFile::new().unwrap();
let original = Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Tangled,
verbosity_preference: Default::default(),
};
original.save_to_path(f.path()).unwrap();
let restored = Config::load_from_path(f.path()).unwrap();
assert_eq!(original, restored);
assert_eq!(restored.origin_preference, OriginPreference::Tangled);
}
#[test]
fn verbosity_default_is_verbose() {
assert_eq!(VerbosityLevel::default(), VerbosityLevel::Verbose);
}
#[test]
fn verbosity_ordering_quiet_lt_verbose_lt_debug() {
assert!(VerbosityLevel::Quiet < VerbosityLevel::Verbose);
assert!(VerbosityLevel::Verbose < VerbosityLevel::Debug);
}
#[test]
fn verbosity_serializes_to_lowercase_string() {
assert_eq!(
serde_json::to_string(&VerbosityLevel::Quiet).unwrap(),
r#""quiet""#
);
assert_eq!(
serde_json::to_string(&VerbosityLevel::Verbose).unwrap(),
r#""verbose""#
);
assert_eq!(
serde_json::to_string(&VerbosityLevel::Debug).unwrap(),
r#""debug""#
);
}
#[test]
fn verbosity_deserializes_from_lowercase_string() {
let q: VerbosityLevel = serde_json::from_str(r#""quiet""#).unwrap();
let v: VerbosityLevel = serde_json::from_str(r#""verbose""#).unwrap();
let d: VerbosityLevel = serde_json::from_str(r#""debug""#).unwrap();
assert_eq!(q, VerbosityLevel::Quiet);
assert_eq!(v, VerbosityLevel::Verbose);
assert_eq!(d, VerbosityLevel::Debug);
}
#[test]
fn verbosity_default_omitted_from_serialized_config() {
let cfg = valid_config(); let json = serde_json::to_string(&cfg).unwrap();
assert!(
!json.contains("verbosity_preference"),
"default verbosity must not appear in JSON: {json}"
);
}
#[test]
fn verbosity_non_default_persisted_in_config() {
let cfg = Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: VerbosityLevel::Quiet,
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(
json.contains("verbosity_preference"),
"non-default verbosity must appear in JSON: {json}"
);
assert!(json.contains("quiet"), "must serialize to 'quiet': {json}");
}
#[test]
fn verbosity_missing_from_json_loads_as_default() {
let f = temp_with(
r#"{"github_username":"cyrusae","tangled_username":"atdot.fyi","origin_preference":"github"}"#,
);
let cfg = Config::load_from_path(f.path()).unwrap();
assert_eq!(
cfg.verbosity_preference,
VerbosityLevel::Verbose,
"absent verbosity_preference must default to Verbose"
);
}
#[test]
fn effective_verbosity_quiet_flag_overrides_config() {
let cfg = Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: VerbosityLevel::Debug, };
assert_eq!(cfg.effective_verbosity(true, false), VerbosityLevel::Quiet);
}
#[test]
fn effective_verbosity_debug_flag_overrides_config() {
let cfg = valid_config(); assert_eq!(cfg.effective_verbosity(false, true), VerbosityLevel::Debug);
}
#[test]
fn effective_verbosity_no_flags_uses_config_preference() {
let cfg = Config {
github_username: "cyrusae".to_string(),
tangled_username: "atdot.fyi".to_string(),
origin_preference: OriginPreference::Github,
verbosity_preference: VerbosityLevel::Quiet,
};
assert_eq!(cfg.effective_verbosity(false, false), VerbosityLevel::Quiet);
}
}