1use std::any::{Any, TypeId};
25use std::collections::HashMap;
26
27use thiserror::Error;
28
29use crate::types::{Designation, ObjectId, Operation};
30
31pub trait DesignationResolver: Send + Sync {
38 fn resolve(
39 &self,
40 target: &ObjectId,
41 operation: &Operation,
42 ctx: &DesignationContext,
43 ) -> Result<Vec<Designation>, ResolverError>;
44}
45
46#[derive(Debug, Default, Clone, Copy)]
53pub struct NoopResolver;
54
55impl DesignationResolver for NoopResolver {
56 fn resolve(
57 &self,
58 _target: &ObjectId,
59 _operation: &Operation,
60 _ctx: &DesignationContext,
61 ) -> Result<Vec<Designation>, ResolverError> {
62 Ok(Vec::new())
63 }
64}
65
66pub struct DesignationContext {
74 pub subject: ObjectId,
76 pub args: Option<serde_json::Value>,
78 extensions: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
79}
80
81impl DesignationContext {
82 pub fn new(subject: ObjectId) -> Self {
85 Self {
86 subject,
87 args: None,
88 extensions: HashMap::new(),
89 }
90 }
91
92 pub fn with_args(mut self, args: serde_json::Value) -> Self {
94 self.args = Some(args);
95 self
96 }
97
98 pub fn insert<T: Any + Send + Sync>(&mut self, ext: T) {
100 self.extensions.insert(TypeId::of::<T>(), Box::new(ext));
101 }
102
103 pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
105 self.extensions
106 .get(&TypeId::of::<T>())
107 .and_then(|b| b.downcast_ref::<T>())
108 }
109}
110
111impl std::fmt::Debug for DesignationContext {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("DesignationContext")
114 .field("subject", &self.subject)
115 .field("args", &self.args)
116 .field("extensions", &format_args!("{} ext", self.extensions.len()))
117 .finish()
118 }
119}
120
121#[derive(Error, Debug)]
123pub enum ResolverError {
124 #[error("resolver could not supply designation '{label}': {detail}")]
127 MissingField { label: String, detail: String },
128
129 #[error("resolver context has the wrong shape: {reason}")]
132 InvalidShape { reason: String },
133
134 #[error("resolver failed: {0}")]
136 Other(String),
137}
138
139#[derive(Debug, Default, Clone)]
164pub struct ArgsResolver {
165 per_target: HashMap<ObjectId, HashMap<String, String>>,
166}
167
168impl ArgsResolver {
169 pub fn builder() -> ArgsResolverBuilder {
171 ArgsResolverBuilder::default()
172 }
173}
174
175impl DesignationResolver for ArgsResolver {
176 fn resolve(
177 &self,
178 target: &ObjectId,
179 _operation: &Operation,
180 ctx: &DesignationContext,
181 ) -> Result<Vec<Designation>, ResolverError> {
182 let Some(mappings) = self.per_target.get(target) else {
183 return Ok(Vec::new());
185 };
186 if mappings.is_empty() {
187 return Ok(Vec::new());
188 }
189
190 let args = ctx.args.as_ref().ok_or_else(|| ResolverError::InvalidShape {
191 reason: format!(
192 "ArgsResolver needs ctx.args to resolve designations for target '{target}', but args is None",
193 ),
194 })?;
195
196 let obj = args
197 .as_object()
198 .ok_or_else(|| ResolverError::InvalidShape {
199 reason: "ArgsResolver expects ctx.args to be a JSON object".to_string(),
200 })?;
201
202 let mut out = Vec::with_capacity(mappings.len());
203 for (arg_field, label) in mappings {
204 let value = obj
205 .get(arg_field)
206 .ok_or_else(|| ResolverError::MissingField {
207 label: label.clone(),
208 detail: format!("arg field '{arg_field}' not present in ctx.args"),
209 })?;
210 let value_str = match value {
211 serde_json::Value::String(s) => s.clone(),
212 serde_json::Value::Number(n) => n.to_string(),
213 serde_json::Value::Bool(b) => b.to_string(),
214 other => {
215 return Err(ResolverError::InvalidShape {
216 reason: format!(
217 "arg '{arg_field}' for designation '{label}' must be a string, number, or bool, got {}",
218 describe_json_kind(other),
219 ),
220 });
221 }
222 };
223 out.push(Designation {
224 label: label.clone(),
225 value: value_str,
226 });
227 }
228 Ok(out)
229 }
230}
231
232fn describe_json_kind(v: &serde_json::Value) -> &'static str {
233 match v {
234 serde_json::Value::Null => "null",
235 serde_json::Value::Bool(_) => "bool",
236 serde_json::Value::Number(_) => "number",
237 serde_json::Value::String(_) => "string",
238 serde_json::Value::Array(_) => "array",
239 serde_json::Value::Object(_) => "object",
240 }
241}
242
243#[derive(Debug, Default)]
246pub struct ArgsResolverBuilder {
247 per_target: HashMap<ObjectId, HashMap<String, String>>,
248 current: Option<ObjectId>,
249}
250
251impl ArgsResolverBuilder {
252 pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
255 let id = target.into();
256 self.per_target.entry(id.clone()).or_default();
257 self.current = Some(id);
258 self
259 }
260
261 pub fn map(mut self, arg_field: impl Into<String>, label: impl Into<String>) -> Self {
270 let current = self
271 .current
272 .as_ref()
273 .expect("ArgsResolverBuilder::map called before for_target()");
274 self.per_target
275 .get_mut(current)
276 .expect("for_target inserted an empty map")
277 .insert(arg_field.into(), label.into());
278 self
279 }
280
281 pub fn build(self) -> ArgsResolver {
283 ArgsResolver {
284 per_target: self.per_target,
285 }
286 }
287}
288
289pub struct CompositeResolver {
317 per_target: HashMap<ObjectId, Box<dyn DesignationResolver>>,
318 default: Option<Box<dyn DesignationResolver>>,
319}
320
321impl CompositeResolver {
322 pub fn builder() -> CompositeResolverBuilder {
324 CompositeResolverBuilder::default()
325 }
326}
327
328impl std::fmt::Debug for CompositeResolver {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 f.debug_struct("CompositeResolver")
331 .field("targets", &self.per_target.keys().collect::<Vec<_>>())
332 .field("has_default", &self.default.is_some())
333 .finish()
334 }
335}
336
337impl DesignationResolver for CompositeResolver {
338 fn resolve(
339 &self,
340 target: &ObjectId,
341 operation: &Operation,
342 ctx: &DesignationContext,
343 ) -> Result<Vec<Designation>, ResolverError> {
344 if let Some(r) = self.per_target.get(target) {
345 return r.resolve(target, operation, ctx);
346 }
347 if let Some(d) = &self.default {
348 return d.resolve(target, operation, ctx);
349 }
350 Ok(Vec::new())
351 }
352}
353
354#[derive(Default)]
356pub struct CompositeResolverBuilder {
357 per_target: HashMap<ObjectId, Box<dyn DesignationResolver>>,
358 default: Option<Box<dyn DesignationResolver>>,
359}
360
361impl CompositeResolverBuilder {
362 pub fn add<R>(mut self, target: impl Into<ObjectId>, resolver: R) -> Self
365 where
366 R: DesignationResolver + 'static,
367 {
368 self.per_target.insert(target.into(), Box::new(resolver));
369 self
370 }
371
372 pub fn with_default<R>(mut self, resolver: R) -> Self
375 where
376 R: DesignationResolver + 'static,
377 {
378 self.default = Some(Box::new(resolver));
379 self
380 }
381
382 pub fn build(self) -> CompositeResolver {
384 CompositeResolver {
385 per_target: self.per_target,
386 default: self.default,
387 }
388 }
389}
390
391#[derive(Debug, Default, Clone)]
402pub struct AuthSession {
403 fields: HashMap<String, String>,
404}
405
406impl AuthSession {
407 pub fn new() -> Self {
408 Self::default()
409 }
410
411 pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
412 self.fields.insert(key.into(), value.into());
413 self
414 }
415
416 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
417 self.fields.insert(key.into(), value.into());
418 }
419
420 pub fn get(&self, key: &str) -> Option<&str> {
421 self.fields.get(key).map(String::as_str)
422 }
423}
424
425#[derive(Debug, Clone)]
429pub struct RequestUrl(pub String);
430
431#[derive(Debug, Default, Clone)]
456pub struct WebappResolver {
457 per_target: HashMap<ObjectId, WebappTargetMappings>,
458}
459
460#[derive(Debug, Default, Clone)]
461struct WebappTargetMappings {
462 session: Vec<(String, String)>,
464 url_patterns: Vec<UrlPattern>,
466}
467
468#[derive(Debug, Clone)]
469struct UrlPattern {
470 segments: Vec<UrlSegment>,
472}
473
474#[derive(Debug, Clone)]
475enum UrlSegment {
476 Literal(String),
477 Capture(String),
478}
479
480impl UrlPattern {
481 fn parse(pattern: &str) -> Self {
482 let segments = pattern
483 .trim_matches('/')
484 .split('/')
485 .filter(|s| !s.is_empty())
486 .map(|seg| {
487 if seg.starts_with('{') && seg.ends_with('}') {
488 UrlSegment::Capture(seg[1..seg.len() - 1].to_string())
489 } else {
490 UrlSegment::Literal(seg.to_string())
491 }
492 })
493 .collect();
494 Self { segments }
495 }
496
497 fn match_url(&self, url: &str) -> Option<Vec<(String, String)>> {
500 let url_segments: Vec<&str> = url
501 .trim_matches('/')
502 .split('/')
503 .filter(|s| !s.is_empty())
504 .collect();
505 if url_segments.len() != self.segments.len() {
506 return None;
507 }
508 let mut captures = Vec::new();
509 for (pat, val) in self.segments.iter().zip(url_segments.iter()) {
510 match pat {
511 UrlSegment::Literal(lit) => {
512 if lit != val {
513 return None;
514 }
515 }
516 UrlSegment::Capture(name) => {
517 captures.push((name.clone(), (*val).to_string()));
518 }
519 }
520 }
521 Some(captures)
522 }
523}
524
525impl WebappResolver {
526 pub fn builder() -> WebappResolverBuilder {
527 WebappResolverBuilder::default()
528 }
529}
530
531impl DesignationResolver for WebappResolver {
532 fn resolve(
533 &self,
534 target: &ObjectId,
535 _operation: &Operation,
536 ctx: &DesignationContext,
537 ) -> Result<Vec<Designation>, ResolverError> {
538 let Some(mappings) = self.per_target.get(target) else {
539 return Ok(Vec::new());
540 };
541 let mut out = Vec::new();
542
543 if !mappings.session.is_empty() {
544 let session = ctx
545 .get::<AuthSession>()
546 .ok_or_else(|| ResolverError::InvalidShape {
547 reason: format!(
548 "WebappResolver needs AuthSession in the context for target '{target}'",
549 ),
550 })?;
551 for (key, label) in &mappings.session {
552 let value = session
553 .get(key)
554 .ok_or_else(|| ResolverError::MissingField {
555 label: label.clone(),
556 detail: format!("session key '{key}' not present"),
557 })?;
558 out.push(Designation {
559 label: label.clone(),
560 value: value.to_string(),
561 });
562 }
563 }
564
565 if !mappings.url_patterns.is_empty() {
566 let url = ctx.get::<RequestUrl>().ok_or_else(|| ResolverError::InvalidShape {
567 reason: format!(
568 "WebappResolver has URL patterns for target '{target}' but no RequestUrl in context",
569 ),
570 })?;
571 let mut matched = false;
573 for pattern in &mappings.url_patterns {
574 if let Some(captures) = pattern.match_url(&url.0) {
575 for (name, value) in captures {
576 out.push(Designation { label: name, value });
577 }
578 matched = true;
579 break;
580 }
581 }
582 if !matched {
583 return Err(ResolverError::InvalidShape {
584 reason: format!(
585 "WebappResolver: no URL pattern for target '{target}' matched request '{}'",
586 url.0,
587 ),
588 });
589 }
590 }
591
592 Ok(out)
593 }
594}
595
596#[derive(Debug, Default)]
598pub struct WebappResolverBuilder {
599 per_target: HashMap<ObjectId, WebappTargetMappings>,
600 current: Option<ObjectId>,
601}
602
603impl WebappResolverBuilder {
604 pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
607 let id = target.into();
608 self.per_target.entry(id.clone()).or_default();
609 self.current = Some(id);
610 self
611 }
612
613 pub fn from_session(
615 mut self,
616 session_key: impl Into<String>,
617 label: impl Into<String>,
618 ) -> Self {
619 let current = self
620 .current
621 .as_ref()
622 .expect("WebappResolverBuilder::from_session called before for_target()");
623 self.per_target
624 .get_mut(current)
625 .expect("for_target inserted an empty entry")
626 .session
627 .push((session_key.into(), label.into()));
628 self
629 }
630
631 pub fn from_url_pattern(mut self, pattern: impl AsRef<str>) -> Self {
635 let current = self
636 .current
637 .as_ref()
638 .expect("WebappResolverBuilder::from_url_pattern called before for_target()");
639 let parsed = UrlPattern::parse(pattern.as_ref());
640 self.per_target
641 .get_mut(current)
642 .expect("for_target inserted an empty entry")
643 .url_patterns
644 .push(parsed);
645 self
646 }
647
648 pub fn build(self) -> WebappResolver {
649 WebappResolver {
650 per_target: self.per_target,
651 }
652 }
653}
654
655#[derive(Debug, Clone)]
663pub struct Event(pub serde_json::Value);
664
665#[derive(Debug, Default, Clone)]
685pub struct EventResolver {
686 per_target: HashMap<ObjectId, HashMap<String, String>>,
687}
688
689impl EventResolver {
690 pub fn builder() -> EventResolverBuilder {
691 EventResolverBuilder::default()
692 }
693}
694
695impl DesignationResolver for EventResolver {
696 fn resolve(
697 &self,
698 target: &ObjectId,
699 _operation: &Operation,
700 ctx: &DesignationContext,
701 ) -> Result<Vec<Designation>, ResolverError> {
702 let Some(mappings) = self.per_target.get(target) else {
703 return Ok(Vec::new());
704 };
705 if mappings.is_empty() {
706 return Ok(Vec::new());
707 }
708
709 let event = ctx
710 .get::<Event>()
711 .ok_or_else(|| ResolverError::InvalidShape {
712 reason: format!("EventResolver needs Event in the context for target '{target}'",),
713 })?;
714
715 let mut out = Vec::with_capacity(mappings.len());
716 for (event_path, label) in mappings {
717 let value = lookup_json_path(&event.0, event_path).ok_or_else(|| {
718 ResolverError::MissingField {
719 label: label.clone(),
720 detail: format!("event path '{event_path}' not present"),
721 }
722 })?;
723 let value_str = match value {
724 serde_json::Value::String(s) => s.clone(),
725 serde_json::Value::Number(n) => n.to_string(),
726 serde_json::Value::Bool(b) => b.to_string(),
727 other => {
728 return Err(ResolverError::InvalidShape {
729 reason: format!(
730 "event path '{event_path}' for designation '{label}' must be a string, number, or bool, got {}",
731 describe_json_kind(other),
732 ),
733 });
734 }
735 };
736 out.push(Designation {
737 label: label.clone(),
738 value: value_str,
739 });
740 }
741 Ok(out)
742 }
743}
744
745fn lookup_json_path<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
749 let mut current = root;
750 for segment in path.split('.') {
751 current = current.as_object()?.get(segment)?;
752 }
753 Some(current)
754}
755
756#[derive(Debug, Default)]
758pub struct EventResolverBuilder {
759 per_target: HashMap<ObjectId, HashMap<String, String>>,
760 current: Option<ObjectId>,
761}
762
763impl EventResolverBuilder {
764 pub fn for_target(mut self, target: impl Into<ObjectId>) -> Self {
765 let id = target.into();
766 self.per_target.entry(id.clone()).or_default();
767 self.current = Some(id);
768 self
769 }
770
771 pub fn map(mut self, event_path: impl Into<String>, label: impl Into<String>) -> Self {
773 let current = self
774 .current
775 .as_ref()
776 .expect("EventResolverBuilder::map called before for_target()");
777 self.per_target
778 .get_mut(current)
779 .expect("for_target inserted an empty map")
780 .insert(event_path.into(), label.into());
781 self
782 }
783
784 pub fn build(self) -> EventResolver {
785 EventResolver {
786 per_target: self.per_target,
787 }
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use serde_json::json;
795
796 fn ctx_with_args(subject: &str, args: serde_json::Value) -> DesignationContext {
797 DesignationContext::new(ObjectId::new(subject)).with_args(args)
798 }
799
800 #[test]
801 fn noop_resolver_returns_empty() {
802 let r = NoopResolver;
803 let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
804 let out = r
805 .resolve(
806 &ObjectId::new("filesystem:source"),
807 &Operation::new("read"),
808 &ctx,
809 )
810 .expect("noop");
811 assert!(out.is_empty());
812 }
813
814 #[test]
815 fn args_resolver_maps_declared_fields() {
816 let resolver = ArgsResolver::builder()
817 .for_target("filesystem:source")
818 .map("path", "path_prefix")
819 .build();
820
821 let ctx = ctx_with_args("agent:jake", json!({ "path": "code/hessra/" }));
822 let out = resolver
823 .resolve(
824 &ObjectId::new("filesystem:source"),
825 &Operation::new("read"),
826 &ctx,
827 )
828 .expect("resolve");
829 assert_eq!(out.len(), 1);
830 assert_eq!(out[0].label, "path_prefix");
831 assert_eq!(out[0].value, "code/hessra/");
832 }
833
834 #[test]
835 fn args_resolver_missing_field_errors() {
836 let resolver = ArgsResolver::builder()
837 .for_target("filesystem:source")
838 .map("path", "path_prefix")
839 .build();
840
841 let ctx = ctx_with_args("agent:jake", json!({ "other": "x" }));
842 let err = resolver
843 .resolve(
844 &ObjectId::new("filesystem:source"),
845 &Operation::new("read"),
846 &ctx,
847 )
848 .expect_err("must miss");
849 match err {
850 ResolverError::MissingField { label, .. } => assert_eq!(label, "path_prefix"),
851 other => panic!("wrong variant: {other:?}"),
852 }
853 }
854
855 #[test]
856 fn args_resolver_unknown_target_returns_empty() {
857 let resolver = ArgsResolver::builder()
858 .for_target("filesystem:source")
859 .map("path", "path_prefix")
860 .build();
861
862 let ctx = ctx_with_args("agent:jake", json!({}));
863 let out = resolver
864 .resolve(
865 &ObjectId::new("tool:other-thing"),
866 &Operation::new("invoke"),
867 &ctx,
868 )
869 .expect("unknown target");
870 assert!(out.is_empty());
871 }
872
873 #[test]
874 fn args_resolver_multi_target() {
875 let resolver = ArgsResolver::builder()
876 .for_target("filesystem:source")
877 .map("path", "path_prefix")
878 .for_target("tool:discord-dm")
879 .map("user_id", "user_id")
880 .build();
881
882 let ctx = ctx_with_args("agent:jake", json!({ "user_id": "u-42" }));
883 let out = resolver
884 .resolve(
885 &ObjectId::new("tool:discord-dm"),
886 &Operation::new("send"),
887 &ctx,
888 )
889 .expect("resolve");
890 assert_eq!(out.len(), 1);
891 assert_eq!(out[0].label, "user_id");
892 assert_eq!(out[0].value, "u-42");
893 }
894
895 #[test]
896 fn args_resolver_rejects_non_object_args() {
897 let resolver = ArgsResolver::builder()
898 .for_target("filesystem:source")
899 .map("path", "path_prefix")
900 .build();
901
902 let ctx = ctx_with_args("agent:jake", json!(["not", "an", "object"]));
903 let err = resolver
904 .resolve(
905 &ObjectId::new("filesystem:source"),
906 &Operation::new("read"),
907 &ctx,
908 )
909 .expect_err("must reject non-object");
910 assert!(matches!(err, ResolverError::InvalidShape { .. }));
911 }
912
913 #[test]
914 fn args_resolver_rejects_missing_args() {
915 let resolver = ArgsResolver::builder()
916 .for_target("filesystem:source")
917 .map("path", "path_prefix")
918 .build();
919
920 let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
922 let err = resolver
923 .resolve(
924 &ObjectId::new("filesystem:source"),
925 &Operation::new("read"),
926 &ctx,
927 )
928 .expect_err("must reject missing args");
929 assert!(matches!(err, ResolverError::InvalidShape { .. }));
930 }
931
932 #[test]
933 fn args_resolver_supports_numeric_and_bool_values() {
934 let resolver = ArgsResolver::builder()
935 .for_target("api:thing")
936 .map("count", "count_label")
937 .map("flag", "flag_label")
938 .build();
939
940 let ctx = ctx_with_args("agent:jake", json!({ "count": 7, "flag": true }));
941 let out = resolver
942 .resolve(&ObjectId::new("api:thing"), &Operation::new("call"), &ctx)
943 .expect("resolve");
944 let by_label: HashMap<_, _> = out
945 .iter()
946 .map(|d| (d.label.as_str(), d.value.as_str()))
947 .collect();
948 assert_eq!(by_label["count_label"], "7");
949 assert_eq!(by_label["flag_label"], "true");
950 }
951
952 #[test]
953 fn context_extensions_round_trip_typed_value() {
954 struct Session {
955 tenant: String,
956 }
957
958 let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
959 ctx.insert(Session {
960 tenant: "acme".to_string(),
961 });
962
963 let session = ctx.get::<Session>().expect("present");
964 assert_eq!(session.tenant, "acme");
965
966 assert!(ctx.get::<u32>().is_none());
968 }
969
970 #[test]
971 #[should_panic(expected = "for_target")]
972 fn map_before_for_target_panics() {
973 let _ = ArgsResolver::builder().map("x", "y");
974 }
975
976 #[test]
981 fn composite_dispatches_to_per_target_resolver() {
982 let fs_resolver = ArgsResolver::builder()
983 .for_target("filesystem:source")
984 .map("path", "path_prefix")
985 .build();
986
987 let composite = CompositeResolver::builder()
988 .add("filesystem:source", fs_resolver)
989 .build();
990
991 let ctx = ctx_with_args("agent:jake", json!({ "path": "code/hessra/" }));
992 let out = composite
993 .resolve(
994 &ObjectId::new("filesystem:source"),
995 &Operation::new("read"),
996 &ctx,
997 )
998 .expect("resolve");
999 assert_eq!(out.len(), 1);
1000 assert_eq!(out[0].label, "path_prefix");
1001 }
1002
1003 #[test]
1004 fn composite_unknown_target_returns_empty_when_no_default() {
1005 let composite = CompositeResolver::builder()
1006 .add(
1007 "filesystem:source",
1008 ArgsResolver::builder()
1009 .for_target("filesystem:source")
1010 .map("path", "path_prefix")
1011 .build(),
1012 )
1013 .build();
1014
1015 let ctx = ctx_with_args("agent:jake", json!({}));
1016 let out = composite
1017 .resolve(
1018 &ObjectId::new("tool:other"),
1019 &Operation::new("invoke"),
1020 &ctx,
1021 )
1022 .expect("resolve");
1023 assert!(out.is_empty());
1024 }
1025
1026 #[test]
1027 fn composite_default_handles_unknown_targets() {
1028 struct ConstResolver;
1029 impl DesignationResolver for ConstResolver {
1030 fn resolve(
1031 &self,
1032 _t: &ObjectId,
1033 _op: &Operation,
1034 _ctx: &DesignationContext,
1035 ) -> Result<Vec<Designation>, ResolverError> {
1036 Ok(vec![Designation {
1037 label: "default_label".into(),
1038 value: "default_value".into(),
1039 }])
1040 }
1041 }
1042
1043 let composite = CompositeResolver::builder()
1044 .add(
1045 "filesystem:source",
1046 ArgsResolver::builder()
1047 .for_target("filesystem:source")
1048 .map("path", "path_prefix")
1049 .build(),
1050 )
1051 .with_default(ConstResolver)
1052 .build();
1053
1054 let ctx = ctx_with_args("agent:jake", json!({}));
1056 let out = composite
1057 .resolve(&ObjectId::new("tool:other"), &Operation::new("op"), &ctx)
1058 .expect("resolve");
1059 assert_eq!(out.len(), 1);
1060 assert_eq!(out[0].label, "default_label");
1061
1062 let ctx = ctx_with_args("agent:jake", json!({ "path": "/x" }));
1064 let out = composite
1065 .resolve(
1066 &ObjectId::new("filesystem:source"),
1067 &Operation::new("read"),
1068 &ctx,
1069 )
1070 .expect("resolve");
1071 assert_eq!(out.len(), 1);
1072 assert_eq!(out[0].label, "path_prefix");
1073 }
1074
1075 #[test]
1080 fn webapp_resolver_extracts_session_fields() {
1081 let resolver = WebappResolver::builder()
1082 .for_target("api:posts")
1083 .from_session("tenant_id", "tenant_id")
1084 .from_session("user", "user_subject")
1085 .build();
1086
1087 let session = AuthSession::new()
1088 .with("tenant_id", "acme")
1089 .with("user", "alice");
1090
1091 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1092 ctx.insert(session);
1093
1094 let out = resolver
1095 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1096 .expect("resolve");
1097 let by_label: HashMap<_, _> = out
1098 .iter()
1099 .map(|d| (d.label.as_str(), d.value.as_str()))
1100 .collect();
1101 assert_eq!(by_label["tenant_id"], "acme");
1102 assert_eq!(by_label["user_subject"], "alice");
1103 }
1104
1105 #[test]
1106 fn webapp_resolver_missing_session_errors() {
1107 let resolver = WebappResolver::builder()
1108 .for_target("api:posts")
1109 .from_session("tenant_id", "tenant_id")
1110 .build();
1111
1112 let ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1113 let err = resolver
1114 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1115 .expect_err("must fail without session");
1116 assert!(matches!(err, ResolverError::InvalidShape { .. }));
1117 }
1118
1119 #[test]
1120 fn webapp_resolver_missing_session_field_errors() {
1121 let resolver = WebappResolver::builder()
1122 .for_target("api:posts")
1123 .from_session("tenant_id", "tenant_id")
1124 .build();
1125
1126 let session = AuthSession::new().with("other", "x");
1127 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1128 ctx.insert(session);
1129
1130 let err = resolver
1131 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1132 .expect_err("must fail with missing field");
1133 match err {
1134 ResolverError::MissingField { label, .. } => assert_eq!(label, "tenant_id"),
1135 other => panic!("wrong variant: {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn webapp_resolver_url_pattern_extracts_named_captures() {
1141 let resolver = WebappResolver::builder()
1142 .for_target("api:posts")
1143 .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1144 .build();
1145
1146 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1147 ctx.insert(RequestUrl("/tenants/acme/posts/p-42".to_string()));
1148
1149 let out = resolver
1150 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1151 .expect("resolve");
1152 let by_label: HashMap<_, _> = out
1153 .iter()
1154 .map(|d| (d.label.as_str(), d.value.as_str()))
1155 .collect();
1156 assert_eq!(by_label["tenant_id"], "acme");
1157 assert_eq!(by_label["resource_id"], "p-42");
1158 }
1159
1160 #[test]
1161 fn webapp_resolver_url_pattern_no_match_errors() {
1162 let resolver = WebappResolver::builder()
1163 .for_target("api:posts")
1164 .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1165 .build();
1166
1167 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1168 ctx.insert(RequestUrl("/wrong/shape".to_string()));
1169
1170 let err = resolver
1171 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1172 .expect_err("pattern must not match");
1173 assert!(matches!(err, ResolverError::InvalidShape { .. }));
1174 }
1175
1176 #[test]
1177 fn webapp_resolver_first_matching_pattern_wins() {
1178 let resolver = WebappResolver::builder()
1180 .for_target("api:posts")
1181 .from_url_pattern("/tenants/{tenant_id}/posts/{resource_id}")
1182 .from_url_pattern("/tenants/{tenant_id}/posts")
1183 .build();
1184
1185 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1186 ctx.insert(RequestUrl("/tenants/acme/posts".to_string()));
1187
1188 let out = resolver
1189 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1190 .expect("second pattern matches");
1191 assert_eq!(out.len(), 1);
1192 assert_eq!(out[0].label, "tenant_id");
1193 assert_eq!(out[0].value, "acme");
1194 }
1195
1196 #[test]
1197 fn webapp_resolver_combines_session_and_url() {
1198 let resolver = WebappResolver::builder()
1199 .for_target("api:posts")
1200 .from_session("user", "user_subject")
1201 .from_url_pattern("/tenants/{tenant_id}")
1202 .build();
1203
1204 let mut ctx = DesignationContext::new(ObjectId::new("service:webapp"));
1205 ctx.insert(AuthSession::new().with("user", "alice"));
1206 ctx.insert(RequestUrl("/tenants/acme".to_string()));
1207
1208 let out = resolver
1209 .resolve(&ObjectId::new("api:posts"), &Operation::new("read"), &ctx)
1210 .expect("resolve");
1211 let by_label: HashMap<_, _> = out
1212 .iter()
1213 .map(|d| (d.label.as_str(), d.value.as_str()))
1214 .collect();
1215 assert_eq!(by_label["user_subject"], "alice");
1216 assert_eq!(by_label["tenant_id"], "acme");
1217 }
1218
1219 #[test]
1224 fn event_resolver_extracts_top_level_field() {
1225 let resolver = EventResolver::builder()
1226 .for_target("tool:discord-dm")
1227 .map("user_id", "user_id")
1228 .build();
1229
1230 let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1231 ctx.insert(Event(json!({ "user_id": "u-42" })));
1232
1233 let out = resolver
1234 .resolve(
1235 &ObjectId::new("tool:discord-dm"),
1236 &Operation::new("send"),
1237 &ctx,
1238 )
1239 .expect("resolve");
1240 assert_eq!(out.len(), 1);
1241 assert_eq!(out[0].label, "user_id");
1242 assert_eq!(out[0].value, "u-42");
1243 }
1244
1245 #[test]
1246 fn event_resolver_extracts_dotted_path() {
1247 let resolver = EventResolver::builder()
1248 .for_target("tool:discord-dm")
1249 .map("user.id", "user_id")
1250 .map("channel.id", "channel_id")
1251 .build();
1252
1253 let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1254 ctx.insert(Event(json!({
1255 "user": { "id": "u-42", "name": "alice" },
1256 "channel": { "id": "c-7" },
1257 })));
1258
1259 let out = resolver
1260 .resolve(
1261 &ObjectId::new("tool:discord-dm"),
1262 &Operation::new("send"),
1263 &ctx,
1264 )
1265 .expect("resolve");
1266 let by_label: HashMap<_, _> = out
1267 .iter()
1268 .map(|d| (d.label.as_str(), d.value.as_str()))
1269 .collect();
1270 assert_eq!(by_label["user_id"], "u-42");
1271 assert_eq!(by_label["channel_id"], "c-7");
1272 }
1273
1274 #[test]
1275 fn event_resolver_missing_event_errors() {
1276 let resolver = EventResolver::builder()
1277 .for_target("tool:discord-dm")
1278 .map("user_id", "user_id")
1279 .build();
1280
1281 let ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1282 let err = resolver
1283 .resolve(
1284 &ObjectId::new("tool:discord-dm"),
1285 &Operation::new("send"),
1286 &ctx,
1287 )
1288 .expect_err("must fail without event");
1289 assert!(matches!(err, ResolverError::InvalidShape { .. }));
1290 }
1291
1292 #[test]
1293 fn event_resolver_missing_event_field_errors() {
1294 let resolver = EventResolver::builder()
1295 .for_target("tool:discord-dm")
1296 .map("user_id", "user_id")
1297 .build();
1298
1299 let mut ctx = DesignationContext::new(ObjectId::new("agent:jake"));
1300 ctx.insert(Event(json!({ "other": "x" })));
1301
1302 let err = resolver
1303 .resolve(
1304 &ObjectId::new("tool:discord-dm"),
1305 &Operation::new("send"),
1306 &ctx,
1307 )
1308 .expect_err("must fail with missing path");
1309 match err {
1310 ResolverError::MissingField { label, .. } => assert_eq!(label, "user_id"),
1311 other => panic!("wrong variant: {other:?}"),
1312 }
1313 }
1314}