1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use xchecker_utils::types::PhaseId;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct GatePolicy {
16 #[serde(default)]
18 pub min_phase: Option<PhaseId>,
19
20 #[serde(default)]
22 pub fail_on_pending_fixups: bool,
23
24 #[serde(default)]
26 pub max_phase_age: Option<Duration>,
27}
28
29pub fn resolve_policy_path(policy_path: Option<&Path>) -> Result<Option<PathBuf>> {
36 if let Some(path) = policy_path {
37 if path.exists() {
39 return Ok(Some(path.to_path_buf()));
40 }
41 anyhow::bail!("Policy file not found: {}", path.display());
42 }
43
44 let cwd = std::env::current_dir().context("Failed to get current directory")?;
46 let local_policy = cwd.join(".xchecker").join("policy.toml");
47 if local_policy.exists() {
48 return Ok(Some(local_policy));
49 }
50
51 let repo_root = find_repo_root(&cwd)?;
53 let repo_policy = repo_root.join(".xchecker").join("policy.toml");
54 if repo_policy.exists() {
55 return Ok(Some(repo_policy));
56 }
57
58 if let Some(config_dir) = dirs::config_dir() {
60 let config_policy = config_dir.join("xchecker").join("policy.toml");
61 if config_policy.exists() {
62 return Ok(Some(config_policy));
63 }
64 }
65
66 Ok(None)
68}
69
70fn find_repo_root(start: &Path) -> Result<PathBuf> {
72 let mut current = start.to_path_buf();
73
74 for _ in 0..10 {
75 if current.join(".git").exists() {
77 return Ok(current);
78 }
79
80 if !current.pop() {
82 break;
83 }
84 }
85
86 Ok(start.to_path_buf())
88}
89
90pub fn load_policy_from_path(path: &Path) -> Result<GatePolicy> {
92 let content = std::fs::read_to_string(path)
93 .with_context(|| format!("Failed to read policy file: {}", path.display()))?;
94
95 let policy: GatePolicy = toml::from_str(&content)
96 .with_context(|| format!("Failed to parse policy TOML: {}", path.display()))?;
97
98 Ok(policy)
99}
100
101pub fn parse_phase(phase_str: &str) -> Result<PhaseId> {
103 match phase_str.to_lowercase().as_str() {
104 "requirements" => Ok(PhaseId::Requirements),
105 "design" => Ok(PhaseId::Design),
106 "tasks" => Ok(PhaseId::Tasks),
107 "review" => Ok(PhaseId::Review),
108 "fixup" => Ok(PhaseId::Fixup),
109 "final" => Ok(PhaseId::Final),
110 _ => anyhow::bail!(
111 "Unknown phase '{}'. Valid phases: requirements, design, tasks, review, fixup, final",
112 phase_str
113 ),
114 }
115}
116
117pub fn parse_duration(duration_str: &str) -> Result<Duration> {
119 let duration_str = duration_str.trim().to_lowercase();
120
121 let mut num_str = String::new();
123 let mut unit_str = String::new();
124
125 for c in duration_str.chars() {
126 if c.is_ascii_digit() || c == '.' {
127 num_str.push(c);
128 } else {
129 unit_str.push(c);
130 }
131 }
132
133 let value: f64 = num_str
134 .parse()
135 .with_context(|| format!("Invalid duration value: {}", num_str))?;
136
137 let duration = match unit_str.as_str() {
138 "s" | "sec" | "second" | "seconds" => Duration::from_secs_f64(value),
139 "m" | "min" | "minute" | "minutes" => Duration::from_secs_f64(value * 60.0),
140 "h" | "hour" | "hours" => Duration::from_secs_f64(value * 3600.0),
141 "d" | "day" | "days" => Duration::from_secs_f64(value * 86400.0),
142 "w" | "week" | "weeks" => Duration::from_secs_f64(value * 604800.0),
143 _ => anyhow::bail!(
144 "Unknown duration unit '{}'. Valid units: s/m/h/d/w",
145 unit_str
146 ),
147 };
148
149 Ok(duration)
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_parse_phase() {
158 assert_eq!(parse_phase("requirements").unwrap(), PhaseId::Requirements);
159 assert_eq!(parse_phase("design").unwrap(), PhaseId::Design);
160 assert_eq!(parse_phase("tasks").unwrap(), PhaseId::Tasks);
161 assert_eq!(parse_phase("REVIEW").unwrap(), PhaseId::Review); assert!(parse_phase("invalid").is_err());
163 }
164
165 #[test]
166 fn test_parse_duration() {
167 assert_eq!(
168 parse_duration("7d").unwrap(),
169 Duration::from_secs(7 * 86400)
170 );
171 assert_eq!(
172 parse_duration("24h").unwrap(),
173 Duration::from_secs(24 * 3600)
174 );
175 assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(30 * 60));
176 assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
177 assert!(parse_duration("invalid").is_err());
178 }
179
180 #[test]
181 fn test_gate_policy_default() {
182 let policy = GatePolicy::default();
183 assert!(policy.min_phase.is_none());
184 assert!(!policy.fail_on_pending_fixups);
185 assert!(policy.max_phase_age.is_none());
186 }
187}