Skip to main content

oxirs_gql/
directive_processor.rs

1//! Custom GraphQL directive processing engine.
2//!
3//! Handles built-in directives (`@skip`, `@include`, `@deprecated`) and custom
4//! directives (`@auth`, `@cache`) with registration, validation, and ordered
5//! execution.
6
7use std::collections::HashMap;
8use std::fmt;
9
10/// Errors that can occur during directive processing.
11#[derive(Debug, Clone, PartialEq)]
12pub enum DirectiveError {
13    /// The directive is not registered.
14    UnknownDirective(String),
15    /// A required argument is missing.
16    MissingArgument { directive: String, argument: String },
17    /// An argument has the wrong type.
18    InvalidArgument {
19        directive: String,
20        argument: String,
21        reason: String,
22    },
23    /// Two directives on the same field are contradictory.
24    Conflict {
25        directive_a: String,
26        directive_b: String,
27        reason: String,
28    },
29    /// A general processing error.
30    ProcessingError(String),
31}
32
33impl fmt::Display for DirectiveError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            DirectiveError::UnknownDirective(name) => write!(f, "Unknown directive: @{name}"),
37            DirectiveError::MissingArgument {
38                directive,
39                argument,
40            } => {
41                write!(f, "@{directive} requires argument '{argument}'")
42            }
43            DirectiveError::InvalidArgument {
44                directive,
45                argument,
46                reason,
47            } => {
48                write!(f, "@{directive} argument '{argument}': {reason}")
49            }
50            DirectiveError::Conflict {
51                directive_a,
52                directive_b,
53                reason,
54            } => {
55                write!(
56                    f,
57                    "Conflict between @{directive_a} and @{directive_b}: {reason}"
58                )
59            }
60            DirectiveError::ProcessingError(msg) => write!(f, "Directive error: {msg}"),
61        }
62    }
63}
64
65impl std::error::Error for DirectiveError {}
66
67/// A typed argument value for directives.
68#[derive(Debug, Clone, PartialEq)]
69pub enum ArgumentValue {
70    Boolean(bool),
71    String(String),
72    Int(i64),
73    Float(f64),
74    List(Vec<ArgumentValue>),
75    Null,
76}
77
78impl ArgumentValue {
79    /// Try to extract a boolean value.
80    pub fn as_bool(&self) -> Option<bool> {
81        match self {
82            ArgumentValue::Boolean(b) => Some(*b),
83            _ => None,
84        }
85    }
86
87    /// Try to extract a string value.
88    pub fn as_str(&self) -> Option<&str> {
89        match self {
90            ArgumentValue::String(s) => Some(s.as_str()),
91            _ => None,
92        }
93    }
94
95    /// Try to extract an integer value.
96    pub fn as_int(&self) -> Option<i64> {
97        match self {
98            ArgumentValue::Int(i) => Some(*i),
99            _ => None,
100        }
101    }
102
103    /// Try to extract a list of strings.
104    pub fn as_string_list(&self) -> Option<Vec<String>> {
105        match self {
106            ArgumentValue::List(items) => {
107                let mut result = Vec::new();
108                for item in items {
109                    match item {
110                        ArgumentValue::String(s) => result.push(s.clone()),
111                        _ => return None,
112                    }
113                }
114                Some(result)
115            }
116            _ => None,
117        }
118    }
119}
120
121impl fmt::Display for ArgumentValue {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            ArgumentValue::Boolean(b) => write!(f, "{b}"),
125            ArgumentValue::String(s) => write!(f, "\"{s}\""),
126            ArgumentValue::Int(i) => write!(f, "{i}"),
127            ArgumentValue::Float(v) => write!(f, "{v}"),
128            ArgumentValue::List(items) => {
129                write!(f, "[")?;
130                for (i, item) in items.iter().enumerate() {
131                    if i > 0 {
132                        write!(f, ", ")?;
133                    }
134                    write!(f, "{item}")?;
135                }
136                write!(f, "]")
137            }
138            ArgumentValue::Null => write!(f, "null"),
139        }
140    }
141}
142
143/// When a directive handler should run relative to field resolution.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
145pub enum DirectivePhase {
146    /// Execute before the field resolver.
147    PreField,
148    /// Execute after the field resolver.
149    PostField,
150}
151
152/// Schema for a single directive argument.
153#[derive(Debug, Clone)]
154pub struct ArgumentSchema {
155    pub name: String,
156    pub required: bool,
157    pub expected_type: ArgumentType,
158    pub default_value: Option<ArgumentValue>,
159}
160
161/// Expected type of a directive argument.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum ArgumentType {
164    Boolean,
165    String,
166    Int,
167    Float,
168    StringList,
169}
170
171impl ArgumentType {
172    fn matches(&self, value: &ArgumentValue) -> bool {
173        match (self, value) {
174            (ArgumentType::Boolean, ArgumentValue::Boolean(_)) => true,
175            (ArgumentType::String, ArgumentValue::String(_)) => true,
176            (ArgumentType::Int, ArgumentValue::Int(_)) => true,
177            (ArgumentType::Float, ArgumentValue::Float(_)) => true,
178            (ArgumentType::StringList, ArgumentValue::List(_)) => value.as_string_list().is_some(),
179            _ => false,
180        }
181    }
182}
183
184/// Result of evaluating a directive on a field.
185#[derive(Debug, Clone, PartialEq)]
186pub enum DirectiveOutcome {
187    /// Continue normal field resolution.
188    Continue,
189    /// Skip field resolution entirely (e.g., `@skip(if: true)`).
190    SkipField,
191    /// Return a cached value instead of resolving.
192    CachedValue(String),
193    /// Deny access to the field.
194    AccessDenied(String),
195    /// Add a deprecation warning to the response.
196    DeprecationWarning(String),
197}
198
199/// A registered directive with its argument schema and phase.
200#[derive(Debug, Clone)]
201pub struct DirectiveDefinition {
202    pub name: String,
203    pub description: String,
204    pub arguments: Vec<ArgumentSchema>,
205    pub phase: DirectivePhase,
206    /// Whether this directive can coexist with others.
207    pub conflicts_with: Vec<String>,
208}
209
210/// An applied directive instance on a specific field.
211#[derive(Debug, Clone)]
212pub struct AppliedDirective {
213    pub name: String,
214    pub arguments: HashMap<String, ArgumentValue>,
215}
216
217impl AppliedDirective {
218    /// Create a new applied directive with the given name.
219    pub fn new(name: impl Into<String>) -> Self {
220        Self {
221            name: name.into(),
222            arguments: HashMap::new(),
223        }
224    }
225
226    /// Add an argument to this applied directive.
227    pub fn with_arg(mut self, name: impl Into<String>, value: ArgumentValue) -> Self {
228        self.arguments.insert(name.into(), value);
229        self
230    }
231
232    /// Get an argument value by name.
233    pub fn get_arg(&self, name: &str) -> Option<&ArgumentValue> {
234        self.arguments.get(name)
235    }
236}
237
238/// Cache entry for the `@cache` directive.
239#[derive(Debug, Clone)]
240pub struct CacheEntry {
241    pub key: String,
242    pub value: String,
243    pub created_at_ms: u64,
244    pub ttl_ms: u64,
245}
246
247impl CacheEntry {
248    /// Check if this cache entry is expired.
249    pub fn is_expired(&self, now_ms: u64) -> bool {
250        now_ms.saturating_sub(self.created_at_ms) > self.ttl_ms
251    }
252}
253
254/// Deprecation record for the `@deprecated` directive.
255#[derive(Debug, Clone, PartialEq)]
256pub struct DeprecationInfo {
257    pub type_name: String,
258    pub field_name: String,
259    pub reason: String,
260}
261
262/// The main directive processor engine.
263pub struct DirectiveProcessor {
264    /// Registered directive definitions.
265    definitions: HashMap<String, DirectiveDefinition>,
266    /// In-memory cache for `@cache` results.
267    cache: HashMap<String, CacheEntry>,
268    /// Tracked deprecations.
269    deprecations: Vec<DeprecationInfo>,
270}
271
272impl DirectiveProcessor {
273    /// Create a new processor with the built-in directives pre-registered.
274    pub fn new() -> Self {
275        let mut processor = Self {
276            definitions: HashMap::new(),
277            cache: HashMap::new(),
278            deprecations: Vec::new(),
279        };
280        processor.register_builtins();
281        processor
282    }
283
284    /// Create an empty processor with no built-in directives.
285    pub fn empty() -> Self {
286        Self {
287            definitions: HashMap::new(),
288            cache: HashMap::new(),
289            deprecations: Vec::new(),
290        }
291    }
292
293    // -- Registration --
294
295    fn register_builtins(&mut self) {
296        // @skip(if: Boolean!)
297        self.register(DirectiveDefinition {
298            name: "skip".into(),
299            description: "Skip this field if the argument is true".into(),
300            arguments: vec![ArgumentSchema {
301                name: "if".into(),
302                required: true,
303                expected_type: ArgumentType::Boolean,
304                default_value: None,
305            }],
306            phase: DirectivePhase::PreField,
307            conflicts_with: vec!["include".into()],
308        });
309
310        // @include(if: Boolean!)
311        self.register(DirectiveDefinition {
312            name: "include".into(),
313            description: "Include this field only if the argument is true".into(),
314            arguments: vec![ArgumentSchema {
315                name: "if".into(),
316                required: true,
317                expected_type: ArgumentType::Boolean,
318                default_value: None,
319            }],
320            phase: DirectivePhase::PreField,
321            conflicts_with: vec!["skip".into()],
322        });
323
324        // @deprecated(reason: String)
325        self.register(DirectiveDefinition {
326            name: "deprecated".into(),
327            description: "Mark a field as deprecated".into(),
328            arguments: vec![ArgumentSchema {
329                name: "reason".into(),
330                required: false,
331                expected_type: ArgumentType::String,
332                default_value: Some(ArgumentValue::String("No longer supported".into())),
333            }],
334            phase: DirectivePhase::PostField,
335            conflicts_with: vec![],
336        });
337
338        // @auth(roles: [String!])
339        self.register(DirectiveDefinition {
340            name: "auth".into(),
341            description: "Restrict field access by role".into(),
342            arguments: vec![
343                ArgumentSchema {
344                    name: "roles".into(),
345                    required: false,
346                    expected_type: ArgumentType::StringList,
347                    default_value: None,
348                },
349                ArgumentSchema {
350                    name: "deny".into(),
351                    required: false,
352                    expected_type: ArgumentType::StringList,
353                    default_value: None,
354                },
355            ],
356            phase: DirectivePhase::PreField,
357            conflicts_with: vec![],
358        });
359
360        // @cache(ttl: Int)
361        self.register(DirectiveDefinition {
362            name: "cache".into(),
363            description: "Cache the field result with a TTL".into(),
364            arguments: vec![ArgumentSchema {
365                name: "ttl".into(),
366                required: false,
367                expected_type: ArgumentType::Int,
368                default_value: Some(ArgumentValue::Int(60)),
369            }],
370            phase: DirectivePhase::PreField,
371            conflicts_with: vec![],
372        });
373    }
374
375    /// Register a custom directive definition.
376    pub fn register(&mut self, definition: DirectiveDefinition) {
377        self.definitions.insert(definition.name.clone(), definition);
378    }
379
380    /// Check if a directive is registered.
381    pub fn is_registered(&self, name: &str) -> bool {
382        self.definitions.contains_key(name)
383    }
384
385    /// Get a directive definition by name.
386    pub fn get_definition(&self, name: &str) -> Option<&DirectiveDefinition> {
387        self.definitions.get(name)
388    }
389
390    /// Return the names of all registered directives.
391    pub fn registered_names(&self) -> Vec<String> {
392        self.definitions.keys().cloned().collect()
393    }
394
395    // -- Validation --
396
397    /// Validate that an applied directive's arguments match its schema.
398    pub fn validate(&self, applied: &AppliedDirective) -> Result<(), DirectiveError> {
399        let def = self
400            .definitions
401            .get(&applied.name)
402            .ok_or_else(|| DirectiveError::UnknownDirective(applied.name.clone()))?;
403
404        for schema in &def.arguments {
405            match applied.arguments.get(&schema.name) {
406                Some(value) => {
407                    if !schema.expected_type.matches(value) {
408                        return Err(DirectiveError::InvalidArgument {
409                            directive: applied.name.clone(),
410                            argument: schema.name.clone(),
411                            reason: format!("expected {:?} but got {value}", schema.expected_type),
412                        });
413                    }
414                }
415                None => {
416                    if schema.required && schema.default_value.is_none() {
417                        return Err(DirectiveError::MissingArgument {
418                            directive: applied.name.clone(),
419                            argument: schema.name.clone(),
420                        });
421                    }
422                }
423            }
424        }
425        Ok(())
426    }
427
428    /// Detect conflicts among a set of directives applied to the same field.
429    pub fn detect_conflicts(&self, directives: &[AppliedDirective]) -> Result<(), DirectiveError> {
430        for (i, da) in directives.iter().enumerate() {
431            if let Some(def_a) = self.definitions.get(&da.name) {
432                for db in directives.iter().skip(i + 1) {
433                    if def_a.conflicts_with.contains(&db.name) {
434                        return Err(DirectiveError::Conflict {
435                            directive_a: da.name.clone(),
436                            directive_b: db.name.clone(),
437                            reason: "mutually exclusive directives on the same field".into(),
438                        });
439                    }
440                }
441            }
442        }
443        Ok(())
444    }
445
446    // -- Execution --
447
448    /// Execute the `@skip` directive.
449    pub fn execute_skip(
450        &self,
451        applied: &AppliedDirective,
452    ) -> Result<DirectiveOutcome, DirectiveError> {
453        let condition = applied
454            .get_arg("if")
455            .and_then(|v| v.as_bool())
456            .ok_or_else(|| DirectiveError::MissingArgument {
457                directive: "skip".into(),
458                argument: "if".into(),
459            })?;
460        if condition {
461            Ok(DirectiveOutcome::SkipField)
462        } else {
463            Ok(DirectiveOutcome::Continue)
464        }
465    }
466
467    /// Execute the `@include` directive.
468    pub fn execute_include(
469        &self,
470        applied: &AppliedDirective,
471    ) -> Result<DirectiveOutcome, DirectiveError> {
472        let condition = applied
473            .get_arg("if")
474            .and_then(|v| v.as_bool())
475            .ok_or_else(|| DirectiveError::MissingArgument {
476                directive: "include".into(),
477                argument: "if".into(),
478            })?;
479        if condition {
480            Ok(DirectiveOutcome::Continue)
481        } else {
482            Ok(DirectiveOutcome::SkipField)
483        }
484    }
485
486    /// Execute the `@deprecated` directive and record the deprecation.
487    pub fn execute_deprecated(
488        &mut self,
489        applied: &AppliedDirective,
490        type_name: &str,
491        field_name: &str,
492    ) -> Result<DirectiveOutcome, DirectiveError> {
493        let reason = applied
494            .get_arg("reason")
495            .and_then(|v| v.as_str())
496            .unwrap_or("No longer supported");
497
498        let info = DeprecationInfo {
499            type_name: type_name.to_string(),
500            field_name: field_name.to_string(),
501            reason: reason.to_string(),
502        };
503        if !self.deprecations.contains(&info) {
504            self.deprecations.push(info);
505        }
506        Ok(DirectiveOutcome::DeprecationWarning(reason.to_string()))
507    }
508
509    /// Execute the `@auth` directive with the user's roles.
510    pub fn execute_auth(
511        &self,
512        applied: &AppliedDirective,
513        user_roles: &[String],
514    ) -> Result<DirectiveOutcome, DirectiveError> {
515        // Check deny list first
516        if let Some(deny) = applied.get_arg("deny").and_then(|v| v.as_string_list()) {
517            for role in user_roles {
518                if deny.contains(role) {
519                    return Ok(DirectiveOutcome::AccessDenied(format!(
520                        "Role '{role}' is denied access"
521                    )));
522                }
523            }
524        }
525
526        // Check required roles
527        if let Some(required) = applied.get_arg("roles").and_then(|v| v.as_string_list()) {
528            if required.is_empty() {
529                return Ok(DirectiveOutcome::Continue);
530            }
531            let has_any = user_roles.iter().any(|r| required.contains(r));
532            if !has_any {
533                return Ok(DirectiveOutcome::AccessDenied(format!(
534                    "Requires one of: {:?}",
535                    required
536                )));
537            }
538        }
539
540        Ok(DirectiveOutcome::Continue)
541    }
542
543    /// Execute the `@cache` directive.
544    pub fn execute_cache(
545        &mut self,
546        _applied: &AppliedDirective,
547        cache_key: &str,
548        current_time_ms: u64,
549    ) -> Result<DirectiveOutcome, DirectiveError> {
550        // Check if we have a valid cache entry
551        if let Some(entry) = self.cache.get(cache_key) {
552            if !entry.is_expired(current_time_ms) {
553                return Ok(DirectiveOutcome::CachedValue(entry.value.clone()));
554            }
555        }
556        Ok(DirectiveOutcome::Continue)
557    }
558
559    /// Store a value in the cache for the `@cache` directive.
560    pub fn cache_store(
561        &mut self,
562        applied: &AppliedDirective,
563        cache_key: &str,
564        value: &str,
565        current_time_ms: u64,
566    ) {
567        let ttl = applied
568            .get_arg("ttl")
569            .and_then(|v| v.as_int())
570            .unwrap_or(60);
571        let ttl_ms = (ttl as u64) * 1000;
572
573        self.cache.insert(
574            cache_key.to_string(),
575            CacheEntry {
576                key: cache_key.to_string(),
577                value: value.to_string(),
578                created_at_ms: current_time_ms,
579                ttl_ms,
580            },
581        );
582    }
583
584    /// Generate a cache key for a field + arguments combination.
585    pub fn generate_cache_key(
586        type_name: &str,
587        field_name: &str,
588        args: &HashMap<String, ArgumentValue>,
589    ) -> String {
590        let mut key = format!("{type_name}:{field_name}");
591        let mut arg_keys: Vec<&String> = args.keys().collect();
592        arg_keys.sort();
593        for k in arg_keys {
594            if let Some(v) = args.get(k) {
595                key.push_str(&format!(":{k}={v}"));
596            }
597        }
598        key
599    }
600
601    /// Process a list of directives for a field in the correct phase order.
602    #[allow(clippy::too_many_arguments)]
603    pub fn process_directives(
604        &mut self,
605        directives: &[AppliedDirective],
606        phase: DirectivePhase,
607        user_roles: &[String],
608        cache_key: &str,
609        current_time_ms: u64,
610        type_name: &str,
611        field_name: &str,
612    ) -> Result<DirectiveOutcome, DirectiveError> {
613        for applied in directives {
614            let def = match self.definitions.get(&applied.name) {
615                Some(d) => d,
616                None => continue,
617            };
618            if def.phase != phase {
619                continue;
620            }
621            let outcome = match applied.name.as_str() {
622                "skip" => self.execute_skip(applied)?,
623                "include" => self.execute_include(applied)?,
624                "deprecated" => self.execute_deprecated(applied, type_name, field_name)?,
625                "auth" => self.execute_auth(applied, user_roles)?,
626                "cache" => self.execute_cache(applied, cache_key, current_time_ms)?,
627                _ => DirectiveOutcome::Continue,
628            };
629            match &outcome {
630                DirectiveOutcome::Continue | DirectiveOutcome::DeprecationWarning(_) => continue,
631                _ => return Ok(outcome),
632            }
633        }
634        Ok(DirectiveOutcome::Continue)
635    }
636
637    // -- Introspection --
638
639    /// Return all tracked deprecations.
640    pub fn deprecations(&self) -> &[DeprecationInfo] {
641        &self.deprecations
642    }
643
644    /// Return the number of cached entries.
645    pub fn cache_size(&self) -> usize {
646        self.cache.len()
647    }
648
649    /// Clear the directive cache.
650    pub fn clear_cache(&mut self) {
651        self.cache.clear();
652    }
653
654    /// Evict expired entries from the cache.
655    pub fn evict_expired(&mut self, current_time_ms: u64) {
656        self.cache
657            .retain(|_, entry| !entry.is_expired(current_time_ms));
658    }
659}
660
661impl Default for DirectiveProcessor {
662    fn default() -> Self {
663        Self::new()
664    }
665}
666
667// ---------------------------------------------------------------------------
668// Tests
669// ---------------------------------------------------------------------------
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    // -- Built-in registration --
676
677    #[test]
678    fn test_builtins_registered() {
679        let proc = DirectiveProcessor::new();
680        assert!(proc.is_registered("skip"));
681        assert!(proc.is_registered("include"));
682        assert!(proc.is_registered("deprecated"));
683        assert!(proc.is_registered("auth"));
684        assert!(proc.is_registered("cache"));
685    }
686
687    #[test]
688    fn test_empty_processor_no_builtins() {
689        let proc = DirectiveProcessor::empty();
690        assert!(!proc.is_registered("skip"));
691    }
692
693    #[test]
694    fn test_custom_directive_registration() {
695        let mut proc = DirectiveProcessor::new();
696        proc.register(DirectiveDefinition {
697            name: "rateLimit".into(),
698            description: "Rate-limit a field".into(),
699            arguments: vec![ArgumentSchema {
700                name: "max".into(),
701                required: true,
702                expected_type: ArgumentType::Int,
703                default_value: None,
704            }],
705            phase: DirectivePhase::PreField,
706            conflicts_with: vec![],
707        });
708        assert!(proc.is_registered("rateLimit"));
709    }
710
711    #[test]
712    fn test_get_definition() {
713        let proc = DirectiveProcessor::new();
714        let def = proc.get_definition("skip").expect("skip definition");
715        assert_eq!(def.name, "skip");
716        assert_eq!(def.arguments.len(), 1);
717    }
718
719    #[test]
720    fn test_registered_names() {
721        let proc = DirectiveProcessor::new();
722        let names = proc.registered_names();
723        assert!(names.contains(&"skip".to_string()));
724        assert!(names.contains(&"include".to_string()));
725    }
726
727    // -- @skip directive --
728
729    #[test]
730    fn test_skip_true_skips() {
731        let proc = DirectiveProcessor::new();
732        let applied = AppliedDirective::new("skip").with_arg("if", ArgumentValue::Boolean(true));
733        let result = proc.execute_skip(&applied).expect("exec");
734        assert_eq!(result, DirectiveOutcome::SkipField);
735    }
736
737    #[test]
738    fn test_skip_false_continues() {
739        let proc = DirectiveProcessor::new();
740        let applied = AppliedDirective::new("skip").with_arg("if", ArgumentValue::Boolean(false));
741        let result = proc.execute_skip(&applied).expect("exec");
742        assert_eq!(result, DirectiveOutcome::Continue);
743    }
744
745    #[test]
746    fn test_skip_missing_if_error() {
747        let proc = DirectiveProcessor::new();
748        let applied = AppliedDirective::new("skip");
749        let err = proc.execute_skip(&applied).expect_err("missing");
750        assert!(matches!(err, DirectiveError::MissingArgument { .. }));
751    }
752
753    // -- @include directive --
754
755    #[test]
756    fn test_include_true_continues() {
757        let proc = DirectiveProcessor::new();
758        let applied = AppliedDirective::new("include").with_arg("if", ArgumentValue::Boolean(true));
759        let result = proc.execute_include(&applied).expect("exec");
760        assert_eq!(result, DirectiveOutcome::Continue);
761    }
762
763    #[test]
764    fn test_include_false_skips() {
765        let proc = DirectiveProcessor::new();
766        let applied =
767            AppliedDirective::new("include").with_arg("if", ArgumentValue::Boolean(false));
768        let result = proc.execute_include(&applied).expect("exec");
769        assert_eq!(result, DirectiveOutcome::SkipField);
770    }
771
772    // -- @deprecated directive --
773
774    #[test]
775    fn test_deprecated_with_reason() {
776        let mut proc = DirectiveProcessor::new();
777        let applied = AppliedDirective::new("deprecated").with_arg(
778            "reason",
779            ArgumentValue::String("Use newField instead".into()),
780        );
781        let result = proc
782            .execute_deprecated(&applied, "Query", "oldField")
783            .expect("exec");
784        assert!(matches!(result, DirectiveOutcome::DeprecationWarning(_)));
785        assert_eq!(proc.deprecations().len(), 1);
786        assert_eq!(proc.deprecations()[0].field_name, "oldField");
787        assert_eq!(proc.deprecations()[0].reason, "Use newField instead");
788    }
789
790    #[test]
791    fn test_deprecated_default_reason() {
792        let mut proc = DirectiveProcessor::new();
793        let applied = AppliedDirective::new("deprecated");
794        let result = proc
795            .execute_deprecated(&applied, "Query", "old")
796            .expect("exec");
797        match result {
798            DirectiveOutcome::DeprecationWarning(reason) => {
799                assert_eq!(reason, "No longer supported");
800            }
801            other => panic!("Expected deprecation warning, got {other:?}"),
802        }
803    }
804
805    #[test]
806    fn test_deprecated_no_duplicates() {
807        let mut proc = DirectiveProcessor::new();
808        let applied = AppliedDirective::new("deprecated")
809            .with_arg("reason", ArgumentValue::String("old".into()));
810        proc.execute_deprecated(&applied, "T", "f").expect("exec");
811        proc.execute_deprecated(&applied, "T", "f").expect("exec");
812        assert_eq!(proc.deprecations().len(), 1);
813    }
814
815    // -- @auth directive --
816
817    #[test]
818    fn test_auth_allowed_role() {
819        let proc = DirectiveProcessor::new();
820        let applied = AppliedDirective::new("auth").with_arg(
821            "roles",
822            ArgumentValue::List(vec![
823                ArgumentValue::String("admin".into()),
824                ArgumentValue::String("editor".into()),
825            ]),
826        );
827        let roles = vec!["editor".to_string()];
828        let result = proc.execute_auth(&applied, &roles).expect("exec");
829        assert_eq!(result, DirectiveOutcome::Continue);
830    }
831
832    #[test]
833    fn test_auth_denied_no_matching_role() {
834        let proc = DirectiveProcessor::new();
835        let applied = AppliedDirective::new("auth").with_arg(
836            "roles",
837            ArgumentValue::List(vec![ArgumentValue::String("admin".into())]),
838        );
839        let roles = vec!["viewer".to_string()];
840        let result = proc.execute_auth(&applied, &roles).expect("exec");
841        assert!(matches!(result, DirectiveOutcome::AccessDenied(_)));
842    }
843
844    #[test]
845    fn test_auth_deny_list_blocks() {
846        let proc = DirectiveProcessor::new();
847        let applied = AppliedDirective::new("auth").with_arg(
848            "deny",
849            ArgumentValue::List(vec![ArgumentValue::String("banned".into())]),
850        );
851        let roles = vec!["banned".to_string()];
852        let result = proc.execute_auth(&applied, &roles).expect("exec");
853        assert!(matches!(result, DirectiveOutcome::AccessDenied(_)));
854    }
855
856    #[test]
857    fn test_auth_deny_does_not_block_other_roles() {
858        let proc = DirectiveProcessor::new();
859        let applied = AppliedDirective::new("auth").with_arg(
860            "deny",
861            ArgumentValue::List(vec![ArgumentValue::String("banned".into())]),
862        );
863        let roles = vec!["admin".to_string()];
864        let result = proc.execute_auth(&applied, &roles).expect("exec");
865        assert_eq!(result, DirectiveOutcome::Continue);
866    }
867
868    #[test]
869    fn test_auth_no_args_continues() {
870        let proc = DirectiveProcessor::new();
871        let applied = AppliedDirective::new("auth");
872        let roles = vec!["admin".to_string()];
873        let result = proc.execute_auth(&applied, &roles).expect("exec");
874        assert_eq!(result, DirectiveOutcome::Continue);
875    }
876
877    #[test]
878    fn test_auth_empty_roles_list_continues() {
879        let proc = DirectiveProcessor::new();
880        let applied = AppliedDirective::new("auth").with_arg("roles", ArgumentValue::List(vec![]));
881        let result = proc.execute_auth(&applied, &[]).expect("exec");
882        assert_eq!(result, DirectiveOutcome::Continue);
883    }
884
885    // -- @cache directive --
886
887    #[test]
888    fn test_cache_miss() {
889        let mut proc = DirectiveProcessor::new();
890        let applied = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(60));
891        let result = proc
892            .execute_cache(&applied, "Query:field", 1000)
893            .expect("exec");
894        assert_eq!(result, DirectiveOutcome::Continue);
895    }
896
897    #[test]
898    fn test_cache_hit() {
899        let mut proc = DirectiveProcessor::new();
900        let applied = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(60));
901        proc.cache_store(&applied, "Query:f", "cached_value", 1000);
902        let result = proc.execute_cache(&applied, "Query:f", 2000).expect("exec");
903        assert_eq!(result, DirectiveOutcome::CachedValue("cached_value".into()));
904    }
905
906    #[test]
907    fn test_cache_expired() {
908        let mut proc = DirectiveProcessor::new();
909        let applied = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(1)); // 1 second TTL
910        proc.cache_store(&applied, "k", "v", 1000);
911        // 2 seconds later => expired
912        let result = proc.execute_cache(&applied, "k", 3000).expect("exec");
913        assert_eq!(result, DirectiveOutcome::Continue);
914    }
915
916    #[test]
917    fn test_cache_evict_expired() {
918        let mut proc = DirectiveProcessor::new();
919        let applied = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(1));
920        proc.cache_store(&applied, "k1", "v1", 1000);
921        proc.cache_store(&applied, "k2", "v2", 5000);
922        assert_eq!(proc.cache_size(), 2);
923        proc.evict_expired(3000);
924        assert_eq!(proc.cache_size(), 1);
925    }
926
927    #[test]
928    fn test_cache_clear() {
929        let mut proc = DirectiveProcessor::new();
930        let applied = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(60));
931        proc.cache_store(&applied, "k", "v", 0);
932        assert_eq!(proc.cache_size(), 1);
933        proc.clear_cache();
934        assert_eq!(proc.cache_size(), 0);
935    }
936
937    #[test]
938    fn test_generate_cache_key() {
939        let mut args = HashMap::new();
940        args.insert("id".into(), ArgumentValue::Int(42));
941        let key = DirectiveProcessor::generate_cache_key("Query", "user", &args);
942        assert!(key.starts_with("Query:user"));
943        assert!(key.contains("id=42"));
944    }
945
946    #[test]
947    fn test_generate_cache_key_stable_ordering() {
948        let mut args = HashMap::new();
949        args.insert("b".into(), ArgumentValue::String("2".into()));
950        args.insert("a".into(), ArgumentValue::String("1".into()));
951        let key = DirectiveProcessor::generate_cache_key("T", "f", &args);
952        // Arguments sorted alphabetically
953        let a_pos = key.find(":a=").expect("a present");
954        let b_pos = key.find(":b=").expect("b present");
955        assert!(a_pos < b_pos);
956    }
957
958    // -- Validation --
959
960    #[test]
961    fn test_validate_valid_skip() {
962        let proc = DirectiveProcessor::new();
963        let applied = AppliedDirective::new("skip").with_arg("if", ArgumentValue::Boolean(true));
964        assert!(proc.validate(&applied).is_ok());
965    }
966
967    #[test]
968    fn test_validate_missing_required_arg() {
969        let proc = DirectiveProcessor::new();
970        let applied = AppliedDirective::new("skip"); // missing 'if'
971        let err = proc.validate(&applied).expect_err("validate");
972        assert!(matches!(err, DirectiveError::MissingArgument { .. }));
973    }
974
975    #[test]
976    fn test_validate_wrong_arg_type() {
977        let proc = DirectiveProcessor::new();
978        let applied =
979            AppliedDirective::new("skip").with_arg("if", ArgumentValue::String("yes".into()));
980        let err = proc.validate(&applied).expect_err("validate");
981        assert!(matches!(err, DirectiveError::InvalidArgument { .. }));
982    }
983
984    #[test]
985    fn test_validate_unknown_directive() {
986        let proc = DirectiveProcessor::new();
987        let applied = AppliedDirective::new("unknown");
988        let err = proc.validate(&applied).expect_err("validate");
989        assert!(matches!(err, DirectiveError::UnknownDirective(_)));
990    }
991
992    // -- Conflict detection --
993
994    #[test]
995    fn test_conflict_skip_include() {
996        let proc = DirectiveProcessor::new();
997        let skip = AppliedDirective::new("skip").with_arg("if", ArgumentValue::Boolean(true));
998        let include =
999            AppliedDirective::new("include").with_arg("if", ArgumentValue::Boolean(false));
1000        let err = proc
1001            .detect_conflicts(&[skip, include])
1002            .expect_err("conflict");
1003        assert!(matches!(err, DirectiveError::Conflict { .. }));
1004    }
1005
1006    #[test]
1007    fn test_no_conflict_independent() {
1008        let proc = DirectiveProcessor::new();
1009        let auth = AppliedDirective::new("auth");
1010        let cache = AppliedDirective::new("cache").with_arg("ttl", ArgumentValue::Int(30));
1011        assert!(proc.detect_conflicts(&[auth, cache]).is_ok());
1012    }
1013
1014    // -- Process directives --
1015
1016    #[test]
1017    fn test_process_skip_in_prefield() {
1018        let mut proc = DirectiveProcessor::new();
1019        let directives =
1020            vec![AppliedDirective::new("skip").with_arg("if", ArgumentValue::Boolean(true))];
1021        let result = proc
1022            .process_directives(&directives, DirectivePhase::PreField, &[], "", 0, "Q", "f")
1023            .expect("process");
1024        assert_eq!(result, DirectiveOutcome::SkipField);
1025    }
1026
1027    #[test]
1028    fn test_process_deprecated_in_postfield() {
1029        let mut proc = DirectiveProcessor::new();
1030        let directives = vec![AppliedDirective::new("deprecated")
1031            .with_arg("reason", ArgumentValue::String("old".into()))];
1032        let result = proc
1033            .process_directives(&directives, DirectivePhase::PostField, &[], "", 0, "Q", "f")
1034            .expect("process");
1035        // DeprecationWarning is non-blocking, so process continues
1036        assert_eq!(result, DirectiveOutcome::Continue);
1037        assert_eq!(proc.deprecations().len(), 1);
1038    }
1039
1040    #[test]
1041    fn test_process_auth_denies_access() {
1042        let mut proc = DirectiveProcessor::new();
1043        let directives = vec![AppliedDirective::new("auth").with_arg(
1044            "roles",
1045            ArgumentValue::List(vec![ArgumentValue::String("admin".into())]),
1046        )];
1047        let result = proc
1048            .process_directives(
1049                &directives,
1050                DirectivePhase::PreField,
1051                &["viewer".to_string()],
1052                "",
1053                0,
1054                "Q",
1055                "f",
1056            )
1057            .expect("process");
1058        assert!(matches!(result, DirectiveOutcome::AccessDenied(_)));
1059    }
1060
1061    // -- ArgumentValue --
1062
1063    #[test]
1064    fn test_argument_value_as_bool() {
1065        assert_eq!(ArgumentValue::Boolean(true).as_bool(), Some(true));
1066        assert_eq!(ArgumentValue::String("x".into()).as_bool(), None);
1067    }
1068
1069    #[test]
1070    fn test_argument_value_as_str() {
1071        assert_eq!(ArgumentValue::String("hi".into()).as_str(), Some("hi"));
1072        assert_eq!(ArgumentValue::Int(1).as_str(), None);
1073    }
1074
1075    #[test]
1076    fn test_argument_value_as_int() {
1077        assert_eq!(ArgumentValue::Int(42).as_int(), Some(42));
1078        assert_eq!(ArgumentValue::Boolean(true).as_int(), None);
1079    }
1080
1081    #[test]
1082    fn test_argument_value_display() {
1083        assert_eq!(format!("{}", ArgumentValue::Boolean(true)), "true");
1084        assert_eq!(format!("{}", ArgumentValue::String("x".into())), "\"x\"");
1085        assert_eq!(format!("{}", ArgumentValue::Null), "null");
1086        assert_eq!(format!("{}", ArgumentValue::Int(5)), "5");
1087    }
1088
1089    #[test]
1090    fn test_argument_value_list_display() {
1091        let list = ArgumentValue::List(vec![
1092            ArgumentValue::String("a".into()),
1093            ArgumentValue::String("b".into()),
1094        ]);
1095        assert_eq!(format!("{list}"), "[\"a\", \"b\"]");
1096    }
1097
1098    #[test]
1099    fn test_as_string_list() {
1100        let list = ArgumentValue::List(vec![
1101            ArgumentValue::String("x".into()),
1102            ArgumentValue::String("y".into()),
1103        ]);
1104        assert_eq!(
1105            list.as_string_list(),
1106            Some(vec!["x".to_string(), "y".to_string()])
1107        );
1108    }
1109
1110    #[test]
1111    fn test_as_string_list_mixed_types_none() {
1112        let list = ArgumentValue::List(vec![
1113            ArgumentValue::String("x".into()),
1114            ArgumentValue::Int(1),
1115        ]);
1116        assert_eq!(list.as_string_list(), None);
1117    }
1118
1119    // -- Error display --
1120
1121    #[test]
1122    fn test_directive_error_display() {
1123        let err = DirectiveError::UnknownDirective("foo".into());
1124        assert!(format!("{err}").contains("@foo"));
1125        let err2 = DirectiveError::MissingArgument {
1126            directive: "skip".into(),
1127            argument: "if".into(),
1128        };
1129        assert!(format!("{err2}").contains("@skip"));
1130        let err3 = DirectiveError::Conflict {
1131            directive_a: "skip".into(),
1132            directive_b: "include".into(),
1133            reason: "test".into(),
1134        };
1135        assert!(format!("{err3}").contains("@skip"));
1136    }
1137
1138    // -- Default trait --
1139
1140    #[test]
1141    fn test_default_processor() {
1142        let proc = DirectiveProcessor::default();
1143        assert!(proc.is_registered("skip"));
1144        assert!(proc.is_registered("include"));
1145    }
1146
1147    // -- CacheEntry --
1148
1149    #[test]
1150    fn test_cache_entry_expiry() {
1151        let entry = CacheEntry {
1152            key: "k".into(),
1153            value: "v".into(),
1154            created_at_ms: 1000,
1155            ttl_ms: 5000,
1156        };
1157        assert!(!entry.is_expired(3000));
1158        assert!(entry.is_expired(7000));
1159    }
1160}