use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
pub const DEMAND_SCHEMA_VERSION: u32 = 1;
pub const MAX_TITLE_LEN: usize = 200;
pub const MAX_DESCRIPTION_LEN: usize = 8000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandType {
Epic,
Story,
Task,
Bug,
}
impl DemandType {
pub fn parse(value: &str) -> Result<Self> {
match value.trim().to_lowercase().as_str() {
"epic" | "epico" | "épico" => Ok(Self::Epic),
"story" | "historia" | "história" => Ok(Self::Story),
"task" | "tarefa" => Ok(Self::Task),
"bug" => Ok(Self::Bug),
other => bail!("tipo de demanda inválido: {other} (use epic|story|task|bug)"),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Epic => "epic",
Self::Story => "story",
Self::Task => "task",
Self::Bug => "bug",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandSource {
Cli,
Jira,
Webhook,
}
impl DemandSource {
pub fn parse(value: &str) -> Result<Self> {
match value.trim().to_lowercase().as_str() {
"cli" | "" => Ok(Self::Cli),
"jira" => Ok(Self::Jira),
"webhook" => Ok(Self::Webhook),
other => bail!("origem de demanda inválida: {other} (use cli|jira|webhook)"),
}
}
#[allow(dead_code)]
pub fn is_untrusted(self) -> bool {
matches!(self, Self::Jira | Self::Webhook)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandStatus {
#[default]
Queued,
Orchestrating,
AwaitingApproval,
ReadyForExec,
Paused,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Demand {
pub schema_version: u32,
pub id: String,
#[serde(rename = "type")]
pub kind: DemandType,
pub title: String,
#[serde(default)]
pub description: String,
pub source: DemandSource,
#[serde(default = "default_priority")]
pub priority: String,
pub created_at: String,
pub slug: String,
#[serde(default)]
pub status: DemandStatus,
}
fn default_priority() -> String {
"normal".to_string()
}
impl Demand {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: impl Into<String>,
kind: DemandType,
title: impl Into<String>,
description: impl Into<String>,
source: DemandSource,
priority: Option<String>,
created_at: impl Into<String>,
) -> Result<Self> {
let id = id.into();
validate_id(&id)?;
let title = title.into();
if title.trim().is_empty() {
bail!("título da demanda vazio");
}
if title.chars().count() > MAX_TITLE_LEN {
bail!("título da demanda excede {MAX_TITLE_LEN} caracteres");
}
let description = description.into();
if description.chars().count() > MAX_DESCRIPTION_LEN {
bail!("descrição da demanda excede {MAX_DESCRIPTION_LEN} caracteres");
}
let slug = derive_slug(&title)?;
let priority = priority
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(default_priority);
Ok(Self {
schema_version: DEMAND_SCHEMA_VERSION,
id,
kind,
title,
description,
source,
priority,
created_at: created_at.into(),
slug,
status: DemandStatus::Queued,
})
}
}
pub fn validate_id(id: &str) -> Result<()> {
if id.is_empty() {
bail!("id de demanda vazio");
}
if id.len() > 64 {
bail!("id de demanda muito longo (máx. 64): {id}");
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!("id de demanda inválido: {id} (use apenas A-Z, a-z, 0-9, '-' e '_')");
}
Ok(())
}
pub fn derive_slug(title: &str) -> Result<String> {
let slug = crate::artifact_slug(title);
validate_slug(&slug)?;
Ok(slug)
}
pub fn validate_slug(slug: &str) -> Result<()> {
if slug.is_empty() {
bail!("slug vazio");
}
if slug.len() > 96 {
bail!("slug muito longo (máx. 96): {slug}");
}
if slug.contains("..") || slug.contains('/') || slug.contains('\\') {
bail!("slug inseguro (path traversal): {slug}");
}
if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
bail!("slug com caracteres inválidos: {slug}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Demand {
Demand::new(
"DEM-1",
DemandType::Story,
"Implementar a Esteira Autônoma",
"descrição da demanda",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap()
}
#[test]
fn new_demand_has_defaults_and_derived_slug() {
let d = sample();
assert_eq!(d.schema_version, DEMAND_SCHEMA_VERSION);
assert_eq!(d.status, DemandStatus::Queued);
assert_eq!(d.priority, "normal");
assert_eq!(d.slug, "implementar-a-esteira-autonoma");
}
#[test]
fn empty_title_is_rejected() {
let err = Demand::new(
"DEM-2",
DemandType::Task,
" ",
"",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap_err();
assert!(err.to_string().contains("título"));
}
#[test]
fn oversized_fields_are_rejected() {
let long_title = "a".repeat(MAX_TITLE_LEN + 1);
assert!(Demand::new(
"DEM-X",
DemandType::Task,
long_title,
"",
DemandSource::Cli,
None,
"t"
)
.is_err());
let long_desc = "b".repeat(MAX_DESCRIPTION_LEN + 1);
assert!(Demand::new(
"DEM-X",
DemandType::Task,
"ok",
long_desc,
DemandSource::Cli,
None,
"t"
)
.is_err());
}
#[test]
fn unsafe_id_is_rejected() {
for bad in ["../escape", "a/b", "with space", ""] {
assert!(validate_id(bad).is_err(), "esperava erro para id {bad:?}");
}
assert!(validate_id("DEM-123_x").is_ok());
}
#[test]
fn slug_strips_accents_and_blocks_traversal() {
assert_eq!(derive_slug("Ação & Café").unwrap(), "acao-cafe");
assert!(validate_slug("..").is_err());
assert!(validate_slug("a/b").is_err());
assert!(validate_slug("ok-slug-1").is_ok());
}
#[test]
fn long_title_slug_is_truncated_before_validation() {
let slug = derive_slug(
"A jogada mais limpa: SDD continua runtime-agnostic. Rig, Flue, LangGraph e Agno viram engines adapters sem tomar posse do contrato SDLC.",
)
.unwrap();
assert!(slug.len() <= 96);
assert!(slug.starts_with("a-jogada-mais-limpa"));
assert!(validate_slug(&slug).is_ok());
}
#[test]
fn priority_falls_back_to_normal_when_blank() {
let d = Demand::new(
"DEM-3",
DemandType::Bug,
"Corrigir X",
"",
DemandSource::Jira,
Some(" ".to_string()),
"2026-06-08T00:00:00Z",
)
.unwrap();
assert_eq!(d.priority, "normal");
assert!(d.source.is_untrusted());
}
#[test]
fn type_and_source_parsing() {
assert_eq!(DemandType::parse("Story").unwrap(), DemandType::Story);
assert_eq!(DemandType::parse("história").unwrap(), DemandType::Story);
assert!(DemandType::parse("feature").is_err());
assert_eq!(DemandSource::parse("").unwrap(), DemandSource::Cli);
assert_eq!(
DemandSource::parse("webhook").unwrap(),
DemandSource::Webhook
);
assert!(DemandSource::parse("email").is_err());
}
#[test]
fn serde_round_trip_preserves_fields() {
let d = sample();
let json = serde_json::to_string_pretty(&d).unwrap();
assert!(json.contains("\"type\": \"story\""));
assert!(json.contains("\"status\": \"queued\""));
let back: Demand = serde_json::from_str(&json).unwrap();
assert_eq!(d, back);
}
}