Skip to main content

plan_archive/validate/
local.rs

1//! Validator for the machine-local config at
2//! `$XDG_CONFIG_HOME/agent-plan-archive/config.yaml`.
3//!
4//! Schema (v1):
5//!
6//! ```yaml
7//! version: 1
8//! archive_clone_path: ~/Project/graysurf/agent-plan-archive
9//! working_repo_roots:
10//!   - ~/Project
11//!   - /workspace/src
12//! performance:
13//!   refresh_batch_size: 50
14//! ```
15//!
16//! Special behaviour:
17//!
18//! - Missing file: returns the documented defaults and exit code 0.
19//! - Malformed file: returns a structured parse error.
20//! - `~` and `$VAR` are expanded in `archive_clone_path` and
21//!   `working_repo_roots`.
22
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use super::ValidationWarning;
29
30const SUPPORTED_VERSION: u32 = 1;
31const DEFAULT_REFRESH_BATCH_SIZE: u32 = 50;
32const DEFAULT_ARCHIVE_CLONE_PATH: &str = "~/Project/graysurf/agent-plan-archive";
33
34/// Parsed and normalized machine-local config.
35#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
36pub struct LocalConfig {
37    pub version: u32,
38    pub archive_clone_path: PathBuf,
39    pub working_repo_roots: Vec<PathBuf>,
40    pub performance: LocalPerformance,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
44pub struct LocalPerformance {
45    pub refresh_batch_size: u32,
46}
47
48/// Whether the validator filled in defaults because the file was absent.
49#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
50pub enum LocalSource {
51    Defaults,
52    File,
53}
54
55/// Output payload of a successful `validate-local` run.
56#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
57pub struct LocalValidationData {
58    pub source: LocalSource,
59    pub config: LocalConfig,
60}
61
62#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
63pub struct LocalValidation {
64    pub data: LocalValidationData,
65    pub warnings: Vec<ValidationWarning>,
66}
67
68/// Validation failure modes for the machine-local config.
69#[derive(Debug, Error)]
70pub enum LocalValidationError {
71    #[error("local config could not be parsed as YAML: {0}")]
72    Parse(String),
73    #[error("local config version {found} is not supported (expected {SUPPORTED_VERSION})")]
74    UnsupportedVersion { found: u32 },
75    #[error("local config `performance.refresh_batch_size` must be > 0")]
76    InvalidBatchSize,
77    #[error("local config could not be read: {0}")]
78    Io(String),
79}
80
81impl LocalValidationError {
82    pub fn code(&self) -> &'static str {
83        match self {
84            Self::Parse(_) => "local-parse-error",
85            Self::UnsupportedVersion { .. } => "local-unsupported-version",
86            Self::InvalidBatchSize => "local-invalid-batch-size",
87            Self::Io(_) => "local-io-error",
88        }
89    }
90}
91
92/// Validate the raw machine-local config content.
93pub fn validate_local_yaml(input: &str) -> Result<LocalValidation, LocalValidationError> {
94    let trimmed = input.trim();
95    if trimmed.is_empty() {
96        return Ok(default_validation(LocalSource::Defaults));
97    }
98
99    let raw: RawLocalFile = serde_yaml_ng::from_str(input)
100        .map_err(|err| LocalValidationError::Parse(err.to_string()))?;
101
102    let version = raw.version.unwrap_or(SUPPORTED_VERSION);
103    if version != SUPPORTED_VERSION {
104        return Err(LocalValidationError::UnsupportedVersion { found: version });
105    }
106
107    let archive_clone_path = raw
108        .archive_clone_path
109        .as_deref()
110        .map(expand_path)
111        .unwrap_or_else(|| expand_path(DEFAULT_ARCHIVE_CLONE_PATH));
112
113    let working_repo_roots = raw
114        .working_repo_roots
115        .unwrap_or_default()
116        .iter()
117        .map(|p| expand_path(p))
118        .collect();
119
120    let refresh_batch_size = raw
121        .performance
122        .as_ref()
123        .and_then(|p| p.refresh_batch_size)
124        .unwrap_or(DEFAULT_REFRESH_BATCH_SIZE);
125    if refresh_batch_size == 0 {
126        return Err(LocalValidationError::InvalidBatchSize);
127    }
128
129    Ok(LocalValidation {
130        data: LocalValidationData {
131            source: LocalSource::File,
132            config: LocalConfig {
133                version,
134                archive_clone_path,
135                working_repo_roots,
136                performance: LocalPerformance { refresh_batch_size },
137            },
138        },
139        warnings: Vec::new(),
140    })
141}
142
143/// Convenience: validate a config file at the given path, falling back to
144/// documented defaults when the file does not exist.
145pub fn validate_local_path(path: &Path) -> Result<LocalValidation, LocalValidationError> {
146    match std::fs::read_to_string(path) {
147        Ok(contents) => validate_local_yaml(&contents),
148        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
149            Ok(default_validation(LocalSource::Defaults))
150        }
151        Err(err) => Err(LocalValidationError::Io(err.to_string())),
152    }
153}
154
155fn default_validation(source: LocalSource) -> LocalValidation {
156    LocalValidation {
157        data: LocalValidationData {
158            source,
159            config: LocalConfig {
160                version: SUPPORTED_VERSION,
161                archive_clone_path: expand_path(DEFAULT_ARCHIVE_CLONE_PATH),
162                working_repo_roots: Vec::new(),
163                performance: LocalPerformance {
164                    refresh_batch_size: DEFAULT_REFRESH_BATCH_SIZE,
165                },
166            },
167        },
168        warnings: vec![ValidationWarning::new(
169            "local-defaults-used",
170            "local config file is missing or empty; falling back to documented defaults",
171        )],
172    }
173}
174
175/// Expand a single `~` prefix and `$VAR` / `${VAR}` references.
176fn expand_path(input: &str) -> PathBuf {
177    let mut s = input.trim().to_string();
178    if let Some(rest) = s.strip_prefix("~/")
179        && let Some(home) = std::env::var_os("HOME")
180    {
181        let mut p = PathBuf::from(home);
182        p.push(rest);
183        return p;
184    } else if s == "~"
185        && let Some(home) = std::env::var_os("HOME")
186    {
187        return PathBuf::from(home);
188    }
189
190    // Expand `$VAR` and `${VAR}`. Unknown variables are left as-is.
191    let mut out = String::with_capacity(s.len());
192    while let Some(idx) = s.find('$') {
193        out.push_str(&s[..idx]);
194        let after = &s[idx + 1..];
195        let (var_name, rest_start) = if let Some(rest) = after.strip_prefix('{') {
196            let close = rest.find('}').map(|c| c + 1);
197            match close {
198                Some(end) => (&rest[..end - 1], end + 1),
199                None => {
200                    out.push('$');
201                    s = after.to_string();
202                    continue;
203                }
204            }
205        } else {
206            let end = after
207                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
208                .unwrap_or(after.len());
209            (&after[..end], end)
210        };
211        if var_name.is_empty() {
212            out.push('$');
213            s = after.to_string();
214            continue;
215        }
216        match std::env::var(var_name) {
217            Ok(value) => out.push_str(&value),
218            Err(_) => {
219                out.push('$');
220                if after.starts_with('{') {
221                    out.push('{');
222                    out.push_str(var_name);
223                    out.push('}');
224                } else {
225                    out.push_str(var_name);
226                }
227            }
228        }
229        s = after[rest_start..].to_string();
230    }
231    out.push_str(&s);
232    PathBuf::from(out)
233}
234
235#[derive(Debug, Deserialize)]
236struct RawLocalFile {
237    #[serde(default)]
238    version: Option<u32>,
239    #[serde(default)]
240    archive_clone_path: Option<String>,
241    #[serde(default)]
242    working_repo_roots: Option<Vec<String>>,
243    #[serde(default)]
244    performance: Option<RawPerformance>,
245    #[allow(dead_code)]
246    #[serde(default)]
247    schema: Option<String>,
248}
249
250#[derive(Debug, Deserialize)]
251struct RawPerformance {
252    #[serde(default)]
253    refresh_batch_size: Option<u32>,
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn empty_input_returns_defaults() {
262        let v = validate_local_yaml("").expect("defaults");
263        assert!(matches!(v.data.source, LocalSource::Defaults));
264        assert!(v.data.config.working_repo_roots.is_empty());
265        assert_eq!(v.data.config.performance.refresh_batch_size, 50);
266        assert!(!v.warnings.is_empty());
267    }
268
269    #[test]
270    fn explicit_overrides_validate() {
271        unsafe { std::env::set_var("HOME", "/Users/test") };
272        let input = r"
273version: 1
274archive_clone_path: ~/Project/agent-plan-archive
275working_repo_roots:
276  - ~/Project
277  - /workspace/src
278performance:
279  refresh_batch_size: 25
280";
281        let v = validate_local_yaml(input).expect("validation");
282        assert!(matches!(v.data.source, LocalSource::File));
283        assert_eq!(v.data.config.performance.refresh_batch_size, 25);
284        assert_eq!(v.data.config.working_repo_roots.len(), 2);
285        assert_eq!(
286            v.data.config.archive_clone_path,
287            PathBuf::from("/Users/test/Project/agent-plan-archive")
288        );
289    }
290
291    #[test]
292    fn unsupported_version_rejected() {
293        let err = validate_local_yaml("version: 5\n").expect_err("unsupported");
294        assert_eq!(err.code(), "local-unsupported-version");
295    }
296
297    #[test]
298    fn zero_batch_rejected() {
299        let input = r"
300version: 1
301performance:
302  refresh_batch_size: 0
303";
304        let err = validate_local_yaml(input).expect_err("zero batch");
305        assert_eq!(err.code(), "local-invalid-batch-size");
306    }
307
308    #[test]
309    fn missing_file_returns_defaults() {
310        let path = PathBuf::from("/nonexistent/agent-plan-archive/config.yaml");
311        let v = validate_local_path(&path).expect("defaults");
312        assert!(matches!(v.data.source, LocalSource::Defaults));
313    }
314
315    #[test]
316    fn env_var_expansion() {
317        unsafe { std::env::set_var("PA_TEST_ROOT", "/var/data") };
318        let input = "version: 1\narchive_clone_path: $PA_TEST_ROOT/archive\n";
319        let v = validate_local_yaml(input).expect("validation");
320        assert_eq!(
321            v.data.config.archive_clone_path,
322            PathBuf::from("/var/data/archive")
323        );
324    }
325}