Skip to main content

dada_lang/main_lib/test/
spec_validation.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::Path;
4
5use dada_util::{Fallible, bail};
6use regex::Regex;
7
8/// Manages validation of spec references from test files
9pub struct SpecValidator {
10    /// All valid spec IDs extracted from the spec mdbook
11    valid_spec_ids: HashSet<String>,
12}
13
14impl SpecValidator {
15    /// Creates a new spec validator by scanning the spec mdbook
16    pub fn new() -> Fallible<Self> {
17        let mut validator = SpecValidator {
18            valid_spec_ids: HashSet::new(),
19        };
20        validator.load_spec_ids()?;
21        Ok(validator)
22    }
23
24    /// Loads all spec IDs from the spec mdbook source files
25    fn load_spec_ids(&mut self) -> Fallible<()> {
26        let spec_src_path = Path::new("spec/src");
27
28        if !spec_src_path.exists() {
29            bail!(
30                "Spec source directory not found at {}. Make sure you're running from the dada project root.",
31                spec_src_path.display()
32            );
33        }
34
35        self.scan_directory(spec_src_path)?;
36        Ok(())
37    }
38
39    /// Recursively scans a directory for markdown files and extracts spec IDs
40    fn scan_directory(&mut self, dir: &Path) -> Fallible<()> {
41        for entry in fs::read_dir(dir)? {
42            let entry = entry?;
43            let path = entry.path();
44
45            if path.is_dir() {
46                self.scan_directory(&path)?;
47            } else if let Some(extension) = path.extension()
48                && extension == "md"
49            {
50                self.extract_spec_ids_from_file(&path)?;
51            }
52        }
53        Ok(())
54    }
55
56    /// Extracts spec IDs from MyST directive syntax, resolving relative IDs
57    /// using the file path and heading context.
58    ///
59    /// 💡 Uses the same resolution logic as the preprocessor (via `dada_spec_common`)
60    /// to ensure test `#:spec` annotations match the IDs generated in the spec HTML.
61    fn extract_spec_ids_from_file(&mut self, file_path: &Path) -> Fallible<()> {
62        let content = fs::read_to_string(file_path)?;
63
64        let spec_src = Path::new("spec/src");
65        let relative_path = file_path.strip_prefix(spec_src).unwrap_or(file_path);
66        let file_prefix = dada_spec_common::file_path_to_prefix(relative_path);
67
68        let directive_start = Regex::new(r"^:::\{spec\}(.*)$")?;
69        let directive_end = Regex::new(r"^:::$")?;
70        let inline_re = Regex::new(r"\{spec\}`([^`]+)`")?;
71
72        let mut heading_tracker = dada_spec_common::HeadingTracker::new();
73        let mut in_directive = false;
74        let mut current_parent_id = String::new();
75
76        for line in content.lines() {
77            let trimmed = line.trim();
78
79            if !in_directive {
80                heading_tracker.process_line(trimmed);
81
82                if let Some(captures) = directive_start.captures(trimmed) {
83                    let rest = captures.get(1).map(|m| m.as_str()).unwrap_or("");
84                    let (local_name, _tags) = dada_spec_common::parse_spec_tokens(rest);
85
86                    let full_id = dada_spec_common::resolve_spec_id(
87                        &file_prefix,
88                        &heading_tracker.current_segments(),
89                        local_name.as_deref().unwrap_or(""),
90                    );
91                    self.valid_spec_ids.insert(full_id.clone());
92                    current_parent_id = full_id;
93                    in_directive = true;
94                }
95            } else if directive_end.is_match(trimmed) {
96                in_directive = false;
97                current_parent_id.clear();
98            } else {
99                // Inside directive: check for inline sub-paragraphs.
100                // Parse the backtick content to separate the name from tags
101                // (e.g., `triple-quoted unimpl` → name="triple-quoted", tags=["unimpl"]).
102                for cap in inline_re.captures_iter(trimmed) {
103                    if let Some(content) = cap.get(1) {
104                        let (name, _tags) = dada_spec_common::parse_spec_tokens(content.as_str());
105                        // For inline sub-paragraphs, the first token is always the name
106                        let name = name.unwrap_or_else(|| content.as_str().to_string());
107                        let sub_id = format!("{}.{}", current_parent_id, name);
108                        self.valid_spec_ids.insert(sub_id);
109                    }
110                }
111            }
112        }
113
114        Ok(())
115    }
116
117    /// Validates a list of spec references, returning any that are invalid
118    pub fn validate_spec_refs(&self, spec_refs: &[String]) -> Vec<String> {
119        spec_refs
120            .iter()
121            .filter(|spec_ref| !self.valid_spec_ids.contains(*spec_ref))
122            .cloned()
123            .collect()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_spec_validation() {
133        // Mock validator for testing
134        let mut validator = SpecValidator {
135            valid_spec_ids: HashSet::new(),
136        };
137        validator
138            .valid_spec_ids
139            .insert("syntax.string-literals.delimiters.quoted".to_string());
140        validator
141            .valid_spec_ids
142            .insert("permissions.lease.transfer".to_string());
143
144        // Test batch validation
145        let refs = vec![
146            "syntax.string-literals.delimiters.quoted".to_string(),
147            "invalid.spec.ref".to_string(),
148            "permissions.lease.transfer".to_string(),
149        ];
150        let invalid_refs = validator.validate_spec_refs(&refs);
151        assert_eq!(invalid_refs, vec!["invalid.spec.ref"]);
152    }
153}