Skip to main content

devflow_core/
config.rs

1//! DevFlow project configuration and fixed git-flow branch model.
2//!
3//! Phase 16 decision D-03 deliberately reopened the earlier no-config-file
4//! decision for a minimal `devflow.toml` containing only Phase 16 knobs.
5//! `DEVFLOW_*` environment variables remain the highest-precedence overrides.
6//! The git-flow branch model remains hardcoded to the opinionated `main`,
7//! `develop`, and `feature/` constants below.
8
9use std::path::Path;
10
11/// Number of capture generations retained when not otherwise configured.
12pub const DEFAULT_CAPTURE_RETENTION: usize = 5;
13
14/// Production/release branch name.
15pub const MAIN: &str = "main";
16/// Development/integration branch name.
17pub const DEVELOP: &str = "develop";
18/// Prefix for per-phase feature branches.
19pub const FEATURE_PREFIX: &str = "feature/";
20
21/// The fixed git-flow branch names used by the current pipeline.
22///
23/// Kept as a struct (rather than bare constants) so the modules that build
24/// branch names — git, ship, agent-result evaluation — can take a single value
25/// and stay readable. `default()` is the only constructor.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct GitFlowConfig {
28    /// Main/production branch name.
29    pub main: String,
30    /// Development/integration branch name.
31    pub develop: String,
32    /// Prefix for feature branches.
33    pub feature_prefix: String,
34}
35
36impl Default for GitFlowConfig {
37    fn default() -> Self {
38        GitFlowConfig {
39            main: MAIN.to_string(),
40            develop: DEVELOP.to_string(),
41            feature_prefix: FEATURE_PREFIX.to_string(),
42        }
43    }
44}
45
46/// The minimal project configuration introduced by Phase 16 decision D-03.
47///
48/// Missing fields inherit their built-in defaults so operators can specify
49/// only the knobs they need in `devflow.toml`.
50#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
51#[serde(default)]
52pub struct DevflowConfig {
53    /// Number of capture generations to retain per pipeline stage.
54    pub capture_retention: usize,
55    /// Custom Ship review angles; `None` keeps the built-in angle list.
56    pub review_angles: Option<Vec<String>>,
57    /// Whether declared external verification commands may run.
58    pub external_verify_enabled: bool,
59}
60
61impl Default for DevflowConfig {
62    fn default() -> Self {
63        Self {
64            capture_retention: DEFAULT_CAPTURE_RETENTION,
65            review_angles: None,
66            external_verify_enabled: true,
67        }
68    }
69}
70
71impl DevflowConfig {
72    /// Return the configured capture-retention count.
73    pub fn capture_retention(&self) -> usize {
74        self.capture_retention
75    }
76
77    /// Return configured review angles, or `None` to use built-in angles.
78    pub fn review_angles(&self) -> Option<&[String]> {
79        self.review_angles.as_deref()
80    }
81
82    /// Return whether external verification is enabled.
83    pub fn external_verify_enabled(&self) -> bool {
84        self.external_verify_enabled
85    }
86}
87
88/// Load the minimal Phase 16 configuration from `<project_root>/devflow.toml`.
89///
90/// A missing file preserves built-in behavior. Read or parse failures are
91/// fail-soft: DevFlow warns and continues with defaults instead of aborting the
92/// workflow.
93pub fn load_config(project_root: &Path) -> DevflowConfig {
94    let path = project_root.join("devflow.toml");
95    let contents = match std::fs::read_to_string(&path) {
96        Ok(contents) => contents,
97        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
98            return DevflowConfig::default();
99        }
100        Err(error) => {
101            tracing::warn!(path = %path.display(), %error, "failed to read devflow config; using defaults");
102            return DevflowConfig::default();
103        }
104    };
105
106    match toml::from_str(&contents) {
107        Ok(config) => config,
108        Err(error) => {
109            tracing::warn!(path = %path.display(), %error, "failed to parse devflow config; using defaults");
110            DevflowConfig::default()
111        }
112    }
113}
114
115/// Resolve capture retention with `DEVFLOW_CAPTURE_RETENTION` taking
116/// precedence over `devflow.toml` and the built-in default.
117pub fn capture_retention(project_root: &Path) -> usize {
118    if let Some(value) = env_value("DEVFLOW_CAPTURE_RETENTION") {
119        match value.parse() {
120            Ok(retention) => return retention,
121            Err(error) => tracing::warn!(
122                value,
123                %error,
124                "invalid DEVFLOW_CAPTURE_RETENTION; using devflow.toml or default"
125            ),
126        }
127    }
128    load_config(project_root).capture_retention
129}
130
131/// Resolve Ship review angles with `DEVFLOW_REVIEW_ANGLES` taking precedence
132/// over `devflow.toml`. The environment value is a comma-separated list.
133pub fn review_angles(project_root: &Path) -> Option<Vec<String>> {
134    if let Some(value) = env_value("DEVFLOW_REVIEW_ANGLES") {
135        let angles: Vec<_> = value
136            .split(',')
137            .map(str::trim)
138            .filter(|angle| !angle.is_empty())
139            .map(str::to_owned)
140            .collect();
141        if !angles.is_empty() {
142            return Some(angles);
143        }
144        tracing::warn!("DEVFLOW_REVIEW_ANGLES contains no review angles; using devflow.toml");
145    }
146    load_config(project_root).review_angles
147}
148
149/// Resolve external verification with `DEVFLOW_EXTERNAL_VERIFY_ENABLED`
150/// taking precedence over `devflow.toml` and the built-in default.
151pub fn external_verify_enabled(project_root: &Path) -> bool {
152    if let Some(value) = env_value("DEVFLOW_EXTERNAL_VERIFY_ENABLED") {
153        match value.parse() {
154            Ok(enabled) => return enabled,
155            Err(error) => tracing::warn!(
156                value,
157                %error,
158                "invalid DEVFLOW_EXTERNAL_VERIFY_ENABLED; using devflow.toml or default"
159            ),
160        }
161    }
162    load_config(project_root).external_verify_enabled
163}
164
165fn env_value(key: &str) -> Option<String> {
166    std::env::var(key).ok().filter(|value| !value.is_empty())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::sync::Mutex;
173
174    static ENV_MUTEX: Mutex<()> = Mutex::new(());
175
176    struct EnvOverride(&'static str);
177
178    impl EnvOverride {
179        fn set(key: &'static str, value: &str) -> Self {
180            // SAFETY: Tests that mutate this process-global variable are
181            // serialized by ENV_MUTEX and the guard removes it on drop.
182            unsafe { std::env::set_var(key, value) };
183            Self(key)
184        }
185    }
186
187    impl Drop for EnvOverride {
188        fn drop(&mut self) {
189            // SAFETY: See EnvOverride::set; the same mutex guard is still held.
190            unsafe { std::env::remove_var(self.0) };
191        }
192    }
193
194    #[test]
195    fn default_uses_hardcoded_constants() {
196        let config = GitFlowConfig::default();
197        assert_eq!(config.main, "main");
198        assert_eq!(config.develop, "develop");
199        assert_eq!(config.feature_prefix, "feature/");
200    }
201
202    #[test]
203    fn missing_file_uses_devflow_defaults() {
204        let dir = tempfile::tempdir().unwrap();
205
206        assert_eq!(load_config(dir.path()), DevflowConfig::default());
207    }
208
209    #[test]
210    fn file_overrides_capture_retention_default() {
211        let dir = tempfile::tempdir().unwrap();
212        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
213
214        assert_eq!(load_config(dir.path()).capture_retention(), 9);
215    }
216
217    #[test]
218    fn env_overrides_file_capture_retention() {
219        let _lock = ENV_MUTEX.lock().unwrap();
220        let dir = tempfile::tempdir().unwrap();
221        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
222        let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
223
224        assert_eq!(capture_retention(dir.path()), 12);
225    }
226
227    #[test]
228    fn env_overrides_file_review_angles() {
229        let _lock = ENV_MUTEX.lock().unwrap();
230        let dir = tempfile::tempdir().unwrap();
231        std::fs::write(
232            dir.path().join("devflow.toml"),
233            "review_angles = [\"file angle\"]\n",
234        )
235        .unwrap();
236        let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
237
238        assert_eq!(
239            review_angles(dir.path()),
240            Some(vec!["security".into(), "docs accuracy".into()])
241        );
242    }
243
244    #[test]
245    fn env_overrides_file_external_verification() {
246        let _lock = ENV_MUTEX.lock().unwrap();
247        let dir = tempfile::tempdir().unwrap();
248        std::fs::write(
249            dir.path().join("devflow.toml"),
250            "external_verify_enabled = false\n",
251        )
252        .unwrap();
253        let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
254
255        assert!(external_verify_enabled(dir.path()));
256    }
257
258    #[test]
259    fn malformed_file_falls_back_to_defaults() {
260        let dir = tempfile::tempdir().unwrap();
261        std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
262
263        assert_eq!(load_config(dir.path()), DevflowConfig::default());
264    }
265}