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}
60
61impl Default for DevflowConfig {
62 fn default() -> Self {
63 Self {
64 capture_retention: DEFAULT_CAPTURE_RETENTION,
65 review_angles: None,
66 external_verify_enabled: true,
67 }
68 }
69}
70
71impl DevflowConfig {
72 pub fn capture_retention(&self) -> usize {
74 self.capture_retention
75 }
76
77 pub fn review_angles(&self) -> Option<&[String]> {
79 self.review_angles.as_deref()
80 }
81
82 pub fn external_verify_enabled(&self) -> bool {
84 self.external_verify_enabled
85 }
86}
87
88pub fn load_config(project_root: &Path) -> DevflowConfig {
94 let path = project_root.join("devflow.toml");
95 let contents = match std::fs::read_to_string(&path) {
96 Ok(contents) => contents,
97 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
98 return DevflowConfig::default();
99 }
100 Err(error) => {
101 tracing::warn!(path = %path.display(), %error, "failed to read devflow config; using defaults");
102 return DevflowConfig::default();
103 }
104 };
105
106 match toml::from_str(&contents) {
107 Ok(config) => config,
108 Err(error) => {
109 tracing::warn!(path = %path.display(), %error, "failed to parse devflow config; using defaults");
110 DevflowConfig::default()
111 }
112 }
113}
114
115pub fn capture_retention(project_root: &Path) -> usize {
118 if let Some(value) = env_value("DEVFLOW_CAPTURE_RETENTION") {
119 match value.parse() {
120 Ok(retention) => return retention,
121 Err(error) => tracing::warn!(
122 value,
123 %error,
124 "invalid DEVFLOW_CAPTURE_RETENTION; using devflow.toml or default"
125 ),
126 }
127 }
128 load_config(project_root).capture_retention
129}
130
131pub fn review_angles(project_root: &Path) -> Option<Vec<String>> {
134 if let Some(value) = env_value("DEVFLOW_REVIEW_ANGLES") {
135 let angles: Vec<_> = value
136 .split(',')
137 .map(str::trim)
138 .filter(|angle| !angle.is_empty())
139 .map(str::to_owned)
140 .collect();
141 if !angles.is_empty() {
142 return Some(angles);
143 }
144 tracing::warn!("DEVFLOW_REVIEW_ANGLES contains no review angles; using devflow.toml");
145 }
146 load_config(project_root).review_angles
147}
148
149pub fn external_verify_enabled(project_root: &Path) -> bool {
152 if let Some(value) = env_value("DEVFLOW_EXTERNAL_VERIFY_ENABLED") {
153 match value.parse() {
154 Ok(enabled) => return enabled,
155 Err(error) => tracing::warn!(
156 value,
157 %error,
158 "invalid DEVFLOW_EXTERNAL_VERIFY_ENABLED; using devflow.toml or default"
159 ),
160 }
161 }
162 load_config(project_root).external_verify_enabled
163}
164
165fn env_value(key: &str) -> Option<String> {
166 std::env::var(key).ok().filter(|value| !value.is_empty())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::sync::Mutex;
173
174 static ENV_MUTEX: Mutex<()> = Mutex::new(());
175
176 struct EnvOverride(&'static str);
177
178 impl EnvOverride {
179 fn set(key: &'static str, value: &str) -> Self {
180 unsafe { std::env::set_var(key, value) };
183 Self(key)
184 }
185 }
186
187 impl Drop for EnvOverride {
188 fn drop(&mut self) {
189 unsafe { std::env::remove_var(self.0) };
191 }
192 }
193
194 #[test]
195 fn default_uses_hardcoded_constants() {
196 let config = GitFlowConfig::default();
197 assert_eq!(config.main, "main");
198 assert_eq!(config.develop, "develop");
199 assert_eq!(config.feature_prefix, "feature/");
200 }
201
202 #[test]
203 fn missing_file_uses_devflow_defaults() {
204 let dir = tempfile::tempdir().unwrap();
205
206 assert_eq!(load_config(dir.path()), DevflowConfig::default());
207 }
208
209 #[test]
210 fn file_overrides_capture_retention_default() {
211 let dir = tempfile::tempdir().unwrap();
212 std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
213
214 assert_eq!(load_config(dir.path()).capture_retention(), 9);
215 }
216
217 #[test]
218 fn env_overrides_file_capture_retention() {
219 let _lock = ENV_MUTEX.lock().unwrap();
220 let dir = tempfile::tempdir().unwrap();
221 std::fs::write(dir.path().join("devflow.toml"), "capture_retention = 9\n").unwrap();
222 let _env = EnvOverride::set("DEVFLOW_CAPTURE_RETENTION", "12");
223
224 assert_eq!(capture_retention(dir.path()), 12);
225 }
226
227 #[test]
228 fn env_overrides_file_review_angles() {
229 let _lock = ENV_MUTEX.lock().unwrap();
230 let dir = tempfile::tempdir().unwrap();
231 std::fs::write(
232 dir.path().join("devflow.toml"),
233 "review_angles = [\"file angle\"]\n",
234 )
235 .unwrap();
236 let _env = EnvOverride::set("DEVFLOW_REVIEW_ANGLES", "security, docs accuracy");
237
238 assert_eq!(
239 review_angles(dir.path()),
240 Some(vec!["security".into(), "docs accuracy".into()])
241 );
242 }
243
244 #[test]
245 fn env_overrides_file_external_verification() {
246 let _lock = ENV_MUTEX.lock().unwrap();
247 let dir = tempfile::tempdir().unwrap();
248 std::fs::write(
249 dir.path().join("devflow.toml"),
250 "external_verify_enabled = false\n",
251 )
252 .unwrap();
253 let _env = EnvOverride::set("DEVFLOW_EXTERNAL_VERIFY_ENABLED", "true");
254
255 assert!(external_verify_enabled(dir.path()));
256 }
257
258 #[test]
259 fn malformed_file_falls_back_to_defaults() {
260 let dir = tempfile::tempdir().unwrap();
261 std::fs::write(dir.path().join("devflow.toml"), "capture_retention =\n").unwrap();
262
263 assert_eq!(load_config(dir.path()), DevflowConfig::default());
264 }
265}