1#![recursion_limit = "256"]
2use proc_macro::TokenStream;
8use proc_macro2::Span;
9use quote::{format_ident, quote};
10use syn::{
11 Expr, ItemStruct, Meta, MetaList, Type, parse_macro_input, parse_quote, punctuated::Punctuated,
12 spanned::Spanned, token::Comma,
13};
14
15#[proc_macro_attribute]
135pub fn modular_agent(attr: TokenStream, item: TokenStream) -> TokenStream {
136 let args = parse_macro_input!(attr with Punctuated<Meta, Comma>::parse_terminated);
137 let item_struct = parse_macro_input!(item as ItemStruct);
138
139 match expand_modular_agent(args, item_struct) {
140 Ok(tokens) => tokens.into(),
141 Err(err) => err.into_compile_error().into(),
142 }
143}
144
145struct AgentArgs {
146 kind: Option<Expr>,
147 name: Option<Expr>,
148 title: Option<Expr>,
149 hide_title: bool,
150 description: Option<Expr>,
151 category: Option<Expr>,
152 inputs: Vec<Expr>,
153 outputs: Vec<Expr>,
154 configs: Vec<ConfigSpec>,
155 global_configs: Vec<ConfigSpec>,
156 hints: Vec<(Expr, Expr)>,
157}
158
159#[derive(Default)]
160struct CommonConfig {
161 name: Option<Expr>,
162 default: Option<Expr>,
163 title: Option<Expr>,
164 description: Option<Expr>,
165 hide_title: bool,
166 hidden: bool,
167 readonly: bool,
168 detail: bool,
169}
170
171struct CustomConfig {
172 name: Expr,
173 default: Expr,
174 type_: Expr,
175 title: Option<Expr>,
176 description: Option<Expr>,
177 hide_title: bool,
178 hidden: bool,
179 readonly: bool,
180 detail: bool,
181}
182
183enum ConfigSpec {
184 Unit(CommonConfig),
185 Boolean(CommonConfig),
186 Integer(CommonConfig),
187 Number(CommonConfig),
188 String(CommonConfig),
189 Text(CommonConfig),
190 Array(CommonConfig),
191 Object(CommonConfig),
192 Custom(CustomConfig),
193}
194
195fn expand_modular_agent(
196 args: Punctuated<Meta, Comma>,
197 item: ItemStruct,
198) -> syn::Result<proc_macro2::TokenStream> {
199 let has_data_field = item.fields.iter().any(|f| match (&f.ident, &f.ty) {
200 (Some(ident), Type::Path(tp)) if ident == "data" => tp
201 .path
202 .segments
203 .last()
204 .map(|seg| seg.ident == "AgentData")
205 .unwrap_or(false),
206 _ => false,
207 });
208
209 if !has_data_field {
210 return Err(syn::Error::new(
211 item.span(),
212 "#[modular_agent] expects the struct to have a `data: AgentData` field",
213 ));
214 }
215
216 let mut parsed = AgentArgs {
217 kind: None,
218 name: None,
219 title: None,
220 hide_title: false,
221 description: None,
222 category: None,
223 inputs: Vec::new(),
224 outputs: Vec::new(),
225 configs: Vec::new(),
226 global_configs: Vec::new(),
227 hints: Vec::new(),
228 };
229
230 for meta in args {
231 match meta {
232 Meta::NameValue(nv) if nv.path.is_ident("kind") => {
233 parsed.kind = Some(nv.value);
234 }
235 Meta::NameValue(nv) if nv.path.is_ident("name") => {
236 parsed.name = Some(nv.value);
237 }
238 Meta::NameValue(nv) if nv.path.is_ident("title") => {
239 parsed.title = Some(nv.value);
240 }
241 Meta::Path(p) if p.is_ident("hide_title") => {
242 parsed.hide_title = true;
243 }
244 Meta::NameValue(nv) if nv.path.is_ident("description") => {
245 parsed.description = Some(nv.value);
246 }
247 Meta::NameValue(nv) if nv.path.is_ident("category") => {
248 parsed.category = Some(nv.value);
249 }
250 Meta::NameValue(nv) if nv.path.is_ident("inputs") => {
251 parsed.inputs = parse_expr_array(nv.value)?;
252 }
253 Meta::NameValue(nv) if nv.path.is_ident("outputs") => {
254 parsed.outputs = parse_expr_array(nv.value)?;
255 }
256 Meta::List(ml) if ml.path.is_ident("inputs") => {
257 parsed.inputs = collect_exprs(ml)?;
258 }
259 Meta::List(ml) if ml.path.is_ident("outputs") => {
260 parsed.outputs = collect_exprs(ml)?;
261 }
262 Meta::List(ml) if ml.path.is_ident("string_config") => {
263 parsed
264 .configs
265 .push(ConfigSpec::String(parse_common_config(ml)?));
266 }
267 Meta::List(ml) if ml.path.is_ident("text_config") => {
268 parsed
269 .configs
270 .push(ConfigSpec::Text(parse_common_config(ml)?));
271 }
272 Meta::List(ml) if ml.path.is_ident("array_config") => {
273 parsed
274 .configs
275 .push(ConfigSpec::Array(parse_common_config(ml)?));
276 }
277 Meta::List(ml) if ml.path.is_ident("boolean_config") => {
278 parsed
279 .configs
280 .push(ConfigSpec::Boolean(parse_common_config(ml)?));
281 }
282 Meta::List(ml) if ml.path.is_ident("integer_config") => {
283 parsed
284 .configs
285 .push(ConfigSpec::Integer(parse_common_config(ml)?));
286 }
287 Meta::List(ml) if ml.path.is_ident("number_config") => {
288 parsed
289 .configs
290 .push(ConfigSpec::Number(parse_common_config(ml)?));
291 }
292 Meta::List(ml) if ml.path.is_ident("object_config") => {
293 parsed
294 .configs
295 .push(ConfigSpec::Object(parse_common_config(ml)?));
296 }
297 Meta::List(ml) if ml.path.is_ident("custom_config") => {
298 parsed
299 .configs
300 .push(ConfigSpec::Custom(parse_custom_config(ml)?));
301 }
302 Meta::List(ml) if ml.path.is_ident("unit_config") => {
303 parsed
304 .configs
305 .push(ConfigSpec::Unit(parse_common_config(ml)?));
306 }
307 Meta::List(ml) if ml.path.is_ident("string_global_config") => {
308 parsed
309 .global_configs
310 .push(ConfigSpec::String(parse_common_config(ml)?));
311 }
312 Meta::List(ml) if ml.path.is_ident("text_global_config") => {
313 parsed
314 .global_configs
315 .push(ConfigSpec::Text(parse_common_config(ml)?));
316 }
317 Meta::List(ml) if ml.path.is_ident("boolean_global_config") => {
318 parsed
319 .global_configs
320 .push(ConfigSpec::Boolean(parse_common_config(ml)?));
321 }
322 Meta::List(ml) if ml.path.is_ident("array_global_config") => {
323 parsed
324 .global_configs
325 .push(ConfigSpec::Array(parse_common_config(ml)?));
326 }
327 Meta::List(ml) if ml.path.is_ident("integer_global_config") => {
328 parsed
329 .global_configs
330 .push(ConfigSpec::Integer(parse_common_config(ml)?));
331 }
332 Meta::List(ml) if ml.path.is_ident("number_global_config") => {
333 parsed
334 .global_configs
335 .push(ConfigSpec::Number(parse_common_config(ml)?));
336 }
337 Meta::List(ml) if ml.path.is_ident("object_global_config") => {
338 parsed
339 .global_configs
340 .push(ConfigSpec::Object(parse_common_config(ml)?));
341 }
342 Meta::List(ml) if ml.path.is_ident("custom_global_config") => {
343 parsed
344 .global_configs
345 .push(ConfigSpec::Custom(parse_custom_config(ml)?));
346 }
347 Meta::List(ml) if ml.path.is_ident("unit_global_config") => {
348 parsed
349 .global_configs
350 .push(ConfigSpec::Unit(parse_common_config(ml)?));
351 }
352 Meta::List(ml) if ml.path.is_ident("hint") => {
353 parsed.hints.extend(parse_hint_pairs(ml)?);
354 }
355 other => {
356 return Err(syn::Error::new_spanned(
357 other,
358 "unsupported modular_agent argument",
359 ));
360 }
361 }
362 }
363
364 if parsed.description.is_none() {
366 if let Some(doc) = extract_doc_comment(&item.attrs) {
367 let lit = syn::LitStr::new(&doc, Span::call_site());
368 parsed.description = Some(parse_quote! { #lit });
369 }
370 }
371
372 let ident = &item.ident;
373 let generics = item.generics.clone();
374 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
375 let data_impl = quote! {
376 impl #impl_generics ::modular_agent_core::HasAgentData for #ident #ty_generics #where_clause {
377 fn data(&self) -> &::modular_agent_core::AgentData {
378 &self.data
379 }
380
381 fn mut_data(&mut self) -> &mut ::modular_agent_core::AgentData {
382 &mut self.data
383 }
384 }
385 };
386
387 let kind = parsed.kind.unwrap_or_else(|| parse_quote! { "Agent" });
388 let name_tokens = parsed.name.map(|n| quote! { #n }).unwrap_or_else(|| {
389 quote! { concat!(module_path!(), "::", stringify!(#ident)) }
390 });
391
392 let title = parsed
393 .title
394 .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `title`"))?;
395 let category = parsed
396 .category
397 .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `category`"))?;
398 let title = quote! { .title(#title) };
399 let hide_title = if parsed.hide_title {
400 quote! { .hide_title() }
401 } else {
402 quote! {}
403 };
404 let description = parsed.description.map(|d| quote! { .description(#d) });
405 let category = quote! { .category(#category) };
406
407 let inputs = if parsed.inputs.is_empty() {
408 quote! {}
409 } else {
410 let values = parsed.inputs;
411 quote! { .inputs(vec![#(#values),*]) }
412 };
413
414 let outputs = if parsed.outputs.is_empty() {
415 quote! {}
416 } else {
417 let values = parsed.outputs;
418 quote! { .outputs(vec![#(#values),*]) }
419 };
420
421 let config_calls = parsed
422 .configs
423 .into_iter()
424 .map(|cfg| match cfg {
425 ConfigSpec::Unit(c) => {
426 let name = c.name.ok_or_else(|| {
427 syn::Error::new(Span::call_site(), "unit_config missing `name`")
428 })?;
429 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
430 let description = c
431 .description
432 .map(|d| quote! { let entry = entry.description(#d); });
433 let hide_title = if c.hide_title {
434 quote! { let entry = entry.hide_title(); }
435 } else {
436 quote! {}
437 };
438 let hidden = if c.hidden {
439 quote! { let entry = entry.hidden(); }
440 } else {
441 quote! {}
442 };
443 let readonly = if c.readonly {
444 quote! { let entry = entry.readonly(); }
445 } else {
446 quote! {}
447 };
448 let detail = if c.detail {
449 quote! { let entry = entry.detail(); }
450 } else {
451 quote! {}
452 };
453 Ok(quote! {
454 .unit_config_with(#name, |entry| {
455 let entry = entry;
456 #title
457 #description
458 #hide_title
459 #hidden
460 #readonly
461 #detail
462 entry
463 })
464 })
465 }
466 ConfigSpec::Boolean(c) => {
467 let name = c.name.ok_or_else(|| {
468 syn::Error::new(Span::call_site(), "boolean_config missing `name`")
469 })?;
470 let default = c.default.unwrap_or_else(|| parse_quote! { false });
471 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
472 let description = c
473 .description
474 .map(|d| quote! { let entry = entry.description(#d); });
475 let hide_title = if c.hide_title {
476 quote! { let entry = entry.hide_title(); }
477 } else {
478 quote! {}
479 };
480 let hidden = if c.hidden {
481 quote! { let entry = entry.hidden(); }
482 } else {
483 quote! {}
484 };
485 let readonly = if c.readonly {
486 quote! { let entry = entry.readonly(); }
487 } else {
488 quote! {}
489 };
490 let detail = if c.detail {
491 quote! { let entry = entry.detail(); }
492 } else {
493 quote! {}
494 };
495 Ok(quote! {
496 .boolean_config_with(#name, #default, |entry| {
497 let entry = entry;
498 #title
499 #description
500 #hide_title
501 #hidden
502 #readonly
503 #detail
504 entry
505 })
506 })
507 }
508 ConfigSpec::Integer(c) => {
509 let name = c.name.ok_or_else(|| {
510 syn::Error::new(Span::call_site(), "integer_config missing `name`")
511 })?;
512 let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
513 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
514 let description = c
515 .description
516 .map(|d| quote! { let entry = entry.description(#d); });
517 let hide_title = if c.hide_title {
518 quote! { let entry = entry.hide_title(); }
519 } else {
520 quote! {}
521 };
522 let hidden = if c.hidden {
523 quote! { let entry = entry.hidden(); }
524 } else {
525 quote! {}
526 };
527 let readonly = if c.readonly {
528 quote! { let entry = entry.readonly(); }
529 } else {
530 quote! {}
531 };
532 let detail = if c.detail {
533 quote! { let entry = entry.detail(); }
534 } else {
535 quote! {}
536 };
537 Ok(quote! {
538 .integer_config_with(#name, #default, |entry| {
539 let entry = entry;
540 #title
541 #description
542 #hide_title
543 #hidden
544 #readonly
545 #detail
546 entry
547 })
548 })
549 }
550 ConfigSpec::Number(c) => {
551 let name = c.name.ok_or_else(|| {
552 syn::Error::new(Span::call_site(), "number_config missing `name`")
553 })?;
554 let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
555 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
556 let description = c
557 .description
558 .map(|d| quote! { let entry = entry.description(#d); });
559 let hide_title = if c.hide_title {
560 quote! { let entry = entry.hide_title(); }
561 } else {
562 quote! {}
563 };
564 let hidden = if c.hidden {
565 quote! { let entry = entry.hidden(); }
566 } else {
567 quote! {}
568 };
569 let readonly = if c.readonly {
570 quote! { let entry = entry.readonly(); }
571 } else {
572 quote! {}
573 };
574 let detail = if c.detail {
575 quote! { let entry = entry.detail(); }
576 } else {
577 quote! {}
578 };
579 Ok(quote! {
580 .number_config_with(#name, #default, |entry| {
581 let entry = entry;
582 #title
583 #description
584 #hide_title
585 #hidden
586 #readonly
587 #detail
588 entry
589 })
590 })
591 }
592 ConfigSpec::String(c) => {
593 let name = c.name.ok_or_else(|| {
594 syn::Error::new(Span::call_site(), "string_config missing `name`")
595 })?;
596 let default = c.default.unwrap_or_else(|| parse_quote! { "" });
597 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
598 let description = c
599 .description
600 .map(|d| quote! { let entry = entry.description(#d); });
601 let hide_title = if c.hide_title {
602 quote! { let entry = entry.hide_title(); }
603 } else {
604 quote! {}
605 };
606 let hidden = if c.hidden {
607 quote! { let entry = entry.hidden(); }
608 } else {
609 quote! {}
610 };
611 let readonly = if c.readonly {
612 quote! { let entry = entry.readonly(); }
613 } else {
614 quote! {}
615 };
616 let detail = if c.detail {
617 quote! { let entry = entry.detail(); }
618 } else {
619 quote! {}
620 };
621 Ok(quote! {
622 .string_config_with(#name, #default, |entry| {
623 let entry = entry;
624 #title
625 #description
626 #hide_title
627 #hidden
628 #readonly
629 #detail
630 entry
631 })
632 })
633 }
634 ConfigSpec::Text(c) => {
635 let name = c.name.ok_or_else(|| {
636 syn::Error::new(Span::call_site(), "text_config missing `name`")
637 })?;
638 let default = c.default.unwrap_or_else(|| parse_quote! { "" });
639 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
640 let description = c
641 .description
642 .map(|d| quote! { let entry = entry.description(#d); });
643 let hide_title = if c.hide_title {
644 quote! { let entry = entry.hide_title(); }
645 } else {
646 quote! {}
647 };
648 let hidden = if c.hidden {
649 quote! { let entry = entry.hidden(); }
650 } else {
651 quote! {}
652 };
653 let readonly = if c.readonly {
654 quote! { let entry = entry.readonly(); }
655 } else {
656 quote! {}
657 };
658 let detail = if c.detail {
659 quote! { let entry = entry.detail(); }
660 } else {
661 quote! {}
662 };
663 Ok(quote! {
664 .text_config_with(#name, #default, |entry| {
665 let entry = entry;
666 #title
667 #description
668 #hide_title
669 #hidden
670 #readonly
671 #detail
672 entry
673 })
674 })
675 }
676 ConfigSpec::Array(c) => {
677 let name = c.name.ok_or_else(|| {
678 syn::Error::new(Span::call_site(), "array_config missing `name`")
679 })?;
680 let default = c.default.unwrap_or_else(|| {
681 parse_quote! { ::modular_agent_core::AgentValue::array_default() }
682 });
683 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
684 let description = c
685 .description
686 .map(|d| quote! { let entry = entry.description(#d); });
687 let hide_title = if c.hide_title {
688 quote! { let entry = entry.hide_title(); }
689 } else {
690 quote! {}
691 };
692 let hidden = if c.hidden {
693 quote! { let entry = entry.hidden(); }
694 } else {
695 quote! {}
696 };
697 let readonly = if c.readonly {
698 quote! { let entry = entry.readonly(); }
699 } else {
700 quote! {}
701 };
702 let detail = if c.detail {
703 quote! { let entry = entry.detail(); }
704 } else {
705 quote! {}
706 };
707 Ok(quote! {
708 .array_config_with(#name, #default, |entry| {
709 let entry = entry;
710 #title
711 #description
712 #hide_title
713 #hidden
714 #readonly
715 #detail
716 entry
717 })
718 })
719 }
720 ConfigSpec::Object(c) => {
721 let name = c.name.ok_or_else(|| {
722 syn::Error::new(Span::call_site(), "object_config missing `name`")
723 })?;
724 let default = c.default.unwrap_or_else(|| {
725 parse_quote! { ::modular_agent_core::AgentValue::object_default() }
726 });
727 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
728 let description = c
729 .description
730 .map(|d| quote! { let entry = entry.description(#d); });
731 let hide_title = if c.hide_title {
732 quote! { let entry = entry.hide_title(); }
733 } else {
734 quote! {}
735 };
736 let hidden = if c.hidden {
737 quote! { let entry = entry.hidden(); }
738 } else {
739 quote! {}
740 };
741 let readonly = if c.readonly {
742 quote! { let entry = entry.readonly(); }
743 } else {
744 quote! {}
745 };
746 let detail = if c.detail {
747 quote! { let entry = entry.detail(); }
748 } else {
749 quote! {}
750 };
751 Ok(quote! {
752 .object_config_with(#name, #default, |entry| {
753 let entry = entry;
754 #title
755 #description
756 #hide_title
757 #hidden
758 #readonly
759 #detail
760 entry
761 })
762 })
763 }
764 ConfigSpec::Custom(c) => custom_config_call("custom_config_with", c),
765 })
766 .collect::<syn::Result<Vec<_>>>()?;
767
768 let global_config_calls = parsed
769 .global_configs
770 .into_iter()
771 .map(|cfg| match cfg {
772 ConfigSpec::Unit(c) => {
773 let name = c.name.ok_or_else(|| {
774 syn::Error::new(Span::call_site(), "unit_global_config missing `name`")
775 })?;
776 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
777 let description = c
778 .description
779 .map(|d| quote! { let entry = entry.description(#d); });
780 let hide_title = if c.hide_title {
781 quote! { let entry = entry.hide_title(); }
782 } else {
783 quote! {}
784 };
785 let hidden = if c.hidden {
786 quote! { let entry = entry.hidden(); }
787 } else {
788 quote! {}
789 };
790 let readonly = if c.readonly {
791 quote! { let entry = entry.readonly(); }
792 } else {
793 quote! {}
794 };
795 let detail = if c.detail {
796 quote! { let entry = entry.detail(); }
797 } else {
798 quote! {}
799 };
800 Ok(quote! {
801 .unit_global_config_with(#name, |entry| {
802 let entry = entry;
803 #title
804 #description
805 #hide_title
806 #hidden
807 #readonly
808 #detail
809 entry
810 })
811 })
812 }
813 ConfigSpec::Boolean(c) => {
814 let name = c.name.ok_or_else(|| {
815 syn::Error::new(Span::call_site(), "boolean_global_config missing `name`")
816 })?;
817 let default = c.default.unwrap_or_else(|| parse_quote! { false });
818 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
819 let description = c
820 .description
821 .map(|d| quote! { let entry = entry.description(#d); });
822 let hide_title = if c.hide_title {
823 quote! { let entry = entry.hide_title(); }
824 } else {
825 quote! {}
826 };
827 let hidden = if c.hidden {
828 quote! { let entry = entry.hidden(); }
829 } else {
830 quote! {}
831 };
832 let readonly = if c.readonly {
833 quote! { let entry = entry.readonly(); }
834 } else {
835 quote! {}
836 };
837 let detail = if c.detail {
838 quote! { let entry = entry.detail(); }
839 } else {
840 quote! {}
841 };
842 Ok(quote! {
843 .boolean_global_config_with(#name, #default, |entry| {
844 let entry = entry;
845 #title
846 #description
847 #hide_title
848 #hidden
849 #readonly
850 #detail
851 entry
852 })
853 })
854 }
855 ConfigSpec::Integer(c) => {
856 let name = c.name.ok_or_else(|| {
857 syn::Error::new(Span::call_site(), "integer_global_config missing `name`")
858 })?;
859 let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
860 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
861 let description = c
862 .description
863 .map(|d| quote! { let entry = entry.description(#d); });
864 let hide_title = if c.hide_title {
865 quote! { let entry = entry.hide_title(); }
866 } else {
867 quote! {}
868 };
869 let hidden = if c.hidden {
870 quote! { let entry = entry.hidden(); }
871 } else {
872 quote! {}
873 };
874 let readonly = if c.readonly {
875 quote! { let entry = entry.readonly(); }
876 } else {
877 quote! {}
878 };
879 let detail = if c.detail {
880 quote! { let entry = entry.detail(); }
881 } else {
882 quote! {}
883 };
884 Ok(quote! {
885 .integer_global_config_with(#name, #default, |entry| {
886 let entry = entry;
887 #title
888 #description
889 #hide_title
890 #hidden
891 #readonly
892 #detail
893 entry
894 })
895 })
896 }
897 ConfigSpec::Number(c) => {
898 let name = c.name.ok_or_else(|| {
899 syn::Error::new(Span::call_site(), "number_global_config missing `name`")
900 })?;
901 let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
902 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
903 let description = c
904 .description
905 .map(|d| quote! { let entry = entry.description(#d); });
906 let hide_title = if c.hide_title {
907 quote! { let entry = entry.hide_title(); }
908 } else {
909 quote! {}
910 };
911 let hidden = if c.hidden {
912 quote! { let entry = entry.hidden(); }
913 } else {
914 quote! {}
915 };
916 let readonly = if c.readonly {
917 quote! { let entry = entry.readonly(); }
918 } else {
919 quote! {}
920 };
921 let detail = if c.detail {
922 quote! { let entry = entry.detail(); }
923 } else {
924 quote! {}
925 };
926 Ok(quote! {
927 .number_global_config_with(#name, #default, |entry| {
928 let entry = entry;
929 #title
930 #description
931 #hide_title
932 #hidden
933 #readonly
934 #detail
935 entry
936 })
937 })
938 }
939 ConfigSpec::String(c) => {
940 let name = c.name.ok_or_else(|| {
941 syn::Error::new(Span::call_site(), "string_global_config missing `name`")
942 })?;
943 let default = c.default.unwrap_or_else(|| parse_quote! { "" });
944 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
945 let description = c
946 .description
947 .map(|d| quote! { let entry = entry.description(#d); });
948 let hide_title = if c.hide_title {
949 quote! { let entry = entry.hide_title(); }
950 } else {
951 quote! {}
952 };
953 let hidden = if c.hidden {
954 quote! { let entry = entry.hidden(); }
955 } else {
956 quote! {}
957 };
958 let readonly = if c.readonly {
959 quote! { let entry = entry.readonly(); }
960 } else {
961 quote! {}
962 };
963 let detail = if c.detail {
964 quote! { let entry = entry.detail(); }
965 } else {
966 quote! {}
967 };
968 Ok(quote! {
969 .string_global_config_with(#name, #default, |entry| {
970 let entry = entry;
971 #title
972 #description
973 #hide_title
974 #hidden
975 #readonly
976 #detail
977 entry
978 })
979 })
980 }
981 ConfigSpec::Text(c) => {
982 let name = c.name.ok_or_else(|| {
983 syn::Error::new(Span::call_site(), "text_global_config missing `name`")
984 })?;
985 let default = c.default.unwrap_or_else(|| parse_quote! { "" });
986 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
987 let description = c
988 .description
989 .map(|d| quote! { let entry = entry.description(#d); });
990 let hide_title = if c.hide_title {
991 quote! { let entry = entry.hide_title(); }
992 } else {
993 quote! {}
994 };
995 let hidden = if c.hidden {
996 quote! { let entry = entry.hidden(); }
997 } else {
998 quote! {}
999 };
1000 let readonly = if c.readonly {
1001 quote! { let entry = entry.readonly(); }
1002 } else {
1003 quote! {}
1004 };
1005 let detail = if c.detail {
1006 quote! { let entry = entry.detail(); }
1007 } else {
1008 quote! {}
1009 };
1010 Ok(quote! {
1011 .text_global_config_with(#name, #default, |entry| {
1012 let entry = entry;
1013 #title
1014 #description
1015 #hide_title
1016 #hidden
1017 #readonly
1018 #detail
1019 entry
1020 })
1021 })
1022 }
1023 ConfigSpec::Array(c) => {
1024 let name = c.name.ok_or_else(|| {
1025 syn::Error::new(Span::call_site(), "array_global_config missing `name`")
1026 })?;
1027 let default = c.default.unwrap_or_else(|| {
1028 parse_quote! { ::modular_agent_core::AgentValue::array_default() }
1029 });
1030 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
1031 let description = c
1032 .description
1033 .map(|d| quote! { let entry = entry.description(#d); });
1034 let hide_title = if c.hide_title {
1035 quote! { let entry = entry.hide_title(); }
1036 } else {
1037 quote! {}
1038 };
1039 let hidden = if c.hidden {
1040 quote! { let entry = entry.hidden(); }
1041 } else {
1042 quote! {}
1043 };
1044 let readonly = if c.readonly {
1045 quote! { let entry = entry.readonly(); }
1046 } else {
1047 quote! {}
1048 };
1049 let detail = if c.detail {
1050 quote! { let entry = entry.detail(); }
1051 } else {
1052 quote! {}
1053 };
1054 Ok(quote! {
1055 .array_global_config_with(#name, #default, |entry| {
1056 let entry = entry;
1057 #title
1058 #description
1059 #hide_title
1060 #hidden
1061 #readonly
1062 #detail
1063 entry
1064 })
1065 })
1066 }
1067 ConfigSpec::Object(c) => {
1068 let name = c.name.ok_or_else(|| {
1069 syn::Error::new(Span::call_site(), "object_global_config missing `name`")
1070 })?;
1071 let default = c.default.unwrap_or_else(|| {
1072 parse_quote! { ::modular_agent_core::AgentValue::object_default() }
1073 });
1074 let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
1075 let description = c
1076 .description
1077 .map(|d| quote! { let entry = entry.description(#d); });
1078 let hide_title = if c.hide_title {
1079 quote! { let entry = entry.hide_title(); }
1080 } else {
1081 quote! {}
1082 };
1083 let hidden = if c.hidden {
1084 quote! { let entry = entry.hidden(); }
1085 } else {
1086 quote! {}
1087 };
1088 let readonly = if c.readonly {
1089 quote! { let entry = entry.readonly(); }
1090 } else {
1091 quote! {}
1092 };
1093 let detail = if c.detail {
1094 quote! { let entry = entry.detail(); }
1095 } else {
1096 quote! {}
1097 };
1098 Ok(quote! {
1099 .object_global_config_with(#name, #default, |entry| {
1100 let entry = entry;
1101 #title
1102 #description
1103 #hide_title
1104 #hidden
1105 #readonly
1106 #detail
1107 entry
1108 })
1109 })
1110 }
1111 ConfigSpec::Custom(c) => custom_config_call("custom_global_config_with", c),
1112 })
1113 .collect::<syn::Result<Vec<_>>>()?;
1114
1115 let hint_calls: Vec<_> = parsed
1116 .hints
1117 .iter()
1118 .map(|(key, value)| {
1119 quote! { .hint(#key, #value) }
1120 })
1121 .collect();
1122
1123 let definition_builder = quote! {
1124 ::modular_agent_core::AgentDefinition::new(
1125 #kind,
1126 #name_tokens,
1127 Some(::modular_agent_core::new_agent_boxed::<#ident>),
1128 )
1129 #title
1130 #hide_title
1131 #description
1132 #category
1133 #inputs
1134 #outputs
1135 #(#config_calls)*
1136 #(#global_config_calls)*
1137 #(#hint_calls)*
1138 };
1139
1140 let expanded = quote! {
1141 #item
1142
1143 #data_impl
1144
1145 impl #impl_generics #ident #ty_generics #where_clause {
1146 pub const DEF_NAME: &'static str = #name_tokens;
1147
1148 pub fn def_name() -> &'static str { Self::DEF_NAME }
1149
1150 pub fn agent_definition() -> ::modular_agent_core::AgentDefinition {
1151 #definition_builder
1152 }
1153
1154 pub fn register(ma: &::modular_agent_core::ModularAgent) {
1155 ma.register_agent_definiton(Self::agent_definition());
1156 }
1157 }
1158
1159 ::modular_agent_core::inventory::submit! {
1160 ::modular_agent_core::AgentRegistration {
1161 build: || #definition_builder,
1162 }
1163 }
1164 };
1165
1166 Ok(expanded)
1167}
1168
1169fn parse_name_type_title_description(
1170 meta: &Meta,
1171 name: &mut Option<Expr>,
1172 type_: &mut Option<Expr>,
1173 title: &mut Option<Expr>,
1174 description: &mut Option<Expr>,
1175) -> bool {
1176 match meta {
1177 Meta::NameValue(nv) if nv.path.is_ident("name") => {
1178 *name = Some(nv.value.clone());
1179 true
1180 }
1181 Meta::NameValue(nv) if nv.path.is_ident("type") => {
1182 *type_ = Some(nv.value.clone());
1183 true
1184 }
1185 Meta::NameValue(nv) if nv.path.is_ident("type_") => {
1186 *type_ = Some(nv.value.clone());
1187 true
1188 }
1189 Meta::NameValue(nv) if nv.path.is_ident("title") => {
1190 *title = Some(nv.value.clone());
1191 true
1192 }
1193 Meta::NameValue(nv) if nv.path.is_ident("description") => {
1194 *description = Some(nv.value.clone());
1195 true
1196 }
1197 _ => false,
1198 }
1199}
1200
1201fn parse_custom_config(list: MetaList) -> syn::Result<CustomConfig> {
1202 let mut name = None;
1203 let mut default = None;
1204 let mut type_ = None;
1205 let mut title = None;
1206 let mut description = None;
1207 let mut hide_title = false;
1208 let mut hidden = false;
1209 let mut readonly = false;
1210 let mut detail = false;
1211 let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1212
1213 for meta in nested {
1214 if parse_name_type_title_description(
1215 &meta,
1216 &mut name,
1217 &mut type_,
1218 &mut title,
1219 &mut description,
1220 ) {
1221 continue;
1222 }
1223
1224 match meta {
1225 Meta::NameValue(nv) if nv.path.is_ident("default") => {
1226 default = Some(nv.value.clone());
1227 }
1228 Meta::Path(p) if p.is_ident("hide_title") => {
1229 hide_title = true;
1230 }
1231 Meta::Path(p) if p.is_ident("hidden") => {
1232 hidden = true;
1233 }
1234 Meta::Path(p) if p.is_ident("readonly") => {
1235 readonly = true;
1236 }
1237 Meta::Path(p) if p.is_ident("detail") => {
1238 detail = true;
1239 }
1240 other => {
1241 return Err(syn::Error::new_spanned(
1242 other,
1243 "custom_config supports name, default, type/type_, title, description, hide_title, hidden, readonly, detail",
1244 ));
1245 }
1246 }
1247 }
1248
1249 let name = name.ok_or_else(|| syn::Error::new(list.span(), "config missing `name`"))?;
1250 if let Expr::Lit(ref lit) = name
1252 && let syn::Lit::Str(ref s) = lit.lit
1253 && s.value().starts_with('_')
1254 {
1255 return Err(syn::Error::new_spanned(
1256 &name,
1257 "config name must not start with `_` (reserved for stale key migration)",
1258 ));
1259 }
1260 let default =
1261 default.ok_or_else(|| syn::Error::new(list.span(), "config missing `default`"))?;
1262 let type_ = type_.ok_or_else(|| syn::Error::new(list.span(), "config missing `type`"))?;
1263
1264 Ok(CustomConfig {
1265 name,
1266 default,
1267 type_,
1268 title,
1269 description,
1270 hide_title,
1271 hidden,
1272 readonly,
1273 detail,
1274 })
1275}
1276
1277fn collect_exprs(list: MetaList) -> syn::Result<Vec<Expr>> {
1278 let values = list.parse_args_with(Punctuated::<Expr, Comma>::parse_terminated)?;
1279 Ok(values.into_iter().collect())
1280}
1281
1282fn parse_expr_array(expr: Expr) -> syn::Result<Vec<Expr>> {
1283 if let Expr::Array(arr) = expr {
1284 Ok(arr.elems.into_iter().collect())
1285 } else {
1286 Err(syn::Error::new_spanned(
1287 expr,
1288 "inputs/outputs expect array expressions",
1289 ))
1290 }
1291}
1292
1293fn parse_common_config(list: MetaList) -> syn::Result<CommonConfig> {
1294 let mut cfg = CommonConfig::default();
1295 let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1296
1297 for meta in nested {
1298 match meta {
1299 Meta::NameValue(nv) if nv.path.is_ident("name") => {
1300 if let Expr::Lit(ref lit) = nv.value
1302 && let syn::Lit::Str(ref s) = lit.lit
1303 && s.value().starts_with('_')
1304 {
1305 return Err(syn::Error::new_spanned(
1306 &nv.value,
1307 "config name must not start with `_` (reserved for stale key migration)",
1308 ));
1309 }
1310 cfg.name = Some(nv.value.clone());
1311 }
1312 Meta::NameValue(nv) if nv.path.is_ident("default") => {
1313 cfg.default = Some(nv.value.clone());
1314 }
1315 Meta::NameValue(nv) if nv.path.is_ident("title") => {
1316 cfg.title = Some(nv.value.clone());
1317 }
1318 Meta::NameValue(nv) if nv.path.is_ident("description") => {
1319 cfg.description = Some(nv.value.clone());
1320 }
1321 Meta::Path(p) if p.is_ident("hide_title") => {
1322 cfg.hide_title = true;
1323 }
1324 Meta::Path(p) if p.is_ident("hidden") => {
1325 cfg.hidden = true;
1326 }
1327 Meta::Path(p) if p.is_ident("readonly") => {
1328 cfg.readonly = true;
1329 }
1330 Meta::Path(p) if p.is_ident("detail") => {
1331 cfg.detail = true;
1332 }
1333 other => {
1334 return Err(syn::Error::new_spanned(
1335 other,
1336 "config supports name, default, title, description, hide_title, hidden, readonly, detail",
1337 ));
1338 }
1339 }
1340 }
1341
1342 if cfg.name.is_none() {
1343 return Err(syn::Error::new(list.span(), "config missing `name`"));
1344 }
1345 Ok(cfg)
1346}
1347
1348fn parse_hint_pairs(list: MetaList) -> syn::Result<Vec<(Expr, Expr)>> {
1349 let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1350 let mut pairs = Vec::new();
1351 for meta in nested {
1352 match meta {
1353 Meta::NameValue(nv) => {
1354 let key = nv
1355 .path
1356 .get_ident()
1357 .ok_or_else(|| {
1358 syn::Error::new_spanned(
1359 &nv.path,
1360 "hint key must be a simple identifier",
1361 )
1362 })?;
1363 let key_str = key.to_string();
1364 let key_lit = syn::LitStr::new(&key_str, key.span());
1365 pairs.push((parse_quote!(#key_lit), nv.value));
1366 }
1367 other => {
1368 return Err(syn::Error::new_spanned(
1369 other,
1370 "hint expects `key = value` pairs (e.g., `hint(color = 3)`)",
1371 ));
1372 }
1373 }
1374 }
1375 Ok(pairs)
1376}
1377
1378fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
1379 let lines: Vec<String> = attrs
1380 .iter()
1381 .filter_map(|attr| {
1382 if !attr.path().is_ident("doc") {
1383 return None;
1384 }
1385 if let Meta::NameValue(nv) = &attr.meta {
1386 if let Expr::Lit(lit) = &nv.value {
1387 if let syn::Lit::Str(s) = &lit.lit {
1388 return Some(s.value());
1389 }
1390 }
1391 }
1392 None
1393 })
1394 .collect();
1395 if lines.is_empty() {
1396 return None;
1397 }
1398 let text = lines
1400 .iter()
1401 .map(|l| l.strip_prefix(' ').unwrap_or(l))
1402 .collect::<Vec<_>>()
1403 .join("\n")
1404 .trim()
1405 .to_string();
1406 if text.is_empty() { None } else { Some(text) }
1407}
1408
1409fn custom_config_call(method: &str, cfg: CustomConfig) -> syn::Result<proc_macro2::TokenStream> {
1410 let CustomConfig {
1411 name,
1412 default,
1413 type_,
1414 title,
1415 description,
1416 hide_title,
1417 hidden,
1418 readonly,
1419 detail,
1420 } = cfg;
1421 let title = title.map(|t| quote! { let entry = entry.title(#t); });
1422 let description = description.map(|d| quote! { let entry = entry.description(#d); });
1423 let hide_title = if hide_title {
1424 quote! { let entry = entry.hide_title(); }
1425 } else {
1426 quote! {}
1427 };
1428 let hidden = if hidden {
1429 quote! { let entry = entry.hidden(); }
1430 } else {
1431 quote! {}
1432 };
1433 let readonly = if readonly {
1434 quote! { let entry = entry.readonly(); }
1435 } else {
1436 quote! {}
1437 };
1438 let detail = if detail {
1439 quote! { let entry = entry.detail(); }
1440 } else {
1441 quote! {}
1442 };
1443 let method_ident = format_ident!("{}", method);
1444
1445 Ok(quote! {
1446 .#method_ident(#name, #default, #type_, |entry| {
1447 let entry = entry;
1448 #title
1449 #description
1450 #hide_title
1451 #hidden
1452 #readonly
1453 #detail
1454 entry
1455 })
1456 })
1457}