1use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::{format_ident, quote};
12use syn::{Expr, ExprLit, ItemFn, Lit, Meta, Token, parse_macro_input, punctuated::Punctuated};
13
14#[proc_macro_attribute]
19pub fn skill(attr: TokenStream, item: TokenStream) -> TokenStream {
20 let item_fn = parse_macro_input!(item as ItemFn);
21 let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
22 match expand_skill(args, item_fn) {
23 Ok(ts) => ts.into(),
24 Err(e) => e.to_compile_error().into(),
25 }
26}
27
28fn expand_skill(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
29 let fn_ident = item_fn.sig.ident.clone();
30
31 let mut name: Option<String> = None;
32 let mut description: Option<String> = None;
33 let mut license: Option<String> = None;
34 let mut compatibility: Option<String> = None;
35 let mut allowed_tools: Option<String> = None;
36 let mut harness_kind: Option<String> = None;
37 let mut harness_risk: Option<String> = None;
38
39 for meta in &args {
40 match meta {
41 Meta::NameValue(nv) => {
42 let key = nv
43 .path
44 .get_ident()
45 .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected ident"))?
46 .to_string();
47 let value = lit_str(&nv.value)?;
48 match key.as_str() {
49 "name" => name = Some(value),
50 "description" => description = Some(value),
51 "license" => license = Some(value),
52 "compatibility" => compatibility = Some(value),
53 "allowed_tools" => allowed_tools = Some(value),
54 other => return err(nv, format!("unknown attribute `{other}`")),
55 }
56 }
57 Meta::List(ml) if ml.path.is_ident("harness") => {
58 let nested: Punctuated<Meta, Token![,]> =
59 ml.parse_args_with(Punctuated::parse_terminated)?;
60 for m in &nested {
61 if let Meta::NameValue(nv) = m {
62 let k = nv
63 .path
64 .get_ident()
65 .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected ident"))?
66 .to_string();
67 let v = lit_str(&nv.value)?;
68 match k.as_str() {
69 "kind" => harness_kind = Some(v),
70 "risk" => harness_risk = Some(v),
71 other => return err(nv, format!("unknown harness(...) key `{other}`")),
72 }
73 } else {
74 return err(m, "expected key = \"value\"");
75 }
76 }
77 }
78 other => return err(other, "expected `key = \"value\"` or `harness(...)`"),
79 }
80 }
81
82 let name = name.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `name`"))?;
83 validate_skill_name(&name).map_err(|r| syn::Error::new_spanned(&fn_ident, r))?;
84 let description = description
85 .or_else(|| extract_doc_comments(&item_fn.attrs))
86 .ok_or_else(|| {
87 syn::Error::new_spanned(&fn_ident, "missing `description` (or `///` doc-comment)")
88 })?;
89 if description.is_empty() {
90 return err(&fn_ident, "description must not be empty");
91 }
92 if description.len() > 1024 {
93 return err(
94 &fn_ident,
95 format!("description exceeds 1024 chars (got {})", description.len()),
96 );
97 }
98
99 let marker = format_ident!("__Harness_Skill_{}", to_pascal_case(&name));
100 let has_harness_meta = harness_kind.is_some() || harness_risk.is_some();
101 let metadata_tok = if has_harness_meta {
102 let mut json = String::from("{");
103 let mut comma = false;
104 if let Some(k) = &harness_kind {
105 json.push_str(&format!("\"kind\":\"{k}\""));
106 comma = true;
107 }
108 if let Some(r) = &harness_risk {
109 if comma {
110 json.push(',');
111 }
112 json.push_str(&format!("\"risk\":\"{r}\""));
113 }
114 json.push('}');
115 quote! {{
116 let mut m = ::std::collections::BTreeMap::new();
117 let v: ::harness_core::__export::serde_json::Value =
118 ::harness_core::__export::serde_json::from_str(#json).unwrap();
119 m.insert("harness".to_string(), v);
120 m
121 }}
122 } else {
123 quote! { ::std::collections::BTreeMap::new() }
124 };
125
126 let lic_tok = opt_string(license.as_deref());
127 let compat_tok = opt_string(compatibility.as_deref());
128 let allowed_tok = opt_string(allowed_tools.as_deref());
129
130 Ok(quote! {
131 #item_fn
132
133 #[doc(hidden)]
134 #[allow(non_camel_case_types)]
135 pub struct #marker;
136
137 impl ::harness_core::Skill for #marker {
138 fn manifest(&self) -> &::harness_core::SkillManifest {
139 static M: ::std::sync::OnceLock<::harness_core::SkillManifest> = ::std::sync::OnceLock::new();
140 M.get_or_init(|| ::harness_core::SkillManifest {
141 name: #name.to_string(),
142 description: #description.to_string(),
143 license: #lic_tok,
144 compatibility: #compat_tok,
145 metadata: #metadata_tok,
146 allowed_tools: #allowed_tok,
147 })
148 }
149 fn body(&self) -> ::std::borrow::Cow<'_, str> {
150 ::std::borrow::Cow::Borrowed(#description)
151 }
152 fn handler(&self) -> ::std::option::Option<::harness_core::SkillHandler> {
153 ::std::option::Option::Some(::std::sync::Arc::new(|ctx, world| {
154 ::std::boxed::Box::pin(#fn_ident(ctx, world))
155 }))
156 }
157 }
158
159 ::harness_core::__export::inventory::submit! {
160 ::harness_core::SkillEntry {
161 factory: || ::std::sync::Arc::new(#marker)
162 as ::std::sync::Arc<dyn ::harness_core::Skill>,
163 }
164 }
165 })
166}
167
168#[proc_macro_attribute]
173pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
174 let item_fn = parse_macro_input!(item as ItemFn);
175 let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
176 match expand_tool(args, item_fn) {
177 Ok(ts) => ts.into(),
178 Err(e) => e.to_compile_error().into(),
179 }
180}
181
182fn expand_tool(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
183 let fn_ident = item_fn.sig.ident.clone();
184 let mut name: Option<String> = None;
185 let mut description: Option<String> = None;
186 let mut risk: String = "read-only".into();
187 let mut schema: Option<String> = None;
188
189 for meta in &args {
190 if let Meta::NameValue(nv) = meta {
191 let key = nv
192 .path
193 .get_ident()
194 .map(|i| i.to_string())
195 .unwrap_or_default();
196 let value = lit_str(&nv.value)?;
197 match key.as_str() {
198 "name" => name = Some(value),
199 "description" => description = Some(value),
200 "risk" => risk = value,
201 "schema" => schema = Some(value),
202 other => return err(nv, format!("unknown attribute `{other}`")),
203 }
204 } else {
205 return err(meta, "expected `key = \"value\"`");
206 }
207 }
208
209 let name = name.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `name`"))?;
210 let description = description
211 .or_else(|| extract_doc_comments(&item_fn.attrs))
212 .ok_or_else(|| {
213 syn::Error::new_spanned(&fn_ident, "missing `description` (or `///` doc-comment)")
214 })?;
215 let schema = schema.unwrap_or_else(|| r#"{"type":"object"}"#.to_string());
216 if let Err(e) = serde_json::from_str::<serde_json::Value>(&schema) {
218 return err(&fn_ident, format!("schema is not valid JSON: {e}"));
219 }
220 let risk_variant = match risk.as_str() {
221 "read-only" => quote!(::harness_core::ToolRisk::ReadOnly),
222 "idempotent" => quote!(::harness_core::ToolRisk::Idempotent),
223 "destructive" => quote!(::harness_core::ToolRisk::Destructive),
224 "network" => quote!(::harness_core::ToolRisk::Network),
225 other => {
226 return err(
227 &fn_ident,
228 format!(
229 "risk must be one of read-only|idempotent|destructive|network, got `{other}`"
230 ),
231 );
232 }
233 };
234 let marker = format_ident!("__Harness_Tool_{}", to_pascal_case(&name));
235
236 Ok(quote! {
237 #item_fn
238
239 #[doc(hidden)]
240 #[allow(non_camel_case_types)]
241 pub struct #marker;
242
243 #[::harness_core::__export::async_trait]
244 impl ::harness_core::Tool for #marker {
245 fn name(&self) -> &str { #name }
246 fn schema(&self) -> &::harness_core::ToolSchema {
247 static S: ::std::sync::OnceLock<::harness_core::ToolSchema> = ::std::sync::OnceLock::new();
248 S.get_or_init(|| ::harness_core::ToolSchema {
249 name: #name.to_string(),
250 description: #description.to_string(),
251 input: ::harness_core::__export::serde_json::from_str(#schema).unwrap(),
252 })
253 }
254 fn risk(&self) -> ::harness_core::ToolRisk { #risk_variant }
255 async fn invoke(
256 &self,
257 args: ::harness_core::__export::serde_json::Value,
258 world: &mut ::harness_core::World,
259 ) -> ::std::result::Result<::harness_core::ToolResult, ::harness_core::ToolError> {
260 #fn_ident(args, world).await
261 }
262 }
263
264 ::harness_core::__export::inventory::submit! {
265 ::harness_core::ToolEntry {
266 factory: || ::std::sync::Arc::new(#marker)
267 as ::std::sync::Arc<dyn ::harness_core::Tool>,
268 }
269 }
270 })
271}
272
273#[proc_macro_attribute]
278pub fn guide(attr: TokenStream, item: TokenStream) -> TokenStream {
279 let item_fn = parse_macro_input!(item as ItemFn);
280 let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
281 match expand_guide(args, item_fn) {
282 Ok(ts) => ts.into(),
283 Err(e) => e.to_compile_error().into(),
284 }
285}
286
287fn expand_guide(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
288 let fn_ident = item_fn.sig.ident.clone();
289 let mut id: Option<String> = None;
290 let mut scope: String = "always".into();
291 let mut kind: String = "inferential".into();
292 let mut task_matches: Vec<String> = Vec::new();
293
294 for meta in &args {
295 if let Meta::NameValue(nv) = meta {
296 let key = nv
297 .path
298 .get_ident()
299 .map(|i| i.to_string())
300 .unwrap_or_default();
301 let value = lit_str(&nv.value)?;
302 match key.as_str() {
303 "id" => id = Some(value),
304 "scope" => scope = value,
305 "kind" => kind = value,
306 "task_matches" => {
307 task_matches = value.split(',').map(|s| s.trim().to_string()).collect()
308 }
309 other => return err(nv, format!("unknown attribute `{other}`")),
310 }
311 } else {
312 return err(meta, "expected `key = \"value\"`");
313 }
314 }
315 let id = id.unwrap_or_else(|| fn_ident.to_string());
316 let kind_variant = match kind.as_str() {
317 "computational" => quote!(::harness_core::Execution::Computational),
318 "inferential" => quote!(::harness_core::Execution::Inferential),
319 other => {
320 return err(
321 &fn_ident,
322 format!("kind must be computational|inferential, got `{other}`"),
323 );
324 }
325 };
326 let scope_expr = match scope.as_str() {
327 "always" => quote!(::harness_core::GuideScope::Always),
328 "task-matches" if !task_matches.is_empty() => {
329 let items = task_matches.iter().map(|s| quote!(#s.to_string()));
330 quote!(::harness_core::GuideScope::TaskMatches(vec![#(#items),*]))
331 }
332 other => {
333 return err(
334 &fn_ident,
335 format!(
336 "unsupported scope `{other}`; use \"always\" or \"task-matches\" + task_matches=..."
337 ),
338 );
339 }
340 };
341 let marker = format_ident!("__Harness_Guide_{}", to_pascal_case(&id));
342
343 Ok(quote! {
344 #item_fn
345
346 #[doc(hidden)]
347 #[allow(non_camel_case_types)]
348 pub struct #marker;
349
350 #[::harness_core::__export::async_trait]
351 impl ::harness_core::Guide for #marker {
352 fn id(&self) -> &::harness_core::GuideId {
353 static I: ::std::sync::OnceLock<::harness_core::GuideId> = ::std::sync::OnceLock::new();
354 I.get_or_init(|| #id.to_string())
355 }
356 fn kind(&self) -> ::harness_core::Execution { #kind_variant }
357 fn scope(&self) -> &::harness_core::GuideScope {
358 static S: ::std::sync::OnceLock<::harness_core::GuideScope> = ::std::sync::OnceLock::new();
359 S.get_or_init(|| #scope_expr)
360 }
361 async fn apply(
362 &self,
363 ctx: &mut ::harness_core::Context,
364 world: &::harness_core::World,
365 ) -> ::std::result::Result<(), ::harness_core::GuideError> {
366 #fn_ident(ctx, world).await
367 }
368 }
369
370 ::harness_core::__export::inventory::submit! {
371 ::harness_core::GuideEntry {
372 factory: || ::std::sync::Arc::new(#marker)
373 as ::std::sync::Arc<dyn ::harness_core::Guide>,
374 }
375 }
376 })
377}
378
379#[proc_macro_attribute]
384pub fn sensor(attr: TokenStream, item: TokenStream) -> TokenStream {
385 let item_fn = parse_macro_input!(item as ItemFn);
386 let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
387 match expand_sensor(args, item_fn) {
388 Ok(ts) => ts.into(),
389 Err(e) => e.to_compile_error().into(),
390 }
391}
392
393fn expand_sensor(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
394 let fn_ident = item_fn.sig.ident.clone();
395 let mut id: Option<String> = None;
396 let mut stage: String = "self-correct".into();
397 let mut kind: String = "computational".into();
398
399 for meta in &args {
400 if let Meta::NameValue(nv) = meta {
401 let key = nv
402 .path
403 .get_ident()
404 .map(|i| i.to_string())
405 .unwrap_or_default();
406 let value = lit_str(&nv.value)?;
407 match key.as_str() {
408 "id" => id = Some(value),
409 "stage" => stage = value,
410 "kind" => kind = value,
411 other => return err(nv, format!("unknown attribute `{other}`")),
412 }
413 } else {
414 return err(meta, "expected `key = \"value\"`");
415 }
416 }
417 let id = id.unwrap_or_else(|| fn_ident.to_string());
418 let kind_variant = match kind.as_str() {
419 "computational" => quote!(::harness_core::Execution::Computational),
420 "inferential" => quote!(::harness_core::Execution::Inferential),
421 other => {
422 return err(
423 &fn_ident,
424 format!("kind must be computational|inferential, got `{other}`"),
425 );
426 }
427 };
428 let stage_variant = match stage.as_str() {
429 "pre-action" => quote!(::harness_core::Stage::PreAction),
430 "self-correct" => quote!(::harness_core::Stage::SelfCorrect),
431 "pre-commit" => quote!(::harness_core::Stage::PreCommit),
432 "post-integrate" => quote!(::harness_core::Stage::PostIntegrate),
433 "continuous" => quote!(::harness_core::Stage::Continuous),
434 other => return err(&fn_ident, format!("unknown stage `{other}`")),
435 };
436 let marker = format_ident!("__Harness_Sensor_{}", to_pascal_case(&id));
437
438 Ok(quote! {
439 #item_fn
440
441 #[doc(hidden)]
442 #[allow(non_camel_case_types)]
443 pub struct #marker;
444
445 #[::harness_core::__export::async_trait]
446 impl ::harness_core::Sensor for #marker {
447 fn id(&self) -> &::harness_core::SensorId {
448 static I: ::std::sync::OnceLock<::harness_core::SensorId> = ::std::sync::OnceLock::new();
449 I.get_or_init(|| #id.to_string())
450 }
451 fn kind(&self) -> ::harness_core::Execution { #kind_variant }
452 fn stage(&self) -> ::harness_core::Stage { #stage_variant }
453 async fn observe(
454 &self,
455 action: &::harness_core::Action,
456 world: &::harness_core::World,
457 ) -> ::std::result::Result<::std::vec::Vec<::harness_core::Signal>, ::harness_core::SensorError> {
458 #fn_ident(action, world).await
459 }
460 }
461
462 ::harness_core::__export::inventory::submit! {
463 ::harness_core::SensorEntry {
464 factory: || ::std::sync::Arc::new(#marker)
465 as ::std::sync::Arc<dyn ::harness_core::Sensor>,
466 }
467 }
468 })
469}
470
471#[proc_macro_attribute]
476pub fn hook(attr: TokenStream, item: TokenStream) -> TokenStream {
477 let item_fn = parse_macro_input!(item as ItemFn);
478 let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
479 match expand_hook(args, item_fn) {
480 Ok(ts) => ts.into(),
481 Err(e) => e.to_compile_error().into(),
482 }
483}
484
485fn expand_hook(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
486 let fn_ident = item_fn.sig.ident.clone();
487 let mut name: Option<String> = None;
488 let mut event: Option<String> = None;
489
490 for meta in &args {
491 if let Meta::NameValue(nv) = meta {
492 let key = nv
493 .path
494 .get_ident()
495 .map(|i| i.to_string())
496 .unwrap_or_default();
497 let value = lit_str(&nv.value)?;
498 match key.as_str() {
499 "name" => name = Some(value),
500 "event" => event = Some(value),
501 other => return err(nv, format!("unknown attribute `{other}`")),
502 }
503 } else {
504 return err(meta, "expected `key = \"value\"`");
505 }
506 }
507 let event =
508 event.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `event`"))?;
509 let name = name.unwrap_or_else(|| fn_ident.to_string());
510 let marker = format_ident!("__Harness_Hook_{}", to_pascal_case(&name));
511
512 Ok(quote! {
513 #item_fn
514
515 #[doc(hidden)]
516 #[allow(non_camel_case_types)]
517 pub struct #marker;
518
519 impl ::harness_core::Hook for #marker {
520 fn name(&self) -> &str { #name }
521 fn matches(&self, ev: &::harness_core::Event<'_>) -> bool {
522 ev.name() == #event
523 }
524 fn fire(
525 &self,
526 ev: &::harness_core::Event<'_>,
527 world: &mut ::harness_core::World,
528 ) -> ::harness_core::HookOutcome {
529 #fn_ident(ev, world)
530 }
531 }
532
533 ::harness_core::__export::inventory::submit! {
534 ::harness_core::HookEntry {
535 factory: || ::std::sync::Arc::new(#marker)
536 as ::std::sync::Arc<dyn ::harness_core::Hook>,
537 }
538 }
539 })
540}
541
542fn lit_str(expr: &Expr) -> syn::Result<String> {
547 if let Expr::Lit(ExprLit {
548 lit: Lit::Str(s), ..
549 }) = expr
550 {
551 Ok(s.value())
552 } else {
553 Err(syn::Error::new_spanned(expr, "expected string literal"))
554 }
555}
556
557fn opt_string(v: Option<&str>) -> TokenStream2 {
558 match v {
559 Some(s) => quote! { ::std::option::Option::Some(#s.to_string()) },
560 None => quote! { ::std::option::Option::None },
561 }
562}
563
564fn validate_skill_name(name: &str) -> Result<(), String> {
565 if name.is_empty() {
566 return Err("name must not be empty".into());
567 }
568 if name.len() > 64 {
569 return Err(format!("name length {} > 64", name.len()));
570 }
571 if name.starts_with('-') || name.ends_with('-') {
572 return Err("name must not start or end with `-`".into());
573 }
574 if name.contains("--") {
575 return Err("name must not contain `--`".into());
576 }
577 for (i, c) in name.char_indices() {
578 if !(c.is_ascii_digit() || c.is_ascii_lowercase() || c == '-') {
579 return Err(format!("name contains invalid char `{c}` at byte {i}"));
580 }
581 }
582 Ok(())
583}
584
585fn extract_doc_comments(attrs: &[syn::Attribute]) -> Option<String> {
586 let mut lines: Vec<String> = Vec::new();
587 for attr in attrs {
588 if !attr.path().is_ident("doc") {
589 continue;
590 }
591 if let Meta::NameValue(nv) = &attr.meta
592 && let Expr::Lit(ExprLit {
593 lit: Lit::Str(s), ..
594 }) = &nv.value
595 {
596 lines.push(s.value().trim().to_string());
597 }
598 }
599 if lines.is_empty() {
600 None
601 } else {
602 Some(lines.join(" ").trim().to_string())
603 }
604}
605
606fn to_pascal_case(s: &str) -> String {
607 let mut out = String::new();
608 let mut upper = true;
609 for c in s.chars() {
610 if c == '-' || c == '_' {
611 upper = true;
612 } else if upper {
613 out.push(c.to_ascii_uppercase());
614 upper = false;
615 } else {
616 out.push(c);
617 }
618 }
619 out
620}
621
622fn err<T: quote::ToTokens, R>(tokens: T, msg: impl Into<String>) -> syn::Result<R> {
623 Err(syn::Error::new_spanned(tokens, msg.into()))
624}