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    /// Whether the Ship gate is standing pre-authorized for this project
60    /// (D-12, `28-CONTEXT.md`) — the gate's approval is supplied
61    /// automatically and attributed in the gate ledger, exactly as if
62    /// `--yes-ship` had been typed on every invocation. Deliberately
63    /// defaults to `false`, unlike `external_verify_enabled`: an absent
64    /// `devflow.toml`, or one that omits this key, must never pre-authorize
65    /// a Ship. This is a deliberate reversal of Phase 23's own D-05
66    /// (`--yes-ship` was a per-run flag only, never config-persistable, "so
67    /// a standing unattended auto-merge can never become the silent
68    /// default"). The reversal's stated cost, recorded twice: relaxing this
69    /// later is easy, but tightening it after operators depend on a
70    /// persisted setting is not. `commands::start` combines this value with
71    /// the CLI flag via logical OR rather than replacing it, because the
72    /// flag has no negative form — passing `--yes-ship` always wins.
73    pub yes_ship: bool,
74}
75
76impl Default for DevflowConfig {
77    fn default() -> Self {
78        Self {
79            capture_retention: DEFAULT_CAPTURE_RETENTION,
80            review_angles: None,
81            external_verify_enabled: true,
82            yes_ship: false,
83        }
84    }
85}
86
87impl DevflowConfig {
88    /// Return the configured capture-retention count.
89    pub fn capture_retention(&self) -> usize {
90        self.capture_retention
91    }
92
93    /// Return configured review angles, or `None` to use built-in angles.
94    pub fn review_angles(&self) -> Option<&[String]> {
95        self.review_angles.as_deref()
96    }
97
98    /// Return whether external verification is enabled.
99    pub fn external_verify_enabled(&self) -> bool {
100        self.external_verify_enabled
101    }
102
103    /// Return whether the Ship gate is standing pre-authorized (D-12).
104    pub fn yes_ship(&self) -> bool {
105        self.yes_ship
106    }
107}
108
109/// Load the minimal Phase 16 configuration from `<project_root>/devflow.toml`.
110///
111/// A missing file preserves built-in behavior. Read or parse failures are
112/// fail-soft: DevFlow warns and continues with defaults instead of aborting the
113/// workflow.
114pub fn load_config(project_root: &Path) -> DevflowConfig {
115    let path = project_root.join("devflow.toml");
116    let contents = match std::fs::read_to_string(&path) {
117        Ok(contents) => contents,
118        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
119            return DevflowConfig::default();
120        }
121        Err(error) => {
122            tracing::warn!(path = %path.display(), %error, "failed to read devflow config; using defaults");
123            return DevflowConfig::default();
124        }
125    };
126
127    match toml::from_str(&contents) {
128        Ok(config) => config,
129        Err(error) => {
130            tracing::warn!(path = %path.display(), %error, "failed to parse devflow config; using defaults");
131            DevflowConfig::default()
132        }
133    }
134}
135
136/// Resolve capture retention with `DEVFLOW_CAPTURE_RETENTION` taking
137/// precedence over `devflow.toml` and the built-in default.
138pub fn capture_retention(project_root: &Path) -> usize {
139    if let Some(value) = env_value("DEVFLOW_CAPTURE_RETENTION") {
140        match value.parse() {
141            Ok(retention) => return retention,
142            Err(error) => tracing::warn!(
143                value,
144                %error,
145                "invalid DEVFLOW_CAPTURE_RETENTION; using devflow.toml or default"
146            ),
147        }
148    }
149    load_config(project_root).capture_retention
150}
151
152/// Resolve Ship review angles with `DEVFLOW_REVIEW_ANGLES` taking precedence
153/// over `devflow.toml`. The environment value is a comma-separated list.
154pub fn review_angles(project_root: &Path) -> Option<Vec<String>> {
155    if let Some(value) = env_value("DEVFLOW_REVIEW_ANGLES") {
156        let angles: Vec<_> = value
157            .split(',')
158            .map(str::trim)
159            .filter(|angle| !angle.is_empty())
160            .map(str::to_owned)
161            .collect();
162        if !angles.is_empty() {
163            return Some(angles);
164        }
165        tracing::warn!("DEVFLOW_REVIEW_ANGLES contains no review angles; using devflow.toml");
166    }
167    load_config(project_root).review_angles
168}
169
170/// Resolve external verification with `DEVFLOW_EXTERNAL_VERIFY_ENABLED`
171/// taking precedence over `devflow.toml` and the built-in default.
172pub fn external_verify_enabled(project_root: &Path) -> bool {
173    if let Some(value) = env_value("DEVFLOW_EXTERNAL_VERIFY_ENABLED") {
174        match value.parse() {
175            Ok(enabled) => return enabled,
176            Err(error) => tracing::warn!(
177                value,
178                %error,
179                "invalid DEVFLOW_EXTERNAL_VERIFY_ENABLED; using devflow.toml or default"
180            ),
181        }
182    }
183    load_config(project_root).external_verify_enabled
184}
185
186/// Resolve the Ship gate's standing pre-authorization (D-12) with
187/// `DEVFLOW_YES_SHIP` taking precedence over `devflow.toml` and the
188/// built-in `false` default. Mirrors `external_verify_enabled`'s resolver
189/// shape exactly. Note that this resolver is not the only path by which
190/// `state.yes_ship` becomes `true` — `commands::start` also ORs in the
191/// `--yes-ship` CLI flag; this function reports only the config/env-derived
192/// half of that combination.
193pub fn yes_ship(project_root: &Path) -> bool {
194    if let Some(value) = env_value("DEVFLOW_YES_SHIP") {
195        match value.parse() {
196            Ok(enabled) => return enabled,
197            Err(error) => tracing::warn!(
198                value,
199                %error,
200                "invalid DEVFLOW_YES_SHIP; using devflow.toml or default"
201            ),
202        }
203    }
204    load_config(project_root).yes_ship
205}
206
207fn env_value(key: &str) -> Option<String> {
208    std::env::var(key).ok().filter(|value| !value.is_empty())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::sync::Mutex;
215
216    static ENV_MUTEX: Mutex<()> = Mutex::new(());
217
218    struct EnvOverride(&'static str);
219
220    impl EnvOverride {
221        fn set(key: &'static str, value: &str) -> Self {
222            // SAFETY: Tests that mutate this process-global variable are
223            // serialized by ENV_MUTEX and the guard removes it on drop.
224            unsafe { std::env::set_var(key, value) };
225            Self(key)
226        }
227    }
228
229    impl Drop for EnvOverride {
230        fn drop(&mut self) {
231            // SAFETY: See EnvOverride::set; the same mutex guard is still held.
232            unsafe { std::env::remove_var(self.0) };
233        }
234    }
235
236    #[test]
237    fn default_uses_hardcoded_constants() {
238        let config = GitFlowConfig::default();
239        assert_eq!(config.main, "main");
240        assert_eq!(config.develop, "develop");
241        assert_eq!(config.feature_prefix, "feature/");
242    }
243
244    #[test]
245    fn missing_file_uses_devflow_defaults() {
246        let dir = tempfile::tempdir().unwrap();
247
248        assert_eq!(load_config(dir.path()), DevflowConfig::default());
249    }
250
251    #[test]
252    fn file_overrides_capture_retention_default() {
253        let dir = tempfile::tempdir().unwrap();
254        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
255
256        assert_eq!(load_config(dir.path()).capture_retention(), 9);
257    }
258
259    #[test]
260    fn env_overrides_file_capture_retention() {
261        let _lock = ENV_MUTEX.lock().unwrap();
262        let dir = tempfile::tempdir().unwrap();
263        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
264        let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
265
266        assert_eq!(capture_retention(dir.path()), 12);
267    }
268
269    #[test]
270    fn env_overrides_file_review_angles() {
271        let _lock = ENV_MUTEX.lock().unwrap();
272        let dir = tempfile::tempdir().unwrap();
273        std::fs::write(
274            dir.path().join("devflow.toml"),
275            "review_angles = [\"file angle\"]\n",
276        )
277        .unwrap();
278        let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
279
280        assert_eq!(
281            review_angles(dir.path()),
282            Some(vec!["security".into(), "docs accuracy".into()])
283        );
284    }
285
286    #[test]
287    fn env_overrides_file_external_verification() {
288        let _lock = ENV_MUTEX.lock().unwrap();
289        let dir = tempfile::tempdir().unwrap();
290        std::fs::write(
291            dir.path().join("devflow.toml"),
292            "external_verify_enabled = false\n",
293        )
294        .unwrap();
295        let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
296
297        assert!(external_verify_enabled(dir.path()));
298    }
299
300    #[test]
301    fn malformed_file_falls_back_to_defaults() {
302        let dir = tempfile::tempdir().unwrap();
303        std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
304
305        assert_eq!(load_config(dir.path()), DevflowConfig::default());
306    }
307
308    /// D-12: an absent `devflow.toml` must never pre-authorize a Ship — the
309    /// deliberate asymmetry with `external_verify_enabled`'s `true` default.
310    #[test]
311    fn yes_ship_defaults_to_false() {
312        assert!(!DevflowConfig::default().yes_ship());
313    }
314
315    /// D-12: no `devflow.toml` present → the resolver falls through to the
316    /// built-in `false` default.
317    #[test]
318    fn yes_ship_missing_file_returns_false() {
319        let dir = tempfile::tempdir().unwrap();
320        assert!(!yes_ship(dir.path()));
321    }
322
323    /// D-12: `devflow.toml` setting the key `true` → the resolver returns
324    /// `true`.
325    #[test]
326    fn yes_ship_file_sets_true() {
327        let dir = tempfile::tempdir().unwrap();
328        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
329
330        assert!(yes_ship(dir.path()));
331    }
332
333    /// D-12: `devflow.toml` setting the key `false` → the resolver returns
334    /// `false`.
335    #[test]
336    fn yes_ship_file_sets_false() {
337        let dir = tempfile::tempdir().unwrap();
338        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
339
340        assert!(!yes_ship(dir.path()));
341    }
342
343    /// D-12: a `devflow.toml` with unrelated keys still loads, and the
344    /// resolver returns the default.
345    #[test]
346    fn yes_ship_unrelated_keys_returns_default() {
347        let dir = tempfile::tempdir().unwrap();
348        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
349
350        assert!(!yes_ship(dir.path()));
351    }
352
353    /// D-12: an unparseable `DEVFLOW_YES_SHIP` value warns and falls back to
354    /// the file/default rather than panicking or returning true.
355    #[test]
356    fn yes_ship_unparseable_env_falls_back_to_file() {
357        let _lock = ENV_MUTEX.lock().unwrap();
358        let dir = tempfile::tempdir().unwrap();
359        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
360        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "not-a-bool");
361
362        assert!(yes_ship(dir.path()));
363    }
364
365    /// D-12: env beats file, matching every sibling resolver.
366    #[test]
367    fn env_overrides_file_yes_ship() {
368        let _lock = ENV_MUTEX.lock().unwrap();
369        let dir = tempfile::tempdir().unwrap();
370        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
371        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "true");
372
373        assert!(yes_ship(dir.path()));
374    }
375}