use std::path::Path;
pub const DEFAULT_CAPTURE_RETENTION: usize = 5;
pub const MAIN: &str = "main";
pub const DEVELOP: &str = "develop";
pub const FEATURE_PREFIX: &str = "feature/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitFlowConfig {
pub main: String,
pub develop: String,
pub feature_prefix: String,
}
impl Default for GitFlowConfig {
fn default() -> Self {
GitFlowConfig {
main: MAIN.to_string(),
develop: DEVELOP.to_string(),
feature_prefix: FEATURE_PREFIX.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(default)]
pub struct DevflowConfig {
pub capture_retention: usize,
pub review_angles: Option<Vec<String>>,
pub external_verify_enabled: bool,
}
impl Default for DevflowConfig {
fn default() -> Self {
Self {
capture_retention: DEFAULT_CAPTURE_RETENTION,
review_angles: None,
external_verify_enabled: true,
}
}
}
impl DevflowConfig {
pub fn capture_retention(&self) -> usize {
self.capture_retention
}
pub fn review_angles(&self) -> Option<&[String]> {
self.review_angles.as_deref()
}
pub fn external_verify_enabled(&self) -> bool {
self.external_verify_enabled
}
}
pub fn load_config(project_root: &Path) -> DevflowConfig {
let path = project_root.join("devflow.toml");
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return DevflowConfig::default();
}
Err(error) => {
tracing::warn!(path = %path.display(), %error, "failed to read devflow config; using defaults");
return DevflowConfig::default();
}
};
match toml::from_str(&contents) {
Ok(config) => config,
Err(error) => {
tracing::warn!(path = %path.display(), %error, "failed to parse devflow config; using defaults");
DevflowConfig::default()
}
}
}
pub fn capture_retention(project_root: &Path) -> usize {
if let Some(value) = env_value("DEVFLOW_CAPTURE_RETENTION") {
match value.parse() {
Ok(retention) => return retention,
Err(error) => tracing::warn!(
value,
%error,
"invalid DEVFLOW_CAPTURE_RETENTION; using devflow.toml or default"
),
}
}
load_config(project_root).capture_retention
}
pub fn review_angles(project_root: &Path) -> Option<Vec<String>> {
if let Some(value) = env_value("DEVFLOW_REVIEW_ANGLES") {
let angles: Vec<_> = value
.split(',')
.map(str::trim)
.filter(|angle| !angle.is_empty())
.map(str::to_owned)
.collect();
if !angles.is_empty() {
return Some(angles);
}
tracing::warn!("DEVFLOW_REVIEW_ANGLES contains no review angles; using devflow.toml");
}
load_config(project_root).review_angles
}
pub fn external_verify_enabled(project_root: &Path) -> bool {
if let Some(value) = env_value("DEVFLOW_EXTERNAL_VERIFY_ENABLED") {
match value.parse() {
Ok(enabled) => return enabled,
Err(error) => tracing::warn!(
value,
%error,
"invalid DEVFLOW_EXTERNAL_VERIFY_ENABLED; using devflow.toml or default"
),
}
}
load_config(project_root).external_verify_enabled
}
fn env_value(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct EnvOverride(&'static str);
impl EnvOverride {
fn set(key: &'static str, value: &str) -> Self {
unsafe { std::env::set_var(key, value) };
Self(key)
}
}
impl Drop for EnvOverride {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) };
}
}
#[test]
fn default_uses_hardcoded_constants() {
let config = GitFlowConfig::default();
assert_eq!(config.main, "main");
assert_eq!(config.develop, "develop");
assert_eq!(config.feature_prefix, "feature/");
}
#[test]
fn missing_file_uses_devflow_defaults() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load_config(dir.path()), DevflowConfig::default());
}
#[test]
fn file_overrides_capture_retention_default() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
assert_eq!(load_config(dir.path()).capture_retention(), 9);
}
#[test]
fn env_overrides_file_capture_retention() {
let _lock = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
assert_eq!(capture_retention(dir.path()), 12);
}
#[test]
fn env_overrides_file_review_angles() {
let _lock = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("devflow.toml"),
"review_angles = [\"file angle\"]\n",
)
.unwrap();
let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
assert_eq!(
review_angles(dir.path()),
Some(vec!["security".into(), "docs accuracy".into()])
);
}
#[test]
fn env_overrides_file_external_verification() {
let _lock = ENV_MUTEX.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("devflow.toml"),
"external_verify_enabled = false\n",
)
.unwrap();
let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
assert!(external_verify_enabled(dir.path()));
}
#[test]
fn malformed_file_falls_back_to_defaults() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
assert_eq!(load_config(dir.path()), DevflowConfig::default());
}
}