use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchSpec {
#[serde(default)]
pub metadata: BatchMetadata,
pub operations: Vec<BatchOperation>,
#[serde(default)]
pub mode: ExecutionMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchMetadata {
pub description: Option<String>,
pub author: Option<String>,
#[serde(default = "default_spec_version")]
pub version: String,
}
impl Default for BatchMetadata {
fn default() -> Self {
Self {
description: None,
author: None,
version: default_spec_version(),
}
}
}
fn default_spec_version() -> String {
"1.0".to_string()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ExecutionMode {
StopOnError,
ContinueOnError,
}
impl Default for ExecutionMode {
fn default() -> Self {
Self::StopOnError
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BatchOperation {
Patch(PatchOp),
Delete(DeleteOp),
Rename(RenameOp),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchOp {
pub file: PathBuf,
pub symbol: String,
pub kind: Option<String>,
pub with: PathBuf,
#[serde(default)]
pub snapshot_before: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteOp {
pub file: PathBuf,
pub symbol: String,
pub kind: Option<String>,
#[serde(default)]
pub snapshot_before: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameOp {
pub file: PathBuf,
pub from: String,
pub to: String,
pub files: Option<Vec<PathBuf>>,
#[serde(default)]
pub snapshot_before: bool,
}
pub fn parse_batch_spec(path: &PathBuf) -> Result<BatchSpec, BatchSpecError> {
let content = std::fs::read_to_string(path).map_err(|e| BatchSpecError::Io {
path: path.clone(),
source: e,
})?;
let spec: BatchSpec =
serde_yaml::from_str(&content).map_err(|e| BatchSpecError::ParseError {
path: path.clone(),
reason: e.to_string(),
})?;
validate_spec(&spec)?;
Ok(spec)
}
fn validate_spec(spec: &BatchSpec) -> Result<(), BatchSpecError> {
if spec.operations.is_empty() {
return Err(BatchSpecError::EmptyOperations);
}
for (idx, op) in spec.operations.iter().enumerate() {
validate_operation(op, idx)?;
}
Ok(())
}
fn validate_operation(op: &BatchOperation, idx: usize) -> Result<(), BatchSpecError> {
match op {
BatchOperation::Patch(p) => {
if p.symbol.is_empty() {
return Err(BatchSpecError::InvalidOperation {
index: idx,
reason: "patch operation requires non-empty symbol name".to_string(),
});
}
if !p.file.exists() {
return Err(BatchSpecError::FileNotFound {
index: idx,
path: p.file.clone(),
});
}
}
BatchOperation::Delete(d) => {
if d.symbol.is_empty() {
return Err(BatchSpecError::InvalidOperation {
index: idx,
reason: "delete operation requires non-empty symbol name".to_string(),
});
}
}
BatchOperation::Rename(r) => {
if r.from.is_empty() || r.to.is_empty() {
return Err(BatchSpecError::InvalidOperation {
index: idx,
reason: "rename operation requires both 'from' and 'to' names".to_string(),
});
}
}
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum BatchSpecError {
#[error("IO error reading {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("Failed to parse {path}: {reason}")]
ParseError {
path: PathBuf,
reason: String,
},
#[error("Batch spec contains no operations")]
EmptyOperations,
#[error("Operation {index} is invalid: {reason}")]
InvalidOperation {
index: usize,
reason: String,
},
#[error("Operation {index} references non-existent file: {path}")]
FileNotFound {
index: usize,
path: PathBuf,
},
}