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
207/// Resolve D-11's legacy-launch opt-out (31-04) from
208/// `DEVFLOW_CLAUDE_LEGACY_LAUNCH`.
209///
210/// Environment only, deliberately: D-11 specifies one flag and one environment
211/// variable, and nothing else. There is no `devflow.toml` key, so this takes no
212/// `project_root` — a standing per-project default for an escape hatch is
213/// exactly the "used routinely, erodes what it protects" shape D-11 warns
214/// about. `--legacy-claude-launch` supplies the other half, OR-ed in
215/// `commands::start` / `pipeline_launch::resume`.
216///
217/// **The value is PARSED as a bool, not merely tested for presence (W4).** A
218/// naive `env::var(..).is_ok()` would make `DEVFLOW_CLAUDE_LEGACY_LAUNCH=false`
219/// *enable* the legacy path — an accidental-reach path D-11 forbids. Garbage
220/// warns and is ignored rather than enabling; the escape hatch fails CLOSED.
221///
222/// Read through [`env_value`] with the variable name as a literal, matching
223/// [`yes_ship`] and [`external_verify_enabled`]. A const-mediated read compiles
224/// and works identically but is INVISIBLE to
225/// `doc_check::source_read_env_vars`, which would then pass green while the
226/// variable went undocumented — the "by blindness" failure 31-02 recorded.
227pub fn claude_legacy_launch() -> bool {
228    if let Some(value) = env_value("DEVFLOW_CLAUDE_LEGACY_LAUNCH") {
229        match value.parse() {
230            Ok(enabled) => return enabled,
231            Err(error) => tracing::warn!(
232                value,
233                %error,
234                "invalid DEVFLOW_CLAUDE_LEGACY_LAUNCH; the legacy Claude launch stays OFF"
235            ),
236        }
237    }
238    false
239}
240
241fn env_value(key: &str) -> Option<String> {
242    std::env::var(key).ok().filter(|value| !value.is_empty())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::sync::Mutex;
249
250    static ENV_MUTEX: Mutex<()> = Mutex::new(());
251
252    struct EnvOverride(&'static str);
253
254    impl EnvOverride {
255        fn set(key: &'static str, value: &str) -> Self {
256            // SAFETY: Tests that mutate this process-global variable are
257            // serialized by ENV_MUTEX and the guard removes it on drop.
258            unsafe { std::env::set_var(key, value) };
259            Self(key)
260        }
261    }
262
263    impl Drop for EnvOverride {
264        fn drop(&mut self) {
265            // SAFETY: See EnvOverride::set; the same mutex guard is still held.
266            unsafe { std::env::remove_var(self.0) };
267        }
268    }
269
270    #[test]
271    fn default_uses_hardcoded_constants() {
272        let config = GitFlowConfig::default();
273        assert_eq!(config.main, "main");
274        assert_eq!(config.develop, "develop");
275        assert_eq!(config.feature_prefix, "feature/");
276    }
277
278    #[test]
279    fn missing_file_uses_devflow_defaults() {
280        let dir = tempfile::tempdir().unwrap();
281
282        assert_eq!(load_config(dir.path()), DevflowConfig::default());
283    }
284
285    #[test]
286    fn file_overrides_capture_retention_default() {
287        let dir = tempfile::tempdir().unwrap();
288        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
289
290        assert_eq!(load_config(dir.path()).capture_retention(), 9);
291    }
292
293    #[test]
294    fn env_overrides_file_capture_retention() {
295        let _lock = ENV_MUTEX.lock().unwrap();
296        let dir = tempfile::tempdir().unwrap();
297        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
298        let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
299
300        assert_eq!(capture_retention(dir.path()), 12);
301    }
302
303    #[test]
304    fn env_overrides_file_review_angles() {
305        let _lock = ENV_MUTEX.lock().unwrap();
306        let dir = tempfile::tempdir().unwrap();
307        std::fs::write(
308            dir.path().join("devflow.toml"),
309            "review_angles = [\"file angle\"]\n",
310        )
311        .unwrap();
312        let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
313
314        assert_eq!(
315            review_angles(dir.path()),
316            Some(vec!["security".into(), "docs accuracy".into()])
317        );
318    }
319
320    #[test]
321    fn env_overrides_file_external_verification() {
322        let _lock = ENV_MUTEX.lock().unwrap();
323        let dir = tempfile::tempdir().unwrap();
324        std::fs::write(
325            dir.path().join("devflow.toml"),
326            "external_verify_enabled = false\n",
327        )
328        .unwrap();
329        let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
330
331        assert!(external_verify_enabled(dir.path()));
332    }
333
334    #[test]
335    fn malformed_file_falls_back_to_defaults() {
336        let dir = tempfile::tempdir().unwrap();
337        std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
338
339        assert_eq!(load_config(dir.path()), DevflowConfig::default());
340    }
341
342    /// D-12: an absent `devflow.toml` must never pre-authorize a Ship — the
343    /// deliberate asymmetry with `external_verify_enabled`'s `true` default.
344    #[test]
345    fn yes_ship_defaults_to_false() {
346        assert!(!DevflowConfig::default().yes_ship());
347    }
348
349    /// D-12: no `devflow.toml` present → the resolver falls through to the
350    /// built-in `false` default.
351    #[test]
352    fn yes_ship_missing_file_returns_false() {
353        let dir = tempfile::tempdir().unwrap();
354        assert!(!yes_ship(dir.path()));
355    }
356
357    /// D-12: `devflow.toml` setting the key `true` → the resolver returns
358    /// `true`.
359    #[test]
360    fn yes_ship_file_sets_true() {
361        let dir = tempfile::tempdir().unwrap();
362        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
363
364        assert!(yes_ship(dir.path()));
365    }
366
367    /// D-12: `devflow.toml` setting the key `false` → the resolver returns
368    /// `false`.
369    #[test]
370    fn yes_ship_file_sets_false() {
371        let dir = tempfile::tempdir().unwrap();
372        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
373
374        assert!(!yes_ship(dir.path()));
375    }
376
377    /// D-12: a `devflow.toml` with unrelated keys still loads, and the
378    /// resolver returns the default.
379    #[test]
380    fn yes_ship_unrelated_keys_returns_default() {
381        let dir = tempfile::tempdir().unwrap();
382        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
383
384        assert!(!yes_ship(dir.path()));
385    }
386
387    /// D-12: an unparseable `DEVFLOW_YES_SHIP` value warns and falls back to
388    /// the file/default rather than panicking or returning true.
389    #[test]
390    fn yes_ship_unparseable_env_falls_back_to_file() {
391        let _lock = ENV_MUTEX.lock().unwrap();
392        let dir = tempfile::tempdir().unwrap();
393        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
394        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "not-a-bool");
395
396        assert!(yes_ship(dir.path()));
397    }
398
399    /// D-12: env beats file, matching every sibling resolver.
400    #[test]
401    fn env_overrides_file_yes_ship() {
402        let _lock = ENV_MUTEX.lock().unwrap();
403        let dir = tempfile::tempdir().unwrap();
404        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
405        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "true");
406
407        assert!(yes_ship(dir.path()));
408    }
409}