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}