use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const AUTOMATION_TASK_SCHEMA_VERSION: i64 = 2;
pub const MAX_TIMEOUT_SECONDS: u64 = 86_400;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutomationTask {
pub id: String,
pub display_name: String,
pub normalized_name: String,
pub stored_prompt_id: String,
pub expected_outcome: String,
pub project_root: PathBuf,
pub timeout_seconds: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutomationTaskInput {
pub id: String,
pub display_name: String,
pub stored_prompt_id: String,
pub expected_outcome: String,
pub project_root: PathBuf,
pub timeout_seconds: u64,
}
pub fn validate_task_input(input: &AutomationTaskInput) -> Result<AutomationTaskInput, String> {
let id = validate_text_id(&input.id, "Task ID")?;
let display_name = validate_text_content(&input.display_name, "Display name")?;
let normalized = normalize_task_name(&display_name);
if normalized.is_empty() {
return Err("Display name must contain alphanumeric characters".to_string());
}
let stored_prompt_id = validate_text_id(&input.stored_prompt_id, "Stored prompt ID")?;
let expected_outcome = validate_text_content(&input.expected_outcome, "Expected outcome")?;
let project_root_str = input.project_root.to_string_lossy();
let project_root_trimmed = project_root_str.trim();
if project_root_trimmed.is_empty() {
return Err("Project root must not be empty".to_string());
}
if input.timeout_seconds == 0 {
return Err("Timeout must be greater than zero".to_string());
}
if input.timeout_seconds > MAX_TIMEOUT_SECONDS {
return Err(format!(
"Timeout must not exceed {} seconds",
MAX_TIMEOUT_SECONDS
));
}
Ok(AutomationTaskInput {
id,
display_name,
stored_prompt_id,
expected_outcome,
project_root: PathBuf::from(project_root_trimmed),
timeout_seconds: input.timeout_seconds,
})
}
pub fn normalize_task_name(name: &str) -> String {
let lower = name.trim().to_lowercase();
let mut result = String::with_capacity(lower.len());
let mut prev_was_hyphen = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() {
result.push(c);
prev_was_hyphen = false;
} else if (c == '-' || c.is_whitespace()) && !prev_was_hyphen && !result.is_empty() {
result.push('-');
prev_was_hyphen = true;
}
}
if result.ends_with('-') {
result.pop();
}
result
}
fn validate_text_id(raw: &str, label: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{} must not be empty", label));
}
if trimmed.as_bytes().iter().any(|b| b.is_ascii_control()) {
return Err(format!("{} must not contain control characters", label));
}
Ok(trimmed.to_string())
}
fn validate_text_content(raw: &str, label: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(format!("{} must not be empty", label));
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input() -> AutomationTaskInput {
AutomationTaskInput {
id: "daily-report".to_string(),
display_name: "Daily Report".to_string(),
stored_prompt_id: "report-prompt".to_string(),
expected_outcome: "A summary of today's activity".to_string(),
project_root: PathBuf::from("/tmp"),
timeout_seconds: 300,
}
}
#[test]
fn normalize_basic() {
assert_eq!(normalize_task_name("Check Email"), "check-email");
}
#[test]
fn normalize_strips_apostrophe() {
assert_eq!(
normalize_task_name("Paul's daily brief"),
"pauls-daily-brief"
);
}
#[test]
fn normalize_collapses_multiple_spaces() {
assert_eq!(normalize_task_name("My Task"), "my-task");
}
#[test]
fn normalize_strips_leading_trailing_whitespace() {
assert_eq!(normalize_task_name(" Spaced Task "), "spaced-task");
}
#[test]
fn normalize_drops_special_chars() {
assert_eq!(normalize_task_name("Task #1!"), "task-1");
}
#[test]
fn normalize_empty_after_processing() {
assert_eq!(normalize_task_name("'!!!'"), "");
}
#[test]
fn normalize_already_kebab() {
assert_eq!(normalize_task_name("check-email"), "check-email");
}
#[test]
fn normalize_case_insensitive() {
assert_eq!(normalize_task_name("DAILY-REPORT"), "daily-report");
assert_eq!(normalize_task_name("Daily Report"), "daily-report");
}
#[test]
fn valid_input_passes_validation() {
let input = valid_input();
let result = validate_task_input(&input).unwrap();
assert_eq!(result.id, "daily-report");
assert_eq!(result.display_name, "Daily Report");
assert_eq!(result.stored_prompt_id, "report-prompt");
assert_eq!(result.expected_outcome, "A summary of today's activity");
assert_eq!(result.project_root, PathBuf::from("/tmp"));
assert_eq!(result.timeout_seconds, 300);
}
#[test]
fn input_is_trimmed() {
let input = AutomationTaskInput {
id: " daily-report ".to_string(),
display_name: " Daily Report ".to_string(),
stored_prompt_id: " report-prompt ".to_string(),
expected_outcome: " Summary ".to_string(),
project_root: PathBuf::from(" /tmp "),
timeout_seconds: 300,
};
let result = validate_task_input(&input).unwrap();
assert_eq!(result.id, "daily-report");
assert_eq!(result.display_name, "Daily Report");
assert_eq!(result.stored_prompt_id, "report-prompt");
assert_eq!(result.expected_outcome, "Summary");
}
#[test]
fn empty_id_rejected() {
let mut input = valid_input();
input.id = " ".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn empty_display_name_rejected() {
let mut input = valid_input();
input.display_name = "".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn display_name_only_special_chars_rejected() {
let mut input = valid_input();
input.display_name = "'!!!'".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn empty_stored_prompt_id_rejected() {
let mut input = valid_input();
input.stored_prompt_id = " ".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn empty_expected_outcome_rejected() {
let mut input = valid_input();
input.expected_outcome = "".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn empty_project_root_rejected() {
let mut input = valid_input();
input.project_root = PathBuf::from("");
assert!(validate_task_input(&input).is_err());
}
#[test]
fn zero_timeout_rejected() {
let mut input = valid_input();
input.timeout_seconds = 0;
assert!(validate_task_input(&input).is_err());
}
#[test]
fn excessive_timeout_rejected() {
let mut input = valid_input();
input.timeout_seconds = MAX_TIMEOUT_SECONDS + 1;
assert!(validate_task_input(&input).is_err());
}
#[test]
fn max_timeout_accepted() {
let mut input = valid_input();
input.timeout_seconds = MAX_TIMEOUT_SECONDS;
assert!(validate_task_input(&input).is_ok());
}
#[test]
fn control_chars_in_id_rejected() {
let mut input = valid_input();
input.id = "task\0id".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn control_chars_in_stored_prompt_id_rejected() {
let mut input = valid_input();
input.stored_prompt_id = "prompt\rid".to_string();
assert!(validate_task_input(&input).is_err());
}
#[test]
fn ids_are_case_sensitive() {
let input_a = AutomationTaskInput {
id: "TaskA".to_string(),
..valid_input()
};
let input_b = AutomationTaskInput {
id: "taska".to_string(),
..valid_input()
};
let a = validate_task_input(&input_a).unwrap();
let b = validate_task_input(&input_b).unwrap();
assert_ne!(a.id, b.id);
}
#[test]
fn multiline_expected_outcome_accepted() {
let mut input = valid_input();
input.expected_outcome = "Line one\nLine two".to_string();
let result = validate_task_input(&input).unwrap();
assert_eq!(result.expected_outcome, "Line one\nLine two");
}
#[test]
fn automation_task_serializes() {
let now = Utc::now();
let task = AutomationTask {
id: "t1".to_string(),
display_name: "Test".to_string(),
normalized_name: "test".to_string(),
stored_prompt_id: "p1".to_string(),
expected_outcome: "Done".to_string(),
project_root: PathBuf::from("/tmp"),
timeout_seconds: 60,
created_at: now,
updated_at: now,
};
let json = serde_json::to_string(&task).unwrap();
let restored: AutomationTask = serde_json::from_str(&json).unwrap();
assert_eq!(task, restored);
}
}