use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use crate::cli::submit::PrMode;
use crate::cli::submit::SyncPrContent;
use crate::cli::submit::TrailerHandling;
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, PartialEq, Eq)]
pub struct RemovedEnvVar {
pub name: &'static str,
pub advice: &'static str,
}
static REMOVED_ENV_VARS: &[RemovedEnvVar] = &[
RemovedEnvVar {
name: "STAKK_DRAFT",
advice: "use STAKK_PR_MODE=draft or pr_mode in stakk.toml",
},
RemovedEnvVar {
name: "STAKK_TEMPLATE",
advice: "use STAKK_TEMPLATE_PATH or template_path in stakk.toml",
},
];
pub fn removed_env_vars(lookup: impl Fn(&str) -> Option<String>) -> Vec<&'static RemovedEnvVar> {
REMOVED_ENV_VARS
.iter()
.filter(|removed| lookup(removed.name).is_some_and(|value| !value.is_empty()))
.collect()
}
pub fn warn_removed_env_vars() {
for removed in removed_env_vars(|name| std::env::var(name).ok()) {
eprintln!(
"Warning: {} is no longer read; {}",
removed.name, removed.advice
);
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_true")]
pub inherit: bool,
pub remote: Option<String>,
pub github_host: Option<String>,
pub pr_mode: Option<PrMode>,
pub template_path: Option<String>,
pub stack_placement: Option<StackPlacement>,
pub sync_pr_content: Option<SyncPrContent>,
pub trailers: Option<TrailerHandling>,
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,
github_host: None,
pr_mode: None,
template_path: None,
stack_placement: None,
sync_pr_content: None,
trailers: 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),
github_host: self.github_host.or(fallback.github_host),
pr_mode: self.pr_mode.or(fallback.pr_mode),
template_path: self.template_path.or(fallback.template_path),
stack_placement: self.stack_placement.or(fallback.stack_placement),
sync_pr_content: self.sync_pr_content.or(fallback.sync_pr_content),
trailers: self.trailers.or(fallback.trailers),
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_path: 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_path.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);
}
fn lookup_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_string())
}
}
fn matched(vars: &[&RemovedEnvVar]) -> Vec<(&'static str, &'static str)> {
vars.iter().map(|v| (v.name, v.advice)).collect()
}
#[test]
fn removed_env_vars_none_set() {
assert!(removed_env_vars(lookup_from(&[])).is_empty());
}
#[test]
fn removed_env_vars_reports_draft_and_template() {
let found = removed_env_vars(lookup_from(&[
("STAKK_DRAFT", "1"),
("STAKK_TEMPLATE", "/path/to/template.md.jinja"),
]));
assert_eq!(
matched(&found),
vec![
(
"STAKK_DRAFT",
"use STAKK_PR_MODE=draft or pr_mode in stakk.toml"
),
(
"STAKK_TEMPLATE",
"use STAKK_TEMPLATE_PATH or template_path in stakk.toml"
),
]
);
}
#[test]
fn removed_env_vars_treats_empty_as_unset() {
let found = removed_env_vars(lookup_from(&[("STAKK_DRAFT", ""), ("STAKK_TEMPLATE", "")]));
assert!(matched(&found).is_empty());
}
#[test]
fn removed_env_vars_ignores_current_names() {
let found = removed_env_vars(lookup_from(&[
("STAKK_PR_MODE", "draft"),
("STAKK_TEMPLATE_PATH", "/path/to/template.md.jinja"),
]));
assert!(matched(&found).is_empty());
}
#[test]
fn user_config_path_is_some() {
let path = user_config_path();
if let Some(p) = path {
assert!(p.ends_with("config.toml"));
}
}
}