mod colors;
mod completion;
mod editor;
mod experimental;
mod history;
mod mode;
pub(crate) mod prompt;
mod r;
mod startup;
pub use colors::{ColorsConfig, MetaColorConfig, RColorConfig, StatusColorConfig, ViColorConfig};
pub use completion::CompletionConfig;
pub use editor::{AutoSuggestions, EditorConfig, EditorMode};
pub use experimental::{
ExperimentalConfig, HistoryForgetConfig, PromptDurationConfig, SpinnerConfig,
};
pub use history::HistoryConfig;
pub use mode::ModeConfig;
#[allow(unused_imports)]
pub use prompt::{
Indicators, ModeIndicatorPosition, PromptConfig, StatusConfig, StatusSymbol, ViConfig,
};
pub use r::RConfig;
pub use startup::{RSource, RSourceMode, RSourceStatus, StartupConfig};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigStatus {
Ok,
ReadError,
ParseError,
}
#[derive(Debug)]
pub enum ConfigLoadError {
Read {
source: std::io::Error,
path: PathBuf,
},
Parse {
source: toml::de::Error,
path: PathBuf,
},
}
impl std::fmt::Display for ConfigLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigLoadError::Read { source, path } => {
write!(
f,
"Failed to read config file {}: {}",
path.display(),
source
)
}
ConfigLoadError::Parse { source, path } => {
write!(
f,
"Failed to parse config file {}: {}",
path.display(),
source
)
}
}
}
}
impl std::error::Error for ConfigLoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigLoadError::Read { source, .. } => Some(source),
ConfigLoadError::Parse { source, .. } => Some(source),
}
}
}
const APP_NAME: &str = "arf";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub startup: StartupConfig,
pub editor: EditorConfig,
pub prompt: PromptConfig,
pub completion: CompletionConfig,
pub history: HistoryConfig,
pub r: RConfig,
pub mode: ModeConfig,
pub colors: ColorsConfig,
#[serde(default)]
pub experimental: ExperimentalConfig,
}
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join(APP_NAME))
}
pub fn data_dir() -> Option<PathBuf> {
dirs::data_dir().map(|p| p.join(APP_NAME))
}
pub fn cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|p| p.join(APP_NAME))
}
pub fn config_file_path() -> Option<PathBuf> {
config_dir().map(|p| p.join("arf.toml"))
}
pub fn history_dir() -> Option<PathBuf> {
data_dir().map(|p| p.join("history"))
}
pub fn mask_home_path(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(stripped) = path.strip_prefix(&home)
{
return format!("~{}{}", std::path::MAIN_SEPARATOR, stripped.display());
}
path.display().to_string()
}
pub fn load_config() -> Result<Config, ConfigLoadError> {
let Some(config_path) = config_file_path() else {
return Ok(Config::default());
};
if !config_path.exists() {
return Ok(Config::default());
}
load_config_from_path(&config_path)
}
pub fn load_config_from_path(path: &std::path::Path) -> Result<Config, ConfigLoadError> {
if !path.exists() {
log::warn!("Config file not found: {:?}", path);
return Ok(Config::default());
}
let content = fs::read_to_string(path).map_err(|e| ConfigLoadError::Read {
source: e,
path: path.to_path_buf(),
})?;
toml::from_str(&content).map_err(|e| ConfigLoadError::Parse {
source: e,
path: path.to_path_buf(),
})
}
pub fn generate_default_config() -> String {
let config = Config::default();
let toml_content = toml::to_string_pretty(&config).expect("Failed to serialize default config");
let header = r#"#:schema https://raw.githubusercontent.com/eitsupi/arf/main/artifacts/arf.schema.json
# arf configuration file
#
# Documentation: https://github.com/eitsupi/arf
"#;
format!("{}{}", header, toml_content)
}
pub fn init_config(force: bool) -> anyhow::Result<std::path::PathBuf> {
let config_path = config_file_path()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
if config_path.exists() && !force {
anyhow::bail!(
"Configuration file already exists at: {}\nUse --force to overwrite.",
config_path.display()
);
}
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let content = generate_default_config();
fs::write(&config_path, content)?;
Ok(config_path)
}
pub fn ensure_directories() -> anyhow::Result<()> {
if let Some(dir) = config_dir() {
fs::create_dir_all(&dir)?;
}
if let Some(dir) = data_dir() {
fs::create_dir_all(&dir)?;
}
if let Some(dir) = cache_dir() {
fs::create_dir_all(&dir)?;
}
Ok(())
}
#[allow(dead_code)]
pub mod schema {
use super::Config;
use schemars::schema_for;
use std::path::PathBuf;
const ARTIFACTS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../artifacts");
pub fn generate_schema() -> String {
let schema = schema_for!(Config);
serde_json::to_string_pretty(&schema).expect("Failed to serialize schema")
}
pub fn schema_path() -> PathBuf {
PathBuf::from(ARTIFACTS_DIR).join("arf.schema.json")
}
pub fn write_schema() -> std::io::Result<()> {
let schema = generate_schema();
let path = schema_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, schema)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crokey::KeyCombination;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(
config.editor.auto_match,
"auto_match should be enabled by default"
);
assert_eq!(config.editor.mode, EditorMode::Emacs);
assert!(matches!(
config.startup.r_source,
RSource::Mode(RSourceMode::Auto)
));
assert!(config.startup.show_banner);
}
#[test]
fn test_default_r_auto_width() {
let config = Config::default();
assert!(
config.r.auto_width,
"auto_width should be enabled by default"
);
}
#[test]
fn test_parse_r_auto_width_disabled() {
let toml_str = r#"
[r]
auto_width = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.r.auto_width);
}
#[test]
fn test_parse_r_auto_width_default_when_omitted() {
let toml_str = r#"
[editor]
mode = "vi"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
config.r.auto_width,
"auto_width should default to true when [r] section is omitted"
);
}
#[test]
fn test_parse_config_with_auto_match_enabled() {
let toml_str = r#"
[editor]
auto_match = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.editor.auto_match);
}
#[test]
fn test_parse_config_with_auto_match_disabled() {
let toml_str = r#"
[editor]
auto_match = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.editor.auto_match);
}
#[test]
fn test_parse_startup_section_config() {
let toml_str = r##"
[startup]
r_source = "rig"
show_banner = false
[startup.mode]
reprex = true
autoformat = true
[editor]
mode = "vi"
auto_match = false
[prompt]
format = "R> "
continuation = ".. "
[completion]
enabled = true
timeout_ms = 100
[mode.reprex]
comment = "# "
"##;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(matches!(
config.startup.r_source,
RSource::Mode(RSourceMode::Rig)
));
assert!(!config.startup.show_banner);
assert_eq!(config.editor.mode, EditorMode::Vi);
assert!(!config.editor.auto_match);
assert_eq!(config.prompt.format, "R> ");
assert!(config.startup.mode.reprex);
assert!(config.startup.mode.autoformat);
assert_eq!(config.mode.reprex.comment, "# ");
}
#[test]
fn test_parse_new_key_map_config() {
let toml_str = r#"
[editor]
mode = "emacs"
[editor.key_map]
"alt-hyphen" = " <- "
"ctrl-shift-m" = " |> "
"alt-=" = " == "
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let alt_hyphen: KeyCombination = "alt-hyphen".parse().unwrap();
let ctrl_shift_m: KeyCombination = "ctrl-shift-m".parse().unwrap();
let alt_eq: KeyCombination = "alt-=".parse().unwrap();
assert_eq!(
config.editor.key_map.get(&alt_hyphen),
Some(&" <- ".to_string())
);
assert_eq!(
config.editor.key_map.get(&ctrl_shift_m),
Some(&" |> ".to_string())
);
assert_eq!(
config.editor.key_map.get(&alt_eq),
Some(&" == ".to_string())
);
}
#[test]
fn test_default_key_map() {
let config = Config::default();
let alt_hyphen: KeyCombination = "alt-hyphen".parse().unwrap();
let alt_p: KeyCombination = "alt-p".parse().unwrap();
assert_eq!(
config.editor.key_map.get(&alt_hyphen),
Some(&" <- ".to_string())
);
assert_eq!(config.editor.key_map.get(&alt_p), Some(&" |> ".to_string()));
}
#[test]
fn test_default_mode_indicator() {
let config = Config::default();
assert_eq!(config.prompt.mode_indicator, ModeIndicatorPosition::Prefix);
assert_eq!(config.prompt.indicators.reprex, "[reprex] ");
assert_eq!(config.prompt.indicators.autoformat, "[format] ");
}
#[test]
fn test_parse_mode_indicator_suffix() {
let toml_str = r#"
[prompt]
mode_indicator = "suffix"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.prompt.mode_indicator, ModeIndicatorPosition::Suffix);
}
#[test]
fn test_parse_mode_indicator_none() {
let toml_str = r#"
[prompt]
mode_indicator = "none"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.prompt.mode_indicator, ModeIndicatorPosition::None);
}
#[test]
fn test_parse_history_disabled() {
let toml_str = r#"
[history]
disabled = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.history.disabled);
}
#[test]
fn test_default_history_forget_config() {
let config = Config::default();
assert!(!config.experimental.history_forget.enabled);
assert_eq!(config.experimental.history_forget.delay, 2);
assert!(!config.experimental.history_forget.on_exit_only);
}
#[test]
fn test_parse_history_forget_config() {
let toml_str = r#"
[experimental.history_forget]
enabled = true
delay = 5
on_exit_only = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.experimental.history_forget.enabled);
assert_eq!(config.experimental.history_forget.delay, 5);
assert!(config.experimental.history_forget.on_exit_only);
}
#[test]
fn test_auto_suggestions_default_is_all() {
let config = Config::default();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::All);
}
#[test]
fn test_parse_auto_suggestions_none_string() {
let toml_str = r#"
[editor]
auto_suggestions = "none"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::None);
}
#[test]
fn test_parse_auto_suggestions_all_string() {
let toml_str = r#"
[editor]
auto_suggestions = "all"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::All);
}
#[test]
fn test_parse_auto_suggestions_cwd_string() {
let toml_str = r#"
[editor]
auto_suggestions = "cwd"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::Cwd);
}
#[test]
fn test_parse_auto_suggestions_bool_true() {
let toml_str = r#"
[editor]
auto_suggestions = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::All);
}
#[test]
fn test_parse_auto_suggestions_bool_false() {
let toml_str = r#"
[editor]
auto_suggestions = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.editor.auto_suggestions, AutoSuggestions::None);
}
#[test]
fn test_parse_auto_suggestions_invalid_string() {
let toml_str = r#"
[editor]
auto_suggestions = "invalid"
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("unknown variant"),
"Error should mention unknown variant: {}",
err
);
}
#[test]
fn test_parse_auto_suggestions_string_true_rejected() {
let toml_str = r#"
[editor]
auto_suggestions = "true"
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "String 'true' should be rejected");
}
#[test]
fn test_parse_auto_suggestions_string_false_rejected() {
let toml_str = r#"
[editor]
auto_suggestions = "false"
"#;
let result: Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err(), "String 'false' should be rejected");
}
#[test]
fn test_parse_r_source_auto() {
let toml_str = r#"
[startup]
r_source = "auto"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(matches!(
config.startup.r_source,
RSource::Mode(RSourceMode::Auto)
));
}
#[test]
fn test_parse_r_source_rig() {
let toml_str = r#"
[startup]
r_source = "rig"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(matches!(
config.startup.r_source,
RSource::Mode(RSourceMode::Rig)
));
}
#[test]
fn test_parse_r_source_path() {
let toml_str = r#"
[startup]
r_source = { path = "/opt/R/4.5.2" }
"#;
let config: Config = toml::from_str(toml_str).unwrap();
match &config.startup.r_source {
RSource::Path { path } => {
assert_eq!(path, &PathBuf::from("/opt/R/4.5.2"));
}
_ => panic!("Expected RSource::Path"),
}
}
#[test]
fn test_parse_r_source_default_when_omitted() {
let toml_str = r#"
[startup]
show_banner = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(matches!(
config.startup.r_source,
RSource::Mode(RSourceMode::Auto)
));
}
#[test]
fn test_generate_default_config() {
let config_str = generate_default_config();
assert!(config_str.starts_with(
"#:schema https://raw.githubusercontent.com/eitsupi/arf/main/artifacts/arf.schema.json"
));
let parsed: Config =
toml::from_str(&config_str).expect("Generated config should be valid TOML");
assert!(matches!(
parsed.startup.r_source,
RSource::Mode(RSourceMode::Auto)
));
assert!(parsed.startup.show_banner);
assert_eq!(parsed.editor.mode, EditorMode::Emacs);
}
#[test]
fn test_generate_default_config_has_new_structure() {
let config_str = generate_default_config();
assert!(
config_str.contains("[startup]"),
"Should have [startup] section"
);
assert!(
config_str.contains("r_source = "),
"Should have r_source in startup section"
);
assert!(
config_str.contains("show_banner = "),
"Should have show_banner in startup section"
);
assert!(
config_str.contains("[startup.mode]"),
"Should have [startup.mode] section"
);
assert!(
config_str.contains("[mode.reprex]"),
"Should have [mode.reprex] section"
);
assert!(
!config_str.contains("[general]"),
"Should NOT have [general] section"
);
assert!(
!config_str.contains("[shortcuts]"),
"Should NOT have [shortcuts] section"
);
assert!(
!config_str.contains("[formatter]"),
"Should NOT have [formatter] section"
);
assert!(
config_str.contains("[editor]"),
"Should have [editor] section"
);
}
mod schema_tests {
use crate::config::schema::{generate_schema, schema_path, write_schema};
#[test]
fn test_schema_snapshot() {
let schema = generate_schema();
insta::assert_snapshot!("config_schema", schema);
}
#[test]
fn test_default_config_snapshot() {
let config = crate::config::generate_default_config();
insta::assert_snapshot!("default_config", config);
}
#[test]
fn test_schema_matches_artifact() {
let schema = generate_schema();
let path = schema_path();
if path.exists() {
let contents = std::fs::read_to_string(&path).expect("Failed to read schema file");
assert_eq!(
schema, contents,
"Schema file is out of date. Run the generate_schema_file test to update."
);
}
}
#[test]
#[ignore]
fn generate_schema_file() {
write_schema().expect("Failed to write schema file");
println!("Schema written to {:?}", schema_path());
}
#[test]
fn test_schema_is_valid_json() {
let schema = generate_schema();
let parsed: serde_json::Value =
serde_json::from_str(&schema).expect("Schema should be valid JSON");
assert!(
parsed.get("$schema").is_some(),
"Schema should have $schema field"
);
assert!(
parsed.get("title").is_some(),
"Schema should have title field"
);
assert!(
parsed.get("type").is_some(),
"Schema should have type field"
);
assert!(
parsed.get("properties").is_some(),
"Schema should have properties field"
);
}
#[test]
fn test_schema_has_new_structure() {
let schema = generate_schema();
let parsed: serde_json::Value =
serde_json::from_str(&schema).expect("Schema should be valid JSON");
let properties = parsed
.get("properties")
.expect("Schema should have properties");
assert!(
properties.get("startup").is_some(),
"Schema should have startup section"
);
assert!(
properties.get("mode").is_some(),
"Schema should have mode section"
);
assert!(
properties.get("editor").is_some(),
"Schema should have editor section"
);
assert!(
properties.get("prompt").is_some(),
"Schema should have prompt section"
);
assert!(
properties.get("completion").is_some(),
"Schema should have completion section"
);
assert!(
properties.get("experimental").is_some(),
"Schema should have experimental section"
);
assert!(
properties.get("general").is_none(),
"Schema should NOT have general section"
);
assert!(
properties.get("reprex").is_none(),
"reprex should be in mode section, not top-level"
);
assert!(
properties.get("r_version").is_none(),
"r_version should be in startup section, not top-level"
);
assert!(
properties.get("show_banner").is_none(),
"show_banner should be in startup section, not top-level"
);
assert!(
properties.get("shortcuts").is_none(),
"Schema should NOT have shortcuts section"
);
assert!(
properties.get("formatter").is_none(),
"Schema should NOT have formatter section"
);
}
}
#[test]
fn test_mask_home_path_with_home_prefix() {
if let Some(home) = dirs::home_dir() {
let test_path = home.join("test").join("file.txt");
let masked = mask_home_path(&test_path);
assert!(
masked.starts_with("~"),
"Path should start with ~: {}",
masked
);
assert!(
masked.contains("test"),
"Path should contain 'test': {}",
masked
);
assert!(
masked.contains("file.txt"),
"Path should contain 'file.txt': {}",
masked
);
}
}
#[test]
fn test_mask_home_path_without_home_prefix() {
let test_path = PathBuf::from("/opt/R/4.5.0");
let expected = test_path.display().to_string();
let masked = mask_home_path(&test_path);
assert_eq!(
masked, expected,
"Path without home prefix should be unchanged"
);
}
#[test]
fn test_mask_home_path_exact_home() {
if let Some(home) = dirs::home_dir() {
let masked = mask_home_path(&home);
assert!(
masked.starts_with("~"),
"Home path should start with ~: {}",
masked
);
}
}
#[test]
fn test_load_config_from_path_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "[editor\nauto_match = true").unwrap();
let result = load_config_from_path(&path);
assert!(result.is_err(), "Invalid TOML should return Err");
let err = result.unwrap_err();
assert!(
matches!(err, ConfigLoadError::Parse { .. }),
"Should be a ParseError: {:?}",
err
);
let msg = err.to_string();
assert!(
msg.contains("bad.toml"),
"Error should mention the file path: {}",
msg
);
}
#[test]
fn test_load_config_from_path_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("good.toml");
std::fs::write(
&path,
r#"
[editor]
auto_match = false
"#,
)
.unwrap();
let result = load_config_from_path(&path);
assert!(result.is_ok(), "Valid TOML should return Ok");
let config = result.unwrap();
assert!(!config.editor.auto_match);
}
#[test]
fn test_load_config_from_path_not_found() {
let path = PathBuf::from("/nonexistent/config.toml");
let result = load_config_from_path(&path);
assert!(result.is_ok(), "Missing file should return Ok(default)");
let config = result.unwrap();
assert!(config.editor.auto_match, "Should be default config");
}
#[test]
fn test_config_load_error_display() {
let err = ConfigLoadError::Parse {
source: toml::from_str::<Config>("[bad\n").unwrap_err(),
path: PathBuf::from("/home/user/.config/arf/arf.toml"),
};
let msg = err.to_string();
assert!(msg.contains("parse"), "Should mention parse: {}", msg);
assert!(msg.contains("arf.toml"), "Should mention path: {}", msg);
}
#[cfg(unix)]
#[test]
fn test_load_config_from_path_read_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("unreadable.toml");
std::fs::write(&path, "[editor]\nauto_match = true").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = load_config_from_path(&path);
assert!(result.is_err(), "Unreadable file should return Err");
let err = result.unwrap_err();
assert!(
matches!(err, ConfigLoadError::Read { .. }),
"Should be a ReadError: {:?}",
err
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
}
}