use crate::error::AppError;
use crate::routines::agents::{available_agents, load_agent_command, AgentLoadError};
use crate::routines::command::{is_valid_env_key, validate_placeholders};
use crate::routines::model::Repository;
pub(super) fn map_write_routine_err(err: &std::io::Error) -> AppError {
if err.kind() == std::io::ErrorKind::AlreadyExists {
AppError::Conflict(err.to_string())
} else {
AppError::Internal
}
}
pub(super) fn reject_blank(field: &str, value: &str) -> Result<(), AppError> {
if value.trim().is_empty() {
return Err(AppError::BadRequest(format!(
"routine {field} must not be empty"
)));
}
Ok(())
}
pub(super) fn reject_zero_secs(field: &str, value: Option<u64>) -> Result<(), AppError> {
if value == Some(0) {
return Err(AppError::BadRequest(format!(
"routine {field} must be greater than zero"
)));
}
Ok(())
}
pub(super) fn reject_over_ceiling(
field: &str,
value: Option<u64>,
ceiling: u64,
) -> Result<(), AppError> {
if let Some(secs) = value {
if secs > ceiling {
return Err(AppError::BadRequest(format!(
"routine {field} {secs} exceeds the ceiling of {ceiling}s derived from this routine's schedule"
)));
}
}
Ok(())
}
pub(super) fn validate_agent(agent: &str) -> Result<(), AppError> {
let agents = available_agents();
if !agents.iter().any(|known| known == agent) {
return Err(AppError::BadRequest(format!(
"unknown agent \"{agent}\"; valid agents: {}",
agents.join(", ")
)));
}
match load_agent_command(agent) {
Ok(command) => validate_placeholders(&command.args)
.map_err(|reason| AppError::BadRequest(format!("agent {agent:?} config: {reason}"))),
Err(AgentLoadError::Missing) => Ok(()),
Err(AgentLoadError::Parse(err)) => Err(AppError::BadRequest(format!(
"agent {agent:?} has a malformed config: {err}"
))),
Err(AgentLoadError::Unreadable(err)) => Err(AppError::BadRequest(format!(
"agent {agent:?} has an unreadable config: {err}"
))),
}
}
pub(super) fn validate_prompt(prompt: &str) -> Result<(), AppError> {
if prompt.trim().is_empty() {
return Err(AppError::BadRequest("prompt must not be empty".to_string()));
}
Ok(())
}
pub(super) const MAX_TITLE_LEN: usize = 200;
pub(super) fn validate_title(title: &str) -> Result<(), AppError> {
if !title.chars().any(|ch| ch.is_ascii_alphanumeric()) {
return Err(AppError::BadRequest(
"title must contain at least one alphanumeric character".to_string(),
));
}
if title.trim().chars().count() > MAX_TITLE_LEN {
return Err(AppError::BadRequest(format!(
"title must be at most {MAX_TITLE_LEN} characters"
)));
}
Ok(())
}
pub(super) fn validate_repositories(repos: &[Repository]) -> Result<Vec<Repository>, AppError> {
let mut normalized = Vec::with_capacity(repos.len());
for (index, repo) in repos.iter().enumerate() {
let repository = repo.repository.trim();
if repository.is_empty() {
return Err(AppError::BadRequest(format!(
"repositories[{index}].repository must not be empty or whitespace-only"
)));
}
let branch = match &repo.branch {
Some(branch) => {
let trimmed = branch.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest(format!(
"repositories[{index}].branch must not be empty or whitespace-only when set"
)));
}
Some(trimmed.to_string())
}
None => None,
};
normalized.push(Repository {
repository: repository.to_string(),
branch,
});
}
Ok(normalized)
}
pub(super) fn validate_tags(tags: &[String]) -> Result<Vec<String>, AppError> {
let mut normalized: Vec<String> = Vec::with_capacity(tags.len());
for (index, tag) in tags.iter().enumerate() {
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest(format!(
"tags[{index}] must not be empty or whitespace-only"
)));
}
if !normalized.iter().any(|existing| existing == trimmed) {
normalized.push(trimmed.to_string());
}
}
Ok(normalized)
}
pub(super) fn validate_env(
env: &std::collections::HashMap<String, String>,
) -> Result<(), AppError> {
for (key, value) in env {
if !is_valid_env_key(key) {
return Err(AppError::BadRequest(format!(
"env key {key:?} is invalid; keys must match [A-Za-z_][A-Za-z0-9_]*"
)));
}
if value.contains('\n') || value.contains('\r') {
return Err(AppError::BadRequest(format!(
"env value for key {key:?} must not contain newline characters"
)));
}
}
Ok(())
}
pub(super) fn validate_machines(machines: &[String]) -> Result<Vec<String>, AppError> {
let mut normalized: Vec<String> = Vec::with_capacity(machines.len());
for (index, machine) in machines.iter().enumerate() {
let trimmed = machine.trim();
if trimmed.is_empty() {
return Err(AppError::BadRequest(format!(
"machines[{index}] must not be empty or whitespace-only"
)));
}
if !normalized.iter().any(|existing| existing == trimmed) {
normalized.push(trimmed.to_string());
}
}
Ok(normalized)
}
pub(super) fn normalize_model(model: Option<String>) -> Option<String> {
model.and_then(|model| {
let trimmed = model.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
const MAX_GOAL_LINES: usize = 5;
pub(super) fn validate_goal(goal: Option<&str>) -> Result<Option<String>, AppError> {
let Some(trimmed) = goal.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if trimmed.lines().count() > MAX_GOAL_LINES {
return Err(AppError::BadRequest(format!(
"goal must be at most {MAX_GOAL_LINES} lines"
)));
}
Ok(Some(trimmed.to_string()))
}