use config;
use inquire::Select;
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
env,
io::Write,
path::{Path, PathBuf},
};
use crate::{
errors::{ConfigError, GitError, Result, RonaError},
git::get_top_level_path,
utils::print_error,
};
#[derive(Debug, Clone)]
pub struct ConfigSource {
pub path: PathBuf,
pub exists: bool,
pub description: String,
pub priority: u8,
}
#[derive(Debug)]
pub struct ConfigInfo {
pub sources: Vec<ConfigSource>,
pub effective_config: Option<ProjectConfig>,
pub search_directory: PathBuf,
}
const DEFAULT_COMMIT_TYPES: &[&str] = &["feat", "fix", "docs", "test", "chore"];
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ProjectConfig {
pub editor: Option<String>,
pub commit_types: Option<Vec<String>>,
pub template: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_fields: Vec<crate::extra_fields::ExtraField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub field_order: Vec<String>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
editor: Some("nano".to_string()),
commit_types: Some(
DEFAULT_COMMIT_TYPES
.iter()
.map(std::string::ToString::to_string)
.collect(),
),
template: Some(
"{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}".to_string(),
),
extra_fields: vec![],
field_order: vec![],
}
}
}
impl ProjectConfig {
pub fn load() -> Result<Self> {
if cfg!(test) {
return Ok(Self::default());
}
let settings = {
let mut builder = config::Config::builder();
let home = dirs::home_dir().ok_or(ConfigError::ConfigNotFound)?;
let old_global = home.join(".config/rona/config.toml");
let new_global = home.join(".config/rona.toml");
if old_global.exists() {
builder = builder.add_source(config::File::from(old_global).required(false));
}
if new_global.exists() {
builder = builder.add_source(config::File::from(new_global).required(false));
}
let project_config_path = env::current_dir()?.join(".rona.toml");
if project_config_path.exists() {
let mut visited = HashSet::new();
for extended in collect_extends_chain(&project_config_path, &mut visited)? {
builder = builder.add_source(config::File::from(extended).required(false));
}
builder =
builder.add_source(config::File::from(project_config_path).required(false));
}
builder.build().map_err(|_| ConfigError::ConfigNotFound)?
};
match settings.try_deserialize() {
Ok(config) => Ok(config),
Err(e) => {
eprintln!("Failed to deserialize config: {e}");
Err(ConfigError::InvalidConfig.into())
}
}
}
pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Err(ConfigError::ConfigNotFound.into());
}
let mut builder = config::Config::builder();
let mut visited = HashSet::new();
for extended in collect_extends_chain(path, &mut visited)? {
builder = builder.add_source(config::File::from(extended).required(false));
}
builder = builder.add_source(config::File::from(path).required(true));
let settings = builder.build().map_err(|_| ConfigError::ConfigNotFound)?;
match settings.try_deserialize() {
Ok(config) => Ok(config),
Err(e) => {
eprintln!("Failed to deserialize config: {e}");
Err(ConfigError::InvalidConfig.into())
}
}
}
pub fn load_from_dir(from_dir: &std::path::Path) -> Result<Self> {
let settings = {
let mut builder = config::Config::builder();
let home = dirs::home_dir().ok_or(ConfigError::ConfigNotFound)?;
let old_global = home.join(".config/rona/config.toml");
let new_global = home.join(".config/rona.toml");
if old_global.exists() {
builder = builder.add_source(config::File::from(old_global).required(false));
}
if new_global.exists() {
builder = builder.add_source(config::File::from(new_global).required(false));
}
let project_config_path = from_dir.join(".rona.toml");
if project_config_path.exists() {
let mut visited = HashSet::new();
for extended in collect_extends_chain(&project_config_path, &mut visited)? {
builder = builder.add_source(config::File::from(extended).required(false));
}
builder =
builder.add_source(config::File::from(project_config_path).required(false));
}
builder.build().map_err(|_| ConfigError::ConfigNotFound)?
};
match settings.try_deserialize() {
Ok(config) => Ok(config),
Err(e) => {
eprintln!("Failed to deserialize config: {e}");
Err(ConfigError::InvalidConfig.into())
}
}
}
}
#[derive(Deserialize)]
struct ExtendsOnly {
extends: Option<String>,
}
fn resolve_extends_path(extends_value: &str, declaring_config: &Path) -> PathBuf {
let p = Path::new(extends_value);
if p.is_absolute() {
p.to_path_buf()
} else {
declaring_config
.parent()
.unwrap_or_else(|| Path::new("."))
.join(p)
}
}
fn collect_extends_chain(
config_path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Vec<PathBuf>> {
let canonical = config_path
.canonicalize()
.unwrap_or_else(|_| config_path.to_path_buf());
if !visited.insert(canonical) {
return Err(ConfigError::CircularExtends {
path: config_path.display().to_string(),
}
.into());
}
if !config_path.exists() {
return Err(ConfigError::ExtendsNotFound {
path: config_path.display().to_string(),
}
.into());
}
let content = std::fs::read_to_string(config_path)?;
let extends_only: ExtendsOnly =
toml::from_str(&content).unwrap_or(ExtendsOnly { extends: None });
let Some(extends_str) = extends_only.extends else {
return Ok(vec![]);
};
let extended_path = resolve_extends_path(&extends_str, config_path);
let mut chain = collect_extends_chain(&extended_path, visited)?;
chain.push(extended_path);
Ok(chain)
}
pub fn find_config_sources(from_dir: Option<&std::path::Path>) -> Result<ConfigInfo> {
let search_dir = match from_dir {
Some(dir) => dir.to_path_buf(),
None => env::current_dir()?,
};
let home = dirs::home_dir().ok_or(ConfigError::ConfigNotFound)?;
let mut sources = Vec::new();
let old_global = home.join(".config/rona/config.toml");
sources.push(ConfigSource {
path: old_global.clone(),
exists: old_global.exists(),
description: "Legacy global config".to_string(),
priority: 1,
});
let new_global = home.join(".config/rona.toml");
sources.push(ConfigSource {
path: new_global.clone(),
exists: new_global.exists(),
description: "Global config".to_string(),
priority: 2,
});
let project_config = search_dir.join(".rona.toml");
if project_config.exists() {
let chain = collect_extends_chain(&project_config, &mut HashSet::new()).unwrap_or_default();
for (i, extended_path) in chain.iter().enumerate() {
sources.push(ConfigSource {
path: extended_path.clone(),
exists: extended_path.exists(),
description: format!("Extended config ({})", i + 1),
priority: 3,
});
}
}
sources.push(ConfigSource {
path: project_config.clone(),
exists: project_config.exists(),
description: "Project config".to_string(),
priority: 4,
});
let effective_config = if cfg!(test) {
Some(ProjectConfig::default())
} else {
ProjectConfig::load_from_dir(&search_dir).ok()
};
Ok(ConfigInfo {
sources,
effective_config,
search_directory: search_dir,
})
}
#[derive(Debug)]
pub struct Config {
root: PathBuf,
pub(crate) verbose: bool,
pub(crate) dry_run: bool,
pub project_config: ProjectConfig,
}
impl Config {
pub fn new() -> Result<Self> {
let root = Self::get_config_root()?;
let project_config = ProjectConfig::load().unwrap_or_default();
let config = Self {
root,
verbose: false,
dry_run: false,
project_config,
};
Ok(config)
}
pub fn with_root(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let project_config = ProjectConfig::load().unwrap_or_default();
Self {
root,
verbose: false,
dry_run: false,
project_config,
}
}
pub fn new_with_config_file(path: &std::path::Path) -> Result<Self> {
let root = Self::get_config_root()?;
let project_config = ProjectConfig::load_from_file(path)?;
Ok(Self {
root,
verbose: false,
dry_run: false,
project_config,
})
}
pub const fn set_verbose(&mut self, verbose: bool) {
self.verbose = verbose;
}
pub const fn set_dry_run(&mut self, dry_run: bool) {
self.dry_run = dry_run;
}
pub fn get_editor(&self) -> Result<String> {
if cfg!(test) {
use regex::Regex;
let config_file = self.get_config_file_path()?;
if !config_file.exists() {
return Err(ConfigError::InvalidConfig.into());
}
let config_content = std::fs::read_to_string(&config_file)?;
let regex =
Regex::new(r#"editor\s*=\s*"(.*?)""#).map_err(|_| ConfigError::InvalidConfig)?;
let editor = regex
.captures(config_content.trim())
.and_then(|captures| captures.get(1))
.map(|match_| match_.as_str().to_string())
.ok_or(ConfigError::InvalidConfig)?;
return Ok(editor.trim().to_string());
}
self.project_config
.editor
.clone()
.ok_or_else(|| ConfigError::InvalidConfig.into())
}
pub fn set_editor(&self, editor: &str) -> Result<()> {
if cfg!(test) {
let config_file = self.get_config_file_path()?;
if !config_file.exists() {
return Err(ConfigError::ConfigNotFound.into());
}
let config_content = format!("editor = \"{editor}\"");
std::fs::write(&config_file, config_content)?;
return Ok(());
}
let options = vec!["Project (./.rona.toml)", "Global (~/.config/rona.toml)"];
let selection = Select::new("Where do you want to set the editor?", options)
.with_starting_cursor(0)
.prompt()
.map_err(|_| ConfigError::InvalidConfig)?;
let config_path = match selection {
"Project (./.rona.toml)" => get_top_level_path().map(|root| root.join(".rona.toml"))?,
"Global (~/.config/rona.toml)" => {
let home = dirs::home_dir().ok_or(ConfigError::ConfigNotFound)?;
home.join(".config/rona.toml")
}
_ => unreachable!(),
};
let mut config = self.project_config.clone();
config.editor = Some(editor.to_string());
let toml_str = toml::to_string_pretty(&config).map_err(|_| ConfigError::InvalidConfig)?;
let mut file = std::fs::File::create(&config_path)?;
file.write_all(toml_str.as_bytes())?;
println!("Editor set in: {}", config_path.display());
Ok(())
}
pub fn create_config_file(&self, editor: &str) -> Result<()> {
if cfg!(test) {
let config_folder = self.get_config_folder_path()?;
if !config_folder.exists() {
std::fs::create_dir_all(config_folder)?;
}
let config_file = self.get_config_file_path()?;
let config_content = format!("editor = \"{editor}\"");
if config_file.exists() {
return Err(ConfigError::ConfigAlreadyExists.into());
}
std::fs::write(&config_file, config_content)?;
return Ok(());
}
let options = vec!["Project (.rona.toml)", "Global (~/.config/rona.toml)"];
let selection = Select::new("Where do you want to initialize the config?", options)
.with_starting_cursor(0)
.prompt()
.map_err(|_| ConfigError::InvalidConfig)?;
let config_path = match selection {
"Project (.rona.toml)" => env::current_dir()?.join(".rona.toml"),
"Global (~/.config/rona.toml)" => {
let home = dirs::home_dir().ok_or(ConfigError::ConfigNotFound)?;
home.join(".config/rona.toml")
}
_ => unreachable!(),
};
let config_folder = config_path.parent().ok_or(ConfigError::ConfigNotFound)?;
if !config_folder.exists() {
std::fs::create_dir_all(config_folder)?;
}
if config_path.exists() {
if !cfg!(test) {
print_error(
"Configuration file already exists.",
&format!(
"A configuration file already exists at {}",
config_path.display()
),
"Use `rona --set-editor <editor>` (or `rona -s <editor>`) to change it.",
);
}
return Err(ConfigError::ConfigAlreadyExists.into());
}
let mut config = self.project_config.clone();
config.editor = Some(editor.to_string());
let toml_str = toml::to_string_pretty(&config).map_err(|_| ConfigError::InvalidConfig)?;
std::fs::write(&config_path, toml_str)?;
Ok(())
}
pub fn get_config_folder_path(&self) -> Result<PathBuf> {
let config_folder_path = self.root.join(".config").join("rona");
Ok(config_folder_path)
}
pub fn get_config_file_path(&self) -> Result<PathBuf> {
let config_folder_path = self.get_config_folder_path()?;
Ok(config_folder_path.join("config.toml"))
}
fn get_config_root() -> Result<PathBuf> {
if env::var("RONA_TEST_DIR").is_ok() || cfg!(test) {
Ok(PathBuf::from(CONFIG_FOLDER_NAME))
} else {
let root = env::var("HOME")
.or_else(|_| env::var("USERPROFILE"))
.map_err(|_| RonaError::from(GitError::RepositoryNotFound))?;
Ok(PathBuf::from(root))
}
}
}
pub const CONFIG_FOLDER_NAME: &str = "rona-test-config";
#[cfg(test)]
mod tests {
use crate::errors::RonaError;
use super::*;
use tempfile::TempDir;
#[test]
fn test_create_config_file() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
let editor = "test_editor";
config.create_config_file(editor)?;
let config_file = config.get_config_file_path()?;
assert!(config_file.exists());
let content = std::fs::read_to_string(&config_file)?;
assert_eq!(content, format!("editor = \"{editor}\""));
assert!(config.create_config_file(editor).is_err());
Ok(())
}
#[test]
fn test_get_editor() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
let editor = "nano";
config.create_config_file(editor)?;
let val = config.get_editor()?;
assert_eq!(val, editor);
Ok(())
}
#[test]
fn test_set_editor() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
let initial_editor = "vim";
config.create_config_file(initial_editor)?;
let new_editor = "emacs";
config.set_editor(new_editor)?;
let val = config.get_editor()?;
assert_eq!(val, new_editor);
Ok(())
}
#[test]
fn test_get_editor_error_no_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
assert!(matches!(
config.get_editor(),
Err(RonaError::Config(ConfigError::InvalidConfig))
));
Ok(())
}
#[test]
fn test_set_editor_error_no_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
assert!(matches!(
config.set_editor("vim"),
Err(RonaError::Config(ConfigError::ConfigNotFound))
));
Ok(())
}
#[test]
fn test_malformed_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let config = Config::with_root(temp_dir.path().to_path_buf());
let config_folder = config.get_config_folder_path()?;
std::fs::create_dir_all(&config_folder)?;
let config_file = config.get_config_file_path()?;
std::fs::write(&config_file, "editor = missing_quotes")?;
assert!(matches!(
config.get_editor(),
Err(RonaError::Config(ConfigError::InvalidConfig))
));
Ok(())
}
#[test]
fn test_extends_basic() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path().join("base.toml");
let project = temp_dir.path().join(".rona.toml");
std::fs::write(&base, r#"editor = "vim""#)?;
std::fs::write(
&project,
format!(r#"extends = "base.toml"{}"#, "\ncommit_types = [\"feat\"]"),
)?;
let cfg = ProjectConfig::load_from_file(&project)?;
assert_eq!(cfg.editor.as_deref(), Some("vim"));
assert_eq!(
cfg.commit_types.as_deref(),
Some(["feat".to_string()].as_slice())
);
Ok(())
}
#[test]
fn test_extends_override() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let base = temp_dir.path().join("base.toml");
let project = temp_dir.path().join(".rona.toml");
std::fs::write(&base, r#"editor = "vim""#)?;
std::fs::write(
&project,
format!(r#"extends = "base.toml"{}"#, "\neditor = \"nano\""),
)?;
let cfg = ProjectConfig::load_from_file(&project)?;
assert_eq!(cfg.editor.as_deref(), Some("nano"));
Ok(())
}
#[test]
fn test_extends_chain() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let grandparent = temp_dir.path().join("grandparent.toml");
let parent = temp_dir.path().join("parent.toml");
let project = temp_dir.path().join(".rona.toml");
std::fs::write(&grandparent, r#"editor = "vim""#)?;
std::fs::write(&parent, r#"extends = "grandparent.toml""#)?;
std::fs::write(
&project,
format!(r#"extends = "parent.toml"{}"#, "\ncommit_types = [\"fix\"]"),
)?;
let cfg = ProjectConfig::load_from_file(&project)?;
assert_eq!(cfg.editor.as_deref(), Some("vim"));
assert_eq!(
cfg.commit_types.as_deref(),
Some(["fix".to_string()].as_slice())
);
Ok(())
}
#[test]
fn test_extends_missing_file() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let project = temp_dir.path().join(".rona.toml");
std::fs::write(&project, r#"extends = "nonexistent.toml""#)?;
let result = ProjectConfig::load_from_file(&project);
assert!(
matches!(
result,
Err(RonaError::Config(ConfigError::ExtendsNotFound { .. }))
),
"expected ExtendsNotFound, got {result:?}"
);
Ok(())
}
#[test]
fn test_extends_circular() -> std::result::Result<(), Box<dyn std::error::Error>> {
let temp_dir = TempDir::new()?;
let a = temp_dir.path().join("a.toml");
let b = temp_dir.path().join("b.toml");
std::fs::write(&a, r#"extends = "b.toml""#)?;
std::fs::write(&b, r#"extends = "a.toml""#)?;
let result = ProjectConfig::load_from_file(&a);
assert!(
matches!(
result,
Err(RonaError::Config(ConfigError::CircularExtends { .. }))
),
"expected CircularExtends, got {result:?}"
);
Ok(())
}
}