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.
12///
13/// **The number is arithmetic, not a round guess** (ROADMAP criterion 7).
14/// `archive_phase_files` runs once per launch and archives the *previous*
15/// stage's files, so a clean five-stage Define→Plan→Code→Validate→Ship run
16/// produces **4** archive events, and each Validate→Code loop-back adds **2**
17/// more.
18///
19/// `12` therefore accommodates a clean run plus four loop-backs **exactly** —
20/// 4 + (4 × 2) = 12, with **zero** headroom at four, because the next archive
21/// event after the twelfth evicts. The bound that carries actual headroom is
22/// **three** loop-backs: 4 + (3 × 2) = 10 ≤ 12. Do not restate this as
23/// "survives four loop-backs with headroom"; it does not.
24///
25/// The prior value of `5` lost Define's capture on the **first** loop-back
26/// (event 6 of 6), silently — `prune_history` deletes without an error or a
27/// log, so the loss surfaces only when someone goes looking for a capture that
28/// is already gone.
29///
30/// This is criterion 7's "changing the constant" branch, chosen over a
31/// run-local `DEVFLOW_CAPTURE_RETENTION` export because the criterion requires
32/// the mitigation leave an **inspectable artifact**: a committed source
33/// constant is greppable and outlives the run, an exported environment
34/// variable is neither. The env and `devflow.toml` overrides are unchanged and
35/// still take precedence — they are simply no longer the mitigation.
36pub const DEFAULT_CAPTURE_RETENTION: usize = 12;
37
38/// Production/release branch name.
39pub const MAIN: &str = "main";
40/// Development/integration branch name.
41pub const DEVELOP: &str = "develop";
42/// Prefix for per-phase feature branches.
43pub const FEATURE_PREFIX: &str = "feature/";
44
45/// The fixed git-flow branch names used by the current pipeline.
46///
47/// Kept as a struct (rather than bare constants) so the modules that build
48/// branch names — git, ship, agent-result evaluation — can take a single value
49/// and stay readable. `default()` is the only constructor.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct GitFlowConfig {
52    /// Main/production branch name.
53    pub main: String,
54    /// Development/integration branch name.
55    pub develop: String,
56    /// Prefix for feature branches.
57    pub feature_prefix: String,
58}
59
60impl Default for GitFlowConfig {
61    fn default() -> Self {
62        GitFlowConfig {
63            main: MAIN.to_string(),
64            develop: DEVELOP.to_string(),
65            feature_prefix: FEATURE_PREFIX.to_string(),
66        }
67    }
68}
69
70/// The minimal project configuration introduced by Phase 16 decision D-03.
71///
72/// Missing fields inherit their built-in defaults so operators can specify
73/// only the knobs they need in `devflow.toml`.
74#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
75#[serde(default)]
76pub struct DevflowConfig {
77    /// Number of capture generations to retain per pipeline stage.
78    pub capture_retention: usize,
79    /// Custom Ship review angles; `None` keeps the built-in angle list.
80    pub review_angles: Option<Vec<String>>,
81    /// Whether declared external verification commands may run.
82    pub external_verify_enabled: bool,
83    /// Whether the Ship gate is standing pre-authorized for this project
84    /// (D-12, `28-CONTEXT.md`) — the gate's approval is supplied
85    /// automatically and attributed in the gate ledger, exactly as if
86    /// `--yes-ship` had been typed on every invocation. Deliberately
87    /// defaults to `false`, unlike `external_verify_enabled`: an absent
88    /// `devflow.toml`, or one that omits this key, must never pre-authorize
89    /// a Ship. This is a deliberate reversal of Phase 23's own D-05
90    /// (`--yes-ship` was a per-run flag only, never config-persistable, "so
91    /// a standing unattended auto-merge can never become the silent
92    /// default"). The reversal's stated cost, recorded twice: relaxing this
93    /// later is easy, but tightening it after operators depend on a
94    /// persisted setting is not. `commands::start` combines this value with
95    /// the CLI flag via logical OR rather than replacing it, because the
96    /// flag has no negative form — passing `--yes-ship` always wins.
97    pub yes_ship: bool,
98}
99
100impl Default for DevflowConfig {
101    fn default() -> Self {
102        Self {
103            capture_retention: DEFAULT_CAPTURE_RETENTION,
104            review_angles: None,
105            external_verify_enabled: true,
106            yes_ship: false,
107        }
108    }
109}
110
111impl DevflowConfig {
112    /// Return the configured capture-retention count.
113    pub fn capture_retention(&self) -> usize {
114        self.capture_retention
115    }
116
117    /// Return configured review angles, or `None` to use built-in angles.
118    pub fn review_angles(&self) -> Option<&[String]> {
119        self.review_angles.as_deref()
120    }
121
122    /// Return whether external verification is enabled.
123    pub fn external_verify_enabled(&self) -> bool {
124        self.external_verify_enabled
125    }
126
127    /// Return whether the Ship gate is standing pre-authorized (D-12).
128    pub fn yes_ship(&self) -> bool {
129        self.yes_ship
130    }
131}
132
133/// Load the minimal Phase 16 configuration from `<project_root>/devflow.toml`.
134///
135/// A missing file preserves built-in behavior. Read or parse failures are
136/// fail-soft: DevFlow warns and continues with defaults instead of aborting the
137/// workflow.
138pub fn load_config(project_root: &Path) -> DevflowConfig {
139    let path = project_root.join("devflow.toml");
140    let contents = match std::fs::read_to_string(&path) {
141        Ok(contents) => contents,
142        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
143            return DevflowConfig::default();
144        }
145        Err(error) => {
146            tracing::warn!(path = %path.display(), %error, "failed to read devflow config; using defaults");
147            return DevflowConfig::default();
148        }
149    };
150
151    match toml::from_str(&contents) {
152        Ok(config) => config,
153        Err(error) => {
154            tracing::warn!(path = %path.display(), %error, "failed to parse devflow config; using defaults");
155            DevflowConfig::default()
156        }
157    }
158}
159
160/// Resolve capture retention with `DEVFLOW_CAPTURE_RETENTION` taking
161/// precedence over `devflow.toml` and the built-in default.
162pub fn capture_retention(project_root: &Path) -> usize {
163    if let Some(value) = env_value("DEVFLOW_CAPTURE_RETENTION") {
164        match value.parse() {
165            Ok(retention) => return retention,
166            Err(error) => tracing::warn!(
167                value,
168                %error,
169                "invalid DEVFLOW_CAPTURE_RETENTION; using devflow.toml or default"
170            ),
171        }
172    }
173    load_config(project_root).capture_retention
174}
175
176/// Resolve Ship review angles with `DEVFLOW_REVIEW_ANGLES` taking precedence
177/// over `devflow.toml`. The environment value is a comma-separated list.
178pub fn review_angles(project_root: &Path) -> Option<Vec<String>> {
179    if let Some(value) = env_value("DEVFLOW_REVIEW_ANGLES") {
180        let angles: Vec<_> = value
181            .split(',')
182            .map(str::trim)
183            .filter(|angle| !angle.is_empty())
184            .map(str::to_owned)
185            .collect();
186        if !angles.is_empty() {
187            return Some(angles);
188        }
189        tracing::warn!("DEVFLOW_REVIEW_ANGLES contains no review angles; using devflow.toml");
190    }
191    load_config(project_root).review_angles
192}
193
194/// Resolve external verification with `DEVFLOW_EXTERNAL_VERIFY_ENABLED`
195/// taking precedence over `devflow.toml` and the built-in default.
196pub fn external_verify_enabled(project_root: &Path) -> bool {
197    if let Some(value) = env_value("DEVFLOW_EXTERNAL_VERIFY_ENABLED") {
198        match value.parse() {
199            Ok(enabled) => return enabled,
200            Err(error) => tracing::warn!(
201                value,
202                %error,
203                "invalid DEVFLOW_EXTERNAL_VERIFY_ENABLED; using devflow.toml or default"
204            ),
205        }
206    }
207    load_config(project_root).external_verify_enabled
208}
209
210/// Resolve the Ship gate's standing pre-authorization (D-12) with
211/// `DEVFLOW_YES_SHIP` taking precedence over `devflow.toml` and the
212/// built-in `false` default. Mirrors `external_verify_enabled`'s resolver
213/// shape exactly. Note that this resolver is not the only path by which
214/// `state.yes_ship` becomes `true` — `commands::start` also ORs in the
215/// `--yes-ship` CLI flag; this function reports only the config/env-derived
216/// half of that combination.
217pub fn yes_ship(project_root: &Path) -> bool {
218    if let Some(value) = env_value("DEVFLOW_YES_SHIP") {
219        match value.parse() {
220            Ok(enabled) => return enabled,
221            Err(error) => tracing::warn!(
222                value,
223                %error,
224                "invalid DEVFLOW_YES_SHIP; using devflow.toml or default"
225            ),
226        }
227    }
228    load_config(project_root).yes_ship
229}
230
231/// Resolve D-11's legacy-launch opt-out (31-04) from
232/// `DEVFLOW_CLAUDE_LEGACY_LAUNCH`.
233///
234/// Environment only, deliberately: D-11 specifies one flag and one environment
235/// variable, and nothing else. There is no `devflow.toml` key, so this takes no
236/// `project_root` — a standing per-project default for an escape hatch is
237/// exactly the "used routinely, erodes what it protects" shape D-11 warns
238/// about. `--legacy-claude-launch` supplies the other half, OR-ed in
239/// `commands::start` / `pipeline_launch::resume`.
240///
241/// **The value is PARSED as a bool, not merely tested for presence (W4).** A
242/// naive `env::var(..).is_ok()` would make `DEVFLOW_CLAUDE_LEGACY_LAUNCH=false`
243/// *enable* the legacy path — an accidental-reach path D-11 forbids. Garbage
244/// warns and is ignored rather than enabling; the escape hatch fails CLOSED.
245///
246/// Read through [`env_value`] with the variable name as a literal, matching
247/// [`yes_ship`] and [`external_verify_enabled`]. A const-mediated read compiles
248/// and works identically but is INVISIBLE to
249/// `doc_check::source_read_env_vars`, which would then pass green while the
250/// variable went undocumented — the "by blindness" failure 31-02 recorded.
251pub fn claude_legacy_launch() -> bool {
252    if let Some(value) = env_value("DEVFLOW_CLAUDE_LEGACY_LAUNCH") {
253        match value.parse() {
254            Ok(enabled) => return enabled,
255            Err(error) => tracing::warn!(
256                value,
257                %error,
258                "invalid DEVFLOW_CLAUDE_LEGACY_LAUNCH; the legacy Claude launch stays OFF"
259            ),
260        }
261    }
262    false
263}
264
265fn env_value(key: &str) -> Option<String> {
266    std::env::var(key).ok().filter(|value| !value.is_empty())
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::sync::Mutex;
273
274    static ENV_MUTEX: Mutex<()> = Mutex::new(());
275
276    struct EnvOverride(&'static str);
277
278    impl EnvOverride {
279        fn set(key: &'static str, value: &str) -> Self {
280            // SAFETY: Tests that mutate this process-global variable are
281            // serialized by ENV_MUTEX and the guard removes it on drop.
282            unsafe { std::env::set_var(key, value) };
283            Self(key)
284        }
285    }
286
287    impl Drop for EnvOverride {
288        fn drop(&mut self) {
289            // SAFETY: See EnvOverride::set; the same mutex guard is still held.
290            unsafe { std::env::remove_var(self.0) };
291        }
292    }
293
294    #[test]
295    fn default_uses_hardcoded_constants() {
296        let config = GitFlowConfig::default();
297        assert_eq!(config.main, "main");
298        assert_eq!(config.develop, "develop");
299        assert_eq!(config.feature_prefix, "feature/");
300    }
301
302    #[test]
303    fn missing_file_uses_devflow_defaults() {
304        let dir = tempfile::tempdir().unwrap();
305
306        assert_eq!(load_config(dir.path()), DevflowConfig::default());
307    }
308
309    #[test]
310    fn file_overrides_capture_retention_default() {
311        let dir = tempfile::tempdir().unwrap();
312        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
313
314        assert_eq!(load_config(dir.path()).capture_retention(), 9);
315    }
316
317    #[test]
318    fn env_overrides_file_capture_retention() {
319        let _lock = ENV_MUTEX.lock().unwrap();
320        let dir = tempfile::tempdir().unwrap();
321        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
322        let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
323
324        assert_eq!(capture_retention(dir.path()), 12);
325    }
326
327    #[test]
328    fn env_overrides_file_review_angles() {
329        let _lock = ENV_MUTEX.lock().unwrap();
330        let dir = tempfile::tempdir().unwrap();
331        std::fs::write(
332            dir.path().join("devflow.toml"),
333            "review_angles = [\"file angle\"]\n",
334        )
335        .unwrap();
336        let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
337
338        assert_eq!(
339            review_angles(dir.path()),
340            Some(vec!["security".into(), "docs accuracy".into()])
341        );
342    }
343
344    #[test]
345    fn env_overrides_file_external_verification() {
346        let _lock = ENV_MUTEX.lock().unwrap();
347        let dir = tempfile::tempdir().unwrap();
348        std::fs::write(
349            dir.path().join("devflow.toml"),
350            "external_verify_enabled = false\n",
351        )
352        .unwrap();
353        let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
354
355        assert!(external_verify_enabled(dir.path()));
356    }
357
358    #[test]
359    fn malformed_file_falls_back_to_defaults() {
360        let dir = tempfile::tempdir().unwrap();
361        std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
362
363        assert_eq!(load_config(dir.path()), DevflowConfig::default());
364    }
365
366    /// D-12: an absent `devflow.toml` must never pre-authorize a Ship — the
367    /// deliberate asymmetry with `external_verify_enabled`'s `true` default.
368    #[test]
369    fn yes_ship_defaults_to_false() {
370        assert!(!DevflowConfig::default().yes_ship());
371    }
372
373    /// D-12: no `devflow.toml` present → the resolver falls through to the
374    /// built-in `false` default.
375    #[test]
376    fn yes_ship_missing_file_returns_false() {
377        let dir = tempfile::tempdir().unwrap();
378        assert!(!yes_ship(dir.path()));
379    }
380
381    /// D-12: `devflow.toml` setting the key `true` → the resolver returns
382    /// `true`.
383    #[test]
384    fn yes_ship_file_sets_true() {
385        let dir = tempfile::tempdir().unwrap();
386        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
387
388        assert!(yes_ship(dir.path()));
389    }
390
391    /// D-12: `devflow.toml` setting the key `false` → the resolver returns
392    /// `false`.
393    #[test]
394    fn yes_ship_file_sets_false() {
395        let dir = tempfile::tempdir().unwrap();
396        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
397
398        assert!(!yes_ship(dir.path()));
399    }
400
401    /// D-12: a `devflow.toml` with unrelated keys still loads, and the
402    /// resolver returns the default.
403    #[test]
404    fn yes_ship_unrelated_keys_returns_default() {
405        let dir = tempfile::tempdir().unwrap();
406        std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
407
408        assert!(!yes_ship(dir.path()));
409    }
410
411    /// D-12: an unparseable `DEVFLOW_YES_SHIP` value warns and falls back to
412    /// the file/default rather than panicking or returning true.
413    #[test]
414    fn yes_ship_unparseable_env_falls_back_to_file() {
415        let _lock = ENV_MUTEX.lock().unwrap();
416        let dir = tempfile::tempdir().unwrap();
417        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = true\n").unwrap();
418        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "not-a-bool");
419
420        assert!(yes_ship(dir.path()));
421    }
422
423    /// D-12: env beats file, matching every sibling resolver.
424    #[test]
425    fn env_overrides_file_yes_ship() {
426        let _lock = ENV_MUTEX.lock().unwrap();
427        let dir = tempfile::tempdir().unwrap();
428        std::fs::write(dir.path().join("devflow.toml"), "yes_ship = false\n").unwrap();
429        let _env = EnvOverride::set("DEVFLOW_YES_SHIP", "true");
430
431        assert!(yes_ship(dir.path()));
432    }
433}