use std::path::{Path, PathBuf};
use crate::app::config::ConfigError;
const MAX_CONFIG_FILE_BYTES: u64 = 10 * 1024 * 1024;
pub(crate) fn default_config_path() -> Result<PathBuf, ConfigError> {
if let Ok(p) = std::env::var("RUNEX_CONFIG") {
if !p.is_empty() {
return Ok(PathBuf::from(p));
}
}
let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir);
Ok(dir.ok_or(ConfigError::NoConfigDir)?.join("runex").join("config.toml"))
}
pub(crate) fn read_config_source(path: &Path) -> Result<String, ConfigError> {
use std::io::Read;
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
let resolved = path.canonicalize()?;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(&resolved)?
};
#[cfg(not(unix))]
let mut file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config path must be a regular file",
)));
}
if meta.len() > MAX_CONFIG_FILE_BYTES {
return Err(ConfigError::FileTooLarge);
}
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
}
#[cfg(unix)]
fn open_config_for_append_safely(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
fn open_config_for_append_safely(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::OpenOptions::new().create(true).append(true).open(path)
}
fn atomically_write_config(path: &Path, contents: &str) -> Result<(), ConfigError> {
use std::io::Write;
let parent = path.parent().ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config path has no parent directory",
))
})?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"config path has no file name",
))
})?;
let tmp = parent.join(format!(".{file_name}.runex.tmp"));
let _ = std::fs::remove_file(&tmp);
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&tmp)
.map_err(ConfigError::Io)?
};
#[cfg(not(unix))]
let mut file = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.map_err(ConfigError::Io)?;
file.write_all(contents.as_bytes()).map_err(ConfigError::Io)?;
file.sync_all().map_err(ConfigError::Io)?;
drop(file);
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
ConfigError::Io(e)
})
}
pub(crate) fn append_abbr_block(
path: &Path,
key: &str,
expand: &str,
when_command_exists: Option<&[String]>,
) -> Result<(), ConfigError> {
let mut block = String::from("\n[[abbr]]\n");
block.push_str(&format!("key = {}\n", toml_quote(key)));
block.push_str(&format!("expand = {}\n", toml_quote(expand)));
if let Some(cmds) = when_command_exists {
let quoted: Vec<String> = cmds.iter().map(|c| toml_quote(c)).collect();
block.push_str(&format!("when_command_exists = [{}]\n", quoted.join(", ")));
}
use std::io::Write;
let mut file = open_config_for_append_safely(path).map_err(ConfigError::Io)?;
file.write_all(block.as_bytes()).map_err(ConfigError::Io)?;
Ok(())
}
pub(crate) fn remove_abbr_block(path: &Path, key: &str) -> Result<usize, ConfigError> {
let content = read_config_source(path)?;
let mut doc = content.parse::<toml_edit::DocumentMut>().map_err(|_| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"failed to parse config as editable TOML",
))
})?;
let removed = if let Some(toml_edit::Item::ArrayOfTables(arr)) = doc.get_mut("abbr") {
let before = arr.len();
let mut i = 0;
while i < arr.len() {
let matches = arr
.get(i)
.and_then(|t| t.get("key"))
.and_then(|v| v.as_str())
.map(|k| k == key)
.unwrap_or(false);
if matches {
arr.remove(i);
} else {
i += 1;
}
}
before - arr.len()
} else {
0
};
if removed > 0 {
atomically_write_config(path, &doc.to_string())?;
}
Ok(removed)
}
fn toml_quote(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::config::load_config;
use serial_test::serial;
#[test]
fn load_config_rejects_oversized_file() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(&vec![b'x'; 11 * 1024 * 1024]).unwrap();
f.flush().unwrap();
assert!(
load_config(f.path()).is_err(),
"must reject files larger than 10 MB"
);
}
#[test]
#[cfg(unix)]
fn load_config_rejects_symlink_to_dev_zero() {
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("fake_config.toml");
std::os::unix::fs::symlink("/dev/zero", &link).unwrap();
assert!(
load_config(&link).is_err(),
"load_config must reject a symlink to /dev/zero"
);
}
#[test]
#[cfg(unix)]
fn load_config_follows_symlink_to_regular_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.toml");
std::fs::write(&target, b"version = 1\n").unwrap();
let link = dir.path().join("link_config.toml");
std::os::unix::fs::symlink(&target, &link).unwrap();
let result = load_config(&link);
assert!(
result.is_ok(),
"load_config must follow a symlink to a regular file: {result:?}"
);
}
#[test]
#[cfg(unix)]
fn load_config_rejects_named_pipe() {
use std::ffi::CString;
let dir = tempfile::tempdir().unwrap();
let pipe = dir.path().join("fake_config.toml");
let path_c = CString::new(pipe.to_str().unwrap()).unwrap();
unsafe { libc::mkfifo(path_c.as_ptr(), 0o600) };
assert!(
load_config(&pipe).is_err(),
"load_config must reject a named pipe"
);
}
#[test]
fn load_config_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
version = 1
[[abbr]]
key = "gcm"
expand = "git commit -m"
"#,
)
.unwrap();
let config = load_config(&path).unwrap();
assert_eq!(config.version, 1);
assert_eq!(config.abbr[0].key, "gcm");
}
#[test]
#[serial]
fn default_config_path_env_override() {
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
unsafe { std::env::set_var("RUNEX_CONFIG", "/tmp/custom.toml") };
let path = default_config_path().unwrap();
unsafe { std::env::remove_var("RUNEX_CONFIG") };
assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
}
#[test]
#[serial]
fn default_config_path_uses_xdg_config_home() {
unsafe { std::env::remove_var("RUNEX_CONFIG") };
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-runex-test") };
let path = default_config_path().unwrap();
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(
path,
PathBuf::from("/tmp/xdg-runex-test/runex/config.toml")
);
}
#[test]
#[serial]
fn default_config_path_ignores_empty_runex_config() {
unsafe { std::env::set_var("RUNEX_CONFIG", "") };
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-empty-test") };
let path = default_config_path().unwrap();
unsafe { std::env::remove_var("RUNEX_CONFIG") };
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(
path,
PathBuf::from("/tmp/xdg-empty-test/runex/config.toml"),
"empty RUNEX_CONFIG must fall through to XDG resolution"
);
}
#[test]
#[serial]
fn xdg_config_home_with_system_resolver_uses_env_var() {
unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test") };
let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir).unwrap();
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
assert_eq!(dir, PathBuf::from("/tmp/xdg-test"));
}
#[test]
#[serial]
fn xdg_config_home_with_system_resolver_empty_env_falls_back() {
unsafe { std::env::set_var("XDG_CONFIG_HOME", "") };
let dir = crate::infra::env::xdg_config_home_with(&crate::infra::env::SystemHomeDir).unwrap();
unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
assert!(
dir.ends_with(".config"),
"expected ~/.config fallback, got {dir:?}"
);
}
}