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 commit_template: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commit_extra_fields: Vec<crate::extra_fields::ExtraField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commit_fields_order: Vec<String>,
pub branch_template: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branch_extra_fields: Vec<crate::extra_fields::ExtraField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branch_field_order: Vec<String>,
pub branch_types: Option<Vec<String>>,
#[serde(default)]
pub merge_branch_and_commit_types: bool,
pub message_prefetch: Option<crate::extra_fields::MessagePrefetchConfig>,
pub commit_message: Option<crate::extra_fields::BuiltInFieldConfig>,
pub branch_description: Option<crate::extra_fields::BuiltInFieldConfig>,
}
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(),
),
commit_template: Some(
"{?commit_number}[{commit_number}] {/commit_number}({commit_type} on {branch_name}) {message}".to_string(),
),
commit_extra_fields: vec![],
commit_fields_order: vec![],
branch_template: Some("{branch_type}/{description}".to_string()),
branch_extra_fields: vec![],
branch_field_order: vec![],
branch_types: None,
merge_branch_and_commit_types: false,
message_prefetch: None,
commit_message: None,
branch_description: None,
}
}
}
#[derive(serde::Deserialize, Default)]
struct RawProjectConfig {
editor: Option<String>,
commit_types: Option<Vec<String>>,
commit_template: Option<String>,
template: Option<String>,
commit_extra_fields: Option<Vec<crate::extra_fields::ExtraField>>,
extra_fields: Option<Vec<crate::extra_fields::ExtraField>>,
commit_fields_order: Option<Vec<String>>,
field_order: Option<Vec<String>>,
branch_template: Option<String>,
branch_extra_fields: Option<Vec<crate::extra_fields::ExtraField>>,
branch_field_order: Option<Vec<String>>,
branch_types: Option<Vec<String>>,
merge_branch_and_commit_types: Option<bool>,
message_prefetch: Option<crate::extra_fields::MessagePrefetchConfig>,
commit_message: Option<crate::extra_fields::BuiltInFieldConfig>,
branch_description: Option<crate::extra_fields::BuiltInFieldConfig>,
}
impl From<RawProjectConfig> for ProjectConfig {
fn from(raw: RawProjectConfig) -> Self {
Self {
editor: raw.editor,
commit_types: raw.commit_types,
commit_template: raw.commit_template,
commit_extra_fields: raw.commit_extra_fields.unwrap_or_default(),
commit_fields_order: raw.commit_fields_order.unwrap_or_default(),
branch_template: raw.branch_template,
branch_extra_fields: raw.branch_extra_fields.unwrap_or_default(),
branch_field_order: raw.branch_field_order.unwrap_or_default(),
branch_types: raw.branch_types,
merge_branch_and_commit_types: raw.merge_branch_and_commit_types.unwrap_or(false),
message_prefetch: raw.message_prefetch,
commit_message: raw.commit_message,
branch_description: raw.branch_description,
}
}
}
fn normalize_raw(mut raw: RawProjectConfig) -> RawProjectConfig {
if raw.commit_template.is_none() {
raw.commit_template = raw.template.take();
}
raw.template = None;
if raw.commit_extra_fields.is_none() {
raw.commit_extra_fields = raw.extra_fields.take();
}
raw.extra_fields = None;
if raw.commit_fields_order.is_none() {
raw.commit_fields_order = raw.field_order.take();
}
raw.field_order = None;
raw
}
fn merge_named_fields(
base: Option<Vec<crate::extra_fields::ExtraField>>,
child: Option<Vec<crate::extra_fields::ExtraField>>,
) -> Option<Vec<crate::extra_fields::ExtraField>> {
match (base, child) {
(None, c) => c,
(b, None) => b,
(Some(mut base_fields), Some(child_fields)) => {
for child_field in child_fields {
if let Some(existing) = base_fields.iter_mut().find(|f| f.name == child_field.name)
{
*existing = child_field;
} else {
base_fields.push(child_field);
}
}
Some(base_fields)
}
}
}
fn merge_raw(base: RawProjectConfig, child: RawProjectConfig) -> RawProjectConfig {
RawProjectConfig {
editor: child.editor.or(base.editor),
commit_types: child.commit_types.or(base.commit_types),
commit_template: child.commit_template.or(base.commit_template),
template: None,
commit_extra_fields: merge_named_fields(
base.commit_extra_fields,
child.commit_extra_fields,
),
extra_fields: None,
commit_fields_order: child.commit_fields_order.or(base.commit_fields_order),
field_order: None,
branch_template: child.branch_template.or(base.branch_template),
branch_extra_fields: merge_named_fields(
base.branch_extra_fields,
child.branch_extra_fields,
),
branch_field_order: child.branch_field_order.or(base.branch_field_order),
branch_types: child.branch_types.or(base.branch_types),
merge_branch_and_commit_types: child
.merge_branch_and_commit_types
.or(base.merge_branch_and_commit_types),
message_prefetch: child.message_prefetch.or(base.message_prefetch),
commit_message: child.commit_message.or(base.commit_message),
branch_description: child.branch_description.or(base.branch_description),
}
}
fn load_single_raw_file(path: &Path) -> Result<RawProjectConfig> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| {
RonaError::Config(ConfigError::ParseError {
file: path.display().to_string(),
reason: e.to_string(),
})
})
}
fn load_and_merge_files(paths: &[PathBuf]) -> Result<RawProjectConfig> {
let mut result = RawProjectConfig::default();
for path in paths {
if path.exists() {
let raw = normalize_raw(load_single_raw_file(path)?);
result = merge_raw(result, raw);
}
}
Ok(result)
}
impl ProjectConfig {
pub fn load() -> Result<Self> {
if cfg!(test) {
return Ok(Self::default());
}
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");
let mut paths: Vec<PathBuf> = Vec::new();
if old_global.exists() {
paths.push(old_global);
}
if new_global.exists() {
paths.push(new_global);
}
let project_config_path = env::current_dir()?.join(".rona.toml");
if project_config_path.exists() {
let mut visited = HashSet::new();
paths.extend(collect_extends_chain(&project_config_path, &mut visited)?);
paths.push(project_config_path);
}
load_and_merge_files(&paths).map(Into::into).map_err(|e| {
eprintln!("Failed to deserialize config: {e}");
e
})
}
pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
if !path.exists() {
return Err(ConfigError::ConfigNotFound.into());
}
let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut visited = HashSet::new();
let mut paths: Vec<PathBuf> = collect_extends_chain(&abs_path, &mut visited)?;
paths.push(abs_path);
load_and_merge_files(&paths).map(Into::into)
}
pub fn load_from_dir(from_dir: &std::path::Path) -> Result<Self> {
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");
let mut paths: Vec<PathBuf> = Vec::new();
if old_global.exists() {
paths.push(old_global);
}
if new_global.exists() {
paths.push(new_global);
}
let project_config_path = from_dir.join(".rona.toml");
if project_config_path.exists() {
let mut visited = HashSet::new();
paths.extend(collect_extends_chain(&project_config_path, &mut visited)?);
paths.push(project_config_path);
}
load_and_merge_files(&paths).map(Into::into).map_err(|e| {
eprintln!("Failed to deserialize config: {e}");
e
})
}
}
#[derive(Deserialize)]
struct ExtendsOnly {
extends: Option<String>,
}
fn resolve_extends_path(extends_value: &str, declaring_config: &Path) -> PathBuf {
let expanded = extends_value.strip_prefix("~/").map_or_else(
|| PathBuf::from(extends_value),
|rest| dirs::home_dir().map_or_else(|| PathBuf::from(extends_value), |h| h.join(rest)),
);
if expanded.is_absolute() {
expanded
} else {
declaring_config
.parent()
.unwrap_or_else(|| Path::new("."))
.join(expanded)
}
}
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(())
}
#[test]
fn test_extra_fields_merged_by_name() -> 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#"
[[commit_extra_fields]]
name = "scope"
prompt = "Scope (base)"
[[commit_extra_fields]]
name = "ticket"
prompt = "Ticket"
"#,
)?;
std::fs::write(
&project,
r#"
extends = "base.toml"
[[commit_extra_fields]]
name = "scope"
prompt = "Scope (project)"
"#,
)?;
let cfg = ProjectConfig::load_from_file(&project)?;
assert_eq!(
cfg.commit_extra_fields.len(),
2,
"both fields should be present"
);
let scope_prompt = cfg
.commit_extra_fields
.iter()
.find(|f| f.name == "scope")
.and_then(|f| f.prompt.as_deref());
let ticket = cfg.commit_extra_fields.iter().find(|f| f.name == "ticket");
assert!(
ticket.is_some(),
"ticket field should be preserved from base"
);
assert_eq!(
scope_prompt,
Some("Scope (project)"),
"scope prompt should be overridden by child"
);
Ok(())
}
#[test]
fn test_branch_extra_fields_merged_by_name()
-> 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#"
[[branch_extra_fields]]
name = "service"
prompt = "Service"
[[branch_extra_fields]]
name = "version"
prompt = "Version (base)"
"#,
)?;
std::fs::write(
&project,
r#"
extends = "base.toml"
[[branch_extra_fields]]
name = "version"
prompt = "Version (project)"
"#,
)?;
let cfg = ProjectConfig::load_from_file(&project)?;
assert_eq!(cfg.branch_extra_fields.len(), 2);
let version_prompt = cfg
.branch_extra_fields
.iter()
.find(|f| f.name == "version")
.and_then(|f| f.prompt.as_deref());
assert_eq!(version_prompt, Some("Version (project)"));
let service = cfg.branch_extra_fields.iter().find(|f| f.name == "service");
assert!(service.is_some(), "service should be preserved from base");
Ok(())
}
}