use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::ValidationWarning;
const SUPPORTED_VERSION: u32 = 1;
const DEFAULT_REFRESH_BATCH_SIZE: u32 = 50;
const DEFAULT_ARCHIVE_CLONE_PATH: &str = "~/Project/graysurf/agent-plan-archive";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LocalConfig {
pub version: u32,
pub archive_clone_path: PathBuf,
pub working_repo_roots: Vec<PathBuf>,
pub performance: LocalPerformance,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LocalPerformance {
pub refresh_batch_size: u32,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub enum LocalSource {
Defaults,
File,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LocalValidationData {
pub source: LocalSource,
pub config: LocalConfig,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LocalValidation {
pub data: LocalValidationData,
pub warnings: Vec<ValidationWarning>,
}
#[derive(Debug, Error)]
pub enum LocalValidationError {
#[error("local config could not be parsed as YAML: {0}")]
Parse(String),
#[error("local config version {found} is not supported (expected {SUPPORTED_VERSION})")]
UnsupportedVersion { found: u32 },
#[error("local config `performance.refresh_batch_size` must be > 0")]
InvalidBatchSize,
#[error("local config could not be read: {0}")]
Io(String),
}
impl LocalValidationError {
pub fn code(&self) -> &'static str {
match self {
Self::Parse(_) => "local-parse-error",
Self::UnsupportedVersion { .. } => "local-unsupported-version",
Self::InvalidBatchSize => "local-invalid-batch-size",
Self::Io(_) => "local-io-error",
}
}
}
pub fn validate_local_yaml(input: &str) -> Result<LocalValidation, LocalValidationError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(default_validation(LocalSource::Defaults));
}
let raw: RawLocalFile = serde_yaml_ng::from_str(input)
.map_err(|err| LocalValidationError::Parse(err.to_string()))?;
let version = raw.version.unwrap_or(SUPPORTED_VERSION);
if version != SUPPORTED_VERSION {
return Err(LocalValidationError::UnsupportedVersion { found: version });
}
let archive_clone_path = raw
.archive_clone_path
.as_deref()
.map(expand_path)
.unwrap_or_else(|| expand_path(DEFAULT_ARCHIVE_CLONE_PATH));
let working_repo_roots = raw
.working_repo_roots
.unwrap_or_default()
.iter()
.map(|p| expand_path(p))
.collect();
let refresh_batch_size = raw
.performance
.as_ref()
.and_then(|p| p.refresh_batch_size)
.unwrap_or(DEFAULT_REFRESH_BATCH_SIZE);
if refresh_batch_size == 0 {
return Err(LocalValidationError::InvalidBatchSize);
}
Ok(LocalValidation {
data: LocalValidationData {
source: LocalSource::File,
config: LocalConfig {
version,
archive_clone_path,
working_repo_roots,
performance: LocalPerformance { refresh_batch_size },
},
},
warnings: Vec::new(),
})
}
pub fn validate_local_path(path: &Path) -> Result<LocalValidation, LocalValidationError> {
match std::fs::read_to_string(path) {
Ok(contents) => validate_local_yaml(&contents),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Ok(default_validation(LocalSource::Defaults))
}
Err(err) => Err(LocalValidationError::Io(err.to_string())),
}
}
fn default_validation(source: LocalSource) -> LocalValidation {
LocalValidation {
data: LocalValidationData {
source,
config: LocalConfig {
version: SUPPORTED_VERSION,
archive_clone_path: expand_path(DEFAULT_ARCHIVE_CLONE_PATH),
working_repo_roots: Vec::new(),
performance: LocalPerformance {
refresh_batch_size: DEFAULT_REFRESH_BATCH_SIZE,
},
},
},
warnings: vec![ValidationWarning::new(
"local-defaults-used",
"local config file is missing or empty; falling back to documented defaults",
)],
}
}
fn expand_path(input: &str) -> PathBuf {
let mut s = input.trim().to_string();
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
let mut p = PathBuf::from(home);
p.push(rest);
return p;
} else if s == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home);
}
let mut out = String::with_capacity(s.len());
while let Some(idx) = s.find('$') {
out.push_str(&s[..idx]);
let after = &s[idx + 1..];
let (var_name, rest_start) = if let Some(rest) = after.strip_prefix('{') {
let close = rest.find('}').map(|c| c + 1);
match close {
Some(end) => (&rest[..end - 1], end + 1),
None => {
out.push('$');
s = after.to_string();
continue;
}
}
} else {
let end = after
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(after.len());
(&after[..end], end)
};
if var_name.is_empty() {
out.push('$');
s = after.to_string();
continue;
}
match std::env::var(var_name) {
Ok(value) => out.push_str(&value),
Err(_) => {
out.push('$');
if after.starts_with('{') {
out.push('{');
out.push_str(var_name);
out.push('}');
} else {
out.push_str(var_name);
}
}
}
s = after[rest_start..].to_string();
}
out.push_str(&s);
PathBuf::from(out)
}
#[derive(Debug, Deserialize)]
struct RawLocalFile {
#[serde(default)]
version: Option<u32>,
#[serde(default)]
archive_clone_path: Option<String>,
#[serde(default)]
working_repo_roots: Option<Vec<String>>,
#[serde(default)]
performance: Option<RawPerformance>,
#[allow(dead_code)]
#[serde(default)]
schema: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawPerformance {
#[serde(default)]
refresh_batch_size: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_returns_defaults() {
let v = validate_local_yaml("").expect("defaults");
assert!(matches!(v.data.source, LocalSource::Defaults));
assert!(v.data.config.working_repo_roots.is_empty());
assert_eq!(v.data.config.performance.refresh_batch_size, 50);
assert!(!v.warnings.is_empty());
}
#[test]
fn explicit_overrides_validate() {
unsafe { std::env::set_var("HOME", "/Users/test") };
let input = r"
version: 1
archive_clone_path: ~/Project/agent-plan-archive
working_repo_roots:
- ~/Project
- /workspace/src
performance:
refresh_batch_size: 25
";
let v = validate_local_yaml(input).expect("validation");
assert!(matches!(v.data.source, LocalSource::File));
assert_eq!(v.data.config.performance.refresh_batch_size, 25);
assert_eq!(v.data.config.working_repo_roots.len(), 2);
assert_eq!(
v.data.config.archive_clone_path,
PathBuf::from("/Users/test/Project/agent-plan-archive")
);
}
#[test]
fn unsupported_version_rejected() {
let err = validate_local_yaml("version: 5\n").expect_err("unsupported");
assert_eq!(err.code(), "local-unsupported-version");
}
#[test]
fn zero_batch_rejected() {
let input = r"
version: 1
performance:
refresh_batch_size: 0
";
let err = validate_local_yaml(input).expect_err("zero batch");
assert_eq!(err.code(), "local-invalid-batch-size");
}
#[test]
fn missing_file_returns_defaults() {
let path = PathBuf::from("/nonexistent/agent-plan-archive/config.yaml");
let v = validate_local_path(&path).expect("defaults");
assert!(matches!(v.data.source, LocalSource::Defaults));
}
#[test]
fn env_var_expansion() {
unsafe { std::env::set_var("PA_TEST_ROOT", "/var/data") };
let input = "version: 1\narchive_clone_path: $PA_TEST_ROOT/archive\n";
let v = validate_local_yaml(input).expect("validation");
assert_eq!(
v.data.config.archive_clone_path,
PathBuf::from("/var/data/archive")
);
}
}