1use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ParsedSpec {
11 pub name: String,
13 pub module: String,
15 pub requirements: Vec<ParsedRequirement>,
17 pub types: Vec<String>,
19 pub functions: Vec<String>,
21 pub tolerances: Option<ToleranceSpec>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ParsedRequirement {
28 pub id: String,
30 pub description: String,
32 pub category_hint: Option<String>,
34 pub input_type: Option<String>,
36 pub output_type: Option<String>,
38 pub critical: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ToleranceSpec {
45 pub atol: Option<f64>,
47 pub rtol: Option<f64>,
49}
50
51#[derive(Debug)]
53pub struct SpecParser {
54 }
56
57impl SpecParser {
58 pub fn new() -> Self {
60 Self {}
61 }
62
63 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 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 fn extract_module(&self, content: &str) -> Option<String> {
87 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 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 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 if line.starts_with('#') {
119 current_section = line.trim_start_matches('#').trim().to_lowercase();
120 continue;
121 }
122
123 if let Some(req) = self.parse_requirement_line(line, ¤t_section, &mut req_counter)
128 {
129 requirements.push(req);
130 }
131 }
132
133 requirements
134 }
135
136 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 if trimmed.is_empty() || trimmed.starts_with("```") {
147 return None;
148 }
149
150 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 let category_hint = self.infer_category(section, trimmed);
166
167 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 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 fn extract_type_hint(&self, content: &str, kind: &str) -> Option<String> {
234 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 fn extract_types(&self, content: &str) -> Vec<String> {
260 let mut types = Vec::new();
261
262 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 fn extract_functions(&self, content: &str) -> Vec<String> {
291 let mut functions = Vec::new();
292
293 for line in content.lines() {
294 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 if line.contains("`.") {
304 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 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 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 fn extract_number(&self, line: &str) -> Option<f64> {
360 let mut best_num: Option<f64> = None;
362
363 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 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 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; }
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 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 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 #[test]
714 fn test_extract_functions_backtick_method_calls() {
715 let parser = SpecParser::new();
716 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 let content = "Use `.build().run()` for execution";
729 let functions = parser.extract_functions(content);
730 assert!(functions.contains(&"run".to_string()));
732 }
733
734 #[test]
735 fn test_extract_functions_backtick_no_method() {
736 let parser = SpecParser::new();
737 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 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 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 assert_eq!(
769 functions.iter().filter(|f| *f == "process").count(),
770 1,
771 "Duplicate functions should be deduped"
772 );
773 }
774
775 #[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 #[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 #[test]
835 fn test_extract_tolerances_tolerance_keyword_with_scientific() {
836 let parser = SpecParser::new();
837 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 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 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 #[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 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 #[test]
910 fn test_parse_with_no_file_stem() {
911 let parser = SpecParser::new();
912 let content = "module: test_mod\n- MUST work";
914 let spec = parser.parse(content, Path::new("/")).expect("unexpected failure");
915 assert!(!spec.name.is_empty());
918 }
919
920 #[test]
925 fn test_infer_category_content_null() {
926 let parser = SpecParser::new();
927 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 #[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 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}