Skip to main content

xchecker_gate/
policy.rs

1//! Gate policy configuration and parsing
2//!
3//! This module provides policy types and parsing functions for the gate command.
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use xchecker_utils::types::PhaseId;
10
11/// Gate policy for spec validation
12///
13/// Defines the rules that a spec must meet to pass the gate.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct GatePolicy {
16    /// Minimum phase that must be completed
17    #[serde(default)]
18    pub min_phase: Option<PhaseId>,
19
20    /// Fail if any pending fixups exist
21    #[serde(default)]
22    pub fail_on_pending_fixups: bool,
23
24    /// Maximum age of the latest successful phase
25    #[serde(default)]
26    pub max_phase_age: Option<Duration>,
27}
28
29/// Resolve policy path from CLI argument or default locations
30///
31/// Searches for policy file in the following order:
32/// 1. Explicit path provided via --policy flag
33/// 2. `.xchecker/policy.toml` in current directory or repo root
34/// 3. `~/.config/xchecker/policy.toml`
35pub fn resolve_policy_path(policy_path: Option<&Path>) -> Result<Option<PathBuf>> {
36    if let Some(path) = policy_path {
37        // Explicit path provided
38        if path.exists() {
39            return Ok(Some(path.to_path_buf()));
40        }
41        anyhow::bail!("Policy file not found: {}", path.display());
42    }
43
44    // Try .xchecker/policy.toml in current directory
45    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    // Try to find repo root and check for .xchecker/policy.toml
52    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    // Try ~/.config/xchecker/policy.toml
59    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    // No policy file found
67    Ok(None)
68}
69
70/// Find repository root by looking for .git directory
71fn find_repo_root(start: &Path) -> Result<PathBuf> {
72    let mut current = start.to_path_buf();
73
74    for _ in 0..10 {
75        // Check for .git directory
76        if current.join(".git").exists() {
77            return Ok(current);
78        }
79
80        // Move to parent directory
81        if !current.pop() {
82            break;
83        }
84    }
85
86    // No .git found, return start directory
87    Ok(start.to_path_buf())
88}
89
90/// Load policy from a TOML file
91pub 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
101/// Parse a phase string into a PhaseId
102pub 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
117/// Parse a duration string (e.g., "7d", "24h", "30m")
118pub fn parse_duration(duration_str: &str) -> Result<Duration> {
119    let duration_str = duration_str.trim().to_lowercase();
120
121    // Parse the numeric part and the unit
122    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); // Case insensitive
162        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}