Skip to main content

ldap_types/
schema.rs

1//! Contains all the code related to representing and parsing LDAP schemas
2//!
3//! LDAP Schema is defined in RFC2252 <https://www.rfc-editor.org/rfc/rfc2252.txt>
4
5use std::collections::HashSet;
6
7#[cfg(feature = "chumsky")]
8use chumsky::{prelude::*, text::digits};
9use educe::Educe;
10use enum_as_inner::EnumAsInner;
11use oid::ObjectIdentifier;
12
13use itertools::Itertools as _;
14
15#[cfg(feature = "chumsky")]
16use std::sync::LazyLock;
17
18use crate::basic::{KeyString, KeyStringOrOID, OIDWithLength};
19
20#[cfg(feature = "chumsky")]
21use crate::basic::{
22    keystring_or_oid_parser, keystring_parser, oid_parser, quoted_keystring_parser,
23};
24
25#[cfg(feature = "serde")]
26use serde::{Deserialize, Serialize};
27
28/// possible tags in the LDAP schema syntax line
29#[cfg(feature = "chumsky")]
30static LDAP_SYNTAX_TAGS: LazyLock<Vec<LDAPSchemaTagDescriptor>> = LazyLock::new(|| {
31    vec![
32        LDAPSchemaTagDescriptor {
33            tag_name: "DESC".to_string(),
34            tag_type: LDAPSchemaTagType::String,
35        },
36        LDAPSchemaTagDescriptor {
37            tag_name: "X-BINARY-TRANSFER-REQUIRED".to_string(),
38            tag_type: LDAPSchemaTagType::Boolean,
39        },
40        LDAPSchemaTagDescriptor {
41            tag_name: "X-NOT-HUMAN-READABLE".to_string(),
42            tag_type: LDAPSchemaTagType::Boolean,
43        },
44    ]
45});
46
47/// possible tags in the LDAP schema matching rule line
48#[cfg(feature = "chumsky")]
49static MATCHING_RULE_TAGS: LazyLock<Vec<LDAPSchemaTagDescriptor>> = LazyLock::new(|| {
50    vec![
51        LDAPSchemaTagDescriptor {
52            tag_name: "NAME".to_string(),
53            tag_type: LDAPSchemaTagType::QuotedKeyStringList,
54        },
55        LDAPSchemaTagDescriptor {
56            tag_name: "SYNTAX".to_string(),
57            tag_type: LDAPSchemaTagType::OIDWithLength,
58        },
59    ]
60});
61
62/// possible tags in the LDAP schema matching rule use line
63#[cfg(feature = "chumsky")]
64static MATCHING_RULE_USE_TAGS: LazyLock<Vec<LDAPSchemaTagDescriptor>> = LazyLock::new(|| {
65    vec![
66        LDAPSchemaTagDescriptor {
67            tag_name: "NAME".to_string(),
68            tag_type: LDAPSchemaTagType::QuotedKeyStringList,
69        },
70        LDAPSchemaTagDescriptor {
71            tag_name: "APPLIES".to_string(),
72            tag_type: LDAPSchemaTagType::KeyStringOrOIDList,
73        },
74    ]
75});
76
77/// possible tags in the LDAP schema attribute type line
78#[cfg(feature = "chumsky")]
79static ATTRIBUTE_TYPE_TAGS: LazyLock<Vec<LDAPSchemaTagDescriptor>> = LazyLock::new(|| {
80    vec![
81        LDAPSchemaTagDescriptor {
82            tag_name: "NAME".to_string(),
83            tag_type: LDAPSchemaTagType::QuotedKeyStringList,
84        },
85        LDAPSchemaTagDescriptor {
86            tag_name: "SUP".to_string(),
87            tag_type: LDAPSchemaTagType::KeyString,
88        },
89        LDAPSchemaTagDescriptor {
90            tag_name: "DESC".to_string(),
91            tag_type: LDAPSchemaTagType::String,
92        },
93        LDAPSchemaTagDescriptor {
94            tag_name: "SYNTAX".to_string(),
95            tag_type: LDAPSchemaTagType::OIDWithLength,
96        },
97        LDAPSchemaTagDescriptor {
98            tag_name: "EQUALITY".to_string(),
99            tag_type: LDAPSchemaTagType::KeyString,
100        },
101        LDAPSchemaTagDescriptor {
102            tag_name: "SUBSTR".to_string(),
103            tag_type: LDAPSchemaTagType::KeyString,
104        },
105        LDAPSchemaTagDescriptor {
106            tag_name: "ORDERING".to_string(),
107            tag_type: LDAPSchemaTagType::KeyString,
108        },
109        LDAPSchemaTagDescriptor {
110            tag_name: "SINGLE-VALUE".to_string(),
111            tag_type: LDAPSchemaTagType::Standalone,
112        },
113        LDAPSchemaTagDescriptor {
114            tag_name: "NO-USER-MODIFICATION".to_string(),
115            tag_type: LDAPSchemaTagType::Standalone,
116        },
117        LDAPSchemaTagDescriptor {
118            tag_name: "USAGE".to_string(),
119            tag_type: LDAPSchemaTagType::KeyString,
120        },
121        LDAPSchemaTagDescriptor {
122            tag_name: "COLLECTIVE".to_string(),
123            tag_type: LDAPSchemaTagType::Standalone,
124        },
125        LDAPSchemaTagDescriptor {
126            tag_name: "OBSOLETE".to_string(),
127            tag_type: LDAPSchemaTagType::Standalone,
128        },
129        LDAPSchemaTagDescriptor {
130            tag_name: "X-ORDERED".to_string(),
131            tag_type: LDAPSchemaTagType::QuotedKeyString,
132        },
133    ]
134});
135
136/// possible tags in the LDAP schema object class line
137#[cfg(feature = "chumsky")]
138static OBJECT_CLASS_TAGS: LazyLock<Vec<LDAPSchemaTagDescriptor>> = LazyLock::new(|| {
139    vec![
140        LDAPSchemaTagDescriptor {
141            tag_name: "NAME".to_string(),
142            tag_type: LDAPSchemaTagType::QuotedKeyStringList,
143        },
144        LDAPSchemaTagDescriptor {
145            tag_name: "SUP".to_string(),
146            tag_type: LDAPSchemaTagType::KeyStringOrOIDList,
147        },
148        LDAPSchemaTagDescriptor {
149            tag_name: "DESC".to_string(),
150            tag_type: LDAPSchemaTagType::String,
151        },
152        LDAPSchemaTagDescriptor {
153            tag_name: "ABSTRACT".to_string(),
154            tag_type: LDAPSchemaTagType::Standalone,
155        },
156        LDAPSchemaTagDescriptor {
157            tag_name: "STRUCTURAL".to_string(),
158            tag_type: LDAPSchemaTagType::Standalone,
159        },
160        LDAPSchemaTagDescriptor {
161            tag_name: "AUXILIARY".to_string(),
162            tag_type: LDAPSchemaTagType::Standalone,
163        },
164        LDAPSchemaTagDescriptor {
165            tag_name: "MUST".to_string(),
166            tag_type: LDAPSchemaTagType::KeyStringOrOIDList,
167        },
168        LDAPSchemaTagDescriptor {
169            tag_name: "MAY".to_string(),
170            tag_type: LDAPSchemaTagType::KeyStringOrOIDList,
171        },
172        LDAPSchemaTagDescriptor {
173            tag_name: "OBSOLETE".to_string(),
174            tag_type: LDAPSchemaTagType::Standalone,
175        },
176    ]
177});
178
179/// all possible tag names in the LDAP schema
180#[cfg(feature = "chumsky")]
181static ALL_SCHEMA_TAG_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
182    let mut tags = HashSet::new();
183    for tag in ATTRIBUTE_TYPE_TAGS.iter() {
184        tags.insert(tag.tag_name.to_owned());
185    }
186    for tag in OBJECT_CLASS_TAGS.iter() {
187        tags.insert(tag.tag_name.to_owned());
188    }
189    for tag in LDAP_SYNTAX_TAGS.iter() {
190        tags.insert(tag.tag_name.to_owned());
191    }
192    for tag in MATCHING_RULE_TAGS.iter() {
193        tags.insert(tag.tag_name.to_owned());
194    }
195    for tag in MATCHING_RULE_USE_TAGS.iter() {
196        tags.insert(tag.tag_name.to_owned());
197    }
198    tags
199});
200
201/// stores the parameter values that can appear behind a tag in an LDAP schema entry
202#[derive(Clone, Debug, EnumAsInner, Educe)]
203#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
204#[educe(PartialEq, Eq, Hash)]
205pub enum LDAPSchemaTagValue {
206    /// the tag has no value
207    Standalone,
208    /// the tag has an OID value
209    OID(#[educe(Hash(method = "crate::basic::hash_oid"))] ObjectIdentifier),
210    /// the tag has an OID value with an optional length
211    OIDWithLength(OIDWithLength),
212    /// the tag has a string value
213    String(String),
214    /// the tag has a key string value
215    KeyString(KeyString),
216    /// the tag has a quoted key string value
217    QuotedKeyString(KeyString),
218    /// the tag has a keystring or an OID value
219    KeyStringOrOID(KeyStringOrOID),
220    /// the tag has a boolean value
221    Boolean(bool),
222    /// the tag has a value that is a list of quoted key strings
223    QuotedKeyStringList(Vec<KeyString>),
224    /// the tag has a value that is a list of key strings or OIDs
225    KeyStringOrOIDList(Vec<KeyStringOrOID>),
226}
227
228/// a single tag in an LDAP schema entry
229#[derive(PartialEq, Eq, Debug, Hash)]
230#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
231pub struct LDAPSchemaTag {
232    /// the name of the tag
233    tag_name: String,
234    /// the value of the tag, if any
235    tag_value: LDAPSchemaTagValue,
236}
237
238/// encodes the expected value type for a schema tag
239/// this allows code reuse in the parser
240#[cfg(feature = "chumsky")]
241#[derive(PartialEq, Eq, Debug, Hash)]
242#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
243pub enum LDAPSchemaTagType {
244    /// the tag is expected to not have a value
245    Standalone,
246    /// the tag is expected to have an OID value
247    OID,
248    /// the tag is expected to have an OID value with an optional length
249    OIDWithLength,
250    /// the tag is expected to have a string value
251    String,
252    /// the tag is expected to have a key string value
253    KeyString,
254    /// the tag is expected to have a quoted key string value
255    QuotedKeyString,
256    /// the tag is expected to have a key string or an OID value
257    KeyStringOrOID,
258    /// the tag is expected to have a boolean value
259    Boolean,
260    /// the tag is expected to have a value that is a list of quoted key strings
261    QuotedKeyStringList,
262    /// the tag is expected to have a value that is a list of keystrings or OIDs
263    KeyStringOrOIDList,
264}
265
266/// describes an expected tag in an LDAP schema entry
267#[cfg(feature = "chumsky")]
268#[derive(PartialEq, Eq, Debug, Hash)]
269#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
270pub struct LDAPSchemaTagDescriptor {
271    /// the tag name of the expected tag
272    pub tag_name: String,
273    /// the type of parameter we expect the tag to have
274    pub tag_type: LDAPSchemaTagType,
275}
276
277/// this parses the LDAP schema tag value that is described by its parameter
278#[cfg(feature = "chumsky")]
279pub fn ldap_schema_tag_value_parser<'src>(
280    tag_type: &LDAPSchemaTagType,
281) -> impl Parser<'src, &'src str, LDAPSchemaTagValue, extra::Err<Rich<'src, char>>> {
282    match tag_type {
283        LDAPSchemaTagType::Standalone => empty()
284            .map(|()| LDAPSchemaTagValue::Standalone)
285            .labelled("no value")
286            .boxed(),
287        LDAPSchemaTagType::OID => oid_parser()
288            .map(LDAPSchemaTagValue::OID)
289            .labelled("OID")
290            .boxed(),
291        LDAPSchemaTagType::OIDWithLength => oid_parser()
292            .then(
293                digits(10)
294                    .collect::<String>()
295                    .delimited_by(just('{'), just('}'))
296                    .try_map(|x, span| {
297                        x.parse().map_err(|e| {
298                            Rich::custom(
299                                span,
300                                format!("Failed to convert parsed digits to integer: {e}"),
301                            )
302                        })
303                    })
304                    .or_not(),
305            )
306            .map(|(oid, len)| LDAPSchemaTagValue::OIDWithLength(OIDWithLength { oid, length: len }))
307            .labelled("OID with optional length")
308            .boxed(),
309        LDAPSchemaTagType::String => none_of("'")
310            .repeated()
311            .collect::<String>()
312            .delimited_by(just('\''), just('\''))
313            .map(LDAPSchemaTagValue::String)
314            .labelled("single-quoted string")
315            .boxed(),
316        LDAPSchemaTagType::KeyString => keystring_parser()
317            .try_map(|ks, span| {
318                if ALL_SCHEMA_TAG_NAMES.contains(&ks.0) {
319                    return Err(Rich::custom(
320                        span,
321                        format!("'{}' is a reserved tag name and cannot be used as a KeyString value here", ks.0),
322                    ));
323                }
324                Ok(ks)
325            })
326            .map(LDAPSchemaTagValue::KeyString)
327            .labelled("keystring")
328            .boxed(),
329        LDAPSchemaTagType::QuotedKeyString => quoted_keystring_parser()
330            .map(LDAPSchemaTagValue::QuotedKeyString)
331            .labelled("quoted keystring")
332            .boxed(),
333        LDAPSchemaTagType::KeyStringOrOID => keystring_or_oid_parser()
334            .try_map(|ksoid, span| {
335                if let KeyStringOrOID::KeyString(ks) = &ksoid
336                    && ALL_SCHEMA_TAG_NAMES.contains(&ks.0)
337                {
338                    return Err(Rich::custom(
339                        span,
340                        format!("'{}' is a reserved tag name and cannot be used as a KeyStringOrOID value here", ks.0),
341                    ));
342                }
343                Ok(ksoid)
344            })
345            .map(LDAPSchemaTagValue::KeyStringOrOID)
346            .labelled("keystring or OID")
347            .boxed(),
348        LDAPSchemaTagType::Boolean => just("TRUE")
349            .to(true)
350            .or(just("FALSE").to(false))
351            .delimited_by(just('\''), just('\''))
352            .map(LDAPSchemaTagValue::Boolean)
353            .labelled("single-quoted uppercase boolean")
354            .boxed(),
355        LDAPSchemaTagType::KeyStringOrOIDList => keystring_or_oid_parser()
356            .padded()
357            .separated_by(just('$'))
358            .collect()
359            .delimited_by(just('('), just(')'))
360            .or(keystring_or_oid_parser().map(|x| vec![x]))
361            .map(LDAPSchemaTagValue::KeyStringOrOIDList)
362            .labelled("list of keystrings or OIDs separated by $")
363            .boxed(),
364        LDAPSchemaTagType::QuotedKeyStringList => quoted_keystring_parser()
365            .padded()
366            .repeated()
367            .collect()
368            .delimited_by(just('('), just(')'))
369            .or(quoted_keystring_parser().map(|x| vec![x]))
370            .map(LDAPSchemaTagValue::QuotedKeyStringList)
371            .labelled("list of quoted keystrings separated by spaces")
372            .boxed(),
373    }
374}
375
376/// this parses an LDAP schema tag described by its parameter
377#[cfg(feature = "chumsky")]
378#[must_use]
379pub fn ldap_schema_tag_parser<'src>(
380    tag_descriptor: &'src LDAPSchemaTagDescriptor,
381) -> impl Parser<'src, &'src str, LDAPSchemaTag, extra::Err<Rich<'src, char>>> + 'src {
382    just(tag_descriptor.tag_name.to_owned())
383        .padded()
384        .ignore_then(ldap_schema_tag_value_parser(&tag_descriptor.tag_type).padded())
385        .map(move |tag_value| LDAPSchemaTag {
386            tag_name: tag_descriptor.tag_name.to_string(),
387            tag_value,
388        })
389}
390
391/// this parses an LDAP schema entry described by its parameter
392///
393/// the tags can be in any order
394///
395/// this function only parses the tags, it does not check if required tags
396/// exist in the output
397///
398/// # Panics
399///
400/// This panics when the tag_descriptors parameter is empty
401#[cfg(feature = "chumsky")]
402#[must_use]
403pub fn ldap_schema_parser<'src>(
404    tag_descriptors: &'src [LDAPSchemaTagDescriptor],
405) -> impl Parser<'src, &'src str, (ObjectIdentifier, Vec<LDAPSchemaTag>), extra::Err<Rich<'src, char>>>
406+ 'src {
407    #[expect(
408        clippy::expect_used,
409        reason = "this fails essentially based on the contents of the tag_descriptors parameter only and chumsky offers no good way to return this type of error"
410    )]
411    let (first, rest) = tag_descriptors
412        .split_first()
413        .expect("tag descriptors must have at least one element");
414    oid_parser()
415        .then(
416            rest.iter()
417                .fold(ldap_schema_tag_parser(first).boxed(), |p, td| {
418                    p.or(ldap_schema_tag_parser(td)).boxed()
419                })
420                .padded()
421                .repeated()
422                .collect(),
423        )
424        .padded()
425        .delimited_by(just('('), just(')'))
426}
427
428/// this is used to extract a required tag's value from the result of [ldap_schema_parser]
429///
430/// # Errors
431///
432/// returns an error if the required tag was not found in schema tag list
433#[cfg(feature = "chumsky")]
434pub fn required_tag<'src>(
435    tag_name: &str,
436    span: &SimpleSpan,
437    tags: &[LDAPSchemaTag],
438) -> Result<LDAPSchemaTagValue, Rich<'src, char>> {
439    tags.iter()
440        .find(|x| x.tag_name == tag_name)
441        .ok_or_else(|| {
442            Rich::custom(
443                *span,
444                format!("No {tag_name} tag in parsed LDAP schema tag list"),
445            )
446        })
447        .map(|x| x.tag_value.to_owned())
448}
449
450/// this is used to extract an optional tag's value from the result of [ldap_schema_parser]
451#[cfg(feature = "chumsky")]
452#[must_use]
453pub fn optional_tag(tag_name: &str, tags: &[LDAPSchemaTag]) -> Option<LDAPSchemaTagValue> {
454    tags.iter()
455        .find(|x| x.tag_name == tag_name)
456        .map(|x| x.tag_value.to_owned())
457}
458
459/// this describes an LDAP syntax schema entry
460#[derive(Clone, Educe)]
461#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
462#[educe(PartialEq, Eq, Hash)]
463pub struct LDAPSyntax {
464    /// the OID of the syntax
465    #[educe(Hash(method = "crate::basic::hash_oid"))]
466    pub oid: ObjectIdentifier,
467    /// the human-readable description of the syntax
468    pub desc: String,
469    /// does this syntax require binary transfer
470    pub x_binary_transfer_required: bool,
471    /// is this syntax human-readable
472    pub x_not_human_readable: bool,
473}
474
475impl std::fmt::Debug for LDAPSyntax {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
477        let string_oid: String = self.oid.clone().into();
478        f.debug_struct("LDAPSyntax")
479            .field("oid", &string_oid)
480            .field("desc", &self.desc)
481            .field(
482                "x_binary_transfer_required",
483                &self.x_binary_transfer_required,
484            )
485            .field("x_not_human_readable", &self.x_not_human_readable)
486            .finish()
487    }
488}
489
490/// parse an LDAP syntax schema entry
491///
492/// <https://ldapwiki.com/wiki/LDAPSyntaxes>
493#[cfg(feature = "chumsky")]
494#[must_use]
495pub fn ldap_syntax_parser<'src>()
496-> impl Parser<'src, &'src str, LDAPSyntax, extra::Err<Rich<'src, char>>> {
497    ldap_schema_parser(&LDAP_SYNTAX_TAGS).try_map(|(oid, tags), span| {
498        Ok(LDAPSyntax {
499            oid,
500            desc: required_tag("DESC", &span, &tags)?
501                .as_string()
502                .ok_or_else(|| Rich::custom(span, "DESC parameter should be a string"))?
503                .to_string(),
504            x_binary_transfer_required: *optional_tag("X-BINARY-TRANSFER-REQUIRED", &tags)
505                .unwrap_or(LDAPSchemaTagValue::Boolean(false))
506                .as_boolean()
507                .ok_or_else(|| {
508                    Rich::custom(
509                        span,
510                        "X-BINARY-TRANSFER_REQUIRED parameter should be a boolean",
511                    )
512                })?,
513            x_not_human_readable: *optional_tag("X-NOT-HUMAN-READABLE", &tags)
514                .unwrap_or(LDAPSchemaTagValue::Boolean(false))
515                .as_boolean()
516                .ok_or_else(|| {
517                    Rich::custom(span, "X-NOT_HUMAN_READABLE parameter should be a boolean")
518                })?,
519        })
520    })
521}
522
523/// a matching rule LDAP schema entry
524///
525/// <https://ldapwiki.com/wiki/MatchingRule>
526#[derive(Clone, Educe)]
527#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
528#[educe(PartialEq, Eq, Hash)]
529pub struct MatchingRule {
530    /// the matching rule's OID
531    #[educe(Hash(method = "crate::basic::hash_oid"))]
532    pub oid: ObjectIdentifier,
533    /// the matching rule's name
534    pub name: Vec<KeyString>,
535    /// the syntax this matching rule can be used with
536    pub syntax: OIDWithLength,
537}
538
539impl std::fmt::Debug for MatchingRule {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
541        let string_oid: String = self.oid.clone().into();
542        f.debug_struct("MatchingRule")
543            .field("oid", &string_oid)
544            .field("name", &self.name)
545            .field("syntax", &self.syntax)
546            .finish()
547    }
548}
549
550/// parse a matching rule LDAP schema entry
551#[cfg(feature = "chumsky")]
552#[must_use]
553pub fn matching_rule_parser<'src>()
554-> impl Parser<'src, &'src str, MatchingRule, extra::Err<Rich<'src, char>>> {
555    ldap_schema_parser(&MATCHING_RULE_TAGS).try_map(|(oid, tags), span| {
556        Ok(MatchingRule {
557            oid,
558            name: required_tag("NAME", &span, &tags)?
559                .as_quoted_key_string_list()
560                .ok_or_else(|| {
561                    Rich::custom(span, "NAME parameter should be a quoted keystring list")
562                })?
563                .to_vec(),
564            syntax: required_tag("SYNTAX", &span, &tags)?
565                .as_oid_with_length()
566                .ok_or_else(|| {
567                    Rich::custom(
568                        span,
569                        "SYNTAX parameter should be an OID with an optional length",
570                    )
571                })?
572                .to_owned(),
573        })
574    })
575}
576
577/// parse a matching rule use LDAP schema entry
578///
579/// <https://ldapwiki.com/wiki/MatchingRuleUse>
580#[derive(Clone, Educe)]
581#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
582#[educe(PartialEq, Eq, Hash)]
583pub struct MatchingRuleUse {
584    /// the OID of the matching rule this applies to
585    #[educe(Hash(method = "crate::basic::hash_oid"))]
586    pub oid: ObjectIdentifier,
587    /// the name of the matching rule
588    pub name: Vec<KeyString>,
589    /// the attributes this matching rule can be used with
590    pub applies: Vec<KeyStringOrOID>,
591}
592
593impl std::fmt::Debug for MatchingRuleUse {
594    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
595        let string_oid: String = self.oid.clone().into();
596        f.debug_struct("MatchingRuleUse")
597            .field("oid", &string_oid)
598            .field("name", &self.name)
599            .field("applies", &self.applies)
600            .finish()
601    }
602}
603
604/// parse a matching rule use LDAP schema entry
605#[cfg(feature = "chumsky")]
606#[must_use]
607pub fn matching_rule_use_parser<'src>()
608-> impl Parser<'src, &'src str, MatchingRuleUse, extra::Err<Rich<'src, char>>> {
609    ldap_schema_parser(&MATCHING_RULE_USE_TAGS).try_map(|(oid, tags), span| {
610        Ok(MatchingRuleUse {
611            oid,
612            name: required_tag("NAME", &span, &tags)?
613                .as_quoted_key_string_list()
614                .ok_or_else(|| {
615                    Rich::custom(span, "NAME parameter should be a quoted keystring list")
616                })?
617                .to_vec(),
618            applies: required_tag("APPLIES", &span, &tags)?
619                .as_key_string_or_oid_list()
620                .ok_or_else(|| {
621                    Rich::custom(span, "APPLIES parameter should be a keystring or OID list")
622                })?
623                .to_vec(),
624        })
625    })
626}
627
628/// an attribute type LDAP schema entry
629///
630/// <https://ldapwiki.com/wiki/AttributeTypes>
631#[derive(Clone, Educe)]
632#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
633#[educe(PartialEq, Eq, Hash)]
634#[expect(
635    clippy::struct_excessive_bools,
636    reason = "this is the LDAP schema, we can not refactor this easily"
637)]
638pub struct AttributeType {
639    /// the OID of the attribute type
640    #[educe(Hash(method = "crate::basic::hash_oid"))]
641    pub oid: ObjectIdentifier,
642    /// the name of the attribute type
643    pub name: Vec<KeyString>,
644    /// the parent in the inheritance tree
645    pub sup: Option<KeyString>,
646    /// a human-readable description
647    pub desc: Option<String>,
648    /// the LDAP syntax of the attribute type
649    pub syntax: Option<OIDWithLength>,
650    /// is this a single or multi-valued attribute
651    pub single_value: bool,
652    /// the equality match to use with this attribute type
653    pub equality: Option<KeyString>,
654    /// the substring match to use with this attribute type
655    pub substr: Option<KeyString>,
656    /// the ordering to use with this attribute type
657    pub ordering: Option<KeyString>,
658    /// is user modification of this attribute type allowed
659    /// (e.g. often operational attributes are not user modifiable)
660    pub no_user_modification: bool,
661    /// if this attribute is a
662    ///
663    /// * user attribute (userApplications)
664    /// * an operational attribute (directoryOperation)
665    /// * an operational attribute that needs to be replicated (distributedOperation)
666    /// * an operational attribute that should not be replicated (dSAOperation)
667    pub usage: Option<KeyString>,
668    /// is this a collective attribute
669    ///
670    /// <https://ldapwiki.com/wiki/Collective%20Attribute>
671    pub collective: bool,
672    /// is this attribute obsolete
673    pub obsolete: bool,
674    /// is this attribute ordered and if so how
675    ///
676    /// * values (order among multiple attribute values is preserved)
677    /// * siblings (order among entries using this attribute as RDN is preserved)
678    ///
679    /// <https://tools.ietf.org/html/draft-chu-ldap-xordered-00>
680    pub x_ordered: Option<KeyString>,
681}
682
683impl std::fmt::Debug for AttributeType {
684    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
685        let string_oid: String = self.oid.clone().into();
686        f.debug_struct("AttributeType")
687            .field("oid", &string_oid)
688            .field("name", &self.name)
689            .field("sup", &self.sup)
690            .field("desc", &self.desc)
691            .field("syntax", &self.syntax)
692            .field("single_value", &self.single_value)
693            .field("equality", &self.equality)
694            .field("substr", &self.substr)
695            .field("ordering", &self.ordering)
696            .field("no_user_modification", &self.no_user_modification)
697            .field("usage", &self.usage)
698            .field("collective", &self.collective)
699            .field("obsolete", &self.obsolete)
700            .field("x_ordered", &self.x_ordered)
701            .finish()
702    }
703}
704
705/// parser for attribute type LDAP schema entries
706#[cfg(feature = "chumsky")]
707#[must_use]
708pub fn attribute_type_parser<'src>()
709-> impl Parser<'src, &'src str, AttributeType, extra::Err<Rich<'src, char>>> {
710    ldap_schema_parser(&ATTRIBUTE_TYPE_TAGS).try_map(|(oid, tags), span| {
711        Ok(AttributeType {
712            oid,
713            name: required_tag("NAME", &span, &tags)?
714                .as_quoted_key_string_list()
715                .ok_or_else(|| {
716                    Rich::custom(span, "NAME parameter should be a quoted keystring list")
717                })?
718                .to_vec(),
719            sup: optional_tag("SUP", &tags)
720                .map(|tag_value| {
721                    tag_value
722                        .as_key_string()
723                        .map(|val| val.to_owned())
724                        .ok_or_else(|| Rich::custom(span, "SUP parameter should be a key string"))
725                })
726                .transpose()?,
727            desc: optional_tag("DESC", &tags)
728                .map(|tag_value| {
729                    tag_value
730                        .as_string()
731                        .map(|val| val.to_string())
732                        .ok_or_else(|| Rich::custom(span, "DESC parameter should be a string"))
733                })
734                .transpose()?,
735            syntax: optional_tag("SYNTAX", &tags)
736                .map(|tag_value| {
737                    tag_value
738                        .as_oid_with_length()
739                        .map(|val| val.to_owned())
740                        .ok_or_else(|| {
741                            Rich::custom(
742                                span,
743                                "SYNTAX parameter should be an OID with an optional length",
744                            )
745                        })
746                })
747                .transpose()?,
748            single_value: optional_tag("SINGLE-VALUE", &tags).is_some(),
749            equality: optional_tag("EQUALITY", &tags)
750                .map(|tag_value| {
751                    tag_value
752                        .as_key_string()
753                        .map(|val| val.to_owned())
754                        .ok_or_else(|| {
755                            Rich::custom(span, "EQUALITY parameter should be a key string")
756                        })
757                })
758                .transpose()?,
759            substr: optional_tag("SUBSTR", &tags)
760                .map(|tag_value| {
761                    tag_value
762                        .as_key_string()
763                        .map(|val| val.to_owned())
764                        .ok_or_else(|| {
765                            Rich::custom(span, "SUBSTR parameter should be a key string")
766                        })
767                })
768                .transpose()?,
769            ordering: optional_tag("ORDERING", &tags)
770                .map(|tag_value| {
771                    tag_value
772                        .as_key_string()
773                        .map(|val| val.to_owned())
774                        .ok_or_else(|| {
775                            Rich::custom(span, "ORDERING parameter should be a key string")
776                        })
777                })
778                .transpose()?,
779            no_user_modification: optional_tag("NO-USER-MODIFICATION", &tags).is_some(),
780            usage: optional_tag("USAGE", &tags)
781                .map(|tag_value| {
782                    tag_value
783                        .as_key_string()
784                        .map(|val| val.to_owned())
785                        .ok_or_else(|| Rich::custom(span, "USAGE parameter should be a key string"))
786                })
787                .transpose()?,
788            collective: optional_tag("COLLECTIVE", &tags).is_some(),
789            obsolete: optional_tag("OBSOLETE", &tags).is_some(),
790            x_ordered: optional_tag("X-ORDERED", &tags)
791                .map(|tag_value| {
792                    tag_value
793                        .as_quoted_key_string()
794                        .map(|val| val.to_owned())
795                        .ok_or_else(|| {
796                            Rich::custom(span, "X-ORDERED parameter should be a quoted key string")
797                        })
798                })
799                .transpose()?,
800        })
801    })
802}
803
804/// type of LDAP object class
805#[derive(PartialEq, Eq, Clone, Debug, EnumAsInner, Hash)]
806#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
807pub enum ObjectClassType {
808    /// this can not be used as an actual object class and is purely used
809    /// as a parent for the other types
810    Abstract,
811    /// this is the main objectclass of an object, other than structural classes
812    /// that are ancestors in the inheritance hierarchy only one of these can be used
813    /// on any given LDAP object
814    Structural,
815    /// these are objectclasses that are added on to the main structural object class
816    /// of an entry
817    Auxiliary,
818}
819
820/// an LDAP schema objectclass entry
821#[derive(Clone, Educe)]
822#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
823#[educe(PartialEq, Eq, Hash)]
824pub struct ObjectClass {
825    /// the OID of the object class
826    #[educe(Hash(method = "crate::basic::hash_oid"))]
827    pub oid: ObjectIdentifier,
828    /// the name of the object class
829    pub name: Vec<KeyString>,
830    /// the parent of the object class
831    pub sup: Vec<KeyStringOrOID>,
832    /// the human-readable description
833    pub desc: Option<String>,
834    /// the type of object class
835    pub object_class_type: ObjectClassType,
836    /// the attributes that must be present on an object with this object class
837    pub must: Vec<KeyStringOrOID>,
838    /// the attributes that may optionally also be present on an object with this
839    /// object class
840    pub may: Vec<KeyStringOrOID>,
841    /// is this object class obsolete
842    pub obsolete: bool,
843}
844
845impl std::fmt::Debug for ObjectClass {
846    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
847        let string_oid: String = self.oid.clone().into();
848        f.debug_struct("ObjectClass")
849            .field("oid", &string_oid)
850            .field("name", &self.name)
851            .field("sup", &self.sup)
852            .field("desc", &self.desc)
853            .field("object_class_type", &self.object_class_type)
854            .field("must", &self.must)
855            .field("may", &self.may)
856            .field("obsolete", &self.obsolete)
857            .finish()
858    }
859}
860
861/// parses an LDAP schema object class entry
862#[cfg(feature = "chumsky")]
863#[must_use]
864pub fn object_class_parser<'src>()
865-> impl Parser<'src, &'src str, ObjectClass, extra::Err<Rich<'src, char>>> {
866    ldap_schema_parser(&OBJECT_CLASS_TAGS).try_map(|(oid, tags), span| {
867        Ok(ObjectClass {
868            oid,
869            name: required_tag("NAME", &span, &tags)?
870                .as_quoted_key_string_list()
871                .ok_or_else(|| {
872                    Rich::custom(span, "NAME parameter should be a quoted keystring list")
873                })?
874                .to_vec(),
875            sup: optional_tag("SUP", &tags)
876                .map(|tag_value| {
877                    tag_value
878                        .as_key_string_or_oid_list()
879                        .map(|val| val.to_owned())
880                        .ok_or_else(|| {
881                            Rich::custom(span, "SUP parameter should be a key string or OID list")
882                        })
883                })
884                .transpose()?
885                .unwrap_or_default(),
886            desc: optional_tag("DESC", &tags)
887                .map(|tag_value| {
888                    tag_value
889                        .as_string()
890                        .map(|val| val.to_string())
891                        .ok_or_else(|| Rich::custom(span, "DESC parameter should be a string"))
892                })
893                .transpose()?,
894            object_class_type: optional_tag("ABSTRACT", &tags)
895                .map(|_| ObjectClassType::Abstract)
896                .or_else(|| optional_tag("STRUCTURAL", &tags).map(|_| ObjectClassType::Structural))
897                .or_else(|| optional_tag("AUXILIARY", &tags).map(|_| ObjectClassType::Auxiliary))
898                .unwrap_or(ObjectClassType::Structural),
899            must: optional_tag("MUST", &tags)
900                .map(|tag_value| {
901                    tag_value
902                        .as_key_string_or_oid_list()
903                        .map(|val| val.to_owned())
904                        .ok_or_else(|| {
905                            Rich::custom(span, "MUST parameter should be a key string or OID list")
906                        })
907                })
908                .transpose()?
909                .unwrap_or_default(),
910            may: optional_tag("MAY", &tags)
911                .map(|tag_value| {
912                    tag_value
913                        .as_key_string_or_oid_list()
914                        .map(|val| val.to_owned())
915                        .ok_or_else(|| {
916                            Rich::custom(span, "MAY parameter should be a key string or OID list")
917                        })
918                })
919                .transpose()?
920                .unwrap_or_default(),
921            obsolete: optional_tag("OBSOLETE", &tags).is_some(),
922        })
923    })
924}
925
926/// an entire LDAP schema for an LDAP server
927#[derive(Debug, Clone, Hash)]
928#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
929#[expect(
930    clippy::module_name_repetitions,
931    reason = "without schema it would just be LDAP which is probably not a good name to use anywhere when working with LDAP"
932)]
933pub struct LDAPSchema {
934    /// the supported LDAP syntaxes
935    pub ldap_syntaxes: Vec<LDAPSyntax>,
936    /// the supported LDAP matching rules
937    pub matching_rules: Vec<MatchingRule>,
938    /// the allowed uses (attributes) for the LDAP matching rules
939    pub matching_rule_use: Vec<MatchingRuleUse>,
940    /// the supported LDAP attribute types
941    pub attribute_types: Vec<AttributeType>,
942    /// the supported LDAP object classes
943    pub object_classes: Vec<ObjectClass>,
944    // these are not implemented by OpenLDAP to the best of my knowledge
945    // pub name_forms: Vec<String>,
946    // pub dit_content_rules: Vec<String>,
947    // pub dit_structure_rules: Vec<String>,
948}
949
950impl LDAPSchema {
951    /// returns the set of allowed attributes (either must or may) for an ObjectClass and all of its super-classes
952    pub fn allowed_attributes(
953        &self,
954        id: impl TryInto<KeyStringOrOID>,
955    ) -> Option<HashSet<&AttributeType>> {
956        if let Some(object_class) = self.find_object_class(id) {
957            let mut result = HashSet::new();
958            for attribute_name in object_class.must.iter().chain(object_class.may.iter()) {
959                if let Some(attribute) = self.find_attribute_type(attribute_name) {
960                    result.insert(attribute);
961                }
962            }
963            for sup in &object_class.sup {
964                if let Some(allowed_attributes) = self.allowed_attributes(sup) {
965                    result.extend(allowed_attributes);
966                }
967            }
968            Some(result)
969        } else {
970            None
971        }
972    }
973
974    /// returns the set of required attributes (must) for an ObjectClass and all of its super-classes
975    pub fn required_attributes(
976        &self,
977        id: impl TryInto<KeyStringOrOID>,
978    ) -> Option<HashSet<&AttributeType>> {
979        if let Some(object_class) = self.find_object_class(id) {
980            let mut result = HashSet::new();
981            for attribute_name in &object_class.must {
982                if let Some(attribute) = self.find_attribute_type(attribute_name) {
983                    result.insert(attribute);
984                }
985            }
986            for sup in &object_class.sup {
987                if let Some(required_attributes) = self.required_attributes(sup) {
988                    result.extend(required_attributes);
989                }
990            }
991            Some(result)
992        } else {
993            None
994        }
995    }
996
997    /// return the object class if it is present in the schema
998    pub fn find_object_class(&self, id: impl TryInto<KeyStringOrOID>) -> Option<&ObjectClass> {
999        let id: Result<KeyStringOrOID, _> = id.try_into();
1000        match id {
1001            Ok(id) => {
1002                let match_fn: Box<dyn FnMut(&&ObjectClass) -> bool> = match id {
1003                    KeyStringOrOID::OID(oid) => Box::new(move |at: &&ObjectClass| at.oid == oid),
1004                    KeyStringOrOID::KeyString(s) => Box::new(move |at: &&ObjectClass| {
1005                        at.name
1006                            .iter()
1007                            .map(|n| n.to_lowercase())
1008                            .contains(&s.to_lowercase())
1009                    }),
1010                };
1011                self.object_classes.iter().find(match_fn)
1012            }
1013            Err(_) => None,
1014        }
1015    }
1016
1017    /// apply the given function to the named object class
1018    /// and all its ancestors in the LDAP schema until one
1019    /// returns Some
1020    pub fn find_object_class_property<'a, R>(
1021        &'a self,
1022        id: impl TryInto<KeyStringOrOID>,
1023        f: fn(&'a ObjectClass) -> Option<&'a R>,
1024    ) -> Option<&'a R> {
1025        let object_class = self.find_object_class(id);
1026        if let Some(object_class) = object_class {
1027            if let Some(r) = f(object_class) {
1028                Some(r)
1029            } else {
1030                let ks_or_oids = &object_class.sup;
1031                for ks_or_oid in ks_or_oids {
1032                    if let Some(r) = self.find_object_class_property(ks_or_oid, f) {
1033                        return Some(r);
1034                    }
1035                }
1036                None
1037            }
1038        } else {
1039            None
1040        }
1041    }
1042
1043    /// return the attribute type if it is present in the schema
1044    pub fn find_attribute_type(&self, id: impl TryInto<KeyStringOrOID>) -> Option<&AttributeType> {
1045        let id: Result<KeyStringOrOID, _> = id.try_into();
1046        match id {
1047            Ok(id) => {
1048                let match_fn: Box<dyn FnMut(&&AttributeType) -> bool> = match id {
1049                    KeyStringOrOID::OID(oid) => Box::new(move |at: &&AttributeType| at.oid == oid),
1050                    KeyStringOrOID::KeyString(s) => Box::new(move |at: &&AttributeType| {
1051                        at.name
1052                            .iter()
1053                            .map(|n| n.to_lowercase())
1054                            .contains(&s.to_lowercase())
1055                    }),
1056                };
1057                self.attribute_types.iter().find(match_fn)
1058            }
1059            Err(_) => None,
1060        }
1061    }
1062
1063    /// apply the given function to the named attribute type
1064    /// and all its ancestors in the LDAP schema until one
1065    /// returns Some
1066    pub fn find_attribute_type_property<'a, R>(
1067        &'a self,
1068        id: impl TryInto<KeyStringOrOID>,
1069        f: fn(&'a AttributeType) -> Option<&'a R>,
1070    ) -> Option<&'a R> {
1071        let attribute_type = self.find_attribute_type(id);
1072        if let Some(attribute_type) = attribute_type {
1073            if let Some(r) = f(attribute_type) {
1074                Some(r)
1075            } else if let Some(sup @ KeyString(_)) = &attribute_type.sup {
1076                self.find_attribute_type_property(KeyStringOrOID::KeyString(sup.to_owned()), f)
1077            } else {
1078                None
1079            }
1080        } else {
1081            None
1082        }
1083    }
1084
1085    /// return the ldap syntax if it is present in the schema
1086    #[cfg(feature = "chumsky")]
1087    pub fn find_ldap_syntax(&self, id: impl TryInto<ObjectIdentifier>) -> Option<&LDAPSyntax> {
1088        let id: Result<ObjectIdentifier, _> = id.try_into();
1089        match id {
1090            Ok(id) => self
1091                .ldap_syntaxes
1092                .iter()
1093                .find(move |ls: &&LDAPSyntax| ls.oid == id),
1094            Err(_) => None,
1095        }
1096    }
1097
1098    /// return the matching rule if it is present in the schema
1099    #[cfg(feature = "chumsky")]
1100    pub fn find_matching_rule(&self, id: impl TryInto<ObjectIdentifier>) -> Option<&MatchingRule> {
1101        let id: Result<ObjectIdentifier, _> = id.try_into();
1102        match id {
1103            Ok(id) => self
1104                .matching_rules
1105                .iter()
1106                .find(move |ls: &&MatchingRule| ls.oid == id),
1107            Err(_) => None,
1108        }
1109    }
1110
1111    /// return the matching rule use if it is present in the schema
1112    #[cfg(feature = "chumsky")]
1113    pub fn find_matching_rule_use(
1114        &self,
1115        id: impl TryInto<ObjectIdentifier>,
1116    ) -> Option<&MatchingRuleUse> {
1117        let id: Result<ObjectIdentifier, _> = id.try_into();
1118        match id {
1119            Ok(id) => self
1120                .matching_rule_use
1121                .iter()
1122                .find(move |ls: &&MatchingRuleUse| ls.oid == id),
1123            Err(_) => None,
1124        }
1125    }
1126}
1127
1128#[cfg(test)]
1129#[expect(
1130    clippy::expect_used,
1131    reason = "In tests it is okay to fail using expect"
1132)]
1133mod test {
1134    #[cfg(feature = "chumsky")]
1135    use super::*;
1136    #[cfg(feature = "chumsky")]
1137    use crate::basic::ChumskyError;
1138    #[cfg(feature = "chumsky")]
1139    use pretty_assertions::assert_eq;
1140
1141    #[cfg(feature = "chumsky")]
1142    #[test]
1143    fn test_parse_ldap_syntax() {
1144        #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1145        ldap_syntax_parser().parse("( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' X-BINARY-TRANSFER-REQUIRED 'TRUE' X-NOT-HUMAN-READABLE 'TRUE' )").into_result().unwrap();
1146    }
1147
1148    #[cfg(feature = "chumsky")]
1149    #[test]
1150    fn test_parse_ldap_syntax_value1() {
1151        assert_eq!(ldap_syntax_parser().parse("( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' X-BINARY-TRANSFER-REQUIRED 'TRUE' X-NOT-HUMAN-READABLE 'TRUE' )").into_result(),
1152            Ok(LDAPSyntax {
1153                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1154                oid: "1.3.6.1.4.1.1466.115.121.1.8".to_string().try_into().unwrap(),
1155                         desc: "Certificate".to_string(),
1156                         x_binary_transfer_required: true,
1157                         x_not_human_readable: true,
1158                       }
1159            ));
1160    }
1161
1162    #[cfg(feature = "chumsky")]
1163    #[test]
1164    fn test_parse_ldap_syntax_value2() {
1165        assert_eq!(ldap_syntax_parser().parse("( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' X-NOT-HUMAN-READABLE 'TRUE' X-BINARY-TRANSFER-REQUIRED 'TRUE' )").into_result(),
1166            Ok(LDAPSyntax {
1167                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1168                oid: "1.3.6.1.4.1.1466.115.121.1.8".to_string().try_into().unwrap(),
1169                         desc: "Certificate".to_string(),
1170                         x_binary_transfer_required: true,
1171                         x_not_human_readable: true,
1172                       }
1173            ));
1174    }
1175
1176    #[cfg(feature = "chumsky")]
1177    #[test]
1178    fn test_parse_ldap_syntax_value3() {
1179        assert_eq!(ldap_syntax_parser().parse("( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' X-BINARY-TRANSFER-REQUIRED 'TRUE' )").into_result(),
1180            Ok(LDAPSyntax {
1181                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1182                oid: "1.3.6.1.4.1.1466.115.121.1.8".to_string().try_into().unwrap(),
1183                         desc: "Certificate".to_string(),
1184                         x_binary_transfer_required: true,
1185                         x_not_human_readable: false,
1186                       }
1187            ));
1188    }
1189
1190    #[cfg(feature = "chumsky")]
1191    #[test]
1192    fn test_parse_ldap_syntax_value4() {
1193        assert_eq!(
1194            ldap_syntax_parser().parse(
1195                "( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' X-NOT-HUMAN-READABLE 'TRUE' )"
1196            ).into_result(),
1197            Ok(LDAPSyntax {
1198                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1199                oid: "1.3.6.1.4.1.1466.115.121.1.8"
1200                    .to_string()
1201                    .try_into()
1202                    .unwrap(),
1203                desc: "Certificate".to_string(),
1204                x_binary_transfer_required: false,
1205                x_not_human_readable: true,
1206            })
1207        );
1208    }
1209
1210    #[cfg(feature = "chumsky")]
1211    #[test]
1212    fn test_parse_ldap_syntax_value5() {
1213        assert_eq!(
1214            ldap_syntax_parser()
1215                .parse("( 1.3.6.1.4.1.1466.115.121.1.8 DESC 'Certificate' )")
1216                .into_result(),
1217            Ok(LDAPSyntax {
1218                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1219                oid: "1.3.6.1.4.1.1466.115.121.1.8"
1220                    .to_string()
1221                    .try_into()
1222                    .unwrap(),
1223                desc: "Certificate".to_string(),
1224                x_binary_transfer_required: false,
1225                x_not_human_readable: false,
1226            })
1227        );
1228    }
1229
1230    #[cfg(feature = "chumsky")]
1231    #[test]
1232    fn test_parse_ldap_syntax_desc_required() {
1233        #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1234        ldap_syntax_parser()
1235            .parse("( 1.3.6.1.4.1.1466.115.121.1.8 )")
1236            .into_result()
1237            .unwrap_err();
1238    }
1239
1240    #[cfg(feature = "chumsky")]
1241    #[test]
1242    fn test_parse_matching_rule() {
1243        #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1244        matching_rule_parser()
1245            .parse("( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )")
1246            .into_result()
1247            .unwrap();
1248    }
1249
1250    #[cfg(feature = "chumsky")]
1251    #[test]
1252    fn test_parse_matching_rule_value() {
1253        assert_eq!(
1254            matching_rule_parser()
1255                .parse("( 1.3.6.1.1.16.3 NAME 'UUIDOrderingMatch' SYNTAX 1.3.6.1.1.16.1 )")
1256                .into_result(),
1257            Ok(MatchingRule {
1258                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1259                oid: "1.3.6.1.1.16.3".to_string().try_into().unwrap(),
1260                name: vec![KeyString("UUIDOrderingMatch".to_string())],
1261                syntax: OIDWithLength {
1262                    #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1263                    oid: "1.3.6.1.1.16.1".to_string().try_into().unwrap(),
1264                    length: None
1265                },
1266            })
1267        );
1268    }
1269
1270    #[cfg(feature = "chumsky")]
1271    #[test]
1272    fn test_parse_matching_rule_uses() {
1273        #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1274        matching_rule_use_parser().parse("( 2.5.13.11 NAME 'caseIgnoreListMatch' APPLIES ( postalAddress $ registeredAddress $ homePostalAddress ) )").into_result().unwrap();
1275    }
1276
1277    #[cfg(feature = "chumsky")]
1278    #[test]
1279    fn test_parse_matching_rule_uses_value() {
1280        assert_eq!(matching_rule_use_parser().parse("( 2.5.13.11 NAME 'caseIgnoreListMatch' APPLIES ( postalAddress $ registeredAddress $ homePostalAddress ) )").into_result(),
1281            Ok(MatchingRuleUse {
1282                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1283                oid: "2.5.13.11".to_string().try_into().unwrap(),
1284                                 name: vec![KeyString("caseIgnoreListMatch".to_string())],
1285                                 applies: vec![KeyStringOrOID::KeyString(KeyString("postalAddress".to_string())),
1286                                               KeyStringOrOID::KeyString(KeyString("registeredAddress".to_string())),
1287                                               KeyStringOrOID::KeyString(KeyString("homePostalAddress".to_string()))
1288                                              ],
1289            })
1290        );
1291    }
1292
1293    #[cfg(feature = "chumsky")]
1294    #[test]
1295    fn test_parse_matching_rule_uses_single_applies_value() {
1296        assert_eq!(
1297            matching_rule_use_parser()
1298                .parse("( 2.5.13.11 NAME 'caseIgnoreListMatch' APPLIES postalAddress )")
1299                .into_result(),
1300            Ok(MatchingRuleUse {
1301                #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1302                oid: "2.5.13.11".to_string().try_into().unwrap(),
1303                name: vec![KeyString("caseIgnoreListMatch".to_string())],
1304                applies: vec![KeyStringOrOID::KeyString(KeyString(
1305                    "postalAddress".to_string()
1306                ))],
1307            })
1308        );
1309    }
1310
1311    mod attribute_type_parser_tests {
1312        use super::*;
1313        #[cfg(feature = "chumsky")]
1314        use pretty_assertions::assert_eq;
1315
1316        #[cfg(feature = "chumsky")]
1317        #[test]
1318        fn test_attribute_type_sup_missing() {
1319            let schema_str =
1320                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1321            let attr_type = attribute_type_parser()
1322                .parse(schema_str)
1323                .into_result()
1324                .expect("Parsing failed");
1325            assert!(attr_type.sup.is_none());
1326        }
1327
1328        #[cfg(feature = "chumsky")]
1329        #[test]
1330        fn test_attribute_type_sup_wrong_type() {
1331            let schema_str = "( 1.2.3 NAME 'test' SUP 'invalid value with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1332            let result = attribute_type_parser().parse(schema_str).into_result();
1333            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1334            let err = result.unwrap_err();
1335            let err_string = format!(
1336                "{}",
1337                ChumskyError {
1338                    description: "attribute type".to_string(),
1339                    source: schema_str.to_string(),
1340                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1341                }
1342            );
1343            assert!(
1344                err_string.contains("Unexpected token while parsing [], expected keystring"),
1345                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
1346            );
1347        }
1348
1349        #[cfg(feature = "chumsky")]
1350        #[test]
1351        fn test_attribute_type_sup_missing_param() {
1352            let schema_str = "( 1.2.3 NAME 'test' SUP DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1353            let result = attribute_type_parser().parse(schema_str).into_result();
1354            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1355            let err = result.unwrap_err();
1356            let err_string = format!(
1357                "{}",
1358                ChumskyError {
1359                    description: "attribute type".to_string(),
1360                    source: schema_str.to_string(),
1361                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1362                }
1363            );
1364            assert!(
1365                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
1366                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
1367            );
1368        }
1369
1370        #[cfg(feature = "chumsky")]
1371        #[test]
1372        fn test_attribute_type_sup_correct() {
1373            let schema_str = "( 1.2.3 NAME 'test' SUP someKeyString DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1374            let attr_type = attribute_type_parser()
1375                .parse(schema_str)
1376                .into_result()
1377                .expect("Parsing failed");
1378            assert_eq!(attr_type.sup, Some(KeyString("someKeyString".to_string())));
1379        }
1380
1381        // Test cases for 'DESC'
1382        #[cfg(feature = "chumsky")]
1383        #[test]
1384        fn test_attribute_type_desc_missing() {
1385            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1386            let attr_type = attribute_type_parser()
1387                .parse(schema_str)
1388                .into_result()
1389                .expect("Parsing failed");
1390            assert!(attr_type.desc.is_none());
1391        }
1392
1393        #[cfg(feature = "chumsky")]
1394        #[test]
1395        fn test_attribute_type_desc_wrong_type() {
1396            let schema_str =
1397                "( 1.2.3 NAME 'test' DESC unquoted String SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1398            let result = attribute_type_parser().parse(schema_str).into_result();
1399            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1400            let err = result.unwrap_err();
1401            let err_string = format!(
1402                "{}",
1403                ChumskyError {
1404                    description: "attribute type".to_string(),
1405                    source: schema_str.to_string(),
1406                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1407                }
1408            );
1409            assert!(
1410                err_string
1411                    .contains("Unexpected token while parsing [], expected single-quoted string"),
1412                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
1413            );
1414        }
1415
1416        #[cfg(feature = "chumsky")]
1417        #[test]
1418        fn test_attribute_type_desc_missing_param() {
1419            let schema_str = "( 1.2.3 NAME 'test' DESC SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1420            let result = attribute_type_parser().parse(schema_str).into_result();
1421            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1422            let err = result.unwrap_err();
1423            let err_string = format!(
1424                "{}",
1425                ChumskyError {
1426                    description: "attribute type".to_string(),
1427                    source: schema_str.to_string(),
1428                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1429                }
1430            );
1431            assert!(
1432                err_string
1433                    .contains("Unexpected token while parsing [], expected single-quoted string"),
1434                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
1435            );
1436        }
1437
1438        #[cfg(feature = "chumsky")]
1439        #[test]
1440        fn test_attribute_type_desc_correct() {
1441            let schema_str = "( 1.2.3 NAME 'test' DESC 'Some description' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1442            let attr_type = attribute_type_parser()
1443                .parse(schema_str)
1444                .into_result()
1445                .expect("Parsing failed");
1446            assert_eq!(attr_type.desc, Some("Some description".to_string()));
1447        }
1448
1449        // Test cases for 'SYNTAX'
1450        #[cfg(feature = "chumsky")]
1451        #[test]
1452        fn test_attribute_type_syntax_missing() {
1453            let schema_str = "( 1.2.3 NAME 'test' DESC 'Test Attribute' )";
1454            let attr_type = attribute_type_parser()
1455                .parse(schema_str)
1456                .into_result()
1457                .expect("Parsing failed");
1458            assert!(attr_type.syntax.is_none());
1459        }
1460
1461        #[cfg(feature = "chumsky")]
1462        #[test]
1463        fn test_attribute_type_syntax_wrong_type() {
1464            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 'not an OID' DESC 'Test Attribute' )";
1465            let result = attribute_type_parser().parse(schema_str).into_result();
1466            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1467            let err = result.unwrap_err();
1468            let err_string = format!(
1469                "{}",
1470                ChumskyError {
1471                    description: "attribute type".to_string(),
1472                    source: schema_str.to_string(),
1473                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1474                }
1475            );
1476            assert!(
1477                err_string.contains(
1478                    "Unexpected end of input while parsing [], expected OID with optional length"
1479                ),
1480                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected OID with optional length'",
1481            );
1482        }
1483
1484        #[cfg(feature = "chumsky")]
1485        #[test]
1486        fn test_attribute_type_syntax_missing_param() {
1487            let schema_str = "( 1.2.3 NAME 'test' SYNTAX DESC 'Test Attribute' )";
1488            let result = attribute_type_parser().parse(schema_str).into_result();
1489            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1490            let err = result.unwrap_err();
1491            let err_string = format!(
1492                "{}",
1493                ChumskyError {
1494                    description: "attribute type".to_string(),
1495                    source: schema_str.to_string(),
1496                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1497                }
1498            );
1499            assert!(
1500                err_string.contains(
1501                    "Unexpected end of input while parsing [], expected OID with optional length"
1502                ),
1503                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected OID with optional length'",
1504            );
1505        }
1506
1507        #[cfg(feature = "chumsky")]
1508        #[test]
1509        fn test_attribute_type_syntax_correct_with_length() {
1510            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{255} DESC 'Test Attribute' )";
1511            let attr_type = attribute_type_parser()
1512                .parse(schema_str)
1513                .into_result()
1514                .expect("Parsing failed");
1515            assert_eq!(
1516                attr_type.syntax,
1517                Some(OIDWithLength {
1518                    #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1519                    oid: "1.3.6.1.4.1.1466.115.121.1.15"
1520                        .to_string()
1521                        .try_into()
1522                        .unwrap(),
1523                    length: Some(255)
1524                })
1525            );
1526        }
1527
1528        #[cfg(feature = "chumsky")]
1529        #[test]
1530        fn test_attribute_type_syntax_correct_without_length() {
1531            let schema_str =
1532                "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Test Attribute' )";
1533            let attr_type = attribute_type_parser()
1534                .parse(schema_str)
1535                .into_result()
1536                .expect("Parsing failed");
1537            assert_eq!(
1538                attr_type.syntax,
1539                Some(OIDWithLength {
1540                    #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
1541                    oid: "1.3.6.1.4.1.1466.115.121.1.15"
1542                        .to_string()
1543                        .try_into()
1544                        .unwrap(),
1545                    length: None
1546                })
1547            );
1548        }
1549
1550        // Test cases for 'SINGLE-VALUE'
1551        #[cfg(feature = "chumsky")]
1552        #[test]
1553        fn test_attribute_type_single_value_missing() {
1554            let schema_str =
1555                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1556            let attr_type = attribute_type_parser()
1557                .parse(schema_str)
1558                .into_result()
1559                .expect("Parsing failed");
1560            assert!(!attr_type.single_value);
1561        }
1562
1563        #[cfg(feature = "chumsky")]
1564        #[test]
1565        fn test_attribute_type_single_value_present() {
1566            let schema_str = "( 1.2.3 NAME 'test' SINGLE-VALUE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1567            let attr_type = attribute_type_parser()
1568                .parse(schema_str)
1569                .into_result()
1570                .expect("Parsing failed");
1571            assert!(attr_type.single_value);
1572        }
1573
1574        // Test cases for 'EQUALITY'
1575        #[cfg(feature = "chumsky")]
1576        #[test]
1577        fn test_attribute_type_equality_missing() {
1578            let schema_str =
1579                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1580            let attr_type = attribute_type_parser()
1581                .parse(schema_str)
1582                .into_result()
1583                .expect("Parsing failed");
1584            assert!(attr_type.equality.is_none());
1585        }
1586
1587        #[cfg(feature = "chumsky")]
1588        #[test]
1589        fn test_attribute_type_equality_wrong_type() {
1590            let schema_str = "( 1.2.3 NAME 'test' EQUALITY 'invalid equality with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1591            let result = attribute_type_parser().parse(schema_str).into_result();
1592            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1593            let err = result.unwrap_err();
1594            let err_string = format!(
1595                "{}",
1596                ChumskyError {
1597                    description: "attribute type".to_string(),
1598                    source: schema_str.to_string(),
1599                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1600                }
1601            );
1602            assert!(
1603                err_string.contains("Unexpected token while parsing [], expected keystring"),
1604                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
1605            );
1606        }
1607
1608        #[cfg(feature = "chumsky")]
1609        #[test]
1610        fn test_attribute_type_equality_missing_param() {
1611            let schema_str = "( 1.2.3 NAME 'test' EQUALITY DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1612            let result = attribute_type_parser().parse(schema_str).into_result();
1613            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1614            let err = result.unwrap_err();
1615            let err_string = format!(
1616                "{}",
1617                ChumskyError {
1618                    description: "attribute type".to_string(),
1619                    source: schema_str.to_string(),
1620                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1621                }
1622            );
1623            assert!(
1624                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
1625                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
1626            );
1627        }
1628
1629        #[cfg(feature = "chumsky")]
1630        #[test]
1631        fn test_attribute_type_equality_correct() {
1632            let schema_str = "( 1.2.3 NAME 'test' EQUALITY caseIgnoreMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1633            let attr_type = attribute_type_parser()
1634                .parse(schema_str)
1635                .into_result()
1636                .expect("Parsing failed");
1637            assert_eq!(
1638                attr_type.equality,
1639                Some(KeyString("caseIgnoreMatch".to_string()))
1640            );
1641        }
1642
1643        // Test cases for 'SUBSTR'
1644        #[cfg(feature = "chumsky")]
1645        #[test]
1646        fn test_attribute_type_substr_missing() {
1647            let schema_str =
1648                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1649            let attr_type = attribute_type_parser()
1650                .parse(schema_str)
1651                .into_result()
1652                .expect("Parsing failed");
1653            assert!(attr_type.substr.is_none());
1654        }
1655
1656        #[cfg(feature = "chumsky")]
1657        #[test]
1658        fn test_attribute_type_substr_wrong_type() {
1659            let schema_str = "( 1.2.3 NAME 'test' SUBSTR 'invalid substr with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1660            let result = attribute_type_parser().parse(schema_str).into_result();
1661            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1662            let err = result.unwrap_err();
1663            let err_string = format!(
1664                "{}",
1665                ChumskyError {
1666                    description: "attribute type".to_string(),
1667                    source: schema_str.to_string(),
1668                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1669                }
1670            );
1671            assert!(
1672                err_string.contains("Unexpected token while parsing [], expected keystring"),
1673                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
1674            );
1675        }
1676
1677        #[cfg(feature = "chumsky")]
1678        #[test]
1679        fn test_attribute_type_substr_missing_param() {
1680            let schema_str = "( 1.2.3 NAME 'test' SUBSTR DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1681            let result = attribute_type_parser().parse(schema_str).into_result();
1682            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1683            let err = result.unwrap_err();
1684            let err_string = format!(
1685                "{}",
1686                ChumskyError {
1687                    description: "attribute type".to_string(),
1688                    source: schema_str.to_string(),
1689                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1690                }
1691            );
1692            assert!(
1693                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
1694                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
1695            );
1696        }
1697
1698        #[cfg(feature = "chumsky")]
1699        #[test]
1700        fn test_attribute_type_substr_correct() {
1701            let schema_str = "( 1.2.3 NAME 'test' SUBSTR caseIgnoreSubstringsMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1702            let attr_type = attribute_type_parser()
1703                .parse(schema_str)
1704                .into_result()
1705                .expect("Parsing failed");
1706            assert_eq!(
1707                attr_type.substr,
1708                Some(KeyString("caseIgnoreSubstringsMatch".to_string()))
1709            );
1710        }
1711
1712        // Test cases for 'ORDERING'
1713        #[cfg(feature = "chumsky")]
1714        #[test]
1715        fn test_attribute_type_ordering_missing() {
1716            let schema_str =
1717                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1718            let attr_type = attribute_type_parser()
1719                .parse(schema_str)
1720                .into_result()
1721                .expect("Parsing failed");
1722            assert!(attr_type.ordering.is_none());
1723        }
1724
1725        #[cfg(feature = "chumsky")]
1726        #[test]
1727        fn test_attribute_type_ordering_wrong_type() {
1728            let schema_str = "( 1.2.3 NAME 'test' ORDERING 'invalid ordering with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1729            let result = attribute_type_parser().parse(schema_str).into_result();
1730            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1731            let err = result.unwrap_err();
1732            let err_string = format!(
1733                "{}",
1734                ChumskyError {
1735                    description: "attribute type".to_string(),
1736                    source: schema_str.to_string(),
1737                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1738                }
1739            );
1740            assert!(
1741                err_string.contains("Unexpected token while parsing [], expected keystring"),
1742                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
1743            );
1744        }
1745
1746        #[cfg(feature = "chumsky")]
1747        #[test]
1748        fn test_attribute_type_ordering_missing_param() {
1749            let schema_str = "( 1.2.3 NAME 'test' ORDERING DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1750            let result = attribute_type_parser().parse(schema_str).into_result();
1751            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1752            let err = result.unwrap_err();
1753            let err_string = format!(
1754                "{}",
1755                ChumskyError {
1756                    description: "attribute type".to_string(),
1757                    source: schema_str.to_string(),
1758                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1759                }
1760            );
1761            assert!(
1762                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
1763                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
1764            );
1765        }
1766
1767        #[cfg(feature = "chumsky")]
1768        #[test]
1769        fn test_attribute_type_ordering_correct() {
1770            let schema_str = "( 1.2.3 NAME 'test' ORDERING caseIgnoreOrderingMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1771            let attr_type = attribute_type_parser()
1772                .parse(schema_str)
1773                .into_result()
1774                .expect("Parsing failed");
1775            assert_eq!(
1776                attr_type.ordering,
1777                Some(KeyString("caseIgnoreOrderingMatch".to_string()))
1778            );
1779        }
1780
1781        // Test cases for 'NO-USER-MODIFICATION'
1782        #[cfg(feature = "chumsky")]
1783        #[test]
1784        fn test_attribute_type_no_user_modification_missing() {
1785            let schema_str =
1786                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1787            let attr_type = attribute_type_parser()
1788                .parse(schema_str)
1789                .into_result()
1790                .expect("Parsing failed");
1791            assert!(!attr_type.no_user_modification);
1792        }
1793
1794        #[cfg(feature = "chumsky")]
1795        #[test]
1796        fn test_attribute_type_no_user_modification_present() {
1797            let schema_str = "( 1.2.3 NAME 'test' NO-USER-MODIFICATION DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1798            let attr_type = attribute_type_parser()
1799                .parse(schema_str)
1800                .into_result()
1801                .expect("Parsing failed");
1802            assert!(attr_type.no_user_modification);
1803        }
1804
1805        // Test cases for 'USAGE'
1806        #[cfg(feature = "chumsky")]
1807        #[test]
1808        fn test_attribute_type_usage_missing() {
1809            let schema_str =
1810                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1811            let attr_type = attribute_type_parser()
1812                .parse(schema_str)
1813                .into_result()
1814                .expect("Parsing failed");
1815            assert!(attr_type.usage.is_none());
1816        }
1817
1818        #[cfg(feature = "chumsky")]
1819        #[test]
1820        fn test_attribute_type_usage_wrong_type() {
1821            let schema_str = "( 1.2.3 NAME 'test' USAGE 'invalid usage with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1822            let result = attribute_type_parser().parse(schema_str).into_result();
1823            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1824            let err = result.unwrap_err();
1825            let err_string = format!(
1826                "{}",
1827                ChumskyError {
1828                    description: "attribute type".to_string(),
1829                    source: schema_str.to_string(),
1830                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1831                }
1832            );
1833            assert!(
1834                err_string.contains("Unexpected token while parsing [], expected keystring"),
1835                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
1836            );
1837        }
1838
1839        #[cfg(feature = "chumsky")]
1840        #[test]
1841        fn test_attribute_type_usage_missing_param() {
1842            let schema_str = "( 1.2.3 NAME 'test' USAGE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1843            let result = attribute_type_parser().parse(schema_str).into_result();
1844            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1845            let err = result.unwrap_err();
1846            let err_string = format!(
1847                "{}",
1848                ChumskyError {
1849                    description: "attribute type".to_string(),
1850                    source: schema_str.to_string(),
1851                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1852                }
1853            );
1854            assert!(
1855                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
1856                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
1857            );
1858        }
1859
1860        #[cfg(feature = "chumsky")]
1861        #[test]
1862        fn test_attribute_type_usage_correct() {
1863            let schema_str = "( 1.2.3 NAME 'test' USAGE userApplications DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1864            let attr_type = attribute_type_parser()
1865                .parse(schema_str)
1866                .into_result()
1867                .expect("Parsing failed");
1868            assert_eq!(
1869                attr_type.usage,
1870                Some(KeyString("userApplications".to_string()))
1871            );
1872        }
1873
1874        // Test cases for 'COLLECTIVE'
1875        #[cfg(feature = "chumsky")]
1876        #[test]
1877        fn test_attribute_type_collective_missing() {
1878            let schema_str =
1879                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1880            let attr_type = attribute_type_parser()
1881                .parse(schema_str)
1882                .into_result()
1883                .expect("Parsing failed");
1884            assert!(!attr_type.collective);
1885        }
1886
1887        #[cfg(feature = "chumsky")]
1888        #[test]
1889        fn test_attribute_type_collective_present() {
1890            let schema_str = "( 1.2.3 NAME 'test' COLLECTIVE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1891            let attr_type = attribute_type_parser()
1892                .parse(schema_str)
1893                .into_result()
1894                .expect("Parsing failed");
1895            assert!(attr_type.collective);
1896        }
1897
1898        // Test cases for 'OBSOLETE'
1899        #[cfg(feature = "chumsky")]
1900        #[test]
1901        fn test_attribute_type_obsolete_missing() {
1902            let schema_str =
1903                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1904            let attr_type = attribute_type_parser()
1905                .parse(schema_str)
1906                .into_result()
1907                .expect("Parsing failed");
1908            assert!(!attr_type.obsolete);
1909        }
1910
1911        #[cfg(feature = "chumsky")]
1912        #[test]
1913        fn test_attribute_type_obsolete_present() {
1914            let schema_str = "( 1.2.3 NAME 'test' OBSOLETE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1915            let attr_type = attribute_type_parser()
1916                .parse(schema_str)
1917                .into_result()
1918                .expect("Parsing failed");
1919            assert!(attr_type.obsolete);
1920        }
1921
1922        // Test cases for 'X-ORDERED'
1923        #[cfg(feature = "chumsky")]
1924        #[test]
1925        fn test_attribute_type_x_ordered_missing() {
1926            let schema_str =
1927                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1928            let attr_type = attribute_type_parser()
1929                .parse(schema_str)
1930                .into_result()
1931                .expect("Parsing failed");
1932            assert!(attr_type.x_ordered.is_none());
1933        }
1934
1935        #[cfg(feature = "chumsky")]
1936        #[test]
1937        fn test_attribute_type_x_ordered_wrong_type() {
1938            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED unquotedString DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1939            let result = attribute_type_parser().parse(schema_str).into_result();
1940            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1941            let err = result.unwrap_err();
1942            let err_string = format!(
1943                "{}",
1944                ChumskyError {
1945                    description: "attribute type".to_string(),
1946                    source: schema_str.to_string(),
1947                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1948                }
1949            );
1950            assert!(
1951                err_string.contains("Unexpected token while parsing [], expected quoted keystring"),
1952                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected quoted keystring'",
1953            );
1954        }
1955
1956        #[cfg(feature = "chumsky")]
1957        #[test]
1958        fn test_attribute_type_x_ordered_missing_param() {
1959            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1960            let result = attribute_type_parser().parse(schema_str).into_result();
1961            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
1962            let err = result.unwrap_err();
1963            let err_string = format!(
1964                "{}",
1965                ChumskyError {
1966                    description: "attribute type".to_string(),
1967                    source: schema_str.to_string(),
1968                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
1969                }
1970            );
1971            assert!(
1972                err_string.contains("Unexpected token while parsing [], expected quoted keystring"),
1973                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected quoted keystring'",
1974            );
1975        }
1976
1977        #[cfg(feature = "chumsky")]
1978        #[test]
1979        fn test_attribute_type_x_ordered_correct() {
1980            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED 'values' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
1981            let attr_type = attribute_type_parser()
1982                .parse(schema_str)
1983                .into_result()
1984                .expect("Parsing failed");
1985            assert_eq!(attr_type.x_ordered, Some(KeyString("values".to_string())));
1986        }
1987    }
1988
1989    mod object_class_parser_tests {
1990        use super::*;
1991        #[cfg(feature = "chumsky")]
1992        use pretty_assertions::assert_eq;
1993
1994        // Test cases for 'SUP'
1995        #[cfg(feature = "chumsky")]
1996        #[test]
1997        fn test_object_class_sup_missing() {
1998            let schema_str = "( 1.2.3 NAME 'testOC' MUST attr1 )";
1999            let object_class = object_class_parser()
2000                .parse(schema_str)
2001                .into_result()
2002                .expect("Parsing failed");
2003            assert!(object_class.sup.is_empty());
2004        }
2005
2006        #[cfg(feature = "chumsky")]
2007        #[test]
2008        fn test_object_class_sup_wrong_type() {
2009            let schema_str = "( 1.2.3 NAME 'testOC' SUP 'invalid value with spaces' MUST attr1 )";
2010            let result = object_class_parser().parse(schema_str).into_result();
2011            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2012            let err = result.unwrap_err();
2013            let err_string = format!(
2014                "{}",
2015                ChumskyError {
2016                    description: "object class".to_string(),
2017                    source: schema_str.to_string(),
2018                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2019                }
2020            );
2021            assert!(
2022                err_string.contains("Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $"),
2023                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $'",
2024            );
2025        }
2026
2027        #[cfg(feature = "chumsky")]
2028        #[test]
2029        fn test_object_class_sup_missing_param() {
2030            let schema_str = "( 1.2.3 NAME 'testOC' SUP MUST attr1 )";
2031            let result = object_class_parser().parse(schema_str).into_result();
2032            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2033            let err = result.unwrap_err();
2034            let err_string = format!(
2035                "{}",
2036                ChumskyError {
2037                    description: "object class".to_string(),
2038                    source: schema_str.to_string(),
2039                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2040                }
2041            );
2042            assert!(
2043                err_string.contains(
2044                    "Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')'"
2045                ),
2046                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')''",
2047            );
2048        }
2049
2050        #[cfg(feature = "chumsky")]
2051        #[test]
2052        fn test_object_class_sup_correct_single() {
2053            let schema_str = "( 1.2.3 NAME 'testOC' SUP top MUST attr1 )";
2054            let object_class = object_class_parser()
2055                .parse(schema_str)
2056                .into_result()
2057                .expect("Parsing failed");
2058            assert_eq!(
2059                object_class.sup,
2060                vec![KeyStringOrOID::KeyString(KeyString("top".to_string()))]
2061            );
2062        }
2063
2064        #[cfg(feature = "chumsky")]
2065        #[test]
2066        fn test_object_class_sup_correct_list() {
2067            let schema_str = "( 1.2.3 NAME 'testOC' SUP ( top $ person ) MUST attr1 )";
2068            let object_class = object_class_parser()
2069                .parse(schema_str)
2070                .into_result()
2071                .expect("Parsing failed");
2072            assert_eq!(
2073                object_class.sup,
2074                vec![
2075                    KeyStringOrOID::KeyString(KeyString("top".to_string())),
2076                    KeyStringOrOID::KeyString(KeyString("person".to_string()))
2077                ]
2078            );
2079        }
2080
2081        // Test cases for 'DESC'
2082        #[cfg(feature = "chumsky")]
2083        #[test]
2084        fn test_object_class_desc_missing() {
2085            let schema_str = "( 1.2.3 NAME 'testOC' MUST attr1 )";
2086            let object_class = object_class_parser()
2087                .parse(schema_str)
2088                .into_result()
2089                .expect("Parsing failed");
2090            assert!(object_class.desc.is_none());
2091        }
2092
2093        #[cfg(feature = "chumsky")]
2094        #[test]
2095        fn test_object_class_desc_wrong_type() {
2096            let schema_str = "( 1.2.3 NAME 'testOC' DESC unquoted String MUST attr1 )";
2097            let result = object_class_parser().parse(schema_str).into_result();
2098            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2099            let err = result.unwrap_err();
2100            let err_string = format!(
2101                "{}",
2102                ChumskyError {
2103                    description: "object class".to_string(),
2104                    source: schema_str.to_string(),
2105                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2106                }
2107            );
2108            assert!(
2109                err_string
2110                    .contains("Unexpected token while parsing [], expected single-quoted string"),
2111                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
2112            );
2113        }
2114
2115        #[cfg(feature = "chumsky")]
2116        #[test]
2117        fn test_object_class_desc_missing_param() {
2118            let schema_str = "( 1.2.3 NAME 'testOC' DESC MUST attr1 )";
2119            let result = object_class_parser().parse(schema_str).into_result();
2120            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2121            let err = result.unwrap_err();
2122            let err_string = format!(
2123                "{}",
2124                ChumskyError {
2125                    description: "object class".to_string(),
2126                    source: schema_str.to_string(),
2127                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2128                }
2129            );
2130            assert!(
2131                err_string
2132                    .contains("Unexpected token while parsing [], expected single-quoted string"),
2133                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
2134            );
2135        }
2136
2137        #[cfg(feature = "chumsky")]
2138        #[test]
2139        fn test_object_class_desc_correct() {
2140            let schema_str = "( 1.2.3 NAME 'testOC' DESC 'Some description' MUST attr1 )";
2141            let object_class = object_class_parser()
2142                .parse(schema_str)
2143                .into_result()
2144                .expect("Parsing failed");
2145            assert_eq!(object_class.desc, Some("Some description".to_string()));
2146        }
2147
2148        // Test cases for 'object_class_type' (ABSTRACT, STRUCTURAL, AUXILIARY)
2149        #[cfg(feature = "chumsky")]
2150        #[test]
2151        fn test_object_class_type_abstract_present() {
2152            let schema_str = "( 1.2.3 NAME 'testOC' ABSTRACT MUST attr1 )";
2153            let object_class = object_class_parser()
2154                .parse(schema_str)
2155                .into_result()
2156                .expect("Parsing failed");
2157            assert_eq!(object_class.object_class_type, ObjectClassType::Abstract);
2158        }
2159
2160        #[cfg(feature = "chumsky")]
2161        #[test]
2162        fn test_object_class_type_structural_present() {
2163            let schema_str = "( 1.2.3 NAME 'testOC' STRUCTURAL MUST attr1 )";
2164            let object_class = object_class_parser()
2165                .parse(schema_str)
2166                .into_result()
2167                .expect("Parsing failed");
2168            assert_eq!(object_class.object_class_type, ObjectClassType::Structural);
2169        }
2170
2171        #[cfg(feature = "chumsky")]
2172        #[test]
2173        fn test_object_class_type_auxiliary_present() {
2174            let schema_str = "( 1.2.3 NAME 'testOC' AUXILIARY MUST attr1 )";
2175            let object_class = object_class_parser()
2176                .parse(schema_str)
2177                .into_result()
2178                .expect("Parsing failed");
2179            assert_eq!(object_class.object_class_type, ObjectClassType::Auxiliary);
2180        }
2181
2182        #[cfg(feature = "chumsky")]
2183        #[test]
2184        fn test_object_class_type_default_structural() {
2185            let schema_str = "( 1.2.3 NAME 'testOC' MUST attr1 )";
2186            let object_class = object_class_parser()
2187                .parse(schema_str)
2188                .into_result()
2189                .expect("Parsing failed");
2190            assert_eq!(object_class.object_class_type, ObjectClassType::Structural);
2191        }
2192
2193        // Test for multiple type tags - parser should pick the first encountered.
2194        // In OBJECT_CLASS_TAGS, ABSTRACT is before STRUCTURAL and AUXILIARY.
2195        #[cfg(feature = "chumsky")]
2196        #[test]
2197        fn test_object_class_type_multiple_tags_abstract_first() {
2198            let schema_str = "( 1.2.3 NAME 'testOC' ABSTRACT STRUCTURAL MUST attr1 )";
2199            let object_class = object_class_parser()
2200                .parse(schema_str)
2201                .into_result()
2202                .expect("Parsing failed");
2203            assert_eq!(object_class.object_class_type, ObjectClassType::Abstract);
2204        }
2205
2206        // Test for multiple type tags - if STRUCTURAL is encountered first.
2207        // This scenario might not be easily parsable with current ldap_schema_parser due to fixed order in OBJECT_CLASS_TAGS.
2208        // But if it were possible to define `STRUCTURAL` before `ABSTRACT`, this test would be relevant.
2209        // For now, let's assume the parser handles input as defined in OBJECT_CLASS_TAGS.
2210        // This test case is more about understanding parsing behavior rather than "error" of multiple tags.
2211        #[cfg(feature = "chumsky")]
2212        #[test]
2213        fn test_object_class_type_multiple_tags_structural_first_if_possible() {
2214            // Note: Due to fixed order of OBJECT_CLASS_TAGS, ABSTRACT is always parsed before STRUCTURAL.
2215            // This test is conceptual unless OBJECT_CLASS_TAGS order can be dynamic or input order matters for tag parsing in ldap_schema_parser.
2216            // Based on `object_class_parser`'s `or_else` chain, the first one found wins.
2217            // The actual input string for parsing doesn't necessarily enforce order, but the `ldap_schema_parser` picks based on its internal `fold` order.
2218            // The `fold` processes tags in the order they appear in `tag_descriptors`.
2219            // So if STRUCTURAL appears before ABSTRACT in the input string, the ldap_schema_parser should still pick ABSTRACT if it's listed earlier in OBJECT_CLASS_TAGS.
2220            // Let's create an input where STRUCTURAL comes first to see if the `or_else` chain correctly picks based on tag definition order.
2221            // However, the `ldap_schema_parser` gets a list of tags and can find them in any order in the input. The `or_else` in the `object_class_parser` logic will then apply a preference.
2222
2223            // The OBJECT_CLASS_TAGS is defined as:
2224            // ABSTRACT, STRUCTURAL, AUXILIARY
2225            // So, ABSTRACT will always be checked first by `object_class_type` logic if present.
2226            // If the input string has STRUCTURAL before ABSTRACT, but ABSTRACT is still found by `ldap_schema_parser` as one of the `tags`,
2227            // then `optional_tag("ABSTRACT", &tags)` will return Some, and the `or_else` chain will stop there.
2228
2229            // So a test where STRUCTURAL is *picked* over ABSTRACT when both are present in the *input* string,
2230            // would require STRUCTURAL to be defined earlier in OBJECT_CLASS_TAGS or a different parsing strategy.
2231            // For now, we'll confirm that if STRUCTURAL is the only one, it's picked.
2232            // The previous `test_object_class_type_structural_present` already covers this.
2233
2234            // Re-evaluating `test_object_class_type_multiple_tags_abstract_first`, if `STRUCTURAL` were to appear before `ABSTRACT` in `OBJECT_CLASS_TAGS`,
2235            // this test would be crucial. For now, given the fixed tag order, I will test the documented behavior.
2236
2237            let schema_str = "( 1.2.3 NAME 'testOC' STRUCTURAL ABSTRACT MUST attr1 )"; // STRUCTURAL appears first in input
2238            let object_class = object_class_parser()
2239                .parse(schema_str)
2240                .into_result()
2241                .expect("Parsing failed");
2242            // Due to `OBJECT_CLASS_TAGS` definition order and `or_else` chain, ABSTRACT takes precedence if both are present in the `tags` list.
2243            assert_eq!(object_class.object_class_type, ObjectClassType::Abstract);
2244        }
2245
2246        // Test cases for 'MUST'
2247        #[cfg(feature = "chumsky")]
2248        #[test]
2249        fn test_object_class_must_missing() {
2250            let schema_str = "( 1.2.3 NAME 'testOC' MAY attr1 )";
2251            let object_class = object_class_parser()
2252                .parse(schema_str)
2253                .into_result()
2254                .expect("Parsing failed");
2255            assert!(object_class.must.is_empty());
2256        }
2257
2258        #[cfg(feature = "chumsky")]
2259        #[test]
2260        fn test_object_class_must_wrong_type() {
2261            let schema_str = "( 1.2.3 NAME 'testOC' MUST 'invalid value with spaces' MAY attr1 )";
2262            let result = object_class_parser().parse(schema_str).into_result();
2263            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2264            let err = result.unwrap_err();
2265            let err_string = format!(
2266                "{}",
2267                ChumskyError {
2268                    description: "object class".to_string(),
2269                    source: schema_str.to_string(),
2270                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2271                }
2272            );
2273            assert!(
2274                err_string.contains("Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $"),
2275                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $'",
2276            );
2277        }
2278
2279        #[cfg(feature = "chumsky")]
2280        #[test]
2281        fn test_object_class_must_missing_param() {
2282            let schema_str = "( 1.2.3 NAME 'testOC' MUST MAY attr1 )";
2283            let result = object_class_parser().parse(schema_str).into_result();
2284            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2285            let err = result.unwrap_err();
2286            let err_string = format!(
2287                "{}",
2288                ChumskyError {
2289                    description: "object class".to_string(),
2290                    source: schema_str.to_string(),
2291                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2292                }
2293            );
2294            assert!(
2295                err_string.contains(
2296                    "Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')'"
2297                ),
2298                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')''",
2299            );
2300        }
2301
2302        #[cfg(feature = "chumsky")]
2303        #[test]
2304        fn test_object_class_must_correct_single() {
2305            let schema_str = "( 1.2.3 NAME 'testOC' MUST cn MAY attr1 )";
2306            let object_class = object_class_parser()
2307                .parse(schema_str)
2308                .into_result()
2309                .expect("Parsing failed");
2310            assert_eq!(
2311                object_class.must,
2312                vec![KeyStringOrOID::KeyString(KeyString("cn".to_string()))]
2313            );
2314        }
2315
2316        #[cfg(feature = "chumsky")]
2317        #[test]
2318        fn test_object_class_must_correct_list() {
2319            let schema_str = "( 1.2.3 NAME 'testOC' MUST ( cn $ sn ) MAY attr1 )";
2320            let object_class = object_class_parser()
2321                .parse(schema_str)
2322                .into_result()
2323                .expect("Parsing failed");
2324            assert_eq!(
2325                object_class.must,
2326                vec![
2327                    KeyStringOrOID::KeyString(KeyString("cn".to_string())),
2328                    KeyStringOrOID::KeyString(KeyString("sn".to_string()))
2329                ]
2330            );
2331        }
2332
2333        // Test cases for 'MAY'
2334        #[cfg(feature = "chumsky")]
2335        #[test]
2336        fn test_object_class_may_missing() {
2337            let schema_str = "( 1.2.3 NAME 'testOC' MUST attr1 )";
2338            let object_class = object_class_parser()
2339                .parse(schema_str)
2340                .into_result()
2341                .expect("Parsing failed");
2342            assert!(object_class.may.is_empty());
2343        }
2344
2345        #[cfg(feature = "chumsky")]
2346        #[test]
2347        fn test_object_class_may_wrong_type() {
2348            let schema_str = "( 1.2.3 NAME 'testOC' MAY 'invalid value with spaces' MUST attr1 )";
2349            let result = object_class_parser().parse(schema_str).into_result();
2350            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2351            let err = result.unwrap_err();
2352            let err_string = format!(
2353                "{}",
2354                ChumskyError {
2355                    description: "object class".to_string(),
2356                    source: schema_str.to_string(),
2357                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2358                }
2359            );
2360            assert!(
2361                err_string.contains("Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $"),
2362                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected list of keystrings or OIDs separated by $'",
2363            );
2364        }
2365
2366        #[cfg(feature = "chumsky")]
2367        #[test]
2368        fn test_object_class_may_missing_param() {
2369            let schema_str = "( 1.2.3 NAME 'testOC' MAY MUST attr1 )";
2370            let result = object_class_parser().parse(schema_str).into_result();
2371            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2372            let err = result.unwrap_err();
2373            let err_string = format!(
2374                "{}",
2375                ChumskyError {
2376                    description: "object class".to_string(),
2377                    source: schema_str.to_string(),
2378                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2379                }
2380            );
2381            assert!(
2382                err_string.contains(
2383                    "Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')'"
2384                ),
2385                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected 'N', 'S', 'D', 'A', 'M', 'O', ')''",
2386            );
2387        }
2388
2389        #[cfg(feature = "chumsky")]
2390        #[test]
2391        fn test_object_class_may_correct_single() {
2392            let schema_str = "( 1.2.3 NAME 'testOC' MAY description MUST attr1 )";
2393            let object_class = object_class_parser()
2394                .parse(schema_str)
2395                .into_result()
2396                .expect("Parsing failed");
2397            assert_eq!(
2398                object_class.may,
2399                vec![KeyStringOrOID::KeyString(KeyString(
2400                    "description".to_string()
2401                ))]
2402            );
2403        }
2404
2405        #[cfg(feature = "chumsky")]
2406        #[test]
2407        fn test_object_class_may_correct_list() {
2408            let schema_str = "( 1.2.3 NAME 'testOC' MAY ( description $ seeAlso ) MUST attr1 )";
2409            let object_class = object_class_parser()
2410                .parse(schema_str)
2411                .into_result()
2412                .expect("Parsing failed");
2413            assert_eq!(
2414                object_class.may,
2415                vec![
2416                    KeyStringOrOID::KeyString(KeyString("description".to_string())),
2417                    KeyStringOrOID::KeyString(KeyString("seeAlso".to_string()))
2418                ]
2419            );
2420        }
2421
2422        // Test cases for 'OBSOLETE'
2423        #[cfg(feature = "chumsky")]
2424        #[test]
2425        fn test_object_class_obsolete_missing() {
2426            let schema_str = "( 1.2.3 NAME 'testOC' MUST attr1 )";
2427            let object_class = object_class_parser()
2428                .parse(schema_str)
2429                .into_result()
2430                .expect("Parsing failed");
2431            assert!(!object_class.obsolete);
2432        }
2433
2434        #[cfg(feature = "chumsky")]
2435        #[test]
2436        fn test_object_class_obsolete_present() {
2437            let schema_str = "( 1.2.3 NAME 'testOC' OBSOLETE MUST attr1 )";
2438            let object_class = object_class_parser()
2439                .parse(schema_str)
2440                .into_result()
2441                .expect("Parsing failed");
2442            assert!(object_class.obsolete);
2443        }
2444
2445        // Test cases for 'DESC'
2446        #[cfg(feature = "chumsky")]
2447        #[test]
2448        fn test_attribute_type_desc_missing() {
2449            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2450            let attr_type = attribute_type_parser()
2451                .parse(schema_str)
2452                .into_result()
2453                .expect("Parsing failed");
2454            assert!(attr_type.desc.is_none());
2455        }
2456
2457        #[cfg(feature = "chumsky")]
2458        #[test]
2459        fn test_attribute_type_desc_wrong_type() {
2460            let schema_str =
2461                "( 1.2.3 NAME 'test' DESC unquoted String SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2462            let result = attribute_type_parser().parse(schema_str).into_result();
2463            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2464            let err = result.unwrap_err();
2465            let err_string = format!(
2466                "{}",
2467                ChumskyError {
2468                    description: "attribute type".to_string(),
2469                    source: schema_str.to_string(),
2470                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2471                }
2472            );
2473            assert!(
2474                err_string
2475                    .contains("Unexpected token while parsing [], expected single-quoted string"),
2476                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
2477            );
2478        }
2479
2480        #[cfg(feature = "chumsky")]
2481        #[test]
2482        fn test_attribute_type_desc_missing_param() {
2483            let schema_str = "( 1.2.3 NAME 'test' DESC SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2484            let result = attribute_type_parser().parse(schema_str).into_result();
2485            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2486            let err = result.unwrap_err();
2487            let err_string = format!(
2488                "{}",
2489                ChumskyError {
2490                    description: "attribute type".to_string(),
2491                    source: schema_str.to_string(),
2492                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2493                }
2494            );
2495            assert!(
2496                err_string
2497                    .contains("Unexpected token while parsing [], expected single-quoted string"),
2498                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected single-quoted string'",
2499            );
2500        }
2501
2502        #[cfg(feature = "chumsky")]
2503        #[test]
2504        fn test_attribute_type_desc_correct() {
2505            let schema_str = "( 1.2.3 NAME 'test' DESC 'Some description' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2506            let attr_type = attribute_type_parser()
2507                .parse(schema_str)
2508                .into_result()
2509                .expect("Parsing failed");
2510            assert_eq!(attr_type.desc, Some("Some description".to_string()));
2511        }
2512
2513        // Test cases for 'SYNTAX'
2514        #[cfg(feature = "chumsky")]
2515        #[test]
2516        fn test_attribute_type_syntax_missing() {
2517            let schema_str = "( 1.2.3 NAME 'test' DESC 'Test Attribute' )";
2518            let attr_type = attribute_type_parser()
2519                .parse(schema_str)
2520                .into_result()
2521                .expect("Parsing failed");
2522            assert!(attr_type.syntax.is_none());
2523        }
2524
2525        #[cfg(feature = "chumsky")]
2526        #[test]
2527        fn test_attribute_type_syntax_wrong_type() {
2528            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 'not an OID' DESC 'Test Attribute' )";
2529            let result = attribute_type_parser().parse(schema_str).into_result();
2530            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2531            let err = result.unwrap_err();
2532            let err_string = format!(
2533                "{}",
2534                ChumskyError {
2535                    description: "attribute type".to_string(),
2536                    source: schema_str.to_string(),
2537                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2538                }
2539            );
2540            assert!(
2541                err_string.contains(
2542                    "Unexpected end of input while parsing [], expected OID with optional length"
2543                ),
2544                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected OID with optional length'",
2545            );
2546        }
2547
2548        #[cfg(feature = "chumsky")]
2549        #[test]
2550        fn test_attribute_type_syntax_missing_param() {
2551            let schema_str = "( 1.2.3 NAME 'test' SYNTAX DESC 'Test Attribute' )";
2552            let result = attribute_type_parser().parse(schema_str).into_result();
2553            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2554            let err = result.unwrap_err();
2555            let err_string = format!(
2556                "{}",
2557                ChumskyError {
2558                    description: "attribute type".to_string(),
2559                    source: schema_str.to_string(),
2560                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2561                }
2562            );
2563            assert!(
2564                err_string.contains(
2565                    "Unexpected end of input while parsing [], expected OID with optional length"
2566                ),
2567                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected OID with optional length'",
2568            );
2569        }
2570
2571        #[cfg(feature = "chumsky")]
2572        #[test]
2573        fn test_attribute_type_syntax_correct_with_length() {
2574            let schema_str = "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{255} DESC 'Test Attribute' )";
2575            let attr_type = attribute_type_parser()
2576                .parse(schema_str)
2577                .into_result()
2578                .expect("Parsing failed");
2579            assert_eq!(
2580                attr_type.syntax,
2581                Some(OIDWithLength {
2582                    #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
2583                    oid: "1.3.6.1.4.1.1466.115.121.1.15"
2584                        .to_string()
2585                        .try_into()
2586                        .unwrap(),
2587                    length: Some(255)
2588                })
2589            );
2590        }
2591
2592        #[cfg(feature = "chumsky")]
2593        #[test]
2594        fn test_attribute_type_syntax_correct_without_length() {
2595            let schema_str =
2596                "( 1.2.3 NAME 'test' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Test Attribute' )";
2597            let attr_type = attribute_type_parser()
2598                .parse(schema_str)
2599                .into_result()
2600                .expect("Parsing failed");
2601            assert_eq!(
2602                attr_type.syntax,
2603                Some(OIDWithLength {
2604                    #[expect(clippy::unwrap_used, reason = "just a literal parse in a test")]
2605                    oid: "1.3.6.1.4.1.1466.115.121.1.15"
2606                        .to_string()
2607                        .try_into()
2608                        .unwrap(),
2609                    length: None
2610                })
2611            );
2612        }
2613
2614        // Test cases for 'SINGLE-VALUE'
2615        #[cfg(feature = "chumsky")]
2616        #[test]
2617        fn test_attribute_type_single_value_missing() {
2618            let schema_str =
2619                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2620            let attr_type = attribute_type_parser()
2621                .parse(schema_str)
2622                .into_result()
2623                .expect("Parsing failed");
2624            assert!(!attr_type.single_value);
2625        }
2626
2627        #[cfg(feature = "chumsky")]
2628        #[test]
2629        fn test_attribute_type_single_value_present() {
2630            let schema_str = "( 1.2.3 NAME 'test' SINGLE-VALUE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2631            let attr_type = attribute_type_parser()
2632                .parse(schema_str)
2633                .into_result()
2634                .expect("Parsing failed");
2635            assert!(attr_type.single_value);
2636        }
2637
2638        // Test cases for 'EQUALITY'
2639        #[cfg(feature = "chumsky")]
2640        #[test]
2641        fn test_attribute_type_equality_missing() {
2642            let schema_str =
2643                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2644            let attr_type = attribute_type_parser()
2645                .parse(schema_str)
2646                .into_result()
2647                .expect("Parsing failed");
2648            assert!(attr_type.equality.is_none());
2649        }
2650
2651        #[cfg(feature = "chumsky")]
2652        #[test]
2653        fn test_attribute_type_equality_wrong_type() {
2654            let schema_str = "( 1.2.3 NAME 'test' EQUALITY 'invalid equality with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2655            let result = attribute_type_parser().parse(schema_str).into_result();
2656            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2657            let err = result.unwrap_err();
2658            let err_string = format!(
2659                "{}",
2660                ChumskyError {
2661                    description: "attribute type".to_string(),
2662                    source: schema_str.to_string(),
2663                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2664                }
2665            );
2666            assert!(
2667                err_string.contains("Unexpected token while parsing [], expected keystring"),
2668                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
2669            );
2670        }
2671
2672        #[cfg(feature = "chumsky")]
2673        #[test]
2674        fn test_attribute_type_equality_missing_param() {
2675            let schema_str = "( 1.2.3 NAME 'test' EQUALITY DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2676            let result = attribute_type_parser().parse(schema_str).into_result();
2677            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2678            let err = result.unwrap_err();
2679            let err_string = format!(
2680                "{}",
2681                ChumskyError {
2682                    description: "attribute type".to_string(),
2683                    source: schema_str.to_string(),
2684                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2685                }
2686            );
2687            assert!(
2688                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
2689                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
2690            );
2691        }
2692
2693        #[cfg(feature = "chumsky")]
2694        #[test]
2695        fn test_attribute_type_equality_correct() {
2696            let schema_str = "( 1.2.3 NAME 'test' EQUALITY caseIgnoreMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2697            let attr_type = attribute_type_parser()
2698                .parse(schema_str)
2699                .into_result()
2700                .expect("Parsing failed");
2701            assert_eq!(
2702                attr_type.equality,
2703                Some(KeyString("caseIgnoreMatch".to_string()))
2704            );
2705        }
2706
2707        // Test cases for 'SUBSTR'
2708        #[cfg(feature = "chumsky")]
2709        #[test]
2710        fn test_attribute_type_substr_missing() {
2711            let schema_str =
2712                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2713            let attr_type = attribute_type_parser()
2714                .parse(schema_str)
2715                .into_result()
2716                .expect("Parsing failed");
2717            assert!(attr_type.substr.is_none());
2718        }
2719
2720        #[cfg(feature = "chumsky")]
2721        #[test]
2722        fn test_attribute_type_substr_wrong_type() {
2723            let schema_str = "( 1.2.3 NAME 'test' SUBSTR 'invalid substr with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2724            let result = attribute_type_parser().parse(schema_str).into_result();
2725            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2726            let err = result.unwrap_err();
2727            let err_string = format!(
2728                "{}",
2729                ChumskyError {
2730                    description: "attribute type".to_string(),
2731                    source: schema_str.to_string(),
2732                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2733                }
2734            );
2735            assert!(
2736                err_string.contains("Unexpected token while parsing [], expected keystring"),
2737                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
2738            );
2739        }
2740
2741        #[cfg(feature = "chumsky")]
2742        #[test]
2743        fn test_attribute_type_substr_missing_param() {
2744            let schema_str = "( 1.2.3 NAME 'test' SUBSTR DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2745            let result = attribute_type_parser().parse(schema_str).into_result();
2746            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2747            let err = result.unwrap_err();
2748            let err_string = format!(
2749                "{}",
2750                ChumskyError {
2751                    description: "attribute type".to_string(),
2752                    source: schema_str.to_string(),
2753                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2754                }
2755            );
2756            assert!(
2757                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
2758                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
2759            );
2760        }
2761
2762        #[cfg(feature = "chumsky")]
2763        #[test]
2764        fn test_attribute_type_substr_correct() {
2765            let schema_str = "( 1.2.3 NAME 'test' SUBSTR caseIgnoreSubstringsMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2766            let attr_type = attribute_type_parser()
2767                .parse(schema_str)
2768                .into_result()
2769                .expect("Parsing failed");
2770            assert_eq!(
2771                attr_type.substr,
2772                Some(KeyString("caseIgnoreSubstringsMatch".to_string()))
2773            );
2774        }
2775
2776        // Test cases for 'ORDERING'
2777        #[cfg(feature = "chumsky")]
2778        #[test]
2779        fn test_attribute_type_ordering_missing() {
2780            let schema_str =
2781                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2782            let attr_type = attribute_type_parser()
2783                .parse(schema_str)
2784                .into_result()
2785                .expect("Parsing failed");
2786            assert!(attr_type.ordering.is_none());
2787        }
2788
2789        #[cfg(feature = "chumsky")]
2790        #[test]
2791        fn test_attribute_type_ordering_wrong_type() {
2792            let schema_str = "( 1.2.3 NAME 'test' ORDERING 'invalid ordering with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2793            let result = attribute_type_parser().parse(schema_str).into_result();
2794            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2795            let err = result.unwrap_err();
2796            let err_string = format!(
2797                "{}",
2798                ChumskyError {
2799                    description: "attribute type".to_string(),
2800                    source: schema_str.to_string(),
2801                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2802                }
2803            );
2804            assert!(
2805                err_string.contains("Unexpected token while parsing [], expected keystring"),
2806                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
2807            );
2808        }
2809
2810        #[cfg(feature = "chumsky")]
2811        #[test]
2812        fn test_attribute_type_ordering_missing_param() {
2813            let schema_str = "( 1.2.3 NAME 'test' ORDERING DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2814            let result = attribute_type_parser().parse(schema_str).into_result();
2815            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2816            let err = result.unwrap_err();
2817            let err_string = format!(
2818                "{}",
2819                ChumskyError {
2820                    description: "attribute type".to_string(),
2821                    source: schema_str.to_string(),
2822                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2823                }
2824            );
2825            assert!(
2826                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
2827                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
2828            );
2829        }
2830
2831        #[cfg(feature = "chumsky")]
2832        #[test]
2833        fn test_attribute_type_ordering_correct() {
2834            let schema_str = "( 1.2.3 NAME 'test' ORDERING caseIgnoreOrderingMatch DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2835            let attr_type = attribute_type_parser()
2836                .parse(schema_str)
2837                .into_result()
2838                .expect("Parsing failed");
2839            assert_eq!(
2840                attr_type.ordering,
2841                Some(KeyString("caseIgnoreOrderingMatch".to_string()))
2842            );
2843        }
2844
2845        // Test cases for 'NO-USER-MODIFICATION'
2846        #[cfg(feature = "chumsky")]
2847        #[test]
2848        fn test_attribute_type_no_user_modification_missing() {
2849            let schema_str =
2850                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2851            let attr_type = attribute_type_parser()
2852                .parse(schema_str)
2853                .into_result()
2854                .expect("Parsing failed");
2855            assert!(!attr_type.no_user_modification);
2856        }
2857
2858        #[cfg(feature = "chumsky")]
2859        #[test]
2860        fn test_attribute_type_no_user_modification_present() {
2861            let schema_str = "( 1.2.3 NAME 'test' NO-USER-MODIFICATION DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2862            let attr_type = attribute_type_parser()
2863                .parse(schema_str)
2864                .into_result()
2865                .expect("Parsing failed");
2866            assert!(attr_type.no_user_modification);
2867        }
2868
2869        // Test cases for 'USAGE'
2870        #[cfg(feature = "chumsky")]
2871        #[test]
2872        fn test_attribute_type_usage_missing() {
2873            let schema_str =
2874                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2875            let attr_type = attribute_type_parser()
2876                .parse(schema_str)
2877                .into_result()
2878                .expect("Parsing failed");
2879            assert!(attr_type.usage.is_none());
2880        }
2881
2882        #[cfg(feature = "chumsky")]
2883        #[test]
2884        fn test_attribute_type_usage_wrong_type() {
2885            let schema_str = "( 1.2.3 NAME 'test' USAGE 'invalid usage with spaces' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2886            let result = attribute_type_parser().parse(schema_str).into_result();
2887            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2888            let err = result.unwrap_err();
2889            let err_string = format!(
2890                "{}",
2891                ChumskyError {
2892                    description: "attribute type".to_string(),
2893                    source: schema_str.to_string(),
2894                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2895                }
2896            );
2897            assert!(
2898                err_string.contains("Unexpected token while parsing [], expected keystring"),
2899                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected keystring'",
2900            );
2901        }
2902
2903        #[cfg(feature = "chumsky")]
2904        #[test]
2905        fn test_attribute_type_usage_missing_param() {
2906            let schema_str = "( 1.2.3 NAME 'test' USAGE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2907            let result = attribute_type_parser().parse(schema_str).into_result();
2908            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
2909            let err = result.unwrap_err();
2910            let err_string = format!(
2911                "{}",
2912                ChumskyError {
2913                    description: "attribute type".to_string(),
2914                    source: schema_str.to_string(),
2915                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
2916                }
2917            );
2918            assert!(
2919                err_string.contains("Unexpected end of input while parsing [], expected keystring"),
2920                "Error string '{err_string}' does not contain 'Unexpected end of input while parsing [], expected keystring'",
2921            );
2922        }
2923
2924        #[cfg(feature = "chumsky")]
2925        #[test]
2926        fn test_attribute_type_usage_correct() {
2927            let schema_str = "( 1.2.3 NAME 'test' USAGE userApplications DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2928            let attr_type = attribute_type_parser()
2929                .parse(schema_str)
2930                .into_result()
2931                .expect("Parsing failed");
2932            assert_eq!(
2933                attr_type.usage,
2934                Some(KeyString("userApplications".to_string()))
2935            );
2936        }
2937
2938        // Test cases for 'COLLECTIVE'
2939        #[cfg(feature = "chumsky")]
2940        #[test]
2941        fn test_attribute_type_collective_missing() {
2942            let schema_str =
2943                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2944            let attr_type = attribute_type_parser()
2945                .parse(schema_str)
2946                .into_result()
2947                .expect("Parsing failed");
2948            assert!(!attr_type.collective);
2949        }
2950
2951        #[cfg(feature = "chumsky")]
2952        #[test]
2953        fn test_attribute_type_collective_present() {
2954            let schema_str = "( 1.2.3 NAME 'test' COLLECTIVE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2955            let attr_type = attribute_type_parser()
2956                .parse(schema_str)
2957                .into_result()
2958                .expect("Parsing failed");
2959            assert!(attr_type.collective);
2960        }
2961
2962        // Test cases for 'OBSOLETE'
2963        #[cfg(feature = "chumsky")]
2964        #[test]
2965        fn test_attribute_type_obsolete_missing() {
2966            let schema_str =
2967                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2968            let attr_type = attribute_type_parser()
2969                .parse(schema_str)
2970                .into_result()
2971                .expect("Parsing failed");
2972            assert!(!attr_type.obsolete);
2973        }
2974
2975        #[cfg(feature = "chumsky")]
2976        #[test]
2977        fn test_attribute_type_obsolete_present() {
2978            let schema_str = "( 1.2.3 NAME 'test' OBSOLETE DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2979            let attr_type = attribute_type_parser()
2980                .parse(schema_str)
2981                .into_result()
2982                .expect("Parsing failed");
2983            assert!(attr_type.obsolete);
2984        }
2985
2986        // Test cases for 'X-ORDERED'
2987        #[cfg(feature = "chumsky")]
2988        #[test]
2989        fn test_attribute_type_x_ordered_missing() {
2990            let schema_str =
2991                "( 1.2.3 NAME 'test' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
2992            let attr_type = attribute_type_parser()
2993                .parse(schema_str)
2994                .into_result()
2995                .expect("Parsing failed");
2996            assert!(attr_type.x_ordered.is_none());
2997        }
2998
2999        #[cfg(feature = "chumsky")]
3000        #[test]
3001        fn test_attribute_type_x_ordered_wrong_type() {
3002            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED unquotedString DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
3003            let result = attribute_type_parser().parse(schema_str).into_result();
3004            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
3005            let err = result.unwrap_err();
3006            let err_string = format!(
3007                "{}",
3008                ChumskyError {
3009                    description: "attribute type".to_string(),
3010                    source: schema_str.to_string(),
3011                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
3012                }
3013            );
3014            assert!(
3015                err_string.contains("Unexpected token while parsing [], expected quoted keystring"),
3016                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected quoted keystring'",
3017            );
3018        }
3019
3020        #[cfg(feature = "chumsky")]
3021        #[test]
3022        fn test_attribute_type_x_ordered_missing_param() {
3023            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
3024            let result = attribute_type_parser().parse(schema_str).into_result();
3025            #[expect(clippy::unwrap_used, reason = "intentional for assertion")]
3026            let err = result.unwrap_err();
3027            let err_string = format!(
3028                "{}",
3029                ChumskyError {
3030                    description: "attribute type".to_string(),
3031                    source: schema_str.to_string(),
3032                    errors: err.into_iter().map(|e| e.into_owned()).collect(),
3033                }
3034            );
3035            assert!(
3036                err_string.contains("Unexpected token while parsing [], expected quoted keystring"),
3037                "Error string '{err_string}' does not contain 'Unexpected token while parsing [], expected quoted keystring'",
3038            );
3039        }
3040
3041        #[cfg(feature = "chumsky")]
3042        #[test]
3043        fn test_attribute_type_x_ordered_correct() {
3044            let schema_str = "( 1.2.3 NAME 'test' X-ORDERED 'values' DESC 'Test Attribute' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )";
3045            let attr_type = attribute_type_parser()
3046                .parse(schema_str)
3047                .into_result()
3048                .expect("Parsing failed");
3049            assert_eq!(attr_type.x_ordered, Some(KeyString("values".to_string())));
3050        }
3051    }
3052}