1use 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#[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#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
50pub enum LocalSource {
51 Defaults,
52 File,
53}
54
55#[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#[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
92pub 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
143pub 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
175fn 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 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}