1use crate::adapter::net::behavior::predicate::{EvalContext, Predicate};
25use crate::adapter::net::behavior::tag::{CapabilityTagError, Tag, TagKey, TaxonomyAxis};
26
27#[derive(Debug, Clone, PartialEq)]
38pub enum RequiredCapability {
39 Tag(Tag),
44 Predicate(Predicate),
48 AxisAny(TaxonomyAxis),
52 AxisKey(TagKey),
55}
56
57impl RequiredCapability {
58 pub fn evaluate(&self, ctx: &EvalContext<'_>) -> bool {
62 match self {
63 Self::Tag(required) => ctx.tags.iter().any(|t| t.semantic_eq(required)),
69 Self::Predicate(p) => p.evaluate(ctx),
70 Self::AxisAny(axis) => ctx.tags.iter().any(|t| t.axis() == Some(*axis)),
71 Self::AxisKey(key) => ctx
72 .tags
73 .iter()
74 .any(|t| matches!(t.axis_key_ref(), Some((a, k)) if a == key.axis && k == key.key)),
75 }
76 }
77}
78
79#[derive(Debug, thiserror::Error)]
87pub enum RequireParseError {
88 #[error("require! input must be non-empty")]
90 Empty,
91 #[error("require! could not parse tag: {0}")]
94 Tag(#[from] CapabilityTagError),
95 #[error("require! numeric value {value:?} for key {key:?} did not parse as f64")]
99 NumericParse {
100 key: String,
102 value: String,
104 },
105 #[error("require! tag key {key:?} must be `<axis>.<key>` with a known axis")]
108 InvalidKey {
109 key: String,
111 },
112 #[error("require_axis! axis {axis:?} is not one of: hardware, software, devices, dataforts")]
114 InvalidAxis {
115 axis: String,
117 },
118}
119
120#[doc(hidden)]
139pub fn __require_parse(s: &str) -> Result<RequiredCapability, RequireParseError> {
140 let s = s.trim();
141 if s.is_empty() {
142 return Err(RequireParseError::Empty);
143 }
144
145 if let Some((lhs, rhs)) = s.split_once("==") {
153 let lhs = lhs.trim();
154 let rhs = rhs.trim().trim_matches('"');
155 let key = parse_tag_key(lhs)?;
156 return Ok(RequiredCapability::Predicate(Predicate::equals(
157 key,
158 rhs.to_string(),
159 )));
160 }
161
162 for (op, build) in [
164 (
165 ">=",
166 (|key: TagKey, n: f64| Predicate::numeric_at_least(key, n))
167 as fn(TagKey, f64) -> Predicate,
168 ),
169 (
170 "<=",
171 (|key: TagKey, n: f64| Predicate::numeric_at_most(key, n))
172 as fn(TagKey, f64) -> Predicate,
173 ),
174 ] {
175 if let Some((lhs, rhs)) = s.split_once(op) {
176 let lhs = lhs.trim();
177 let rhs = rhs.trim();
178 let key = parse_tag_key(lhs)?;
179 let n: f64 = rhs.parse().map_err(|_| RequireParseError::NumericParse {
180 key: lhs.to_string(),
181 value: rhs.to_string(),
182 })?;
183 return Ok(RequiredCapability::Predicate(build(key, n)));
184 }
185 }
186
187 let tag = Tag::parse_user(s)?;
189 Ok(RequiredCapability::Tag(tag))
190}
191
192#[doc(hidden)]
195pub fn __require_axis_parse(s: &str) -> Result<TaxonomyAxis, RequireParseError> {
196 TaxonomyAxis::from_prefix(s.trim()).ok_or_else(|| RequireParseError::InvalidAxis {
197 axis: s.to_string(),
198 })
199}
200
201#[doc(hidden)]
204pub fn __require_axis_value_parse(axis: &str, key: &str) -> Result<TagKey, RequireParseError> {
205 let axis =
206 TaxonomyAxis::from_prefix(axis.trim()).ok_or_else(|| RequireParseError::InvalidAxis {
207 axis: axis.to_string(),
208 })?;
209 let key = key.trim();
210 if key.is_empty() {
211 return Err(RequireParseError::InvalidKey { key: String::new() });
212 }
213 Ok(TagKey::new(axis, key))
214}
215
216fn parse_tag_key(s: &str) -> Result<TagKey, RequireParseError> {
226 let (axis_str, key) = s
227 .split_once('.')
228 .ok_or_else(|| RequireParseError::InvalidKey { key: s.to_string() })?;
229 let axis_str = axis_str.trim();
230 let key = key.trim();
231 let axis = TaxonomyAxis::from_prefix(axis_str)
232 .ok_or_else(|| RequireParseError::InvalidKey { key: s.to_string() })?;
233 if key.is_empty() {
234 return Err(RequireParseError::InvalidKey { key: s.to_string() });
235 }
236 Ok(TagKey::new(axis, key.to_string()))
237}
238
239#[macro_export]
264macro_rules! require {
265 ($spec:literal) => {
266 $crate::adapter::net::behavior::required_capability::__require_parse($spec)
267 .unwrap_or_else(|e| panic!("require!({:?}) failed at parse time: {}", $spec, e))
268 };
269}
270
271#[macro_export]
283macro_rules! require_axis {
284 ($axis:literal) => {
285 $crate::adapter::net::behavior::required_capability::RequiredCapability::AxisAny(
286 $crate::adapter::net::behavior::required_capability::__require_axis_parse($axis)
287 .unwrap_or_else(|e| panic!("require_axis!({:?}) failed: {}", $axis, e)),
288 )
289 };
290}
291
292#[macro_export]
306macro_rules! require_axis_value {
307 ($axis:literal, $key:literal) => {
308 $crate::adapter::net::behavior::required_capability::RequiredCapability::AxisKey(
309 $crate::adapter::net::behavior::required_capability::__require_axis_value_parse(
310 $axis, $key,
311 )
312 .unwrap_or_else(|e| {
313 panic!("require_axis_value!({:?}, {:?}) failed: {}", $axis, $key, e)
314 }),
315 )
316 };
317}
318
319#[cfg(test)]
324mod tests {
325 use std::collections::BTreeMap;
326
327 use super::*;
328 use crate::adapter::net::behavior::tag::{AxisSeparator, Tag, TaxonomyAxis};
329
330 fn axis_present(axis: TaxonomyAxis, key: &str) -> Tag {
331 Tag::AxisPresent {
332 axis,
333 key: key.into(),
334 }
335 }
336
337 fn axis_eq(axis: TaxonomyAxis, key: &str, value: &str) -> Tag {
338 Tag::AxisValue {
339 axis,
340 key: key.into(),
341 value: value.into(),
342 separator: AxisSeparator::Eq,
343 }
344 }
345
346 fn axis_colon(axis: TaxonomyAxis, key: &str, value: &str) -> Tag {
347 Tag::AxisValue {
348 axis,
349 key: key.into(),
350 value: value.into(),
351 separator: AxisSeparator::Colon,
352 }
353 }
354
355 fn meta() -> BTreeMap<String, String> {
356 BTreeMap::new()
357 }
358
359 #[test]
362 fn require_axis_presence() {
363 let r = require!("hardware.gpu");
364 assert_eq!(
365 r,
366 RequiredCapability::Tag(Tag::AxisPresent {
367 axis: TaxonomyAxis::Hardware,
368 key: "gpu".into(),
369 })
370 );
371 }
372
373 #[test]
374 fn require_axis_value_eq() {
375 let r = require!("hardware.gpu.vram_gb=80");
376 assert_eq!(
377 r,
378 RequiredCapability::Tag(Tag::AxisValue {
379 axis: TaxonomyAxis::Hardware,
380 key: "gpu.vram_gb".into(),
381 value: "80".into(),
382 separator: AxisSeparator::Eq,
383 })
384 );
385 }
386
387 #[test]
388 fn require_dataforts_pre_typed_colon() {
389 let r = require!("software.daemon:postgres");
390 match r {
391 RequiredCapability::Tag(Tag::AxisValue {
392 axis,
393 key,
394 value,
395 separator,
396 }) => {
397 assert_eq!(axis, TaxonomyAxis::Software);
398 assert_eq!(key, "daemon");
399 assert_eq!(value, "postgres");
400 assert_eq!(separator, AxisSeparator::Colon);
401 }
402 other => panic!("expected AxisValue with `:` separator, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn require_numeric_at_least() {
408 let r = require!("hardware.gpu.vram_gb >= 24");
409 match r {
410 RequiredCapability::Predicate(Predicate::NumericAtLeast { key, threshold }) => {
411 assert_eq!(key.axis, TaxonomyAxis::Hardware);
412 assert_eq!(key.key, "gpu.vram_gb");
413 assert!((threshold - 24.0).abs() < f64::EPSILON);
414 }
415 other => panic!("expected NumericAtLeast, got {other:?}"),
416 }
417 }
418
419 #[test]
420 fn require_numeric_at_most() {
421 let r = require!("hardware.cpu_cores <= 64");
422 match r {
423 RequiredCapability::Predicate(Predicate::NumericAtMost { key, threshold }) => {
424 assert_eq!(key.key, "cpu_cores");
425 assert!((threshold - 64.0).abs() < f64::EPSILON);
426 }
427 other => panic!("expected NumericAtMost, got {other:?}"),
428 }
429 }
430
431 #[test]
432 fn require_numeric_threshold_can_be_float() {
433 let r = require!("hardware.cpu_cores >= 1.5");
436 match r {
437 RequiredCapability::Predicate(Predicate::NumericAtLeast { threshold, .. }) => {
438 assert!((threshold - 1.5).abs() < f64::EPSILON);
439 }
440 other => panic!("expected NumericAtLeast, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn require_string_equality() {
446 let r = require!("software.runtime == \"cuda-12.4\"");
447 match r {
448 RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
449 assert_eq!(key.axis, TaxonomyAxis::Software);
450 assert_eq!(key.key, "runtime");
451 assert_eq!(value, "cuda-12.4");
452 }
453 other => panic!("expected Equals, got {other:?}"),
454 }
455 }
456
457 #[test]
463 fn require_equality_value_containing_ge_is_not_claimed_by_numeric_branch() {
464 let r = require!("software.id == v>=1.0");
465 match r {
466 RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
467 assert_eq!(key.axis, TaxonomyAxis::Software);
468 assert_eq!(key.key, "id");
469 assert_eq!(value, "v>=1.0");
470 }
471 other => panic!(
472 "expected Equals(software.id, v>=1.0), got {other:?} \
473 — `==` should bind tighter than `>=`"
474 ),
475 }
476 }
477
478 #[test]
485 fn require_parse_tag_key_trims_whitespace_around_dot() {
486 let r = require!("hardware. gpu == nvidia");
487 match r {
488 RequiredCapability::Predicate(Predicate::Equals { key, value }) => {
489 assert_eq!(key.axis, TaxonomyAxis::Hardware);
490 assert_eq!(key.key, "gpu", "key must not carry leading whitespace");
491 assert_eq!(value, "nvidia");
492 }
493 other => panic!("expected Equals(hardware.gpu, nvidia), got {other:?}"),
494 }
495
496 let r = require!(" hardware .gpu == nvidia");
498 match r {
499 RequiredCapability::Predicate(Predicate::Equals { key, value: _ }) => {
500 assert_eq!(key.axis, TaxonomyAxis::Hardware);
501 assert_eq!(key.key, "gpu");
502 }
503 other => panic!("expected Equals on hardware axis, got {other:?}"),
504 }
505 }
506
507 #[test]
510 fn require_axis_each_taxonomy() {
511 for axis in TaxonomyAxis::all() {
512 let r = match axis {
513 TaxonomyAxis::Hardware => require_axis!("hardware"),
514 TaxonomyAxis::Software => require_axis!("software"),
515 TaxonomyAxis::Devices => require_axis!("devices"),
516 TaxonomyAxis::Dataforts => require_axis!("dataforts"),
517 };
518 assert_eq!(r, RequiredCapability::AxisAny(axis));
519 }
520 }
521
522 #[test]
525 fn require_axis_value_basic() {
526 let r = require_axis_value!("software", "model");
527 assert_eq!(
528 r,
529 RequiredCapability::AxisKey(TagKey::new(TaxonomyAxis::Software, "model"))
530 );
531 }
532
533 #[test]
536 fn tag_variant_matches_exact_tag() {
537 let tags = [axis_present(TaxonomyAxis::Hardware, "gpu")];
538 let m = meta();
539 let ctx = EvalContext::new(&tags, &m);
540 let r = require!("hardware.gpu");
541 assert!(r.evaluate(&ctx));
542 let r = require!("hardware.tpu");
544 assert!(!r.evaluate(&ctx));
545 }
546
547 #[test]
548 fn tag_variant_value_matches_exactly() {
549 let tags = [axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80")];
550 let m = meta();
551 let ctx = EvalContext::new(&tags, &m);
552 let r = require!("hardware.gpu.vram_gb=80");
553 assert!(r.evaluate(&ctx));
554 let r = require!("hardware.gpu.vram_gb=24");
556 assert!(!r.evaluate(&ctx));
557 }
558
559 #[test]
560 fn tag_variant_evaluates_across_separator_forms() {
561 let m = meta();
567
568 let tags = [axis_colon(TaxonomyAxis::Software, "os", "linux")];
570 let ctx = EvalContext::new(&tags, &m);
571 let r = require!("software.os=linux");
572 assert!(r.evaluate(&ctx));
573
574 let tags = [axis_eq(TaxonomyAxis::Software, "os", "linux")];
576 let ctx = EvalContext::new(&tags, &m);
577 let r = require!("software.os:linux");
578 assert!(r.evaluate(&ctx));
579
580 let r = require!("software.os:darwin");
582 assert!(!r.evaluate(&ctx));
583 }
584
585 #[test]
586 fn predicate_variant_evaluates_via_predicate() {
587 let tags = [axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80")];
588 let m = meta();
589 let ctx = EvalContext::new(&tags, &m);
590 let r = require!("hardware.gpu.vram_gb >= 24");
592 assert!(r.evaluate(&ctx));
593 let r = require!("hardware.gpu.vram_gb >= 96");
594 assert!(!r.evaluate(&ctx));
595 }
596
597 #[test]
598 fn axis_any_matches_any_tag_in_axis() {
599 let tags = [axis_present(TaxonomyAxis::Devices, "lidar")];
600 let m = meta();
601 let ctx = EvalContext::new(&tags, &m);
602 let r = require_axis!("devices");
603 assert!(r.evaluate(&ctx));
604 let tags = [axis_present(TaxonomyAxis::Hardware, "gpu")];
606 let ctx = EvalContext::new(&tags, &m);
607 assert!(!r.evaluate(&ctx));
608 }
609
610 #[test]
611 fn axis_key_matches_presence_or_value() {
612 let tags = [axis_present(TaxonomyAxis::Software, "model")];
614 let m = meta();
615 let ctx = EvalContext::new(&tags, &m);
616 let r = require_axis_value!("software", "model");
617 assert!(r.evaluate(&ctx));
618 let tags = [axis_colon(TaxonomyAxis::Software, "model", "llama-7b")];
620 let ctx = EvalContext::new(&tags, &m);
621 assert!(r.evaluate(&ctx));
622 let tags = [axis_present(TaxonomyAxis::Software, "runtime")];
624 let ctx = EvalContext::new(&tags, &m);
625 assert!(!r.evaluate(&ctx));
626 }
627
628 #[test]
631 fn require_unknown_axis_falls_through_to_legacy_tag() {
632 let r = __require_parse("bogus.foo").unwrap();
639 match r {
640 RequiredCapability::Tag(Tag::Legacy(s)) => assert_eq!(s, "bogus.foo"),
641 other => panic!("expected Tag(Legacy(...)), got {other:?}"),
642 }
643 }
644
645 #[test]
646 fn require_parses_unparseable_threshold_as_error() {
647 match __require_parse("hardware.cpu_cores >= many") {
648 Err(RequireParseError::NumericParse { key, value }) => {
649 assert_eq!(key, "hardware.cpu_cores");
650 assert_eq!(value, "many");
651 }
652 other => panic!("expected NumericParse error, got {other:?}"),
653 }
654 }
655
656 #[test]
657 fn require_rejects_reserved_prefix() {
658 match __require_parse("scope:prod") {
659 Err(RequireParseError::Tag(CapabilityTagError::ReservedPrefix { prefix, .. })) => {
660 assert_eq!(prefix, "scope:");
661 }
662 other => panic!("expected ReservedPrefix, got {other:?}"),
663 }
664 }
665
666 #[test]
667 fn require_rejects_empty() {
668 match __require_parse("") {
669 Err(RequireParseError::Empty) => {}
670 other => panic!("expected Empty, got {other:?}"),
671 }
672 match __require_parse(" ") {
673 Err(RequireParseError::Empty) => {}
674 other => panic!("expected Empty, got {other:?}"),
675 }
676 }
677
678 #[test]
679 fn require_axis_rejects_unknown_axis() {
680 match __require_axis_parse("bogus") {
681 Err(RequireParseError::InvalidAxis { axis }) => {
682 assert_eq!(axis, "bogus");
683 }
684 other => panic!("expected InvalidAxis, got {other:?}"),
685 }
686 }
687
688 #[test]
689 fn require_axis_value_rejects_empty_key() {
690 match __require_axis_value_parse("software", "") {
691 Err(RequireParseError::InvalidKey { .. }) => {}
692 other => panic!("expected InvalidKey, got {other:?}"),
693 }
694 }
695
696 #[test]
699 fn intent_registry_defaults_examples_compile_and_evaluate() {
700 let reqs = [
704 require!("hardware.gpu"),
705 require!("hardware.gpu.vram_gb >= 24"),
706 ];
707
708 let tags = [
710 axis_present(TaxonomyAxis::Hardware, "gpu"),
711 axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "80"),
712 ];
713 let m = meta();
714 let ctx = EvalContext::new(&tags, &m);
715 assert!(reqs.iter().all(|r| r.evaluate(&ctx)));
716
717 let tags = [
719 axis_present(TaxonomyAxis::Hardware, "gpu"),
720 axis_eq(TaxonomyAxis::Hardware, "gpu.vram_gb", "16"),
721 ];
722 let ctx = EvalContext::new(&tags, &m);
723 assert!(!reqs.iter().all(|r| r.evaluate(&ctx)));
724
725 let tags: Vec<Tag> = vec![];
727 let ctx = EvalContext::new(&tags, &m);
728 assert!(!reqs.iter().any(|r| r.evaluate(&ctx)));
729 }
730}