use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use crate::cli::submit::PrMode;
use crate::cli::submit::SyncPrContent;
use crate::forge::comment::StackPlacement;
pub fn pre_parse_config_path() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--config" {
if let Some(value) = args.next() {
return Some(PathBuf::from(value));
}
} else if let Some(value) = arg.strip_prefix("--config=") {
return Some(PathBuf::from(value));
}
}
std::env::var("STAKK_CONFIG").ok().map(PathBuf::from)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_true")]
pub inherit: bool,
pub remote: Option<String>,
pub pr_mode: Option<PrMode>,
pub template: Option<String>,
pub stack_placement: Option<StackPlacement>,
pub sync_pr_content: Option<SyncPrContent>,
pub auto_prefix: Option<String>,
pub bookmark_command: Option<String>,
pub bookmarks_revset: Option<String>,
pub heads_revset: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
inherit: true,
remote: None,
pr_mode: None,
template: None,
stack_placement: None,
sync_pr_content: None,
auto_prefix: None,
bookmark_command: None,
bookmarks_revset: None,
heads_revset: None,
}
}
}
fn default_true() -> bool {
true
}
impl Config {
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
let contents = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => {
return Err(ConfigError::ReadFailed {
path: path.display().to_string(),
source: e,
});
}
};
toml::from_str(&contents).map_err(|source| ConfigError::ParseFailed {
path: path.display().to_string(),
source,
})
}
pub fn load(explicit_path: Option<PathBuf>) -> Result<Self, ConfigError> {
let repo_config = match explicit_path.or_else(discover_repo_config) {
Some(path) => Self::load_from(&path)?,
None => Self::default(),
};
if !repo_config.inherit {
return Ok(repo_config);
}
let user_config = match user_config_path() {
Some(path) => Self::load_from(&path)?,
None => Self::default(),
};
Ok(repo_config.merge(user_config))
}
fn merge(self, fallback: Self) -> Self {
Self {
inherit: self.inherit,
remote: self.remote.or(fallback.remote),
pr_mode: self.pr_mode.or(fallback.pr_mode),
template: self.template.or(fallback.template),
stack_placement: self.stack_placement.or(fallback.stack_placement),
sync_pr_content: self.sync_pr_content.or(fallback.sync_pr_content),
auto_prefix: self.auto_prefix.or(fallback.auto_prefix),
bookmark_command: self.bookmark_command.or(fallback.bookmark_command),
bookmarks_revset: self.bookmarks_revset.or(fallback.bookmarks_revset),
heads_revset: self.heads_revset.or(fallback.heads_revset),
}
}
}
fn discover_repo_config() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join("stakk.toml");
if candidate.is_file() {
return Some(candidate);
}
if dir.join(".jj").is_dir() {
return None;
}
if !dir.pop() {
return None;
}
}
}
fn user_config_path() -> Option<PathBuf> {
let proj = directories::ProjectDirs::from("", "", "stakk")?;
Some(proj.config_dir().join("config.toml"))
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum ConfigError {
#[error("failed to read config file {path}")]
#[diagnostic(help("check file permissions"))]
ReadFailed {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file {path}")]
#[diagnostic(help("check the TOML syntax and field names"))]
ParseFailed {
path: String,
#[source]
source: toml::de::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_self_wins() {
let a = Config {
remote: Some("from-repo".into()),
pr_mode: Some(PrMode::Draft),
..Default::default()
};
let b = Config {
remote: Some("from-user".into()),
pr_mode: Some(PrMode::Regular),
template: Some("user-template".into()),
..Default::default()
};
let merged = a.merge(b);
assert_eq!(merged.remote.as_deref(), Some("from-repo"));
assert_eq!(merged.pr_mode, Some(PrMode::Draft));
assert_eq!(merged.template.as_deref(), Some("user-template"));
}
#[test]
fn merge_fallback_fills_gaps() {
let a = Config::default();
let b = Config {
remote: Some("from-user".into()),
..Default::default()
};
let merged = a.merge(b);
assert_eq!(merged.remote.as_deref(), Some("from-user"));
}
#[test]
fn inherit_defaults_to_true() {
let config: Config = toml::from_str("").unwrap();
assert!(config.inherit);
}
#[test]
fn inherit_false_in_toml() {
let config: Config = toml::from_str("inherit = false").unwrap();
assert!(!config.inherit);
}
#[test]
fn load_from_nonexistent_returns_default() {
let config = Config::load_from(Path::new("/nonexistent/stakk.toml")).unwrap();
assert!(config.remote.is_none());
assert!(config.inherit);
}
#[test]
fn user_config_path_is_some() {
let path = user_config_path();
if let Some(p) = path {
assert!(p.ends_with("config.toml"));
}
}
}