diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 155f9d6..a93b146 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -102,6 +102,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ascii-canvas" version = "3.0.0" @@ -871,6 +883,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1066,6 +1092,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.10.1" @@ -1330,6 +1362,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core 0.6.4", "serde", "sha2 0.10.9", "subtle", @@ -2671,6 +2704,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "kei-pet" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "chrono", + "clap", + "ed25519-dalek", + "hex", + "rand_core 0.6.4", + "serde", + "tempfile", + "thiserror 1.0.69", + "toml", +] + [[package]] name = "kei-pipe" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 322eb17..3a7cb98 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -73,6 +73,8 @@ members = [ "kei-shared", # v0.32 Wave 15 — read-only DNA adjacency/cluster/precedent over kei-ledger "kei-dna-index", + # Pet UI v1 — persona manifest parse/validate + Ed25519 identity + overlay renderer + "kei-pet", ] [workspace.package] diff --git a/_primitives/_rust/kei-pet/Cargo.toml b/_primitives/_rust/kei-pet/Cargo.toml new file mode 100644 index 0000000..bb2350a --- /dev/null +++ b/_primitives/_rust/kei-pet/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "kei-pet" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Pet persona manifest: pet.toml parse, validate, system-prompt overlay. Standard Ed25519 identity. CQRS-compatible; no proprietary math." +license = "MIT" + +[[bin]] +name = "kei-pet" +path = "src/bin/kei-pet.rs" + +[lib] +name = "kei_pet" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" +thiserror = "1" +anyhow = "1" +clap = { version = "4", features = ["derive"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +ed25519-dalek = { version = "2", features = ["rand_core", "std"] } +rand_core = { version = "0.6", features = ["std"] } +blake3 = "1" +hex = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-pet/examples/full.toml b/_primitives/_rust/kei-pet/examples/full.toml new file mode 100644 index 0000000..36858d2 --- /dev/null +++ b/_primitives/_rust/kei-pet/examples/full.toml @@ -0,0 +1,90 @@ +# Full pet.toml — every optional section populated. + +schema = 1 + +[identity] +pet_name = "Kei" +user_name = "Denis" +addressing = "by-name" +languages = ["ru", "en"] + +[voice] +tone_primary = "dry" +tone_secondary = ["supportive"] +humor_style = "dark+meta" +humor_frequency = "medium" + +[edge] +profanity = "mirror-user" +profanity_languages = ["ru"] +directness = "hard" +initiative = "tap-on-shoulder" + +[appearance] +base_shape = "cat" +size = "small" +color_primary = "#8B4513" +color_secondary = "#FFD700" +eyes = "round" +expression = "focused" +accessories = ["glasses", "laptop"] + +[room] +theme = "study" +lighting = "warm" +decor = ["desk", "bookshelf", "window-forest", "plant"] +time_sync = true + +[privacy] +public_profile = true +publish_allowed = true +share_dreams = false +share_garden = "summary" + +[[interests]] +topic = "distributed-systems" +depth = "expert" +freshness = "weekly" +vault_path = "memory/interests/distributed-systems" +last_refresh = "" + +[[interests]] +topic = "rust-async-runtimes" +depth = "expert" +freshness = "daily" +vault_path = "memory/interests/rust-async-runtimes" +last_refresh = "" + +[[routines]] +kind = "morning-digest" +schedule = "09:00" +template = "pet-routine-morning" +enabled = true + +[[routines]] +kind = "evening-recap" +schedule = "19:00" +template = "pet-routine-evening" +enabled = true + +[[routines]] +kind = "weekly-deepdive" +schedule = "sun-10:00" +template = "pet-routine-weekly" +enabled = true + +[[routines]] +kind = "idle-check" +schedule = "no-commit-for-3h" +template = "pet-routine-idle-nudge" +enabled = true + +[forbidden] +topics = ["politics", "crypto-hype"] +tone_patterns = ["motivational-platitudes", "empty-affirmations"] + +[meta] +schema_version_written_by = "kei-pet 0.1.0" +created_at = "2026-04-23T12:30:00Z" +last_tuned = "2026-04-23T12:30:00Z" +tune_count = 0 diff --git a/_primitives/_rust/kei-pet/examples/minimal.toml b/_primitives/_rust/kei-pet/examples/minimal.toml new file mode 100644 index 0000000..58aef59 --- /dev/null +++ b/_primitives/_rust/kei-pet/examples/minimal.toml @@ -0,0 +1,34 @@ +# Minimal pet.toml — smallest valid manifest. +# Required: schema, identity (all 4 fields), voice (4 fields), edge (3 fields), +# forbidden (both arrays can be empty), meta (3 fields). +# Optional: appearance, room, privacy, interests, routines. + +schema = 1 + +[identity] +pet_name = "Kei" +user_name = "Alex" +addressing = "by-name" +languages = ["en"] + +[voice] +tone_primary = "neutral" +tone_secondary = [] +humor_style = "none" +humor_frequency = "rare" + +[edge] +profanity = "never" +profanity_languages = [] +directness = "balanced" +initiative = "wait" + +[forbidden] +topics = [] +tone_patterns = [] + +[meta] +schema_version_written_by = "kei-pet 0.1.0" +created_at = "2026-04-23T12:30:00Z" +last_tuned = "2026-04-23T12:30:00Z" +tune_count = 0 diff --git a/_primitives/_rust/kei-pet/src/bin/kei-pet.rs b/_primitives/_rust/kei-pet/src/bin/kei-pet.rs new file mode 100644 index 0000000..5f2441f --- /dev/null +++ b/_primitives/_rust/kei-pet/src/bin/kei-pet.rs @@ -0,0 +1,140 @@ +//! kei-pet — CLI wrapper over the `kei_pet` library. +//! +//! Subcommands: +//! validate Parse + run R1–R19 on a pet.toml, print PASS/FAIL +//! show Print the rendered system-prompt overlay +//! identity new | show — generate or display Ed25519 keypair +//! tune Surgical axis edit (kv: `voice.tone_primary=warm`) + +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use kei_pet::{parse, system_prompt, generate_keypair}; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "kei-pet", version, about = "Pet persona manifest tool")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Parse and validate a pet.toml file. + Validate { path: PathBuf }, + + /// Render and print the system-prompt overlay for a pet.toml. + Show { path: PathBuf }, + + /// Ed25519 identity operations. + Identity { + #[arg(value_parser = ["new", "show"])] + action: String, + #[arg(long, default_value = "~/.keisei/identity.key")] + path: String, + }, + + /// Surgical edit of one axis. `path` is the pet.toml, `kv` is key=value. + /// Example: `kei-pet tune ~/.keisei/pet.toml voice.tone_primary=warm` + Tune { path: PathBuf, kv: String }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.cmd { + Cmd::Validate { path } => cmd_validate(&path), + Cmd::Show { path } => cmd_show(&path), + Cmd::Identity { action, path } => cmd_identity(&action, &path), + Cmd::Tune { path, kv } => cmd_tune(&path, &kv), + } +} + +fn cmd_validate(path: &PathBuf) -> Result<()> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + match parse(&text) { + Ok(m) => { + println!( + "PASS — {} ({}) | schema v{} | {} interest(s) | {} routine(s)", + m.identity.pet_name, + m.identity.user_name, + m.schema, + m.interests.len(), + m.routines.len(), + ); + Ok(()) + } + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } +} + +fn cmd_show(path: &PathBuf) -> Result<()> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let m = parse(&text)?; + print!("{}", system_prompt(&m)); + Ok(()) +} + +fn cmd_identity(action: &str, path_str: &str) -> Result<()> { + let path = expand_tilde(path_str); + match action { + "new" => { + if path.exists() { + bail!("identity file already exists at {} — refusing to overwrite", path.display()); + } + let kp = generate_keypair(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, kp.secret_hex())?; + set_permissions_0600(&path).ok(); + println!("generated new identity"); + println!(" public: {}", kp.public_hex()); + println!(" user_id: {}", kp.user_id()); + println!(" stored: {}", path.display()); + Ok(()) + } + "show" => { + if !path.exists() { + bail!("no identity file at {}", path.display()); + } + let secret_hex = fs::read_to_string(&path)?; + let kp = kei_pet::identity::Keypair::from_secret_hex(secret_hex.trim())?; + println!("public: {}", kp.public_hex()); + println!("user_id: {}", kp.user_id()); + Ok(()) + } + _ => bail!("unknown identity action: {action}"), + } +} + +fn cmd_tune(_path: &PathBuf, _kv: &str) -> Result<()> { + // Full tune implementation (axis lookup + mutate + revalidate + persist) + // arrives with `/pet-tune` skill (Day 2). Today we ship the parse layer + // and leave this as a typed stub so the CLI surface is stable from v0.1. + eprintln!("tune: not yet implemented (Day 2 — /pet-tune skill)"); + std::process::exit(2); +} + +fn expand_tilde(s: &str) -> PathBuf { + if let Some(rest) = s.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(rest); + } + } + PathBuf::from(s) +} + +#[cfg(unix)] +fn set_permissions_0600(p: &std::path::Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(p)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(p, perms) +} + +#[cfg(not(unix))] +fn set_permissions_0600(_p: &std::path::Path) -> std::io::Result<()> { Ok(()) } diff --git a/_primitives/_rust/kei-pet/src/identity.rs b/_primitives/_rust/kei-pet/src/identity.rs new file mode 100644 index 0000000..3e45913 --- /dev/null +++ b/_primitives/_rust/kei-pet/src/identity.rs @@ -0,0 +1,130 @@ +//! Ed25519 identity (RFC 8032) — no proprietary crypto, no matrix math. +//! +//! Identity flow: +//! 1. Client generates `Keypair` on first run (`generate_keypair`). +//! 2. `user_id` is the first 16 hex chars of `blake3(public_key_bytes)`. +//! 3. Requests are signed with the private key; the server verifies using +//! the advertised public key. +//! +//! The public key is safe to publish; the private key is stored locally in +//! `~/.keisei/identity.key` (filesystem permissions `0600`). + +use ed25519_dalek::{Signature, SigningKey, VerifyingKey, Signer, Verifier}; +use rand_core::OsRng; + +#[derive(Debug, Clone)] +pub struct Keypair { + pub signing: SigningKey, +} + +impl Keypair { + pub fn verifying_key(&self) -> VerifyingKey { + self.signing.verifying_key() + } + + pub fn sign(&self, msg: &[u8]) -> Signature { + self.signing.sign(msg) + } + + pub fn public_hex(&self) -> String { + hex::encode(self.verifying_key().as_bytes()) + } + + pub fn secret_hex(&self) -> String { + hex::encode(self.signing.to_bytes()) + } + + pub fn user_id(&self) -> String { + user_id_from_pubkey(self.verifying_key().as_bytes()) + } + + /// Reconstruct from a 32-byte secret hex string. + pub fn from_secret_hex(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str)?; + if bytes.len() != 32 { + anyhow::bail!("secret key must be 32 bytes (got {})", bytes.len()); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Ok(Keypair { signing: SigningKey::from_bytes(&arr) }) + } +} + +/// Derive a stable 16-hex-char user id from a 32-byte Ed25519 public key. +pub fn user_id_from_pubkey(pubkey: &[u8; 32]) -> String { + let h = blake3::hash(pubkey); + hex::encode(&h.as_bytes()[..8]) +} + +/// Generate a fresh Ed25519 keypair using the OS RNG. +pub fn generate_keypair() -> Keypair { + Keypair { signing: SigningKey::generate(&mut OsRng) } +} + +/// Verify a signature against a public key and message. +pub fn verify(pubkey_hex: &str, msg: &[u8], sig_hex: &str) -> Result<(), anyhow::Error> { + let pub_bytes = hex::decode(pubkey_hex)?; + if pub_bytes.len() != 32 { + anyhow::bail!("public key must be 32 bytes (got {})", pub_bytes.len()); + } + let mut pub_arr = [0u8; 32]; + pub_arr.copy_from_slice(&pub_bytes); + let verifying = VerifyingKey::from_bytes(&pub_arr)?; + + let sig_bytes = hex::decode(sig_hex)?; + if sig_bytes.len() != 64 { + anyhow::bail!("signature must be 64 bytes (got {})", sig_bytes.len()); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let sig = Signature::from_bytes(&sig_arr); + + verifying.verify(msg, &sig).map_err(|e| anyhow::anyhow!("signature verify failed: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_sign_verify() { + let kp = generate_keypair(); + let msg = b"hello pet"; + let sig = kp.sign(msg); + assert!(kp.verifying_key().verify(msg, &sig).is_ok()); + } + + #[test] + fn user_id_is_16_hex() { + let kp = generate_keypair(); + let id = kp.user_id(); + assert_eq!(id.len(), 16); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn user_id_is_deterministic() { + let kp = generate_keypair(); + let id1 = kp.user_id(); + let id2 = user_id_from_pubkey(kp.verifying_key().as_bytes()); + assert_eq!(id1, id2); + } + + #[test] + fn secret_roundtrip() { + let kp1 = generate_keypair(); + let hex = kp1.secret_hex(); + let kp2 = Keypair::from_secret_hex(&hex).unwrap(); + assert_eq!(kp1.public_hex(), kp2.public_hex()); + assert_eq!(kp1.user_id(), kp2.user_id()); + } + + #[test] + fn verify_via_hex_api() { + let kp = generate_keypair(); + let msg = b"cross-boundary verify"; + let sig = kp.sign(msg); + let sig_hex = hex::encode(sig.to_bytes()); + assert!(verify(&kp.public_hex(), msg, &sig_hex).is_ok()); + } +} diff --git a/_primitives/_rust/kei-pet/src/lib.rs b/_primitives/_rust/kei-pet/src/lib.rs new file mode 100644 index 0000000..347255b --- /dev/null +++ b/_primitives/_rust/kei-pet/src/lib.rs @@ -0,0 +1,40 @@ +//! kei-pet — pet persona manifest parse/validate/overlay. +//! +//! Scope boundaries: this crate implements a standard TOML-backed persona +//! manifest. Identity is Ed25519 (RFC 8032). Cache/projection patterns are +//! standard CQRS. NO imports, references, or conceptual mentions of sibling +//! research-grade projects are permitted in this crate. + +pub mod schema; +pub mod validate; +pub mod overlay; +pub mod identity; + +pub use schema::PetManifest; +pub use validate::{ValidationError, validate}; +pub use overlay::system_prompt; +pub use identity::{generate_keypair, user_id_from_pubkey, Keypair}; + +/// Current schema version written by this crate. +pub const SCHEMA_VERSION: u32 = 1; + +/// Parse TOML text → `PetManifest`, running full validation. +/// +/// Returns the manifest on success, or the accumulated validation errors. +pub fn parse(toml_text: &str) -> Result { + let manifest: PetManifest = toml::from_str(toml_text)?; + validate(&manifest).map_err(|errs| { + anyhow::anyhow!( + "pet.toml validation failed ({} error{}):\n{}", + errs.len(), + if errs.len() == 1 { "" } else { "s" }, + errs.iter().map(|e| format!(" - {e}")).collect::>().join("\n") + ) + })?; + Ok(manifest) +} + +/// Serialize a validated manifest back to TOML text. +pub fn to_toml(manifest: &PetManifest) -> Result { + toml::to_string_pretty(manifest) +} diff --git a/_primitives/_rust/kei-pet/src/overlay.rs b/_primitives/_rust/kei-pet/src/overlay.rs new file mode 100644 index 0000000..639b59a --- /dev/null +++ b/_primitives/_rust/kei-pet/src/overlay.rs @@ -0,0 +1,159 @@ +//! Render a validated `PetManifest` → system-prompt overlay string. +//! +//! Used by any agent spawn / routine render: prepend this text to the agent's +//! base system prompt. Deterministic — same manifest → same overlay, byte-for-byte. + +use crate::schema::*; +use std::fmt::Write; + +/// Build the overlay prefix that a `PetManifest` contributes to a system prompt. +pub fn system_prompt(m: &PetManifest) -> String { + let mut s = String::with_capacity(1024); + + writeln!( + s, + "You are {}, a companion to {}.{}", + m.identity.pet_name, + m.identity.user_name, + addressing_hint(m.identity.addressing, &m.identity.user_name), + ).unwrap(); + + // Voice + write!(s, "Primary tone: {}.", tone_str(m.voice.tone_primary)).unwrap(); + if !m.voice.tone_secondary.is_empty() { + write!(s, " Blended with: ").unwrap(); + let blended: Vec<&str> = m.voice.tone_secondary.iter().copied().map(tone_str).collect(); + write!(s, "{}.", blended.join(", ")).unwrap(); + } + writeln!(s).unwrap(); + + writeln!( + s, + "Humor: {} at {} frequency.", + humor_style_str(m.voice.humor_style), + humor_freq_str(m.voice.humor_frequency), + ).unwrap(); + + // Edge + writeln!(s, "{}", profanity_line(&m.edge)).unwrap(); + writeln!( + s, + "Directness: {}. Initiative: {}.", + directness_str(m.edge.directness), + initiative_str(m.edge.initiative), + ).unwrap(); + + // Interests + if !m.interests.is_empty() { + writeln!(s, "\n{}'s interests (treat as peer at the declared depth — no basics explain-back):", m.identity.user_name).unwrap(); + for i in &m.interests { + writeln!(s, " - {} ({})", i.topic, depth_str(i.depth)).unwrap(); + } + } + + // Forbidden + if !m.forbidden.topics.is_empty() || !m.forbidden.tone_patterns.is_empty() { + writeln!(s).unwrap(); + if !m.forbidden.topics.is_empty() { + writeln!(s, "Never engage with: {}.", m.forbidden.topics.join(", ")).unwrap(); + } + if !m.forbidden.tone_patterns.is_empty() { + writeln!(s, "Never use: {}.", m.forbidden.tone_patterns.join(", ")).unwrap(); + } + } + + // Language preference + if m.identity.languages.len() > 1 { + let first = &m.identity.languages[0]; + let rest = m.identity.languages[1..].join(", "); + writeln!( + s, + "\nLanguage: prefer {}, code-switch to {} for domain terms.", + first, rest, + ).unwrap(); + } else if let Some(only) = m.identity.languages.first() { + writeln!(s, "\nLanguage: {}.", only).unwrap(); + } + + s +} + +fn addressing_hint(a: Addressing, user: &str) -> String { + match a { + Addressing::ByName => format!(" Address {user} by name."), + Addressing::Nickname => format!(" Address {user} by an affectionate nickname (ask once; reuse thereafter)."), + Addressing::Formal => format!(" Address {user} formally (вы / Mr./Ms. / sir/madam as language dictates)."), + Addressing::NoAddress => String::new(), + } +} + +fn tone_str(t: Tone) -> &'static str { + match t { + Tone::Warm => "warm", + Tone::Dry => "dry", + Tone::Sarcastic => "sarcastic", + Tone::Neutral => "neutral", + Tone::Supportive => "supportive", + } +} + +fn humor_style_str(h: HumorStyle) -> &'static str { + match h { + HumorStyle::None => "no humor", + HumorStyle::Puns => "wordplay", + HumorStyle::Dark => "dark humor", + HumorStyle::Absurd => "absurdist humor", + HumorStyle::EngineeringMeta => "engineering meta-humor", + HumorStyle::DarkMeta => "dark + engineering-meta humor", + } +} + +fn humor_freq_str(f: HumorFrequency) -> &'static str { + match f { + HumorFrequency::Rare => "rare", + HumorFrequency::Medium => "medium", + HumorFrequency::Often => "often", + } +} + +fn directness_str(d: Directness) -> &'static str { + match d { + Directness::Soft => "soft, hedge when uncertain", + Directness::Balanced => "balanced, plain but polite", + Directness::Hard => "hard — say what you see, no hedging", + } +} + +fn initiative_str(i: Initiative) -> &'static str { + match i { + Initiative::Wait => "wait until asked", + Initiative::Suggest => "suggest occasionally", + Initiative::TapOnShoulder => "tap on the shoulder when you spot a problem", + } +} + +fn depth_str(d: Depth) -> &'static str { + match d { + Depth::Shallow => "shallow", + Depth::Intermediate => "intermediate", + Depth::Expert => "expert", + } +} + +fn profanity_line(e: &Edge) -> String { + match e.profanity { + Profanity::Never => "Profanity: never.".to_string(), + Profanity::Accent => format!( + "Profanity: rare, used only for strong accent in {}.", + e.profanity_languages.join(", ") + ), + Profanity::Casual => format!( + "Profanity: casual in {}.", + e.profanity_languages.join(", ") + ), + Profanity::MirrorUser => format!( + "Profanity: mirror the user's style in {}.", + e.profanity_languages.join(", ") + ), + } +} diff --git a/_primitives/_rust/kei-pet/src/schema.rs b/_primitives/_rust/kei-pet/src/schema.rs new file mode 100644 index 0000000..1c4473c --- /dev/null +++ b/_primitives/_rust/kei-pet/src/schema.rs @@ -0,0 +1,317 @@ +//! Schema types for pet.toml. +//! +//! Enums use `#[serde(rename_all = "kebab-case")]` to match the TOML wire +//! format (e.g. `"mirror-user"`). Optional fields use `Option` and are +//! omitted on serialize when `None`. Arrays default to `Vec::new()`. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PetManifest { + /// Schema version. Must be `1` for this crate. + pub schema: u32, + + pub identity: Identity, + pub voice: Voice, + pub edge: Edge, + + #[serde(default)] + pub appearance: Option, + + #[serde(default)] + pub room: Option, + + #[serde(default)] + pub privacy: Option, + + #[serde(default, rename = "interests")] + pub interests: Vec, + + #[serde(default, rename = "routines")] + pub routines: Vec, + + pub forbidden: Forbidden, + + pub meta: Meta, +} + +// ─────────────────────────────── identity ──────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Identity { + pub pet_name: String, + pub user_name: String, + pub addressing: Addressing, + pub languages: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Addressing { + ByName, + Nickname, + Formal, + NoAddress, +} + +// ───────────────────────────────── voice ───────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Voice { + pub tone_primary: Tone, + #[serde(default)] + pub tone_secondary: Vec, + pub humor_style: HumorStyle, + pub humor_frequency: HumorFrequency, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum Tone { + Warm, + Dry, + Sarcastic, + Neutral, + Supportive, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum HumorStyle { + None, + Puns, + Dark, + Absurd, + #[serde(rename = "engineering-meta")] + EngineeringMeta, + #[serde(rename = "dark+meta")] + DarkMeta, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum HumorFrequency { + Rare, + Medium, + Often, +} + +// ────────────────────────────────── edge ───────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Edge { + pub profanity: Profanity, + #[serde(default)] + pub profanity_languages: Vec, + pub directness: Directness, + pub initiative: Initiative, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Profanity { + Never, + Accent, + Casual, + MirrorUser, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Directness { + Soft, + Balanced, + Hard, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Initiative { + Wait, + Suggest, + TapOnShoulder, +} + +// ─────────────────────────────── appearance ────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Appearance { + pub base_shape: BaseShape, + pub size: Size, + pub color_primary: String, + pub color_secondary: String, + pub eyes: Eyes, + pub expression: Expression, + #[serde(default)] + pub accessories: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum BaseShape { + Cat, + Dog, + Blob, + Owl, + Bot, + Capybara, + Dragon, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Size { + Tiny, + Small, + Medium, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Eyes { + Round, + Sleepy, + Sharp, + Dots, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Expression { + Focused, + Curious, + Grumpy, + Happy, + Neutral, +} + +// ────────────────────────────────── room ───────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Room { + pub theme: RoomTheme, + pub lighting: Lighting, + #[serde(default)] + pub decor: Vec, + #[serde(default = "default_true")] + pub time_sync: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum RoomTheme { + Study, + Nature, + Cyberpunk, + Minimalist, + Cozy, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Lighting { + Warm, + Cool, + Natural, + Moody, +} + +// ──────────────────────────────── privacy ──────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Privacy { + #[serde(default = "default_true")] + pub public_profile: bool, + #[serde(default = "default_true")] + pub publish_allowed: bool, + #[serde(default)] + pub share_dreams: bool, + #[serde(default = "default_summary")] + pub share_garden: GardenVisibility, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GardenVisibility { + Full, + Summary, + None, +} + +// ─────────────────────────────── interests ─────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Interest { + pub topic: String, + pub depth: Depth, + pub freshness: Freshness, + #[serde(default)] + pub vault_path: String, + #[serde(default)] + pub last_refresh: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Depth { + Shallow, + Intermediate, + Expert, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Freshness { + Daily, + Weekly, + Monthly, + OnDemand, +} + +// ──────────────────────────────── routines ─────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Routine { + pub kind: RoutineKind, + pub schedule: String, + pub template: String, + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum RoutineKind { + MorningDigest, + EveningRecap, + WeeklyDeepdive, + IdleCheck, + ErrorSpike, + Custom, +} + +// ─────────────────────────────── forbidden ─────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Forbidden { + #[serde(default)] + pub topics: Vec, + #[serde(default)] + pub tone_patterns: Vec, +} + +// ────────────────────────────────── meta ───────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Meta { + pub schema_version_written_by: String, + pub created_at: String, + pub last_tuned: String, + #[serde(default)] + pub tune_count: u32, +} + +// ──────────────────────────────── helpers ──────────────────────────────── + +fn default_true() -> bool { true } +fn default_summary() -> GardenVisibility { GardenVisibility::Summary } diff --git a/_primitives/_rust/kei-pet/src/validate.rs b/_primitives/_rust/kei-pet/src/validate.rs new file mode 100644 index 0000000..39ff7e9 --- /dev/null +++ b/_primitives/_rust/kei-pet/src/validate.rs @@ -0,0 +1,239 @@ +//! Validation rules R1–R19. +//! +//! `validate()` returns `Err(Vec)` 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 R1–R19. Returns `Err(Vec)` on any failure. +pub fn validate(m: &PetManifest) -> Result<(), Vec> { + 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::().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::().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::().is_ok() && n2.parse::().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() +} diff --git a/_primitives/_rust/kei-pet/tests/validation_tests.rs b/_primitives/_rust/kei-pet/tests/validation_tests.rs new file mode 100644 index 0000000..16e42a6 --- /dev/null +++ b/_primitives/_rust/kei-pet/tests/validation_tests.rs @@ -0,0 +1,271 @@ +//! Integration tests for the R1–R19 validator. +//! +//! Each `reject_*` test asserts a specific rule fires with a specific variant. +//! Accepting cases parse `examples/minimal.toml` and `examples/full.toml` +//! unmodified. + +use kei_pet::{parse, validate}; +use kei_pet::schema::*; +use kei_pet::validate::ValidationError; + +const MINIMAL: &str = include_str!("../examples/minimal.toml"); +const FULL: &str = include_str!("../examples/full.toml"); + +fn base() -> PetManifest { + parse(MINIMAL).expect("minimal.toml must validate") +} + +#[test] +fn accept_minimal_example() { + let m = parse(MINIMAL).unwrap(); + assert_eq!(m.schema, 1); + assert_eq!(m.identity.pet_name, "Kei"); +} + +#[test] +fn accept_full_example() { + let m = parse(FULL).unwrap(); + assert_eq!(m.interests.len(), 2); + assert_eq!(m.routines.len(), 4); + assert!(m.appearance.is_some()); + assert!(m.room.is_some()); + assert!(m.privacy.is_some()); +} + +// ─────────────────────────── R1 schema version ─────────────────────────── + +#[test] +fn r1_wrong_schema() { + let mut m = base(); + m.schema = 99; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::SchemaVersion(99)))); +} + +// ──────────────────────────── R2 name bounds ───────────────────────────── + +#[test] +fn r2_empty_pet_name() { + let mut m = base(); + m.identity.pet_name.clear(); + assert!(validate(&m).unwrap_err().contains(&ValidationError::PetNameInvalid)); +} + +#[test] +fn r2_pet_name_too_long() { + let mut m = base(); + m.identity.pet_name = "a".repeat(25); + assert!(validate(&m).unwrap_err().contains(&ValidationError::PetNameInvalid)); +} + +#[test] +fn r2_empty_user_name() { + let mut m = base(); + m.identity.user_name.clear(); + assert!(validate(&m).unwrap_err().contains(&ValidationError::UserNameInvalid)); +} + +// ───────────────────────────── R4 languages ────────────────────────────── + +#[test] +fn r4_empty_languages() { + let mut m = base(); + m.identity.languages.clear(); + assert!(validate(&m).unwrap_err().contains(&ValidationError::LanguagesEmpty)); +} + +#[test] +fn r4_non_iso_language() { + let mut m = base(); + m.identity.languages = vec!["english".to_string()]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::LanguageNotIso(0, s) if s == "english"))); +} + +// ─────────────────────────────── R6 tones ──────────────────────────────── + +#[test] +fn r6_too_many_secondary_tones() { + let mut m = base(); + m.voice.tone_secondary = vec![Tone::Warm, Tone::Sarcastic, Tone::Supportive]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::ToneSecondaryTooMany(3)))); +} + +#[test] +fn r6_primary_duplicated_in_secondary() { + let mut m = base(); + m.voice.tone_primary = Tone::Warm; + m.voice.tone_secondary = vec![Tone::Warm]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::ToneSecondaryDuplicatePrimary(Tone::Warm)))); +} + +// ───────────────────────── R10 profanity/languages ─────────────────────── + +#[test] +fn r10_never_with_language_list_populated() { + let mut m = base(); + m.edge.profanity = Profanity::Never; + m.edge.profanity_languages = vec!["en".to_string()]; + assert!(validate(&m).unwrap_err().contains(&ValidationError::ProfanityLanguagesWhenNever)); +} + +#[test] +fn r10_profanity_language_not_in_identity() { + let mut m = base(); + m.edge.profanity = Profanity::MirrorUser; + m.edge.profanity_languages = vec!["de".to_string()]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::ProfanityLanguageNotDeclared(s) if s == "de"))); +} + +// ──────────────────────────── R12 slug-safe ────────────────────────────── + +#[test] +fn r12_non_slug_topic() { + let mut m = base(); + m.interests = vec![Interest { + topic: "Distributed Systems".into(), + depth: Depth::Expert, + freshness: Freshness::Weekly, + vault_path: String::new(), + last_refresh: String::new(), + }]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::InterestTopicNotSlug(0, _)))); +} + +#[test] +fn r12_leading_dash() { + let mut m = base(); + m.interests = vec![Interest { + topic: "-bad".into(), + depth: Depth::Expert, + freshness: Freshness::Weekly, + vault_path: String::new(), + last_refresh: String::new(), + }]; + assert!(validate(&m).is_err()); +} + +// ──────────────────────── R14 interest/forbidden overlap ───────────────── + +#[test] +fn r14_interest_in_forbidden() { + let mut m = base(); + m.interests = vec![Interest { + topic: "ai-hype".into(), + depth: Depth::Shallow, + freshness: Freshness::OnDemand, + vault_path: String::new(), + last_refresh: String::new(), + }]; + m.forbidden.topics = vec!["ai-hype".into()]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::InterestForbiddenContradiction(0, _)))); +} + +// ────────────────────────────── R16 schedules ──────────────────────────── + +#[test] +fn r16_valid_schedules_accepted() { + let mut m = base(); + for sched in &[ + "09:00", "23:59", "00:00", + "sun-10:00", "mon-08:30", + "every-4h", "no-commit-for-3h", + "3-errors-in-20-calls", + ] { + m.routines = vec![Routine { + kind: RoutineKind::Custom, + schedule: (*sched).to_string(), + template: "pet-routine-morning".to_string(), + enabled: true, + }]; + validate(&m).unwrap_or_else(|e| panic!("schedule '{sched}' rejected: {e:?}")); + } +} + +#[test] +fn r16_invalid_schedule() { + let mut m = base(); + m.routines = vec![Routine { + kind: RoutineKind::Custom, + schedule: "whenever".into(), + template: "pet-routine-morning".into(), + enabled: true, + }]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::RoutineScheduleInvalid(0, s) if s == "whenever"))); +} + +#[test] +fn r16_invalid_hour() { + let mut m = base(); + m.routines = vec![Routine { + kind: RoutineKind::Custom, + schedule: "25:00".into(), + template: "x".into(), + enabled: true, + }]; + assert!(validate(&m).is_err()); +} + +// ────────────────────────────── R18 empty strings ──────────────────────── + +#[test] +fn r18_empty_forbidden_topic() { + let mut m = base(); + m.forbidden.topics = vec![" ".into()]; + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::ForbiddenTopicEmpty(0)))); +} + +// ──────────────────────────────── R19 ISO-8601 ─────────────────────────── + +#[test] +fn r19_bad_timestamp() { + let mut m = base(); + m.meta.created_at = "yesterday".into(); + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::MetaTimestampInvalid("created_at", _)))); +} + +// ─────────────────────────────── hex colours ───────────────────────────── + +#[test] +fn hex_color_invalid() { + let mut m = parse(FULL).unwrap(); + if let Some(ref mut app) = m.appearance { + app.color_primary = "brown".into(); + } + let errs = validate(&m).unwrap_err(); + assert!(errs.iter().any(|e| matches!(e, ValidationError::HexColorInvalid(s) if s == "brown"))); +} + +// ─────────────────────── multiple errors accumulate ────────────────────── + +#[test] +fn errors_accumulate_not_fail_fast() { + let mut m = base(); + m.schema = 99; // R1 + m.identity.pet_name.clear(); // R2 + m.identity.languages.clear(); // R4 + let errs = validate(&m).unwrap_err(); + // Ensure we got ≥3 distinct errors, proving we accumulated rather than + // short-circuited on the first. + assert!(errs.len() >= 3, "expected ≥3 accumulated errors, got {}: {errs:?}", errs.len()); +} + +// ─────────────────────────────── overlay smoke ─────────────────────────── + +#[test] +fn overlay_contains_names() { + let m = parse(FULL).unwrap(); + let overlay = kei_pet::system_prompt(&m); + assert!(overlay.contains("Kei")); + assert!(overlay.contains("Denis")); + assert!(overlay.contains("distributed-systems")); + assert!(overlay.contains("politics")); +}