1use std::collections::HashMap;
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum DirectiveError {
13 UnknownDirective(String),
15 MissingArgument { directive: String, argument: String },
17 InvalidArgument {
19 directive: String,
20 argument: String,
21 reason: String,
22 },
23 Conflict {
25 directive_a: String,
26 directive_b: String,
27 reason: String,
28 },
29 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#[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 pub fn as_bool(&self) -> Option<bool> {
81 match self {
82 ArgumentValue::Boolean(b) => Some(*b),
83 _ => None,
84 }
85 }
86
87 pub fn as_str(&self) -> Option<&str> {
89 match self {
90 ArgumentValue::String(s) => Some(s.as_str()),
91 _ => None,
92 }
93 }
94
95 pub fn as_int(&self) -> Option<i64> {
97 match self {
98 ArgumentValue::Int(i) => Some(*i),
99 _ => None,
100 }
101 }
102
103 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
145pub enum DirectivePhase {
146 PreField,
148 PostField,
150}
151
152#[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#[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#[derive(Debug, Clone, PartialEq)]
186pub enum DirectiveOutcome {
187 Continue,
189 SkipField,
191 CachedValue(String),
193 AccessDenied(String),
195 DeprecationWarning(String),
197}
198
199#[derive(Debug, Clone)]
201pub struct DirectiveDefinition {
202 pub name: String,
203 pub description: String,
204 pub arguments: Vec<ArgumentSchema>,
205 pub phase: DirectivePhase,
206 pub conflicts_with: Vec<String>,
208}
209
210#[derive(Debug, Clone)]
212pub struct AppliedDirective {
213 pub name: String,
214 pub arguments: HashMap<String, ArgumentValue>,
215}
216
217impl AppliedDirective {
218 pub fn new(name: impl Into<String>) -> Self {
220 Self {
221 name: name.into(),
222 arguments: HashMap::new(),
223 }
224 }
225
226 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 pub fn get_arg(&self, name: &str) -> Option<&ArgumentValue> {
234 self.arguments.get(name)
235 }
236}
237
238#[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 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#[derive(Debug, Clone, PartialEq)]
256pub struct DeprecationInfo {
257 pub type_name: String,
258 pub field_name: String,
259 pub reason: String,
260}
261
262pub struct DirectiveProcessor {
264 definitions: HashMap<String, DirectiveDefinition>,
266 cache: HashMap<String, CacheEntry>,
268 deprecations: Vec<DeprecationInfo>,
270}
271
272impl DirectiveProcessor {
273 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 pub fn empty() -> Self {
286 Self {
287 definitions: HashMap::new(),
288 cache: HashMap::new(),
289 deprecations: Vec::new(),
290 }
291 }
292
293 fn register_builtins(&mut self) {
296 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 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 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 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 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 pub fn register(&mut self, definition: DirectiveDefinition) {
377 self.definitions.insert(definition.name.clone(), definition);
378 }
379
380 pub fn is_registered(&self, name: &str) -> bool {
382 self.definitions.contains_key(name)
383 }
384
385 pub fn get_definition(&self, name: &str) -> Option<&DirectiveDefinition> {
387 self.definitions.get(name)
388 }
389
390 pub fn registered_names(&self) -> Vec<String> {
392 self.definitions.keys().cloned().collect()
393 }
394
395 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 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 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 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 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 pub fn execute_auth(
511 &self,
512 applied: &AppliedDirective,
513 user_roles: &[String],
514 ) -> Result<DirectiveOutcome, DirectiveError> {
515 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 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 pub fn execute_cache(
545 &mut self,
546 _applied: &AppliedDirective,
547 cache_key: &str,
548 current_time_ms: u64,
549 ) -> Result<DirectiveOutcome, DirectiveError> {
550 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 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 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 #[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 pub fn deprecations(&self) -> &[DeprecationInfo] {
641 &self.deprecations
642 }
643
644 pub fn cache_size(&self) -> usize {
646 self.cache.len()
647 }
648
649 pub fn clear_cache(&mut self) {
651 self.cache.clear();
652 }
653
654 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#[cfg(test)]
672mod tests {
673 use super::*;
674
675 #[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 #[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 #[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 #[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 #[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 #[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)); proc.cache_store(&applied, "k", "v", 1000);
911 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 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 #[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"); 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 #[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 #[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 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 #[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 #[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 #[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 #[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}