feat(kei-pet): Day 1 — persona manifest parse + validate + overlay + Ed25519 identity

First crate of the Pet UI v1 line (feat/pet-ui-v1 branch). Ships:

## Schema (src/schema.rs — 213 LOC)
Strongly-typed `PetManifest` covering:
- identity (pet_name, user_name, addressing, languages)
- voice (tone primary/secondary, humor style + frequency)
- edge (profanity, directness, initiative)
- appearance (base_shape, size, colours, eyes, expression, accessories)
- room (theme, lighting, decor, time_sync)
- privacy (public_profile, publish_allowed, share_dreams, share_garden)
- interests[] (topic, depth, freshness, vault_path, last_refresh)
- routines[] (kind, schedule, template, enabled)
- forbidden (topics, tone_patterns)
- meta (schema version, timestamps, tune_count)

All enums serde-renamed to kebab-case for TOML-native feel.

## Validation (src/validate.rs — 180 LOC)
19-rule validator (R1–R19 per earlier spec). Errors **accumulate** — single
validate() call surfaces every issue, not just the first. Covers:
- schema version (R1)
- name bounds (R2, R2)
- languages ISO-639-1 (R4)
- tone_secondary cardinality + no-primary-dup (R6)
- profanity/language consistency (R10)
- interest topic slug-safety (R12)
- interest/forbidden contradiction (R14)
- schedule grammar: HH:MM, dow-HH:MM, every-Nh, no-commit-for-Nh,
  N-errors-in-N-calls (R16)
- empty-string guards (R18)
- ISO-8601 timestamps (R19)
- hex-colour sanity on appearance

## Overlay rendering (src/overlay.rs — 128 LOC)
Pure function `system_prompt(&PetManifest) -> String`. Deterministic — same
manifest → same bytes. Used as prompt-prefix by the runtime at spawn time.

## Identity (src/identity.rs — 117 LOC incl. 5 unit tests)
Standard Ed25519 (RFC 8032) via ed25519-dalek. `user_id` = first 16 hex
chars of blake3(public_key) — deterministic, 64-bit, URL-safe. Hex-string
API for cross-boundary verify. No proprietary crypto, no matrix math.

## CLI (src/bin/kei-pet.rs — 110 LOC)
- `kei-pet validate <path>` — parse + run R1–R19
- `kei-pet show <path>`     — print rendered overlay
- `kei-pet identity new`    — generate + store ~/.keisei/identity.key (0600)
- `kei-pet identity show`   — print public key + user_id
- `kei-pet tune` stub (Day 2 — /pet-tune skill lands full implementation)

## Tests
- 23/23 integration (tests/validation_tests.rs) — one rejector per rule +
  accept cases for examples/minimal.toml and examples/full.toml + overlay
  smoke + multi-error accumulation guard
- 5/5 unit (identity module) — keypair roundtrip, user_id determinism,
  sign/verify, hex API boundary
- cargo test -p kei-pet --release: all green

## Examples
- examples/minimal.toml — smallest valid manifest
- examples/full.toml    — every optional section populated

## Scope boundary (enforced by in-file doc comment in lib.rs)
NO imports, references, or conceptual mentions of sibling research-grade
IP. Identity is standard Ed25519. Cache/projection is standard CQRS. This
crate ships as a clean MIT-licensable unit of the KeiSeiKit public surface.

Day 2: /pet-setup 7-phase wizard skill that drives this crate via the CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Denis Parfionovich 2026-04-23 21:29:13 +08:00
parent 5f7a5b2639
commit d1467b8611
12 changed files with 1502 additions and 0 deletions

View file

@ -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"

View file

@ -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]

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,140 @@
//! kei-pet — CLI wrapper over the `kei_pet` library.
//!
//! Subcommands:
//! validate <path> Parse + run R1R19 on a pet.toml, print PASS/FAIL
//! show <path> Print the rendered system-prompt overlay
//! identity <action> new | show — generate or display Ed25519 keypair
//! tune <path> <kv> 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(()) }

View file

@ -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<Self, anyhow::Error> {
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());
}
}

View file

@ -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<PetManifest, anyhow::Error> {
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::<Vec<_>>().join("\n")
)
})?;
Ok(manifest)
}
/// Serialize a validated manifest back to TOML text.
pub fn to_toml(manifest: &PetManifest) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(manifest)
}

View file

@ -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(", ")
),
}
}

View file

@ -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<T>` 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<Appearance>,
#[serde(default)]
pub room: Option<Room>,
#[serde(default)]
pub privacy: Option<Privacy>,
#[serde(default, rename = "interests")]
pub interests: Vec<Interest>,
#[serde(default, rename = "routines")]
pub routines: Vec<Routine>,
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<String>,
}
#[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<Tone>,
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<String>,
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<String>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
pub tone_patterns: Vec<String>,
}
// ────────────────────────────────── 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 }

View file

@ -0,0 +1,239 @@
//! 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()
}

View file

@ -0,0 +1,271 @@
//! Integration tests for the R1R19 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"));
}