Skip to main content

batuta/oracle/falsify/
parser.rs

1//! Specification Parser
2//!
3//! Parses markdown specifications into structured requirements.
4
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8/// Parsed specification
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ParsedSpec {
11    /// Specification name
12    pub name: String,
13    /// Module/crate name
14    pub module: String,
15    /// Extracted requirements
16    pub requirements: Vec<ParsedRequirement>,
17    /// Types mentioned
18    pub types: Vec<String>,
19    /// Functions mentioned
20    pub functions: Vec<String>,
21    /// Numerical tolerances if specified
22    pub tolerances: Option<ToleranceSpec>,
23}
24
25/// A parsed requirement from the spec
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ParsedRequirement {
28    /// Requirement ID (from spec or generated)
29    pub id: String,
30    /// Description
31    pub description: String,
32    /// Category hint (boundary, invariant, numerical, etc.)
33    pub category_hint: Option<String>,
34    /// Input type hint
35    pub input_type: Option<String>,
36    /// Output type hint
37    pub output_type: Option<String>,
38    /// Is this a critical requirement?
39    pub critical: bool,
40}
41
42/// Numerical tolerance specifications
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ToleranceSpec {
45    /// Absolute tolerance
46    pub atol: Option<f64>,
47    /// Relative tolerance
48    pub rtol: Option<f64>,
49}
50
51/// Specification parser
52#[derive(Debug)]
53pub struct SpecParser {
54    // Configuration options could go here
55}
56
57impl SpecParser {
58    /// Create a new parser
59    pub fn new() -> Self {
60        Self {}
61    }
62
63    /// Parse a specification file
64    pub fn parse_file(&self, path: &Path) -> anyhow::Result<ParsedSpec> {
65        let content = std::fs::read_to_string(path)?;
66        self.parse(&content, path)
67    }
68
69    /// Parse specification content
70    pub fn parse(&self, content: &str, source: &Path) -> anyhow::Result<ParsedSpec> {
71        let name = source
72            .file_stem()
73            .map(|s| s.to_string_lossy().to_string())
74            .unwrap_or_else(|| "unnamed".to_string());
75
76        let module = self.extract_module(content).unwrap_or_else(|| name.clone());
77        let requirements = self.extract_requirements(content);
78        let types = self.extract_types(content);
79        let functions = self.extract_functions(content);
80        let tolerances = self.extract_tolerances(content);
81
82        Ok(ParsedSpec { name, module, requirements, types, functions, tolerances })
83    }
84
85    /// Extract module name from spec
86    fn extract_module(&self, content: &str) -> Option<String> {
87        // Look for patterns like "module: foo" or "crate: foo"
88        for line in content.lines() {
89            let lower = line.to_lowercase();
90            if lower.starts_with("module:") || lower.starts_with("crate:") {
91                return line.split(':').nth(1).map(|s| s.trim().to_string());
92            }
93        }
94
95        // Try to extract from code blocks
96        for line in content.lines() {
97            if line.contains("use ") && line.contains("::") {
98                if let Some(start) = line.find("use ") {
99                    let rest = &line[start + 4..];
100                    if let Some(end) = rest.find("::") {
101                        return Some(rest[..end].trim().to_string());
102                    }
103                }
104            }
105        }
106
107        None
108    }
109
110    /// Extract requirements from spec
111    fn extract_requirements(&self, content: &str) -> Vec<ParsedRequirement> {
112        let mut requirements = Vec::new();
113        let mut current_section = String::new();
114        let mut req_counter = 0;
115
116        for line in content.lines() {
117            // Track sections (headers)
118            if line.starts_with('#') {
119                current_section = line.trim_start_matches('#').trim().to_lowercase();
120                continue;
121            }
122
123            // Look for requirement patterns:
124            // - Bullet points that look like requirements
125            // - "MUST", "SHALL", "SHOULD" keywords
126            // - Numbered items
127            if let Some(req) = self.parse_requirement_line(line, &current_section, &mut req_counter)
128            {
129                requirements.push(req);
130            }
131        }
132
133        requirements
134    }
135
136    /// Parse a potential requirement line
137    fn parse_requirement_line(
138        &self,
139        line: &str,
140        section: &str,
141        counter: &mut u32,
142    ) -> Option<ParsedRequirement> {
143        let trimmed = line.trim();
144
145        // Skip empty lines and non-requirement lines
146        if trimmed.is_empty() || trimmed.starts_with("```") {
147            return None;
148        }
149
150        // Check for requirement keywords
151        let upper = trimmed.to_uppercase();
152        let is_requirement = upper.contains("MUST")
153            || upper.contains("SHALL")
154            || upper.contains("SHOULD")
155            || upper.contains("REQUIRE")
156            || (trimmed.starts_with("- ") && trimmed.len() > 10);
157
158        if !is_requirement {
159            return None;
160        }
161
162        *counter += 1;
163
164        // Determine category hint from section name
165        let category_hint = self.infer_category(section, trimmed);
166
167        // Check if critical
168        let critical =
169            upper.contains("CRITICAL") || upper.contains("MUST NOT") || upper.contains("SHALL NOT");
170
171        Some(ParsedRequirement {
172            id: format!("REQ-{:03}", counter),
173            description: trimmed.trim_start_matches('-').trim_start_matches('*').trim().to_string(),
174            category_hint,
175            input_type: self.extract_type_hint(trimmed, "input"),
176            output_type: self.extract_type_hint(trimmed, "output"),
177            critical,
178        })
179    }
180
181    /// Infer category from section name and content
182    fn infer_category(&self, section: &str, content: &str) -> Option<String> {
183        let lower_section = section.to_lowercase();
184        let lower_content = content.to_lowercase();
185
186        if lower_section.contains("boundary")
187            || lower_content.contains("empty")
188            || lower_content.contains("null")
189            || lower_content.contains("limit")
190        {
191            return Some("boundary".to_string());
192        }
193
194        if lower_section.contains("invariant")
195            || lower_content.contains("idempotent")
196            || lower_content.contains("commutative")
197        {
198            return Some("invariant".to_string());
199        }
200
201        if lower_section.contains("numeric")
202            || lower_content.contains("precision")
203            || lower_content.contains("floating")
204        {
205            return Some("numerical".to_string());
206        }
207
208        if lower_section.contains("concurren")
209            || lower_content.contains("thread")
210            || lower_content.contains("race")
211        {
212            return Some("concurrency".to_string());
213        }
214
215        if lower_section.contains("resource")
216            || lower_content.contains("memory")
217            || lower_content.contains("exhaust")
218        {
219            return Some("resource".to_string());
220        }
221
222        if lower_section.contains("parity")
223            || lower_content.contains("reference")
224            || lower_content.contains("match")
225        {
226            return Some("parity".to_string());
227        }
228
229        None
230    }
231
232    /// Extract type hint from content
233    fn extract_type_hint(&self, content: &str, kind: &str) -> Option<String> {
234        // Look for patterns like "input: Vec<f64>" or "returns f64"
235        let lower = content.to_lowercase();
236
237        if kind == "input" {
238            if lower.contains("vec") || lower.contains("array") || lower.contains("list") {
239                return Some("Vec<T>".to_string());
240            }
241            if lower.contains("string") || lower.contains("str") {
242                return Some("String".to_string());
243            }
244        }
245
246        if kind == "output" && lower.contains("returns") {
247            if lower.contains("bool") {
248                return Some("bool".to_string());
249            }
250            if lower.contains("result") || lower.contains("error") {
251                return Some("Result<T, E>".to_string());
252            }
253        }
254
255        None
256    }
257
258    /// Extract types mentioned in spec
259    fn extract_types(&self, content: &str) -> Vec<String> {
260        let mut types = Vec::new();
261
262        // Look for struct/enum definitions
263        for line in content.lines() {
264            if line.contains("struct ") {
265                if let Some(name) = line.split("struct ").nth(1) {
266                    if let Some(name) =
267                        name.split(|c: char| !c.is_alphanumeric() && c != '_').next()
268                    {
269                        types.push(name.to_string());
270                    }
271                }
272            }
273            if line.contains("enum ") {
274                if let Some(name) = line.split("enum ").nth(1) {
275                    if let Some(name) =
276                        name.split(|c: char| !c.is_alphanumeric() && c != '_').next()
277                    {
278                        types.push(name.to_string());
279                    }
280                }
281            }
282        }
283
284        types.sort();
285        types.dedup();
286        types
287    }
288
289    /// Extract functions mentioned in spec
290    fn extract_functions(&self, content: &str) -> Vec<String> {
291        let mut functions = Vec::new();
292
293        for line in content.lines() {
294            // Look for fn definitions
295            if line.contains("fn ") {
296                if let Some(name) = line.split("fn ").nth(1) {
297                    if let Some(name) = name.split('(').next() {
298                        functions.push(name.trim().to_string());
299                    }
300                }
301            }
302            // Look for method calls mentioned
303            if line.contains("`.") {
304                // Markdown code inline
305                let parts: Vec<&str> = line.split('`').collect();
306                for (i, part) in parts.iter().enumerate() {
307                    if i % 2 == 1 && part.contains('.') {
308                        if let Some(method) = part.split('.').next_back() {
309                            if let Some(name) = method.split('(').next() {
310                                functions.push(name.to_string());
311                            }
312                        }
313                    }
314                }
315            }
316        }
317
318        functions.sort();
319        functions.dedup();
320        functions
321    }
322
323    /// Extract tolerance specifications
324    fn extract_tolerances(&self, content: &str) -> Option<ToleranceSpec> {
325        let mut atol = None;
326        let mut rtol = None;
327
328        for line in content.lines() {
329            let lower = line.to_lowercase();
330
331            // Look for tolerance specifications
332            if lower.contains("atol") || lower.contains("absolute") {
333                if let Some(val) = self.extract_number(line) {
334                    atol = Some(val);
335                }
336            }
337            if lower.contains("rtol") || lower.contains("relative") {
338                if let Some(val) = self.extract_number(line) {
339                    rtol = Some(val);
340                }
341            }
342            if lower.contains("tolerance") && lower.contains("1e-") {
343                if let Some(val) = self.extract_number(line) {
344                    if atol.is_none() {
345                        atol = Some(val);
346                    }
347                }
348            }
349        }
350
351        if atol.is_some() || rtol.is_some() {
352            Some(ToleranceSpec { atol, rtol })
353        } else {
354            None
355        }
356    }
357
358    /// Extract a number from a line
359    fn extract_number(&self, line: &str) -> Option<f64> {
360        // Use regex-like pattern matching for scientific notation
361        let mut best_num: Option<f64> = None;
362
363        // Look for patterns like 1e-5, 0.001, 1.5e-10
364        let chars: Vec<char> = line.chars().collect();
365        let mut i = 0;
366
367        while i < chars.len() {
368            if chars[i].is_ascii_digit() {
369                // Found start of potential number
370                let mut num_str = String::new();
371                while i < chars.len() {
372                    let c = chars[i];
373                    if c.is_ascii_digit() || c == '.' {
374                        num_str.push(c);
375                        i += 1;
376                    } else if (c == 'e' || c == 'E') && i + 1 < chars.len() {
377                        num_str.push(c);
378                        i += 1;
379                        // Handle optional sign after e
380                        if i < chars.len() && (chars[i] == '-' || chars[i] == '+') {
381                            num_str.push(chars[i]);
382                            i += 1;
383                        }
384                    } else {
385                        break;
386                    }
387                }
388
389                if let Ok(val) = num_str.parse::<f64>() {
390                    best_num = Some(val);
391                    break; // Return first valid number
392                }
393            } else {
394                i += 1;
395            }
396        }
397
398        best_num
399    }
400}
401
402impl Default for SpecParser {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_parser_creation() {
414        let parser = SpecParser::new();
415        // Verify parser can parse empty content without panic
416        let result = parser.parse("", Path::new("test.md"));
417        assert!(result.is_ok());
418    }
419
420    #[test]
421    fn test_parse_simple_spec() {
422        let parser = SpecParser::new();
423        let content = r#"
424# Test Spec
425
426module: test_module
427
428## Requirements
429
430- MUST handle empty input
431- SHOULD return error on invalid input
432- The function MUST NOT panic
433"#;
434        let spec = parser.parse(content, Path::new("test-spec.md")).expect("unexpected failure");
435        assert_eq!(spec.name, "test-spec");
436        assert_eq!(spec.module, "test_module");
437        assert!(!spec.requirements.is_empty());
438    }
439
440    #[test]
441    fn test_extract_tolerances() {
442        let parser = SpecParser::new();
443        // Use format that matches the parser's expectations
444        let content = r#"
445Use a tolerance of 1e-5 for comparisons
446"#;
447        let tolerances = parser.extract_tolerances(content);
448        assert!(tolerances.is_some(), "Should extract tolerance from '1e-5'");
449        let tol = tolerances.expect("unexpected failure");
450        assert!(tol.atol.is_some(), "atol should be extracted");
451        assert!((tol.atol.expect("unexpected failure") - 1e-5).abs() < 1e-10);
452    }
453
454    #[test]
455    fn test_infer_category() {
456        let parser = SpecParser::new();
457
458        assert_eq!(
459            parser.infer_category("boundary conditions", "handle empty input"),
460            Some("boundary".to_string())
461        );
462
463        assert_eq!(
464            parser.infer_category("numerical", "floating point precision"),
465            Some("numerical".to_string())
466        );
467
468        assert_eq!(
469            parser.infer_category("other", "thread safety"),
470            Some("concurrency".to_string())
471        );
472    }
473
474    #[test]
475    fn test_parser_default() {
476        let parser = SpecParser::default();
477        let content = "module: test\n- MUST work";
478        let spec = parser.parse(content, Path::new("test.md")).expect("unexpected failure");
479        assert_eq!(spec.module, "test");
480    }
481
482    #[test]
483    fn test_extract_module_from_use_statement() {
484        let parser = SpecParser::new();
485        let content = "```rust\nuse aprender::knn::KnnClassifier;\n```";
486        let module = parser.extract_module(content);
487        assert_eq!(module, Some("aprender".to_string()));
488    }
489
490    #[test]
491    fn test_extract_module_none() {
492        let parser = SpecParser::new();
493        let content = "Just some text without module info";
494        assert!(parser.extract_module(content).is_none());
495    }
496
497    #[test]
498    fn test_extract_types_struct() {
499        let parser = SpecParser::new();
500        let content = "```rust\nstruct MyType { field: u32 }\nenum MyEnum { A, B }\n```";
501        let types = parser.extract_types(content);
502        assert!(types.contains(&"MyType".to_string()));
503        assert!(types.contains(&"MyEnum".to_string()));
504    }
505
506    #[test]
507    fn test_extract_types_empty() {
508        let parser = SpecParser::new();
509        let content = "No types here";
510        let types = parser.extract_types(content);
511        assert!(types.is_empty());
512    }
513
514    #[test]
515    fn test_extract_functions() {
516        let parser = SpecParser::new();
517        let content = "```rust\nfn process_data(input: &str) -> Result<()> {}\n```";
518        let functions = parser.extract_functions(content);
519        assert!(functions.contains(&"process_data".to_string()));
520    }
521
522    #[test]
523    fn test_extract_functions_fn_definition() {
524        let parser = SpecParser::new();
525        let content = "fn process_data(input: &str) -> Result<()>\nfn other() {}";
526        let functions = parser.extract_functions(content);
527        assert!(functions.contains(&"process_data".to_string()));
528        assert!(functions.contains(&"other".to_string()));
529    }
530
531    #[test]
532    fn test_extract_type_hint_vec() {
533        let parser = SpecParser::new();
534        let hint = parser.extract_type_hint("accepts a vector of values", "input");
535        assert_eq!(hint, Some("Vec<T>".to_string()));
536    }
537
538    #[test]
539    fn test_extract_type_hint_string() {
540        let parser = SpecParser::new();
541        let hint = parser.extract_type_hint("input is a string", "input");
542        assert_eq!(hint, Some("String".to_string()));
543    }
544
545    #[test]
546    fn test_extract_type_hint_bool_output() {
547        let parser = SpecParser::new();
548        let hint = parser.extract_type_hint("returns bool indicating success", "output");
549        assert_eq!(hint, Some("bool".to_string()));
550    }
551
552    #[test]
553    fn test_extract_type_hint_result_output() {
554        let parser = SpecParser::new();
555        let hint = parser.extract_type_hint("returns result or error", "output");
556        assert_eq!(hint, Some("Result<T, E>".to_string()));
557    }
558
559    #[test]
560    fn test_extract_number_scientific() {
561        let parser = SpecParser::new();
562        let num = parser.extract_number("tolerance: 1e-10");
563        assert!(num.is_some());
564        assert!((num.expect("unexpected failure") - 1e-10).abs() < 1e-15);
565    }
566
567    #[test]
568    fn test_extract_number_decimal() {
569        let parser = SpecParser::new();
570        let num = parser.extract_number("precision: 0.001");
571        assert!(num.is_some());
572        assert!((num.expect("unexpected failure") - 0.001).abs() < 1e-10);
573    }
574
575    #[test]
576    fn test_extract_number_with_sign() {
577        let parser = SpecParser::new();
578        let num = parser.extract_number("value 5E+3");
579        assert!(num.is_some());
580        assert!((num.expect("unexpected failure") - 5000.0).abs() < 1e-10);
581    }
582
583    #[test]
584    fn test_extract_number_none() {
585        let parser = SpecParser::new();
586        let num = parser.extract_number("no numbers here");
587        assert!(num.is_none());
588    }
589
590    #[test]
591    fn test_parse_requirement_line_must() {
592        let parser = SpecParser::new();
593        let mut counter = 0;
594        let req = parser.parse_requirement_line(
595            "- MUST handle empty input",
596            "requirements",
597            &mut counter,
598        );
599        assert!(req.is_some());
600        let req = req.expect("unexpected failure");
601        assert_eq!(req.id, "REQ-001");
602        assert!(req.description.contains("handle empty input"));
603    }
604
605    #[test]
606    fn test_parse_requirement_line_critical() {
607        let parser = SpecParser::new();
608        let mut counter = 0;
609        let req =
610            parser.parse_requirement_line("CRITICAL: MUST NOT panic", "requirements", &mut counter);
611        assert!(req.is_some());
612        assert!(req.expect("unexpected failure").critical);
613    }
614
615    #[test]
616    fn test_parse_requirement_line_skip_code_block() {
617        let parser = SpecParser::new();
618        let mut counter = 0;
619        let req = parser.parse_requirement_line("```rust", "code", &mut counter);
620        assert!(req.is_none());
621    }
622
623    #[test]
624    fn test_parse_requirement_line_skip_empty() {
625        let parser = SpecParser::new();
626        let mut counter = 0;
627        let req = parser.parse_requirement_line("   ", "section", &mut counter);
628        assert!(req.is_none());
629    }
630
631    #[test]
632    fn test_infer_category_invariant() {
633        let parser = SpecParser::new();
634        assert_eq!(
635            parser.infer_category("invariants", "must be idempotent"),
636            Some("invariant".to_string())
637        );
638    }
639
640    #[test]
641    fn test_infer_category_resource() {
642        let parser = SpecParser::new();
643        assert_eq!(
644            parser.infer_category("resource limits", "memory exhaustion"),
645            Some("resource".to_string())
646        );
647    }
648
649    #[test]
650    fn test_infer_category_parity() {
651        let parser = SpecParser::new();
652        assert_eq!(
653            parser.infer_category("parity tests", "must match reference"),
654            Some("parity".to_string())
655        );
656    }
657
658    #[test]
659    fn test_infer_category_none() {
660        let parser = SpecParser::new();
661        assert!(parser.infer_category("overview", "general description").is_none());
662    }
663
664    #[test]
665    fn test_extract_tolerances_with_rtol() {
666        let parser = SpecParser::new();
667        let content = "relative tolerance rtol of 1e-6";
668        let tol = parser.extract_tolerances(content);
669        assert!(tol.is_some());
670        assert!(tol.expect("unexpected failure").rtol.is_some());
671    }
672
673    #[test]
674    fn test_extract_tolerances_none() {
675        let parser = SpecParser::new();
676        let content = "no tolerance specified";
677        let tol = parser.extract_tolerances(content);
678        assert!(tol.is_none());
679    }
680
681    #[test]
682    fn test_parsed_spec_fields() {
683        let spec = ParsedSpec {
684            name: "test".to_string(),
685            module: "mod".to_string(),
686            requirements: vec![],
687            types: vec!["T".to_string()],
688            functions: vec!["f".to_string()],
689            tolerances: Some(ToleranceSpec { atol: Some(1e-5), rtol: None }),
690        };
691        assert_eq!(spec.name, "test");
692        assert!(spec.tolerances.is_some());
693    }
694
695    #[test]
696    fn test_parsed_requirement_fields() {
697        let req = ParsedRequirement {
698            id: "REQ-001".to_string(),
699            description: "test requirement".to_string(),
700            category_hint: Some("boundary".to_string()),
701            input_type: Some("Vec<T>".to_string()),
702            output_type: Some("Result<T, E>".to_string()),
703            critical: true,
704        };
705        assert!(req.critical);
706        assert_eq!(req.category_hint, Some("boundary".to_string()));
707    }
708
709    // =====================================================================
710    // extract_functions: backtick method extraction coverage
711    // =====================================================================
712
713    #[test]
714    fn test_extract_functions_backtick_method_calls() {
715        let parser = SpecParser::new();
716        // The backtick extraction path triggers when line contains "`." (backtick + dot).
717        // Use `.method()` inline code style which places the dot right after the backtick.
718        let content = "Call `.predict()` to get results\nUse `.fit()` for training";
719        let functions = parser.extract_functions(content);
720        assert!(functions.contains(&"predict".to_string()));
721        assert!(functions.contains(&"fit".to_string()));
722    }
723
724    #[test]
725    fn test_extract_functions_backtick_chained_methods() {
726        let parser = SpecParser::new();
727        // Backtick extraction splits on '.', so chained `.build().run()` extracts last segment
728        let content = "Use `.build().run()` for execution";
729        let functions = parser.extract_functions(content);
730        // Should extract the last method in the chain
731        assert!(functions.contains(&"run".to_string()));
732    }
733
734    #[test]
735    fn test_extract_functions_backtick_no_method() {
736        let parser = SpecParser::new();
737        // Backtick content without a dot - should not add anything
738        let content = "Use `some_value` directly";
739        let functions = parser.extract_functions(content);
740        assert!(functions.is_empty());
741    }
742
743    #[test]
744    fn test_extract_functions_mixed_fn_and_backtick() {
745        let parser = SpecParser::new();
746        // fn definition on first line, backtick `.method()` on second
747        let content = "fn compute(x: f64) -> f64\nCall `.transform()` first";
748        let functions = parser.extract_functions(content);
749        assert!(functions.contains(&"compute".to_string()));
750        assert!(functions.contains(&"transform".to_string()));
751    }
752
753    #[test]
754    fn test_extract_functions_backtick_with_parens() {
755        let parser = SpecParser::new();
756        // Backtick extraction requires "`." in the line
757        let content = "Method `.validate(input)` must succeed";
758        let functions = parser.extract_functions(content);
759        assert!(functions.contains(&"validate".to_string()));
760    }
761
762    #[test]
763    fn test_extract_functions_dedup() {
764        let parser = SpecParser::new();
765        let content = "fn process()\nfn process()";
766        let functions = parser.extract_functions(content);
767        // Should be deduplicated
768        assert_eq!(
769            functions.iter().filter(|f| *f == "process").count(),
770            1,
771            "Duplicate functions should be deduped"
772        );
773    }
774
775    // =====================================================================
776    // parse_file: filesystem parsing coverage
777    // =====================================================================
778
779    #[test]
780    fn test_parse_file_valid() {
781        let parser = SpecParser::new();
782        let temp_dir = std::env::temp_dir().join("batuta_parser_test");
783        let _ = std::fs::remove_dir_all(&temp_dir);
784        std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
785
786        let spec_path = temp_dir.join("test-spec.md");
787        std::fs::write(
788            &spec_path,
789            "# My Spec\n\nmodule: my_module\n\n## Requirements\n\n- MUST handle edge cases\n",
790        )
791        .expect("unexpected failure");
792
793        let result = parser.parse_file(&spec_path);
794        assert!(result.is_ok());
795        let spec = result.expect("operation failed");
796        assert_eq!(spec.name, "test-spec");
797        assert_eq!(spec.module, "my_module");
798        assert!(!spec.requirements.is_empty());
799
800        let _ = std::fs::remove_dir_all(&temp_dir);
801    }
802
803    #[test]
804    fn test_parse_file_not_found() {
805        let parser = SpecParser::new();
806        let result = parser.parse_file(Path::new("/nonexistent/spec.md"));
807        assert!(result.is_err());
808    }
809
810    // =====================================================================
811    // extract_module: crate: prefix coverage
812    // =====================================================================
813
814    #[test]
815    fn test_extract_module_crate_prefix() {
816        let parser = SpecParser::new();
817        let content = "crate: my_crate\nSome description";
818        let module = parser.extract_module(content);
819        assert_eq!(module, Some("my_crate".to_string()));
820    }
821
822    #[test]
823    fn test_extract_module_use_with_nested_path() {
824        let parser = SpecParser::new();
825        let content = "```rust\nuse trueno::simd::avx2::kernel;\n```";
826        let module = parser.extract_module(content);
827        assert_eq!(module, Some("trueno".to_string()));
828    }
829
830    // =====================================================================
831    // extract_tolerances: tolerance + 1e- path + atol.is_none() check
832    // =====================================================================
833
834    #[test]
835    fn test_extract_tolerances_tolerance_keyword_with_scientific() {
836        let parser = SpecParser::new();
837        // Triggers the third branch: "tolerance" + "1e-"
838        let content = "Use a tolerance of 1e-8 for all comparisons";
839        let tol = parser.extract_tolerances(content);
840        assert!(tol.is_some());
841        let t = tol.expect("unexpected failure");
842        assert!(t.atol.is_some());
843        assert!((t.atol.expect("unexpected failure") - 1e-8).abs() < 1e-15);
844    }
845
846    #[test]
847    fn test_extract_tolerances_atol_then_tolerance_keyword() {
848        let parser = SpecParser::new();
849        // atol set first, then "tolerance 1e-" should NOT overwrite atol
850        let content = "atol = 1e-5\ntolerance of 1e-3";
851        let tol = parser.extract_tolerances(content);
852        assert!(tol.is_some());
853        let t = tol.expect("unexpected failure");
854        // atol should still be 1e-5 (first match), because atol.is_none() check prevents overwrite
855        assert!(t.atol.is_some());
856        assert!((t.atol.expect("unexpected failure") - 1e-5).abs() < 1e-10);
857    }
858
859    #[test]
860    fn test_extract_tolerances_both_atol_rtol() {
861        let parser = SpecParser::new();
862        let content = "absolute tolerance atol = 1e-6\nrelative rtol = 1e-4";
863        let tol = parser.extract_tolerances(content);
864        assert!(tol.is_some());
865        let t = tol.expect("unexpected failure");
866        assert!(t.atol.is_some());
867        assert!(t.rtol.is_some());
868        assert!((t.atol.expect("unexpected failure") - 1e-6).abs() < 1e-12);
869        assert!((t.rtol.expect("unexpected failure") - 1e-4).abs() < 1e-10);
870    }
871
872    // =====================================================================
873    // extract_type_hint: additional coverage for non-matching kinds
874    // =====================================================================
875
876    #[test]
877    fn test_extract_type_hint_input_array() {
878        let parser = SpecParser::new();
879        let hint = parser.extract_type_hint("takes an array of floats", "input");
880        assert_eq!(hint, Some("Vec<T>".to_string()));
881    }
882
883    #[test]
884    fn test_extract_type_hint_input_list() {
885        let parser = SpecParser::new();
886        let hint = parser.extract_type_hint("accepts a list of items", "input");
887        assert_eq!(hint, Some("Vec<T>".to_string()));
888    }
889
890    #[test]
891    fn test_extract_type_hint_output_no_returns() {
892        let parser = SpecParser::new();
893        // "output" kind but no "returns" keyword
894        let hint = parser.extract_type_hint("produces a bool value", "output");
895        assert!(hint.is_none());
896    }
897
898    #[test]
899    fn test_extract_type_hint_no_match() {
900        let parser = SpecParser::new();
901        let hint = parser.extract_type_hint("does something", "input");
902        assert!(hint.is_none());
903    }
904
905    // =====================================================================
906    // parse: unnamed source path coverage
907    // =====================================================================
908
909    #[test]
910    fn test_parse_with_no_file_stem() {
911        let parser = SpecParser::new();
912        // Path with no file stem
913        let content = "module: test_mod\n- MUST work";
914        let spec = parser.parse(content, Path::new("/")).expect("unexpected failure");
915        // With "/" as path, file_stem returns None, so name becomes "unnamed"
916        // Actually "/" returns None for file_stem in some cases
917        assert!(!spec.name.is_empty());
918    }
919
920    // =====================================================================
921    // infer_category: content-based matching without section match
922    // =====================================================================
923
924    #[test]
925    fn test_infer_category_content_null() {
926        let parser = SpecParser::new();
927        // Section doesn't match but content contains "null"
928        let cat = parser.infer_category("general", "handle null values");
929        assert_eq!(cat, Some("boundary".to_string()));
930    }
931
932    #[test]
933    fn test_infer_category_content_limit() {
934        let parser = SpecParser::new();
935        let cat = parser.infer_category("edge cases", "check the limit");
936        assert_eq!(cat, Some("boundary".to_string()));
937    }
938
939    #[test]
940    fn test_infer_category_content_commutative() {
941        let parser = SpecParser::new();
942        let cat = parser.infer_category("math", "operation must be commutative");
943        assert_eq!(cat, Some("invariant".to_string()));
944    }
945
946    #[test]
947    fn test_infer_category_content_race() {
948        let parser = SpecParser::new();
949        let cat = parser.infer_category("safety", "avoid race conditions");
950        assert_eq!(cat, Some("concurrency".to_string()));
951    }
952
953    #[test]
954    fn test_infer_category_content_exhaust() {
955        let parser = SpecParser::new();
956        let cat = parser.infer_category("limits", "may exhaust resources");
957        assert_eq!(cat, Some("resource".to_string()));
958    }
959
960    // =====================================================================
961    // parse_requirement_line: SHALL and REQUIRE keywords
962    // =====================================================================
963
964    #[test]
965    fn test_parse_requirement_line_shall() {
966        let parser = SpecParser::new();
967        let mut counter = 0;
968        let req = parser.parse_requirement_line(
969            "The system SHALL validate input",
970            "section",
971            &mut counter,
972        );
973        assert!(req.is_some());
974        assert_eq!(req.expect("unexpected failure").id, "REQ-001");
975        assert_eq!(counter, 1);
976    }
977
978    #[test]
979    fn test_parse_requirement_line_require() {
980        let parser = SpecParser::new();
981        let mut counter = 5;
982        let req =
983            parser.parse_requirement_line("REQUIRE proper authentication", "section", &mut counter);
984        assert!(req.is_some());
985        assert_eq!(req.expect("unexpected failure").id, "REQ-006");
986        assert_eq!(counter, 6);
987    }
988
989    #[test]
990    fn test_parse_requirement_line_short_bullet() {
991        let parser = SpecParser::new();
992        let mut counter = 0;
993        // Short bullet (< 10 chars after "- ") without keywords
994        let req = parser.parse_requirement_line("- short", "section", &mut counter);
995        assert!(req.is_none());
996    }
997
998    #[test]
999    fn test_parse_requirement_line_shall_not() {
1000        let parser = SpecParser::new();
1001        let mut counter = 0;
1002        let req =
1003            parser.parse_requirement_line("SHALL NOT expose secrets", "section", &mut counter);
1004        assert!(req.is_some());
1005        assert!(req.expect("unexpected failure").critical);
1006    }
1007
1008    #[test]
1009    fn test_parse_requirement_line_star_bullet() {
1010        let parser = SpecParser::new();
1011        let mut counter = 0;
1012        let req = parser.parse_requirement_line(
1013            "* MUST handle large inputs gracefully",
1014            "section",
1015            &mut counter,
1016        );
1017        assert!(req.is_some());
1018        let r = req.expect("unexpected failure");
1019        assert!(r.description.starts_with("MUST"));
1020    }
1021}