1use std::cell::RefCell;
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11use std::time::SystemTime;
12
13use proc_macro::TokenStream;
14use quote::quote;
15use syn::parse::{Parse, ParseStream};
16use syn::punctuated::Punctuated;
17use syn::{Expr, ItemFn, Lit, Token};
18
19struct CachedGraph {
28 graph: Rc<supersigil_core::DocumentGraph>,
29 input_fingerprint: Vec<InputFingerprintEntry>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33struct InputFingerprintEntry {
34 path: PathBuf,
35 modified: SystemTime,
36 len: u64,
37}
38
39thread_local! {
40 static GRAPH_CACHE: RefCell<Option<CachedGraph>> = const { RefCell::new(None) };
41}
42
43struct VerifiesArgs {
49 refs: Punctuated<Expr, Token![,]>,
50}
51
52impl Parse for VerifiesArgs {
53 fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
54 let refs = Punctuated::parse_terminated(input)?;
55 Ok(Self { refs })
56 }
57}
58
59fn fingerprint_inputs(paths: &[PathBuf]) -> Vec<InputFingerprintEntry> {
64 paths
65 .iter()
66 .map(|path| match std::fs::metadata(path) {
67 Ok(metadata) => InputFingerprintEntry {
68 path: path.clone(),
69 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
70 len: metadata.len(),
71 },
72 Err(_) => InputFingerprintEntry {
73 path: path.clone(),
74 modified: SystemTime::UNIX_EPOCH,
75 len: 0,
76 },
77 })
78 .collect()
79}
80
81fn resolve_project_root() -> Result<Option<PathBuf>, String> {
89 if let Ok(root) = std::env::var("SUPERSIGIL_PROJECT_ROOT") {
91 if root.is_empty() {
93 return Ok(None);
94 }
95 let p = PathBuf::from(&root);
96 if p.join(supersigil_core::CONFIG_FILENAME).is_file() {
97 return Ok(Some(p));
98 }
99 return Err(format!(
101 "SUPERSIGIL_PROJECT_ROOT is set to \"{root}\" but no supersigil.toml \
102 was found at that path"
103 ));
104 }
105
106 let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else {
108 return Ok(None);
109 };
110 Ok(supersigil_core::find_config(Path::new(&manifest_dir))
111 .ok()
112 .flatten()
113 .and_then(|p| p.parent().map(Path::to_path_buf)))
114}
115
116fn should_validate(config: &supersigil_core::Config) -> bool {
118 should_validate_with_profile(config, &std::env::var("PROFILE").unwrap_or_default())
119}
120
121fn should_validate_with_profile(config: &supersigil_core::Config, profile: &str) -> bool {
122 use supersigil_core::RustValidationPolicy;
123
124 let policy = config
125 .ecosystem
126 .rust
127 .as_ref()
128 .map_or(RustValidationPolicy::Dev, |r| r.validation);
129
130 match policy {
131 RustValidationPolicy::Off => false,
132 RustValidationPolicy::All => true,
133 RustValidationPolicy::Dev => profile != "release",
134 }
135}
136
137type GraphErrors = Vec<(Option<String>, String)>;
142
143fn graph_error(context: &str, errors: &[impl std::fmt::Display]) -> GraphErrors {
144 let detail = errors
145 .iter()
146 .map(ToString::to_string)
147 .collect::<Vec<_>>()
148 .join("; ");
149 vec![(None, format!("supersigil: {context}: {detail}"))]
150}
151
152fn check_cached_graph() -> Option<Rc<supersigil_core::DocumentGraph>> {
157 GRAPH_CACHE.with(|cache| {
158 let borrow = cache.borrow();
159 let cached = borrow.as_ref()?;
160 let still_valid = cached.input_fingerprint.iter().all(|entry| {
161 match std::fs::metadata(&entry.path) {
162 Ok(meta) => {
163 meta.modified().unwrap_or(SystemTime::UNIX_EPOCH) == entry.modified
164 && meta.len() == entry.len
165 }
166 Err(_) => entry.modified == SystemTime::UNIX_EPOCH && entry.len == 0,
168 }
169 });
170 still_valid.then(|| Rc::clone(&cached.graph))
171 })
172}
173
174fn get_or_build_graph(
175 project_root: &Path,
176) -> Result<Option<Rc<supersigil_core::DocumentGraph>>, GraphErrors> {
177 if let Some(graph) = check_cached_graph() {
180 return Ok(Some(graph));
181 }
182
183 let config_path = project_root.join(supersigil_core::CONFIG_FILENAME);
185 let config = match supersigil_core::load_config(&config_path) {
186 Ok(c) => c,
187 Err(errs) => {
188 return Err(graph_error(
189 &format!("failed to load config at \"{}\"", config_path.display()),
190 &errs,
191 ));
192 }
193 };
194
195 if !should_validate(&config) {
196 return Ok(None);
197 }
198
199 let inputs = supersigil_core::resolve_workspace_validation_inputs(&config, project_root)
200 .map_err(|err| vec![(None, format!("supersigil: {err}"))])?;
201 let current_fingerprint = fingerprint_inputs(&inputs.all_paths());
202
203 let component_defs = supersigil_core::ComponentDefs::merge(
204 supersigil_core::ComponentDefs::defaults(),
205 config.components.clone(),
206 )
207 .map_err(|errs| graph_error("invalid component definitions", &errs))?;
208
209 let mut documents = Vec::new();
210 let mut parse_errors: Vec<String> = Vec::new();
211 for file in &inputs.spec_files {
212 match supersigil_parser::parse_file(file, &component_defs) {
213 Ok(supersigil_core::ParseResult::Document(doc)) => documents.push(doc),
214 Ok(supersigil_core::ParseResult::NotSupersigil(_)) => {}
215 Err(errs) => {
216 let detail = errs
217 .iter()
218 .map(ToString::to_string)
219 .collect::<Vec<_>>()
220 .join("; ");
221 parse_errors.push(format!("{}: {detail}", file.display()));
222 }
223 }
224 }
225 if !parse_errors.is_empty() {
226 return Err(graph_error("failed to parse spec files", &parse_errors));
227 }
228
229 let graph = match supersigil_core::build_graph(documents, &config) {
230 Ok(g) => g,
231 Err(errs) => return Err(graph_error("failed to build document graph", &errs)),
232 };
233
234 let graph = Rc::new(graph);
236 GRAPH_CACHE.with(|cache| {
237 *cache.borrow_mut() = Some(CachedGraph {
238 graph: Rc::clone(&graph),
239 input_fingerprint: current_fingerprint,
240 });
241 });
242
243 Ok(Some(graph))
244}
245
246fn validate_refs(refs: &[String], project_root: &Path) -> Vec<(Option<String>, String)> {
252 let graph = match get_or_build_graph(project_root) {
253 Ok(Some(g)) => g,
254 Ok(None) => return Vec::new(),
255 Err(errors) => return errors,
256 };
257
258 let mut errors = Vec::new();
260 for ref_str in refs {
261 let Some((doc_id, fragment)) = ref_str.split_once('#') else {
262 continue;
263 };
264
265 if graph.component(doc_id, fragment).is_none() {
266 errors.push((
267 Some(ref_str.clone()),
268 format!(
269 "unresolved criterion reference \"{ref_str}\": \
270 no matching criterion found in the specification graph"
271 ),
272 ));
273 }
274 }
275 errors
276}
277
278fn validate_ref_shape(ref_str: &str, span: proc_macro2::Span) -> syn::Result<()> {
279 if !supersigil_core::is_valid_criterion_ref(ref_str) {
280 return Err(syn::Error::new(
281 span,
282 format!(
283 "invalid criterion reference \"{ref_str}\": expected `document-id#criterion-id`"
284 ),
285 ));
286 }
287 Ok(())
288}
289
290#[proc_macro_attribute]
314pub fn verifies(attr: TokenStream, item: TokenStream) -> TokenStream {
315 let args: VerifiesArgs = match syn::parse(attr) {
317 Ok(a) => a,
318 Err(e) => return e.to_compile_error().into(),
319 };
320
321 if args.refs.is_empty() {
323 let err = syn::Error::new(
324 proc_macro2::Span::call_site(),
325 "`#[verifies(...)]` requires at least one criterion reference string",
326 );
327 return err.to_compile_error().into();
328 }
329
330 let mut ref_strings: Vec<String> = Vec::new();
332 let mut ref_spans: Vec<proc_macro2::Span> = Vec::new();
333 for expr in &args.refs {
334 let Expr::Lit(syn::ExprLit {
335 lit: Lit::Str(s), ..
336 }) = expr
337 else {
338 let err = syn::Error::new_spanned(
339 expr,
340 format!(
341 "expected a string literal criterion reference, found `{}`",
342 quote!(#expr)
343 ),
344 );
345 return err.to_compile_error().into();
346 };
347
348 let ref_string = s.value();
349 if let Err(err) = validate_ref_shape(&ref_string, s.span()) {
350 return err.to_compile_error().into();
351 }
352 ref_strings.push(ref_string);
353 ref_spans.push(s.span());
354 }
355
356 let item_clone: proc_macro2::TokenStream = item.clone().into();
358 if syn::parse2::<ItemFn>(item_clone).is_err() {
359 let err = syn::Error::new(
360 proc_macro2::Span::call_site(),
361 "`#[verifies(...)]` can only be applied to functions",
362 );
363 return err.to_compile_error().into();
364 }
365
366 match resolve_project_root() {
368 Ok(Some(project_root)) => {
369 let errors = validate_refs(&ref_strings, &project_root);
370 if !errors.is_empty() {
371 let mut combined: Option<syn::Error> = None;
372 for (ref_str, message) in &errors {
373 let span = ref_str
374 .as_ref()
375 .and_then(|r| ref_strings.iter().position(|s| s == r))
376 .map_or_else(proc_macro2::Span::call_site, |idx| ref_spans[idx]);
377 let err = syn::Error::new(span, message);
378 match &mut combined {
379 None => combined = Some(err),
380 Some(existing) => existing.combine(err),
381 }
382 }
383 if let Some(combined) = combined {
384 return combined.to_compile_error().into();
385 }
386 }
387 }
388 Ok(None) => {
389 }
391 Err(msg) => {
392 let err = syn::Error::new(proc_macro2::Span::call_site(), msg);
393 return err.to_compile_error().into();
394 }
395 }
396
397 item
399}
400
401#[cfg(test)]
402mod tests {
403 use std::fs;
404
405 use tempfile::TempDir;
406
407 use super::*;
408
409 fn clear_graph_cache() {
410 GRAPH_CACHE.with(|cache| {
411 *cache.borrow_mut() = None;
412 });
413 }
414
415 fn write_config(root: &Path) {
416 fs::write(
417 root.join("supersigil.toml"),
418 "paths = [\"specs/**/*.md\"]\n",
419 )
420 .unwrap();
421 }
422
423 fn write_spec(root: &Path, criterion_id: &str) {
424 fs::create_dir_all(root.join("specs")).unwrap();
425 fs::write(
426 root.join("specs/auth.md"),
427 format!(
428 "---\nsupersigil:\n id: auth/req\n type: requirements\n status: approved\n---\n\n```supersigil-xml\n<AcceptanceCriteria>\n <Criterion id=\"{criterion_id}\">\n Must log in.\n </Criterion>\n</AcceptanceCriteria>\n```\n"
429 ),
430 )
431 .unwrap();
432 }
433
434 fn config_with_policy(
435 policy: supersigil_core::RustValidationPolicy,
436 ) -> supersigil_core::Config {
437 supersigil_core::Config {
438 ecosystem: supersigil_core::EcosystemConfig {
439 rust: Some(supersigil_core::RustEcosystemConfig {
440 validation: policy,
441 ..Default::default()
442 }),
443 ..Default::default()
444 },
445 ..Default::default()
446 }
447 }
448
449 #[test]
450 fn should_validate_off_skips() {
451 let config = config_with_policy(supersigil_core::RustValidationPolicy::Off);
452 assert!(!should_validate(&config), "policy=off must skip validation");
453 }
454
455 #[test]
456 fn should_validate_all_always_validates() {
457 let config = config_with_policy(supersigil_core::RustValidationPolicy::All);
458 assert!(
459 should_validate(&config),
460 "policy=all must validate unconditionally"
461 );
462 }
463
464 #[test]
465 fn should_validate_dev_validates_in_debug() {
466 let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
467 assert!(
468 should_validate_with_profile(&config, "debug"),
469 "policy=dev must validate when PROFILE=debug"
470 );
471 }
472
473 #[test]
474 fn should_validate_dev_skips_in_release() {
475 let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
476 assert!(
477 !should_validate_with_profile(&config, "release"),
478 "policy=dev must skip validation when PROFILE=release"
479 );
480 }
481
482 #[test]
483 fn should_validate_default_is_dev() {
484 let config = supersigil_core::Config::default();
486 assert!(
487 should_validate_with_profile(&config, "debug"),
488 "default policy (dev) must validate in debug"
489 );
490 assert!(
491 !should_validate_with_profile(&config, "release"),
492 "default policy (dev) must skip validation in release"
493 );
494 }
495
496 #[test]
497 fn validate_refs_rebuilds_graph_when_spec_file_changes() {
498 let tmp = TempDir::new().unwrap();
499 let project_root = tmp.path();
500 write_config(project_root);
501 write_spec(project_root, "ac-1");
502 clear_graph_cache();
503
504 let refs = vec!["auth/req#ac-1".to_string()];
505 let first = validate_refs(&refs, project_root);
506 assert!(first.is_empty(), "initial ref should resolve: {first:?}");
507
508 write_spec(project_root, "criterion-two-longer-than-before");
509
510 let second = validate_refs(&refs, project_root);
511 assert!(
512 second
513 .iter()
514 .any(|(_, message)| message.contains("unresolved criterion reference")),
515 "changed spec should invalidate the cache and make the old ref fail: {second:?}",
516 );
517 }
518}