speccoder-kiro 0.13.0

Kiro adapter for speccoder: spec discovery, requirements with their EARS criteria, tasks.md parsing, the engine's tick.
Documentation
//! A bugfix spec's `bugfix.md`: its Current, Expected and Unchanged
//! statements, numbered as the file numbers them (`2.1 WHEN … SHALL …`).

/// One statement: its number in the file (`2.1`, else its position) and text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Statement {
    /// `2.1`.
    pub id: String,
    /// The statement, wrapped lines joined.
    pub text: String,
}

/// The triad of a `bugfix.md`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bugfix {
    /// Current Behavior (Defect).
    pub current: Vec<Statement>,
    /// Expected Behavior (Correct).
    pub expected: Vec<Statement>,
    /// Unchanged Behavior (Regression Prevention).
    pub unchanged: Vec<Statement>,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
    Current,
    Expected,
    Unchanged,
}

fn section(heading: &str) -> Option<Section> {
    let h = heading.to_lowercase();
    if h.contains("current behavio") {
        Some(Section::Current)
    } else if h.contains("expected behavio") {
        Some(Section::Expected)
    } else if h.contains("unchanged behavio") {
        Some(Section::Unchanged)
    } else {
        None
    }
}

/// `1.1 WHEN …`, `1. WHEN …`, `- WHEN …`: the item's number (if any) and text.
fn item(line: &str) -> Option<(Option<String>, &str)> {
    let t = line.trim_start();
    if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
        return Some((None, rest.trim()));
    }
    let t = t.trim_start_matches("**");
    let number: String = t
        .chars()
        .take_while(|c| c.is_ascii_digit() || *c == '.')
        .collect();
    if number.is_empty() || !number.starts_with(|c: char| c.is_ascii_digit()) {
        return None;
    }
    let rest = &t[number.len()..];
    let rest = rest.trim_start_matches("**");
    // `1.1 WHEN`, `1. WHEN`: a number then a space; never `1.5x faster`.
    if !rest.starts_with(' ') {
        return None;
    }
    let number = number.trim_end_matches('.');
    Some((Some(number.to_owned()), rest.trim()))
}

/// Reads `bugfix.md`.
///
/// # Errors
///
/// No Current or no Expected statement: the triad is not there to run.
pub fn read(text: &str) -> Result<Bugfix, String> {
    let mut b = Bugfix {
        current: Vec::new(),
        expected: Vec::new(),
        unchanged: Vec::new(),
    };
    let mut at: Option<Section> = None;
    let mut open = false;
    let mut fence = false;
    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
            fence = !fence;
            open = false;
            continue;
        }
        if fence {
            continue;
        }
        if trimmed.starts_with('#') {
            at = section(trimmed);
            open = false;
            continue;
        }
        let Some(s) = at else { continue };
        let list = match s {
            Section::Current => &mut b.current,
            Section::Expected => &mut b.expected,
            Section::Unchanged => &mut b.unchanged,
        };
        if trimmed.is_empty() {
            open = false;
            continue;
        }
        if let Some((number, rest)) = item(line) {
            // `2.1` as written; a plain `1.` or `-` is numbered in its section.
            let n = match s {
                Section::Current => 1,
                Section::Expected => 2,
                Section::Unchanged => 3,
            };
            let id = match number {
                Some(x) if x.contains('.') => x,
                Some(x) => format!("{n}.{x}"),
                None => format!("{n}.{}", list.len() + 1),
            };
            list.push(Statement {
                id,
                text: rest.to_owned(),
            });
            open = true;
        } else if open && let Some(last) = list.last_mut() {
            last.text.push(' ');
            last.text.push_str(trimmed);
        }
    }
    if b.current.is_empty() {
        return Err("no Current Behavior statement".to_owned());
    }
    if b.expected.is_empty() {
        return Err("no Expected Behavior statement".to_owned());
    }
    Ok(b)
}

/// `2.1–2.3`, `3.1`: a statement list's numbers as a range.
#[must_use]
pub fn span(statements: &[Statement]) -> String {
    match statements {
        [] => String::new(),
        [only] => only.id.clone(),
        [first, .., last] => format!("{}–{}", first.id, last.id),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn kiros_numbered_wrapped_statements_are_read() {
        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";
        let b = read(text).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(b.current.len(), 2);
        assert_eq!(
            b.current[0].text,
            "WHEN a run has more than 100 jobs THEN the system fetches only the first 100 jobs"
        );
        assert_eq!(b.expected[0].id, "2.1");
        assert!(b.expected[0].text.ends_with("SHALL fetch all jobs"));
        assert_eq!(b.unchanged.len(), 1);
        assert_eq!(span(&b.current), "1.1–1.2");
    }

    #[test]
    fn list_markers_and_numbered_lists_are_read() {
        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";
        let b = read(text).unwrap_or_else(|e| panic!("{e}"));
        assert_eq!(b.current[0].id, "1.1");
        assert_eq!(
            b.expected.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
            ["2.1", "2.2"]
        );
        assert!(b.unchanged.is_empty());
    }

    #[test]
    fn a_triad_without_current_or_expected_is_refused() {
        assert!(read("## Expected Behavior\n2.1 WHEN x THEN SHALL y\n").is_err());
        assert!(read("## Current Behavior\n1.1 WHEN x THEN y\n").is_err());
    }
}