Skip to main content

speccoder_kiro/
bugfix.rs

1//! A bugfix spec's `bugfix.md`: its Current, Expected and Unchanged
2//! statements, numbered as the file numbers them (`2.1 WHEN … SHALL …`).
3
4/// One statement: its number in the file (`2.1`, else its position) and text.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Statement {
7    /// `2.1`.
8    pub id: String,
9    /// The statement, wrapped lines joined.
10    pub text: String,
11}
12
13/// The triad of a `bugfix.md`.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Bugfix {
16    /// Current Behavior (Defect).
17    pub current: Vec<Statement>,
18    /// Expected Behavior (Correct).
19    pub expected: Vec<Statement>,
20    /// Unchanged Behavior (Regression Prevention).
21    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
44/// `1.1 WHEN …`, `1. WHEN …`, `- WHEN …`: the item's number (if any) and text.
45fn 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    // `1.1 WHEN`, `1. WHEN`: a number then a space; never `1.5x faster`.
61    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
68/// Reads `bugfix.md`.
69///
70/// # Errors
71///
72/// No Current or no Expected statement: the triad is not there to run.
73pub 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            // `2.1` as written; a plain `1.` or `-` is numbered in its section.
109            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/// `2.1–2.3`, `3.1`: a statement list's numbers as a range.
139#[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}