1#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Statement {
7 pub id: String,
9 pub text: String,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Bugfix {
16 pub current: Vec<Statement>,
18 pub expected: Vec<Statement>,
20 pub unchanged: Vec<Statement>,
22}
23
24#[derive(Clone, Copy, PartialEq, Eq)]
25enum Section {
26 Current,
27 Expected,
28 Unchanged,
29}
30
31fn section(heading: &str) -> Option<Section> {
32 let h = heading.to_lowercase();
33 if h.contains("current behavio") {
34 Some(Section::Current)
35 } else if h.contains("expected behavio") {
36 Some(Section::Expected)
37 } else if h.contains("unchanged behavio") {
38 Some(Section::Unchanged)
39 } else {
40 None
41 }
42}
43
44fn item(line: &str) -> Option<(Option<String>, &str)> {
46 let t = line.trim_start();
47 if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
48 return Some((None, rest.trim()));
49 }
50 let t = t.trim_start_matches("**");
51 let number: String = t
52 .chars()
53 .take_while(|c| c.is_ascii_digit() || *c == '.')
54 .collect();
55 if number.is_empty() || !number.starts_with(|c: char| c.is_ascii_digit()) {
56 return None;
57 }
58 let rest = &t[number.len()..];
59 let rest = rest.trim_start_matches("**");
60 if !rest.starts_with(' ') {
62 return None;
63 }
64 let number = number.trim_end_matches('.');
65 Some((Some(number.to_owned()), rest.trim()))
66}
67
68pub fn read(text: &str) -> Result<Bugfix, String> {
74 let mut b = Bugfix {
75 current: Vec::new(),
76 expected: Vec::new(),
77 unchanged: Vec::new(),
78 };
79 let mut at: Option<Section> = None;
80 let mut open = false;
81 let mut fence = false;
82 for line in text.lines() {
83 let trimmed = line.trim();
84 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
85 fence = !fence;
86 open = false;
87 continue;
88 }
89 if fence {
90 continue;
91 }
92 if trimmed.starts_with('#') {
93 at = section(trimmed);
94 open = false;
95 continue;
96 }
97 let Some(s) = at else { continue };
98 let list = match s {
99 Section::Current => &mut b.current,
100 Section::Expected => &mut b.expected,
101 Section::Unchanged => &mut b.unchanged,
102 };
103 if trimmed.is_empty() {
104 open = false;
105 continue;
106 }
107 if let Some((number, rest)) = item(line) {
108 let n = match s {
110 Section::Current => 1,
111 Section::Expected => 2,
112 Section::Unchanged => 3,
113 };
114 let id = match number {
115 Some(x) if x.contains('.') => x,
116 Some(x) => format!("{n}.{x}"),
117 None => format!("{n}.{}", list.len() + 1),
118 };
119 list.push(Statement {
120 id,
121 text: rest.to_owned(),
122 });
123 open = true;
124 } else if open && let Some(last) = list.last_mut() {
125 last.text.push(' ');
126 last.text.push_str(trimmed);
127 }
128 }
129 if b.current.is_empty() {
130 return Err("no Current Behavior statement".to_owned());
131 }
132 if b.expected.is_empty() {
133 return Err("no Expected Behavior statement".to_owned());
134 }
135 Ok(b)
136}
137
138#[must_use]
140pub fn span(statements: &[Statement]) -> String {
141 match statements {
142 [] => String::new(),
143 [only] => only.id.clone(),
144 [first, .., last] => format!("{}–{}", first.id, last.id),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn kiros_numbered_wrapped_statements_are_read() {
154 let text = "# Bugfix Requirements Document\n\n## Introduction\n\n1. Not a statement.\n\n## Bug Analysis\n\n### Current Behavior (Defect)\n\n1.1 WHEN a run has more than 100 jobs THEN the system fetches only the\nfirst 100 jobs\n\n1.2 WHEN called THEN it uses per_page 100\n\n### Expected Behavior (Correct)\n\n2.1 WHEN a run has more than 100 jobs THEN the system SHALL fetch all\njobs\n\n### Unchanged Behavior (Regression Prevention)\n\n3.1 WHEN a run has 100 or fewer jobs THEN the system SHALL CONTINUE TO\nfetch all jobs\n\n---\n\n## Bug Condition (Formal)\n\n```pascal\n1.1 FUNCTION isBugCondition(X)\n```\n";
155 let b = read(text).unwrap_or_else(|e| panic!("{e}"));
156 assert_eq!(b.current.len(), 2);
157 assert_eq!(
158 b.current[0].text,
159 "WHEN a run has more than 100 jobs THEN the system fetches only the first 100 jobs"
160 );
161 assert_eq!(b.expected[0].id, "2.1");
162 assert!(b.expected[0].text.ends_with("SHALL fetch all jobs"));
163 assert_eq!(b.unchanged.len(), 1);
164 assert_eq!(span(&b.current), "1.1–1.2");
165 }
166
167 #[test]
168 fn list_markers_and_numbered_lists_are_read() {
169 let text = "## Current Behavior (Defect)\n- WHEN idle THEN logged out\n## Expected Behavior (Correct)\n1. WHEN idle THEN the system SHALL keep the session\n2. WHEN active THEN the system SHALL keep it\n## Unchanged Behavior\n";
170 let b = read(text).unwrap_or_else(|e| panic!("{e}"));
171 assert_eq!(b.current[0].id, "1.1");
172 assert_eq!(
173 b.expected.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
174 ["2.1", "2.2"]
175 );
176 assert!(b.unchanged.is_empty());
177 }
178
179 #[test]
180 fn a_triad_without_current_or_expected_is_refused() {
181 assert!(read("## Expected Behavior\n2.1 WHEN x THEN SHALL y\n").is_err());
182 assert!(read("## Current Behavior\n1.1 WHEN x THEN y\n").is_err());
183 }
184}