KeiSeiKit-1.0/_primitives/_rust/kei-pet/src/validate.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

239 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Validation rules R1R19.
//!
//! `validate()` returns `Err(Vec<ValidationError>)` accumulating ALL errors,
//! not just the first. This lets `/pet-setup` and `kei-pet validate` surface
//! the full diagnostic in one pass.
use crate::schema::*;
use crate::SCHEMA_VERSION;
use thiserror::Error;
const PET_NAME_MAX: usize = 24;
const USER_NAME_MAX: usize = 48;
#[derive(Debug, Error, PartialEq)]
pub enum ValidationError {
#[error("R1: schema version mismatch: found {0}, expected {SCHEMA_VERSION}")]
SchemaVersion(u32),
#[error("R2: identity.pet_name empty or exceeds {PET_NAME_MAX} chars")]
PetNameInvalid,
#[error("R2: identity.user_name empty or exceeds {USER_NAME_MAX} chars")]
UserNameInvalid,
#[error("R4: identity.languages must have ≥1 entry (ISO 639-1)")]
LanguagesEmpty,
#[error("R4: identity.languages[{0}] '{1}' not a valid ISO 639-1 2-letter code")]
LanguageNotIso(usize, String),
#[error("R6: voice.tone_secondary length {0} exceeds max 2")]
ToneSecondaryTooMany(usize),
#[error("R6: voice.tone_primary {0:?} present in tone_secondary (must differ)")]
ToneSecondaryDuplicatePrimary(Tone),
#[error("R10: edge.profanity = Never but profanity_languages is non-empty")]
ProfanityLanguagesWhenNever,
#[error("R10: edge.profanity_languages['{0}'] not in identity.languages")]
ProfanityLanguageNotDeclared(String),
#[error("R12: interests[{0}].topic '{1}' not slug-safe (must match [a-z0-9-]+ with no leading/trailing dash)")]
InterestTopicNotSlug(usize, String),
#[error("R14: interests[{0}].topic '{1}' also appears in forbidden.topics (contradiction)")]
InterestForbiddenContradiction(usize, String),
#[error("R16: routines[{0}].schedule '{1}' does not parse as known grammar (HH:MM / dow-HH:MM / every-Nh / no-commit-for-Nh / N-errors-in-N-calls)")]
RoutineScheduleInvalid(usize, String),
#[error("R18: forbidden.topics[{0}] is empty/whitespace")]
ForbiddenTopicEmpty(usize),
#[error("R18: forbidden.tone_patterns[{0}] is empty/whitespace")]
ForbiddenTonePatternEmpty(usize),
#[error("R19: meta.{0} is not a valid ISO-8601 timestamp: '{1}'")]
MetaTimestampInvalid(&'static str, String),
#[error("appearance.color_primary '{0}' not a valid hex colour (#RRGGBB)")]
HexColorInvalid(String),
}
/// Run R1R19. Returns `Err(Vec<ValidationError>)` on any failure.
pub fn validate(m: &PetManifest) -> Result<(), Vec<ValidationError>> {
let mut errs = Vec::new();
// R1 — schema version
if m.schema != SCHEMA_VERSION {
errs.push(ValidationError::SchemaVersion(m.schema));
}
// R2 — name bounds
if m.identity.pet_name.is_empty() || m.identity.pet_name.chars().count() > PET_NAME_MAX {
errs.push(ValidationError::PetNameInvalid);
}
if m.identity.user_name.is_empty() || m.identity.user_name.chars().count() > USER_NAME_MAX {
errs.push(ValidationError::UserNameInvalid);
}
// R3 — addressing: enum-checked by serde at parse time; no runtime check needed.
// R4 — languages: ≥1, each 2 ASCII-lower
if m.identity.languages.is_empty() {
errs.push(ValidationError::LanguagesEmpty);
} else {
for (i, lang) in m.identity.languages.iter().enumerate() {
if lang.len() != 2 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
errs.push(ValidationError::LanguageNotIso(i, lang.clone()));
}
}
}
// R5, R7, R8, R11, R13, R15, R17 — enum membership guaranteed by serde.
// R6 — tone_secondary cardinality + no duplicate of primary
if m.voice.tone_secondary.len() > 2 {
errs.push(ValidationError::ToneSecondaryTooMany(m.voice.tone_secondary.len()));
}
if m.voice.tone_secondary.contains(&m.voice.tone_primary) {
errs.push(ValidationError::ToneSecondaryDuplicatePrimary(m.voice.tone_primary));
}
// R9 — profanity enum: serde-validated.
// R10 — profanity/language consistency
if m.edge.profanity == Profanity::Never && !m.edge.profanity_languages.is_empty() {
errs.push(ValidationError::ProfanityLanguagesWhenNever);
}
if m.edge.profanity != Profanity::Never {
for lang in &m.edge.profanity_languages {
if !m.identity.languages.contains(lang) {
errs.push(ValidationError::ProfanityLanguageNotDeclared(lang.clone()));
}
}
}
// R12 — interests[].topic slug-safe
for (i, interest) in m.interests.iter().enumerate() {
if !is_slug_safe(&interest.topic) {
errs.push(ValidationError::InterestTopicNotSlug(i, interest.topic.clone()));
}
// R14 — no overlap with forbidden.topics
if m.forbidden.topics.contains(&interest.topic) {
errs.push(ValidationError::InterestForbiddenContradiction(i, interest.topic.clone()));
}
}
// R16 — routine schedule grammar
for (i, routine) in m.routines.iter().enumerate() {
if !is_valid_schedule(&routine.schedule) {
errs.push(ValidationError::RoutineScheduleInvalid(i, routine.schedule.clone()));
}
}
// R17 — routines[].template existence is checked by the runtime (we don't
// have filesystem context here). Left to /pet-setup verify step.
// R18 — forbidden entries non-empty strings
for (i, t) in m.forbidden.topics.iter().enumerate() {
if t.trim().is_empty() {
errs.push(ValidationError::ForbiddenTopicEmpty(i));
}
}
for (i, t) in m.forbidden.tone_patterns.iter().enumerate() {
if t.trim().is_empty() {
errs.push(ValidationError::ForbiddenTonePatternEmpty(i));
}
}
// R19 — meta ISO-8601
if !is_iso8601(&m.meta.created_at) {
errs.push(ValidationError::MetaTimestampInvalid("created_at", m.meta.created_at.clone()));
}
if !is_iso8601(&m.meta.last_tuned) {
errs.push(ValidationError::MetaTimestampInvalid("last_tuned", m.meta.last_tuned.clone()));
}
// Bonus — hex colours (appearance is optional; only validate when present)
if let Some(app) = &m.appearance {
if !is_hex_colour(&app.color_primary) {
errs.push(ValidationError::HexColorInvalid(app.color_primary.clone()));
}
if !is_hex_colour(&app.color_secondary) {
errs.push(ValidationError::HexColorInvalid(app.color_secondary.clone()));
}
}
if errs.is_empty() { Ok(()) } else { Err(errs) }
}
fn is_slug_safe(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !s.starts_with('-')
&& !s.ends_with('-')
&& !s.contains("--")
}
fn is_hex_colour(s: &str) -> bool {
s.len() == 7
&& s.starts_with('#')
&& s[1..].chars().all(|c| c.is_ascii_hexdigit())
}
/// Recognised schedule grammar — strings that the runtime scheduler can act on.
///
/// Accepted shapes:
/// - `HH:MM` — fixed daily time
/// - `dow-HH:MM` (e.g. `sun-10:00`) — fixed weekly time
/// - `every-Nh` — every N hours
/// - `no-commit-for-Nh` — idle trigger
/// - `N-errors-in-N-calls` (e.g. `3-errors-in-20-calls`)
fn is_valid_schedule(s: &str) -> bool {
// HH:MM
if parse_hhmm(s).is_some() { return true; }
// dow-HH:MM
if let Some((dow, rest)) = s.split_once('-') {
let dows = ["mon","tue","wed","thu","fri","sat","sun"];
if dows.contains(&dow) && parse_hhmm(rest).is_some() { return true; }
}
// every-Nh
if let Some(rest) = s.strip_prefix("every-") {
if let Some(n) = rest.strip_suffix('h') {
if n.parse::<u32>().is_ok() { return true; }
}
}
// no-commit-for-Nh
if let Some(rest) = s.strip_prefix("no-commit-for-") {
if let Some(n) = rest.strip_suffix('h') {
if n.parse::<u32>().is_ok() { return true; }
}
}
// N-errors-in-N-calls
if let Some((n1, rest)) = s.split_once("-errors-in-") {
if let Some(n2) = rest.strip_suffix("-calls") {
if n1.parse::<u32>().is_ok() && n2.parse::<u32>().is_ok() { return true; }
}
}
false
}
fn parse_hhmm(s: &str) -> Option<(u32, u32)> {
let (h, m) = s.split_once(':')?;
let h: u32 = h.parse().ok()?;
let m: u32 = m.parse().ok()?;
if h <= 23 && m <= 59 { Some((h, m)) } else { None }
}
fn is_iso8601(s: &str) -> bool {
chrono::DateTime::parse_from_rfc3339(s).is_ok()
}