Skip to main content

cookie_agent_identity/
lib.rs

1//! Strict shared identities for agents, providers, models, and variants.
2
3use std::{borrow::Cow, fmt, str::FromStr};
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use thiserror::Error;
8
9/// Identity parsing failure.
10#[derive(Clone, Debug, Error, Eq, PartialEq)]
11pub enum IdentityError {
12    #[error("invalid agent id")]
13    Agent,
14    #[error("invalid provider id")]
15    Provider,
16    #[error("invalid provider model id")]
17    ProviderModel,
18    #[error("invalid model key")]
19    ModelKey,
20    #[error("invalid variant id")]
21    Variant,
22    #[error("invalid safe code")]
23    SafeCode,
24    #[error("invalid wildcard pattern")]
25    WildcardPattern,
26    #[error("invalid strict identifier")]
27    StrictIdentifier,
28    #[error("invalid revision")]
29    Revision,
30}
31
32macro_rules! string_identity {
33    ($name:ident, $error:ident, $validate:ident, $max:literal, $pattern:literal) => {
34        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
35        pub struct $name(String);
36
37        impl $name {
38            pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
39                let value = value.into();
40                if $validate(&value) {
41                    Ok(Self(value))
42                } else {
43                    Err(IdentityError::$error)
44                }
45            }
46
47            #[must_use]
48            pub fn as_str(&self) -> &str {
49                &self.0
50            }
51
52            #[must_use]
53            pub fn into_string(self) -> String {
54                self.0
55            }
56        }
57
58        impl fmt::Debug for $name {
59            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60                formatter
61                    .debug_tuple(stringify!($name))
62                    .field(&self.0)
63                    .finish()
64            }
65        }
66
67        impl fmt::Display for $name {
68            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69                formatter.write_str(&self.0)
70            }
71        }
72
73        impl FromStr for $name {
74            type Err = IdentityError;
75
76            fn from_str(value: &str) -> Result<Self, Self::Err> {
77                Self::new(value)
78            }
79        }
80
81        impl Serialize for $name {
82            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83            where
84                S: Serializer,
85            {
86                serializer.serialize_str(&self.0)
87            }
88        }
89
90        impl<'de> Deserialize<'de> for $name {
91            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92            where
93                D: Deserializer<'de>,
94            {
95                let value = String::deserialize(deserializer)?;
96                Self::new(value).map_err(de::Error::custom)
97            }
98        }
99
100        impl JsonSchema for $name {
101            fn inline_schema() -> bool {
102                true
103            }
104
105            fn schema_name() -> Cow<'static, str> {
106                stringify!($name).into()
107            }
108
109            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
110                json_schema!({
111                    "type": "string",
112                    "minLength": 1,
113                    "maxLength": $max,
114                    "pattern": $pattern
115                })
116            }
117        }
118    };
119}
120
121string_identity!(
122    AgentId,
123    Agent,
124    valid_agent_id,
125    64,
126    "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*$"
127);
128
129/// Stable lowercase machine-readable code shared by configuration and durable protocol values.
130#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub struct SafeCode(String);
132
133impl SafeCode {
134    pub const MAX_BYTES: usize = 128;
135    pub const JSON_SCHEMA_PATTERN: &'static str = "^[a-z0-9][a-z0-9._-]*$";
136
137    pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
138        let value = value.into();
139        if (1..=Self::MAX_BYTES).contains(&value.len())
140            && value.bytes().enumerate().all(|(index, byte)| {
141                byte.is_ascii_lowercase()
142                    || byte.is_ascii_digit()
143                    || (index > 0 && matches!(byte, b'.' | b'_' | b'-'))
144            })
145        {
146            Ok(Self(value))
147        } else {
148            Err(IdentityError::SafeCode)
149        }
150    }
151
152    #[must_use]
153    pub fn as_str(&self) -> &str {
154        &self.0
155    }
156}
157
158/// Current wildcard grammar shared by authored configuration and frozen protocol values.
159#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
160pub struct WildcardPattern(String);
161
162impl WildcardPattern {
163    pub const MAX_BYTES: usize = 4096;
164    pub const WORKSPACE_DIR_EXPRESSION: &'static str = "${workspace_dir}";
165    pub const JSON_SCHEMA_MAX_UTF8_BYTES_EXTENSION: &'static str = "x-cookie-agent-maxUtf8Bytes";
166    pub const JSON_SCHEMA_PATTERN: &'static str =
167        "^(?:\\$\\{workspace_dir\\}|[^\\u0000-\\u001f\\u007f-\\u009f\\\\\\[\\]\\{\\}])+$";
168
169    pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
170        let value = value.into();
171        let without_workspace_expression = value.replace(Self::WORKSPACE_DIR_EXPRESSION, "");
172        if (1..=Self::MAX_BYTES).contains(&value.len())
173            && !value.chars().any(char::is_control)
174            && !without_workspace_expression
175                .chars()
176                .any(|character| matches!(character, '\\' | '[' | ']' | '{' | '}'))
177        {
178            Ok(Self(value))
179        } else {
180            Err(IdentityError::WildcardPattern)
181        }
182    }
183
184    #[must_use]
185    pub fn as_str(&self) -> &str {
186        &self.0
187    }
188}
189
190macro_rules! shared_permission_string_impl {
191    ($name:ident, $schema:expr) => {
192        impl fmt::Display for $name {
193            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194                formatter.write_str(&self.0)
195            }
196        }
197
198        impl FromStr for $name {
199            type Err = IdentityError;
200
201            fn from_str(value: &str) -> Result<Self, Self::Err> {
202                Self::new(value)
203            }
204        }
205
206        impl Serialize for $name {
207            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
208            where
209                S: Serializer,
210            {
211                serializer.serialize_str(&self.0)
212            }
213        }
214
215        impl<'de> Deserialize<'de> for $name {
216            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
217            where
218                D: Deserializer<'de>,
219            {
220                Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
221            }
222        }
223
224        impl JsonSchema for $name {
225            fn inline_schema() -> bool {
226                true
227            }
228
229            fn schema_name() -> Cow<'static, str> {
230                stringify!($name).into()
231            }
232
233            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
234                $schema
235            }
236        }
237    };
238}
239
240shared_permission_string_impl!(
241    SafeCode,
242    json_schema!({
243        "type": "string",
244        "minLength": 1,
245        "maxLength": SafeCode::MAX_BYTES,
246        "pattern": SafeCode::JSON_SCHEMA_PATTERN,
247        "description": "Stable lowercase machine-readable code."
248    })
249);
250shared_permission_string_impl!(
251    WildcardPattern,
252    json_schema!({
253        "type": "string",
254        "minLength": 1,
255        "pattern": WildcardPattern::JSON_SCHEMA_PATTERN,
256        "x-cookie-agent-maxUtf8Bytes": WildcardPattern::MAX_BYTES,
257        "description": "Current wildcard grammar: '*' matches any characters, '?' matches one character, and the exact '${workspace_dir}' expression is allowed; controls, globstar, escapes, classes, and every other brace form are forbidden. Runtime deserialization additionally enforces a maximum of 4096 UTF-8 bytes; x-cookie-agent-maxUtf8Bytes records that byte limit because JSON Schema maxLength counts Unicode code points."
258    })
259);
260string_identity!(
261    ProviderId,
262    Provider,
263    valid_provider_id,
264    128,
265    "^[a-z0-9][a-z0-9._-]{0,127}$"
266);
267string_identity!(
268    ProviderModelId,
269    ProviderModel,
270    valid_provider_model_id,
271    384,
272    "^[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
273);
274string_identity!(
275    VariantId,
276    Variant,
277    valid_variant_id,
278    64,
279    "^(?!base$)[a-z0-9][a-z0-9._-]{0,63}$"
280);
281
282macro_rules! strict_identifier {
283    ($($name:ident),+ $(,)?) => {$(
284        string_identity!(
285            $name,
286            StrictIdentifier,
287            valid_strict_identifier,
288            128,
289            "^[a-z0-9][a-z0-9._-]{0,127}$"
290        );
291    )+};
292}
293
294strict_identifier!(
295    AuthMethodId,
296    AdapterId,
297    ProviderRecipeId,
298    ProtocolRecipeId,
299    ProviderSetupRecipeId,
300    AuthRecipeId,
301    RecipeCompilerVersion,
302    CacheEntryId,
303    StoreEntryId,
304    ManifestEntryId,
305);
306
307macro_rules! field_identifier {
308    ($($name:ident),+ $(,)?) => {$(
309        string_identity!(
310            $name,
311            StrictIdentifier,
312            valid_field_identifier,
313            128,
314            "^[a-z][a-z0-9_]{0,127}$"
315        );
316    )+};
317}
318
319field_identifier!(SetupFieldId, AuthParameterId, AuthFieldName);
320
321string_identity!(
322    CanonicalModelId,
323    StrictIdentifier,
324    valid_provider_model_id,
325    384,
326    "^[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
327);
328
329macro_rules! revision_identity {
330    ($($name:ident),+ $(,)?) => {$(
331        #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
332        pub struct $name(String);
333
334        impl $name {
335            pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
336                let value = value.into();
337                if valid_revision(&value) { Ok(Self(value)) } else { Err(IdentityError::Revision) }
338            }
339            #[must_use]
340            pub fn as_str(&self) -> &str { &self.0 }
341            #[must_use]
342            pub fn into_string(self) -> String { self.0 }
343        }
344        impl fmt::Debug for $name {
345            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346                f.debug_tuple(stringify!($name)).field(&self.0).finish()
347            }
348        }
349        impl fmt::Display for $name {
350            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
351        }
352        impl FromStr for $name {
353            type Err = IdentityError;
354            fn from_str(value: &str) -> Result<Self, Self::Err> { Self::new(value) }
355        }
356        impl Serialize for $name {
357            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
358            where S: Serializer { serializer.serialize_str(&self.0) }
359        }
360        impl<'de> Deserialize<'de> for $name {
361            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
362            where D: Deserializer<'de> {
363                Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
364            }
365        }
366        impl JsonSchema for $name {
367            fn inline_schema() -> bool { true }
368            fn schema_name() -> Cow<'static, str> { stringify!($name).into() }
369            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
370                json_schema!({
371                    "type": "string",
372                    "pattern": "^sha256:[0-9a-f]{64}$",
373                    "minLength": 71,
374                    "maxLength": 71
375                })
376            }
377        }
378    )+};
379}
380
381revision_identity!(
382    CatalogRevision,
383    ModelRevision,
384    RecipeRegistryRevision,
385    ProviderStoreRevision,
386    ProviderStateRevision,
387    ModelSnapshotRevision,
388    RuntimeRevision,
389    AgentRevision,
390    ManifestRevision,
391    CacheRevision,
392);
393
394fn valid_agent_id(value: &str) -> bool {
395    (1..=64).contains(&value.len())
396        && value
397            .bytes()
398            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
399        && value
400            .as_bytes()
401            .first()
402            .is_some_and(u8::is_ascii_alphanumeric)
403        && value
404            .as_bytes()
405            .last()
406            .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
407        && !value.as_bytes().windows(2).any(|pair| pair == b"--")
408}
409
410fn valid_provider_id(value: &str) -> bool {
411    (1..=128).contains(&value.len())
412        && value
413            .as_bytes()
414            .first()
415            .is_some_and(u8::is_ascii_alphanumeric)
416        && value.bytes().all(|byte| {
417            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
418        })
419}
420
421fn valid_provider_model_id(value: &str) -> bool {
422    (1..=384).contains(&value.len())
423        && !value.contains(['[', ']'])
424        && !value.split('/').any(|segment| segment.is_empty())
425        && !value
426            .chars()
427            .any(|character| character.is_control() || character.is_whitespace())
428}
429
430fn valid_variant_id(value: &str) -> bool {
431    value != "base"
432        && (1..=64).contains(&value.len())
433        && value
434            .as_bytes()
435            .first()
436            .is_some_and(u8::is_ascii_alphanumeric)
437        && value.bytes().all(|byte| {
438            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
439        })
440}
441
442fn valid_strict_identifier(value: &str) -> bool {
443    (1..=128).contains(&value.len())
444        && value
445            .as_bytes()
446            .first()
447            .is_some_and(u8::is_ascii_alphanumeric)
448        && value.bytes().all(|byte| {
449            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
450        })
451}
452
453fn valid_field_identifier(value: &str) -> bool {
454    (1..=128).contains(&value.len())
455        && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
456        && value
457            .bytes()
458            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
459}
460
461fn valid_revision(value: &str) -> bool {
462    value.len() == 71
463        && value.starts_with("sha256:")
464        && value[7..]
465            .bytes()
466            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
467}
468
469/// Direct runnable `provider/model-id` identity.
470#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
471pub struct ModelKey(String);
472
473impl ModelKey {
474    pub fn new(provider: ProviderId, model: ProviderModelId) -> Result<Self, IdentityError> {
475        Self::from_parts(&provider, &model)
476    }
477
478    fn from_parts(provider: &ProviderId, model: &ProviderModelId) -> Result<Self, IdentityError> {
479        let value = format!("{provider}/{model}");
480        if value.len() <= 512 {
481            Ok(Self(value))
482        } else {
483            Err(IdentityError::ModelKey)
484        }
485    }
486
487    #[must_use]
488    pub fn as_str(&self) -> &str {
489        &self.0
490    }
491
492    #[must_use]
493    pub fn provider_id(&self) -> ProviderId {
494        ProviderId::new(self.0.split_once('/').expect("validated model key").0)
495            .expect("validated provider id")
496    }
497
498    #[must_use]
499    pub fn model_id(&self) -> ProviderModelId {
500        ProviderModelId::new(self.0.split_once('/').expect("validated model key").1)
501            .expect("validated model id")
502    }
503}
504
505impl FromStr for ModelKey {
506    type Err = IdentityError;
507
508    fn from_str(value: &str) -> Result<Self, Self::Err> {
509        if value.len() > 512 {
510            return Err(IdentityError::ModelKey);
511        }
512        let (provider, model) = value.split_once('/').ok_or(IdentityError::ModelKey)?;
513        Self::from_parts(&ProviderId::new(provider)?, &ProviderModelId::new(model)?)
514    }
515}
516
517impl fmt::Debug for ModelKey {
518    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
519        formatter.debug_tuple("ModelKey").field(&self.0).finish()
520    }
521}
522
523impl fmt::Display for ModelKey {
524    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
525        formatter.write_str(&self.0)
526    }
527}
528
529impl Serialize for ModelKey {
530    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
531    where
532        S: Serializer,
533    {
534        serializer.serialize_str(&self.0)
535    }
536}
537
538impl<'de> Deserialize<'de> for ModelKey {
539    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
540    where
541        D: Deserializer<'de>,
542    {
543        String::deserialize(deserializer)?
544            .parse()
545            .map_err(de::Error::custom)
546    }
547}
548
549impl JsonSchema for ModelKey {
550    fn inline_schema() -> bool {
551        true
552    }
553
554    fn schema_name() -> Cow<'static, str> {
555        "ModelKey".into()
556    }
557
558    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
559        json_schema!({
560            "type": "string",
561            "minLength": 3,
562            "maxLength": 512,
563            "pattern": "^[a-z0-9][a-z0-9._-]{0,127}/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
564        })
565    }
566}
567
568/// One exact base or named-variant model selection.
569#[derive(
570    Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
571)]
572#[serde(deny_unknown_fields)]
573pub struct ModelSelection {
574    pub model: ModelKey,
575    pub variant: Option<VariantId>,
576}
577
578impl fmt::Display for ModelSelection {
579    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580        write!(formatter, "{}[", self.model)?;
581        match &self.variant {
582            Some(variant) => variant.fmt(formatter)?,
583            None => formatter.write_str("base")?,
584        }
585        formatter.write_str("]")
586    }
587}
588
589/// Three-state agent fallback authoring after field-presence decoding.
590#[derive(Clone, Debug, Eq, PartialEq)]
591pub enum ConfiguredVariantRef {
592    Base,
593    Named(VariantId),
594}
595
596/// Three-state provider default authoring after field-presence decoding.
597#[derive(Clone, Debug, Eq, PartialEq)]
598pub enum ConfiguredModelDefault {
599    Base,
600    Named(VariantId),
601}
602
603macro_rules! variant_reference_serde {
604    ($name:ident) => {
605        impl Serialize for $name {
606            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
607            where
608                S: Serializer,
609            {
610                match self {
611                    Self::Base => serializer.serialize_str("base"),
612                    Self::Named(id) => serializer.serialize_str(id.as_str()),
613                }
614            }
615        }
616
617        impl<'de> Deserialize<'de> for $name {
618            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
619            where
620                D: Deserializer<'de>,
621            {
622                let value = String::deserialize(deserializer)?;
623                if value == "base" {
624                    Ok(Self::Base)
625                } else {
626                    VariantId::new(value)
627                        .map(Self::Named)
628                        .map_err(de::Error::custom)
629                }
630            }
631        }
632    };
633}
634
635variant_reference_serde!(ConfiguredVariantRef);
636variant_reference_serde!(ConfiguredModelDefault);
637
638macro_rules! variant_reference_schema {
639    ($name:ident) => {
640        impl JsonSchema for $name {
641            fn inline_schema() -> bool {
642                true
643            }
644
645            fn schema_name() -> Cow<'static, str> {
646                stringify!($name).into()
647            }
648
649            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
650                json_schema!({
651                    "type": "string",
652                    "minLength": 1,
653                    "maxLength": 64,
654                    "pattern": "^[a-z0-9._-]+$"
655                })
656            }
657        }
658    };
659}
660
661variant_reference_schema!(ConfiguredVariantRef);
662variant_reference_schema!(ConfiguredModelDefault);
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn identities_are_strict_and_model_keys_split_once() {
670        assert!(AgentId::new("worker-one").is_ok());
671        assert!(AgentId::new("Worker").is_err());
672        assert!(ProviderId::new("openai.compat").is_ok());
673        assert!(ProviderModelId::new("model/child").is_ok());
674        assert!(VariantId::new("base").is_err());
675        let key: ModelKey = "openai/gpt-5.6-sol".parse().unwrap();
676        assert_eq!(key.provider_id().as_str(), "openai");
677        assert_eq!(key.model_id().as_str(), "gpt-5.6-sol");
678        assert_eq!(
679            "openai/group/model"
680                .parse::<ModelKey>()
681                .unwrap()
682                .model_id()
683                .as_str(),
684            "group/model"
685        );
686    }
687
688    #[test]
689    fn base_and_named_default_are_not_conflated() {
690        assert_eq!(
691            serde_json::from_str::<ConfiguredVariantRef>("\"base\"").unwrap(),
692            ConfiguredVariantRef::Base
693        );
694        assert_eq!(
695            serde_json::from_str::<ConfiguredVariantRef>("\"default\"").unwrap(),
696            ConfiguredVariantRef::Named(VariantId::new("default").unwrap())
697        );
698    }
699
700    #[test]
701    fn model_selection_format_is_exact_and_serialization_is_unchanged() {
702        let base = ModelSelection {
703            model: "openai/gpt-5.6-sol".parse().unwrap(),
704            variant: None,
705        };
706        let named_default = ModelSelection {
707            model: "openai/gpt-5.6-sol".parse().unwrap(),
708            variant: Some(VariantId::new("default").unwrap()),
709        };
710        let high = ModelSelection {
711            model: "anthropic/claude-sonnet-4-6".parse().unwrap(),
712            variant: Some(VariantId::new("high").unwrap()),
713        };
714
715        assert_eq!(base.to_string(), "openai/gpt-5.6-sol[base]");
716        assert_eq!(named_default.to_string(), "openai/gpt-5.6-sol[default]");
717        assert_eq!(high.to_string(), "anthropic/claude-sonnet-4-6[high]");
718        assert_eq!(
719            serde_json::to_value(&base).unwrap(),
720            serde_json::json!({"model": "openai/gpt-5.6-sol", "variant": null})
721        );
722        assert_eq!(
723            serde_json::to_value(&named_default).unwrap(),
724            serde_json::json!({"model": "openai/gpt-5.6-sol", "variant": "default"})
725        );
726    }
727
728    #[test]
729    fn agent_schema_exposes_runtime_bounds() {
730        let schema = serde_json::to_value(schemars::schema_for!(AgentId)).unwrap();
731        assert_eq!(schema["maxLength"], 64);
732        assert_eq!(schema["pattern"], "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*$");
733    }
734
735    #[test]
736    fn provider_schema_exposes_runtime_bounds() {
737        let schema = serde_json::to_value(schemars::schema_for!(ProviderId)).unwrap();
738        assert_eq!(schema["maxLength"], 128);
739        assert_eq!(schema["pattern"], "^[a-z0-9][a-z0-9._-]{0,127}$");
740    }
741
742    #[test]
743    fn provider_model_schema_exposes_runtime_bounds() {
744        let schema = serde_json::to_value(schemars::schema_for!(ProviderModelId)).unwrap();
745        assert_eq!(schema["maxLength"], 384);
746        assert!(schema["pattern"].as_str().unwrap().contains("\\s"));
747    }
748
749    #[test]
750    fn variant_schema_excludes_reserved_base() {
751        let schema = serde_json::to_value(schemars::schema_for!(VariantId)).unwrap();
752        assert_eq!(schema["maxLength"], 64);
753        assert!(schema["pattern"].as_str().unwrap().contains("?!base$"));
754    }
755
756    #[test]
757    fn model_key_schema_exposes_combined_bound_and_separator() {
758        let schema = serde_json::to_value(schemars::schema_for!(ModelKey)).unwrap();
759        assert_eq!(schema["maxLength"], 512);
760        assert!(schema["pattern"].as_str().unwrap().contains('/'));
761    }
762
763    #[test]
764    fn configured_variant_schema_includes_base_spelling() {
765        let schema = serde_json::to_value(schemars::schema_for!(ConfiguredVariantRef)).unwrap();
766        assert_eq!(schema["type"], "string");
767        assert!(schema["pattern"].as_str().unwrap().contains("a-z"));
768    }
769
770    #[test]
771    fn permission_values_enforce_current_shared_grammar() {
772        assert!(SafeCode::new("allow-read_1.test").is_ok());
773        assert!(SafeCode::new("Allow-read").is_err());
774        assert!(SafeCode::new("-allow-read").is_err());
775        assert!(SafeCode::new("a".repeat(128)).is_ok());
776        assert!(SafeCode::new("a".repeat(129)).is_err());
777
778        for pattern in [
779            "*",
780            "file?.rs",
781            "literal(value)",
782            "資料/?",
783            "${workspace_dir}/src/*",
784            "**",
785            "a**b",
786            "src/**",
787        ] {
788            assert!(WildcardPattern::new(pattern).is_ok(), "{pattern:?}");
789        }
790        for pattern in [
791            "",
792            r"a\\*",
793            "[ab]",
794            "{a,b}",
795            "${foo}/src/*",
796            "${workspace_dir}/src/{x}",
797            "a\n",
798        ] {
799            assert!(WildcardPattern::new(pattern).is_err(), "{pattern:?}");
800        }
801        assert!(WildcardPattern::new("a".repeat(4096)).is_ok());
802        assert!(WildcardPattern::new("a".repeat(4097)).is_err());
803        assert!(WildcardPattern::new("界".repeat(1365)).is_ok());
804        assert!(WildcardPattern::new("界".repeat(1366)).is_err());
805        assert!(WildcardPattern::new("😀".repeat(1024)).is_ok());
806        assert!(WildcardPattern::new("😀".repeat(1025)).is_err());
807    }
808
809    #[test]
810    fn permission_value_schemas_expose_runtime_constraints() {
811        let code = serde_json::to_value(schemars::schema_for!(SafeCode)).unwrap();
812        assert_eq!(code["maxLength"], SafeCode::MAX_BYTES);
813        assert_eq!(code["pattern"], SafeCode::JSON_SCHEMA_PATTERN);
814
815        let wildcard = serde_json::to_value(schemars::schema_for!(WildcardPattern)).unwrap();
816        assert!(wildcard.get("maxLength").is_none());
817        assert_eq!(
818            wildcard[WildcardPattern::JSON_SCHEMA_MAX_UTF8_BYTES_EXTENSION],
819            WildcardPattern::MAX_BYTES
820        );
821        assert_eq!(wildcard["pattern"], WildcardPattern::JSON_SCHEMA_PATTERN);
822        let description = wildcard["description"].as_str().unwrap();
823        assert!(description.contains("globstar"));
824        assert!(description.contains("escapes"));
825        assert!(description.contains("UTF-8 bytes"));
826        assert!(description.contains("maxLength counts Unicode code points"));
827    }
828
829    #[test]
830    fn agent_id_accepts_exact_maximum_length() {
831        assert!(AgentId::new("a".repeat(64)).is_ok());
832        assert!(AgentId::new("a".repeat(65)).is_err());
833    }
834
835    #[test]
836    fn provider_id_accepts_exact_maximum_length() {
837        assert!(ProviderId::new("p".repeat(128)).is_ok());
838        assert!(ProviderId::new("p".repeat(129)).is_err());
839    }
840
841    #[test]
842    fn provider_model_id_rejects_trimmed_and_control_forms() {
843        assert!(ProviderModelId::new(" model").is_err());
844        assert!(ProviderModelId::new("model\n").is_err());
845    }
846
847    #[test]
848    fn variant_id_accepts_named_default_but_not_base() {
849        assert!(VariantId::new("default").is_ok());
850        assert!(VariantId::new("base").is_err());
851    }
852}