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
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 unsafe { std::env::set_var(key, value) };
225 Self(key)
226 }
227 }
228
229 impl Drop for EnvOverride {
230 fn drop(&mut self) {
231 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 #[test]
311 fn yes_ship_defaults_to_false() {
312 assert!(!DevflowConfig::default().yes_ship());
313 }
314
315 #[test]
318 fn yes_ship_missing_file_returns_false() {
319 let dir = tempfile::tempdir().unwrap();
320 assert!(!yes_ship(dir.path()));
321 }
322
323 #[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 #[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 #[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 #[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 #[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}