1use std::path::Path;
10
11pub const DEFAULT_CAPTURE_RETENTION: usize = 5;
13
14pub const MAIN: &str = "main";
16pub const DEVELOP: &str = "develop";
18pub const FEATURE_PREFIX: &str = "feature/";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct GitFlowConfig {
28 pub main: String,
30 pub develop: String,
32 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#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
51#[serde(default)]
52pub struct DevflowConfig {
53 pub capture_retention: usize,
55 pub review_angles: Option<Vec<String>>,
57 pub external_verify_enabled: bool,
59 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 pub fn capture_retention(&self) -> usize {
90 self.capture_retention
91 }
92
93 pub fn review_angles(&self) -> Option<&[String]> {
95 self.review_angles.as_deref()
96 }
97
98 pub fn external_verify_enabled(&self) -> bool {
100 self.external_verify_enabled
101 }
102
103 pub fn yes_ship(&self) -> bool {
105 self.yes_ship
106 }
107}
108
109pub 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
136pub 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
152pub 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
170pub 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
186pub 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
207pub 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 unsafe { std::env::set_var(key, value) };
259 Self(key)
260 }
261 }
262
263 impl Drop for EnvOverride {
264 fn drop(&mut self) {
265 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 #[test]
345 fn yes_ship_defaults_to_false() {
346 assert!(!DevflowConfig::default().yes_ship());
347 }
348
349 #[test]
352 fn yes_ship_missing_file_returns_false() {
353 let dir = tempfile::tempdir().unwrap();
354 assert!(!yes_ship(dir.path()));
355 }
356
357 #[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 #[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 #[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 #[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 #[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}