Skip to main content

speccoder_kiro/
parse.rs

1//! Kiro's files, read as written: `requirements.md` (numbered EARS
2//! criteria under `### Requirement N`), `tasks.md` line by line (`[ ]*`
3//! is not a Markdown task) and the steering with when it applies.
4
5use speccoder_core::snapshot::{
6    DependencyMode, Diagnostic, ParsedTasks, Phase, Severity, Task, Totals,
7};
8
9use crate::hash::sha256_hex;
10
11/// One acceptance criterion: `N.M` and its text.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Criterion {
14    /// `1.2`: the requirement's number and the criterion's list number.
15    pub id: String,
16    /// The criterion as written (an EARS sentence).
17    pub text: String,
18}
19
20/// One `### Requirement N` with its criteria.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Requirement {
23    /// `N`.
24    pub number: String,
25    /// The heading after `Requirement N:`, else `Requirement N`.
26    pub title: String,
27    /// The whole section, heading included.
28    pub text: String,
29    /// Its numbered acceptance criteria.
30    pub criteria: Vec<Criterion>,
31}
32
33impl Requirement {
34    /// `Requirement 1: Automatic labels`: how a person names it.
35    #[must_use]
36    pub fn key(&self) -> String {
37        if self.title == format!("Requirement {}", self.number) {
38            self.title.clone()
39        } else {
40            format!("Requirement {}: {}", self.number, self.title)
41        }
42    }
43}
44
45/// The requirements of a `requirements.md`, in order.
46#[must_use]
47pub fn requirements(text: &str) -> Vec<Requirement> {
48    let mut out: Vec<Requirement> = Vec::new();
49    let mut in_criteria = false;
50    for line in text.lines() {
51        if let Some(rest) = line.strip_prefix("### ") {
52            in_criteria = false;
53            let rest = rest.trim();
54            let Some(after) = rest.strip_prefix("Requirement ") else {
55                continue;
56            };
57            let number: String = after.chars().take_while(char::is_ascii_digit).collect();
58            if number.is_empty() {
59                continue;
60            }
61            let title = after[number.len()..]
62                .trim_start_matches([':', ' ', '-', '–'])
63                .trim();
64            out.push(Requirement {
65                title: if title.is_empty() {
66                    format!("Requirement {number}")
67                } else {
68                    title.to_owned()
69                },
70                number,
71                text: format!("{line}\n"),
72                criteria: Vec::new(),
73            });
74            continue;
75        }
76        if line.starts_with("## ") {
77            in_criteria = false;
78            continue;
79        }
80        let Some(r) = out.last_mut() else { continue };
81        r.text.push_str(line);
82        r.text.push('\n');
83        if line.starts_with("#### ") {
84            in_criteria = line.to_lowercase().contains("acceptance criteria");
85            continue;
86        }
87        if !in_criteria {
88            continue;
89        }
90        let trimmed = line.trim_start();
91        let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
92        if !digits.is_empty() && trimmed[digits.len()..].starts_with(". ") {
93            r.criteria.push(Criterion {
94                id: format!("{}.{digits}", r.number),
95                text: trimmed[digits.len() + 2..].trim().to_owned(),
96            });
97        } else if !trimmed.is_empty()
98            && line.starts_with([' ', '\t'])
99            && let Some(c) = r.criteria.last_mut()
100        {
101            // A criterion's continuation line.
102            c.text.push(' ');
103            c.text.push_str(trimmed);
104        }
105    }
106    out
107}
108
109/// A parent task: the engine ticks it with its last required sub-task.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Parent {
112    /// `2`.
113    pub id: String,
114    /// Its line's byte range and hash (a `## 1. Group` heading has none).
115    pub line: Option<([usize; 2], String)>,
116    /// Ticked.
117    pub done: bool,
118    /// Its required sub-tasks.
119    pub children: Vec<String>,
120}
121
122/// `tasks.md` read: the tasks speccoder runs, the parents it closes and
123/// the optional tasks it leaves alone.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Tasks {
126    /// The runnable tasks, as every format's.
127    pub parsed: ParsedTasks,
128    /// Parent tasks, by id.
129    pub parents: Vec<Parent>,
130    /// Optional (`[ ]*`) tasks, by id.
131    pub optional: Vec<String>,
132}
133
134/// One checkbox line: `- [ ]`, `[x]`, `[X]`, an optional `*` (after the
135/// box or the number), a number.
136struct Line {
137    done: bool,
138    open: bool,
139    optional: bool,
140    id: String,
141    title: String,
142}
143
144fn checkbox(line: &str) -> Option<Line> {
145    let indent = line.len() - line.trim_start().len();
146    let rest = line[indent..].strip_prefix(['-', '*'])?.strip_prefix(' ')?;
147    let body = rest.strip_prefix('[')?;
148    let close = body.find(']')?;
149    let inner = &body[..close];
150    if inner.chars().count() > 1 {
151        return None;
152    }
153    let after = &body[close + 1..];
154    let (optional, after) = match after.strip_prefix('*') {
155        Some(a) => (true, a),
156        None => (false, after),
157    };
158    let after = after.trim_start();
159    let id: String = after
160        .chars()
161        .take_while(|c| c.is_ascii_digit() || *c == '.')
162        .collect();
163    // cc-sdd also writes the star after the number: `- [ ] 7.9* (P) …`.
164    let optional = optional || after[id.len()..].starts_with('*');
165    let id = id.trim_end_matches('.').to_owned();
166    if id.is_empty() || !id.starts_with(|c: char| c.is_ascii_digit()) {
167        return None;
168    }
169    let title = after[after.find(char::is_whitespace).unwrap_or(after.len())..]
170        .trim()
171        .to_owned();
172    Some(Line {
173        done: matches!(inner, "x" | "X"),
174        open: inner == " ",
175        optional,
176        id,
177        title,
178    })
179}
180
181/// `_Requirements: 1.2, 3_` → `["1.2", "3"]`; free text cites nothing.
182fn citations(detail: &str) -> Option<Vec<String>> {
183    let d = detail.trim().trim_start_matches(['-', '*', ' ']);
184    let inner = d
185        .strip_prefix("_Requirements:")
186        .and_then(|r| r.strip_suffix('_'))
187        .or_else(|| {
188            // `**Validates: Requirements 1.2**`: its leading `**` went with the marker.
189            d.strip_prefix("Validates: Requirements")
190                .map(|r| r.trim_end().trim_end_matches('*'))
191        })?;
192    Some(
193        inner
194            .split(',')
195            .map(str::trim)
196            .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_digit() || c == '.'))
197            .map(str::to_owned)
198            .collect(),
199    )
200}
201
202/// `_Depends: 1.1, 1.2_` → the tasks named.
203fn depends(detail: &str) -> Option<Vec<String>> {
204    let d = detail.trim().trim_start_matches(['-', '*', ' ']);
205    let inner = d.strip_prefix("_Depends:")?.strip_suffix('_')?;
206    Some(
207        inner
208            .split(',')
209            .map(|w| w.trim().trim_end_matches('.').to_owned())
210            .filter(|w| !w.is_empty())
211            .collect(),
212    )
213}
214
215/// The parent's id of `id` (`2.1` → `2`), if it has one.
216fn parent_of(id: &str) -> Option<&str> {
217    id.rsplit_once('.').map(|(p, _)| p)
218}
219
220/// Reads a Kiro or cc-sdd `tasks.md` at `path` (for diagnostics).
221/// Citations stay as written: a whole requirement (`3`) is expanded by the
222/// adapter, which has the requirements.
223#[must_use]
224pub fn tasks(path: &str, text: &str) -> (Tasks, Vec<Diagnostic>) {
225    struct Entry {
226        line: Line,
227        no: usize,
228        range: [usize; 2],
229        hash: String,
230        cites: Vec<String>,
231        depends: Vec<String>,
232    }
233    let mut diags = Vec::new();
234    let diag = |severity, code: &str, no: usize, message: String| {
235        Diagnostic::new(severity, code, message)
236            .at(path)
237            .line(u32::try_from(no + 1).unwrap_or(u32::MAX))
238    };
239    let mut entries: Vec<Entry> = Vec::new();
240    // `## 1. Group` headings (cc-sdd): a parent without a box.
241    let mut groups: Vec<(String, String, usize)> = Vec::new();
242    let mut fence = false;
243    let mut offset = 0;
244    for (no, full) in text.split_inclusive('\n').enumerate() {
245        let start = offset;
246        offset += full.len();
247        let line = full.trim_end_matches(['\n', '\r']);
248        let trimmed = line.trim_start();
249        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
250            fence = !fence;
251            continue;
252        }
253        if fence {
254            continue;
255        }
256        if let Some(h) = line.strip_prefix("## ") {
257            let number: String = h.chars().take_while(char::is_ascii_digit).collect();
258            if !number.is_empty() && h[number.len()..].starts_with('.') {
259                groups.push((number.clone(), h.trim().to_owned(), no));
260            }
261            continue;
262        }
263        if let Some(l) = checkbox(line) {
264            entries.push(Entry {
265                no,
266                range: [start, start + line.len()],
267                hash: sha256_hex(line.as_bytes()),
268                line: l,
269                cites: Vec::new(),
270                depends: Vec::new(),
271            });
272            continue;
273        }
274        // A detail line belongs to the task above it.
275        if let Some(e) = entries.last_mut()
276            && line.len() > line.trim_start().len()
277        {
278            if let Some(c) = citations(line) {
279                e.cites.extend(c);
280            } else if let Some(d) = depends(line) {
281                e.depends.extend(d);
282            }
283        }
284    }
285    let known =
286        |id: &str| entries.iter().any(|e| e.line.id == id) || groups.iter().any(|g| g.0 == id);
287    let mut seen = std::collections::BTreeSet::new();
288    for e in &entries {
289        if !seen.insert(e.line.id.clone()) {
290            diags.push(diag(
291                Severity::Error,
292                "tasks.duplicate-id",
293                e.no,
294                format!("task {} appears twice", e.line.id),
295            ));
296        }
297        if let Some(p) = parent_of(&e.line.id)
298            && !known(p)
299        {
300            diags.push(diag(
301                Severity::Error,
302                "tasks.orphan",
303                e.no,
304                format!("task {} has no task {p} above it", e.line.id),
305            ));
306        }
307        if !e.line.done && !e.line.open {
308            diags.push(diag(
309                Severity::Warning,
310                "tasks.marker",
311                e.no,
312                format!(
313                    "task {}'s box is neither open nor done: speccoder cannot tick it",
314                    e.line.id
315                ),
316            ));
317        }
318    }
319    let has_children = |id: &str| entries.iter().any(|c| parent_of(&c.line.id) == Some(id));
320    let required_children = |id: &str| -> Vec<String> {
321        entries
322            .iter()
323            .filter(|c| parent_of(&c.line.id) == Some(id) && !c.line.optional)
324            .map(|c| c.line.id.clone())
325            .collect()
326    };
327    let mut parents: Vec<Parent> = entries
328        .iter()
329        .filter(|e| has_children(&e.line.id))
330        .map(|e| Parent {
331            id: e.line.id.clone(),
332            line: Some((e.range, e.hash.clone())),
333            done: e.line.done,
334            children: required_children(&e.line.id),
335        })
336        .collect();
337    parents.extend(
338        groups
339            .iter()
340            .filter(|g| !entries.iter().any(|e| e.line.id == g.0))
341            .map(|g| Parent {
342                id: g.0.clone(),
343                line: None,
344                done: false,
345                children: required_children(&g.0),
346            }),
347    );
348    let optional: Vec<String> = entries
349        .iter()
350        .filter(|e| e.line.optional)
351        .map(|e| e.line.id.clone())
352        .collect();
353    // A group per top-level task: its title is the phase its sub-tasks show under.
354    let top = |id: &str| id.split('.').next().unwrap_or(id).to_owned();
355    let phase_title = |id: &str| -> String {
356        let t = top(id);
357        entries
358            .iter()
359            .find(|e| e.line.id == t)
360            .map(|e| format!("{t}. {}", e.line.title))
361            .or_else(|| groups.iter().find(|g| g.0 == t).map(|g| g.1.clone()))
362            .unwrap_or_else(|| format!("{t}."))
363    };
364    let mut phases: Vec<Phase> = Vec::new();
365    let mut tasks_out: Vec<Task> = Vec::new();
366    for e in &entries {
367        if e.line.optional || has_children(&e.line.id) {
368            continue;
369        }
370        let phase = phase_title(&e.line.id);
371        if phases.last().is_none_or(|p| p.title != phase) {
372            phases.push(Phase {
373                title: phase.clone(),
374                line: u32::try_from(e.no + 1).unwrap_or(u32::MAX),
375                section_hash: String::new(),
376            });
377        }
378        let (parallel, title) = match e.line.title.strip_prefix("(P)") {
379            Some(t) => (true, t.trim().to_owned()),
380            None => (false, e.line.title.clone()),
381        };
382        for d in &e.depends {
383            if !known(d) {
384                diags.push(diag(
385                    Severity::Error,
386                    "tasks.unknown-dependency",
387                    e.no,
388                    format!(
389                        "task {} depends on {d}, which is not in the list",
390                        e.line.id
391                    ),
392                ));
393            }
394        }
395        tasks_out.push(Task {
396            id: e.line.id.clone(),
397            done: e.line.done,
398            parallel,
399            story: None,
400            yours: false,
401            title,
402            depends_on: e.depends.clone(),
403            scenarios: e.cites.clone(),
404            phase: Some(phase),
405            subsection: None,
406            line: u32::try_from(e.no + 1).unwrap_or(u32::MAX),
407            byte_range: e.range,
408            line_hash: e.hash.clone(),
409        });
410    }
411    // Each group's section: from its first line to the next group's.
412    let starts: Vec<usize> = phases
413        .iter()
414        .map(|p| {
415            text.split_inclusive('\n')
416                .take(p.line as usize - 1)
417                .map(str::len)
418                .sum()
419        })
420        .collect();
421    for (i, p) in phases.iter_mut().enumerate() {
422        let end = starts.get(i + 1).copied().unwrap_or(text.len());
423        p.section_hash = sha256_hex(&text.as_bytes()[starts[i]..end]);
424    }
425    let done = tasks_out.iter().filter(|t| t.done).count();
426    let total = tasks_out.len();
427    (
428        Tasks {
429            parsed: ParsedTasks {
430                path: path.to_owned(),
431                phases,
432                tasks: tasks_out,
433                totals: Totals {
434                    total,
435                    done,
436                    open: total - done,
437                },
438                dependency_mode: DependencyMode::Chain,
439            },
440            parents,
441            optional,
442        },
443        diags,
444    )
445}
446
447/// When a steering file applies, as Kiro includes it.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub enum When {
450    /// `inclusion: always`, or no front matter.
451    Always,
452    /// `inclusion: fileMatch`: work on files matching these patterns.
453    Files(Vec<String>),
454    /// `inclusion: auto`: work its description is about.
455    About(String),
456}
457
458/// One steering file an attempt reads.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct Rule {
461    /// Its file name (`tech.md`).
462    pub name: String,
463    /// When it applies.
464    pub when: When,
465    /// Its text, front matter removed.
466    pub text: String,
467}
468
469/// `.kiro/steering/` read: the rules, and the files left out with why.
470#[derive(Debug, Clone, PartialEq, Eq, Default)]
471pub struct Steering {
472    /// What an attempt reads, by file name.
473    pub rules: Vec<Rule>,
474    /// `(file, why)`: `manual` files, a `fileMatch` without a pattern.
475    pub left_out: Vec<(String, String)>,
476}
477
478/// A front-matter value without its quotes.
479fn unquote(v: &str) -> String {
480    v.trim().trim_matches(['"', '\'']).to_owned()
481}
482
483/// `"a"`, `["a", "b"]`, or a `- a` list on the lines after the key.
484fn patterns(front: &str) -> Vec<String> {
485    let mut lines = front.lines();
486    while let Some(l) = lines.next() {
487        let Some(v) = l.trim_start().strip_prefix("fileMatchPattern:") else {
488            continue;
489        };
490        let v = v.trim();
491        if let Some(inner) = v.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
492            return inner
493                .split(',')
494                .map(unquote)
495                .filter(|p| !p.is_empty())
496                .collect();
497        }
498        if !v.is_empty() {
499            return vec![unquote(v)];
500        }
501        return lines
502            .map_while(|l| l.trim_start().strip_prefix("- "))
503            .map(unquote)
504            .filter(|p| !p.is_empty())
505            .collect();
506    }
507    Vec::new()
508}
509
510/// The value of `key:` in `front`.
511fn value(front: &str, key: &str) -> Option<String> {
512    front
513        .lines()
514        .find_map(|l| l.trim_start().strip_prefix(key)?.strip_prefix(':'))
515        .map(unquote)
516}
517
518/// The steering in `dir` (`.kiro/steering`), by file name: `always` and
519/// unmarked files as rules; `fileMatch` and `auto` with their condition;
520/// `manual` left out, as Kiro leaves it out until a person names it.
521#[must_use]
522pub fn steering(dir: &std::path::Path) -> Steering {
523    let Ok(read) = std::fs::read_dir(dir) else {
524        return Steering::default();
525    };
526    let mut files: Vec<_> = read
527        .flatten()
528        .map(|e| e.path())
529        .filter(|p| p.extension().is_some_and(|x| x == "md"))
530        .collect();
531    files.sort();
532    let mut out = Steering::default();
533    for p in files {
534        let (Ok(text), Some(name)) = (std::fs::read_to_string(&p), p.file_name()) else {
535            continue;
536        };
537        let name = name.to_string_lossy().into_owned();
538        let Some((front, body)) = text
539            .strip_prefix("---\n")
540            .and_then(|rest| rest.split_once("\n---\n"))
541        else {
542            out.rules.push(Rule {
543                name,
544                when: When::Always,
545                text,
546            });
547            continue;
548        };
549        let body = body.trim_start().to_owned();
550        let when = match value(front, "inclusion").as_deref() {
551            None | Some("always") => When::Always,
552            Some("fileMatch") => match patterns(front) {
553                p if p.is_empty() => {
554                    out.left_out
555                        .push((name, "`fileMatch` without a `fileMatchPattern`".to_owned()));
556                    continue;
557                }
558                p => When::Files(p),
559            },
560            Some("auto") => match value(front, "description").filter(|d| !d.is_empty()) {
561                Some(d) => When::About(d),
562                None => {
563                    out.left_out
564                        .push((name, "`auto` without a `description`".to_owned()));
565                    continue;
566                }
567            },
568            Some("manual") => {
569                out.left_out
570                    .push((name, "manual: read only when a person names it".to_owned()));
571                continue;
572            }
573            Some(other) => {
574                out.left_out
575                    .push((name, format!("unknown inclusion `{other}`")));
576                continue;
577            }
578        };
579        out.rules.push(Rule {
580            name,
581            when,
582            text: body,
583        });
584    }
585    out
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    fn fixture(project: &str, file: &str) -> String {
593        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
594            .join("tests/fixtures")
595            .join(project)
596            .join(".kiro/specs")
597            .join(project)
598            .join(file);
599        std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display()))
600    }
601
602    #[test]
603    fn kiro_requirements_are_numbered_criteria() {
604        let reqs = requirements(&fixture("issue-triage", "requirements.md"));
605        assert_eq!(reqs.len(), 2);
606        assert_eq!(reqs[0].key(), "Requirement 1: Automatic labels");
607        let ids: Vec<&str> = reqs[0].criteria.iter().map(|c| c.id.as_str()).collect();
608        assert_eq!(ids, ["1.1", "1.2", "1.3"]);
609        assert!(
610            reqs[0].criteria[0]
611                .text
612                .starts_with("WHEN a new issue mentions \"crash\", THE Triage_Bot SHALL")
613        );
614        assert!(reqs[0].text.contains("**User Story:**"));
615        assert_eq!(reqs[1].criteria.len(), 1);
616    }
617
618    #[test]
619    fn cc_sdd_requirements_read_the_same() {
620        let reqs = requirements(&fixture("photo-albums", "requirements.md"));
621        assert_eq!(reqs.len(), 2);
622        assert!(reqs[0].text.contains("**Objective:**"));
623        assert!(
624            reqs[0].criteria[1]
625                .text
626                .starts_with("IF an album contains no photos THEN")
627        );
628    }
629
630    #[test]
631    fn kiro_tasks_leave_parents_and_optional_tasks_to_the_engine() {
632        let (t, diags) = tasks("tasks.md", &fixture("issue-triage", "tasks.md"));
633        assert!(diags.is_empty(), "{diags:?}");
634        let ids: Vec<&str> = t.parsed.tasks.iter().map(|t| t.id.as_str()).collect();
635        assert_eq!(ids, ["1", "2.1", "2.2", "3", "4"]);
636        assert_eq!(t.optional, ["2.3"]);
637        assert_eq!(t.parents.len(), 1);
638        assert_eq!(t.parents[0].id, "2");
639        assert_eq!(t.parents[0].children, ["2.1", "2.2"]);
640        let task = |id: &str| t.parsed.tasks.iter().find(|t| t.id == id).unwrap();
641        assert!(task("1").done, "ticked before speccoder ran");
642        assert!(task("1").scenarios.is_empty(), "free text cites nothing");
643        assert_eq!(task("2.2").scenarios, ["1.2", "1.3"]);
644        assert_eq!(
645            task("2.1").phase.as_deref(),
646            Some("2. Implement the labelling rules")
647        );
648        assert_eq!(
649            task("4").scenarios,
650            ["2"],
651            "a whole requirement, expanded by the adapter"
652        );
653        assert_eq!(task("3").title, "Checkpoint - Rules working");
654        assert_eq!(t.parsed.dependency_mode, DependencyMode::Chain);
655    }
656
657    #[test]
658    fn cc_sdd_tasks_find_parents_by_number_and_read_depends() {
659        let (t, diags) = tasks("tasks.md", &fixture("photo-albums", "tasks.md"));
660        assert!(diags.is_empty(), "{diags:?}");
661        let ids: Vec<&str> = t.parsed.tasks.iter().map(|t| t.id.as_str()).collect();
662        assert_eq!(ids, ["1.1", "1.2", "2.1", "3"]);
663        let task = |id: &str| t.parsed.tasks.iter().find(|t| t.id == id).unwrap();
664        assert!(task("1.1").parallel);
665        assert_eq!(task("1.1").title, "Create the album model in `src/lib.rs`");
666        assert_eq!(task("2.1").depends_on, ["1.1"]);
667        assert_eq!(
668            t.parents.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
669            ["1", "2"]
670        );
671    }
672
673    #[test]
674    fn a_star_after_the_number_is_optional_too() {
675        let (t, _) = tasks(
676            "tasks.md",
677            "- [ ] 7.8 E2E tests\n- [ ] 7.9* (P) Performance tests\n",
678        );
679        let ids: Vec<&str> = t.parsed.tasks.iter().map(|t| t.id.as_str()).collect();
680        assert_eq!(ids, ["7.8"]);
681        assert_eq!(t.optional, ["7.9"]);
682    }
683
684    #[test]
685    fn a_validates_line_cites_its_criteria() {
686        let (t, _) = tasks(
687            "tasks.md",
688            "- [ ] 1. Property test\n  - **Property 1: One label**\n  - **Validates: Requirements 1.2, 1.3**\n",
689        );
690        assert_eq!(t.parsed.tasks[0].scenarios, ["1.2", "1.3"]);
691    }
692
693    #[test]
694    fn broken_lists_are_named() {
695        let (_, diags) = tasks(
696            "tasks.md",
697            "- [ ] 1. A\n- [ ] 1. B\n- [ ] 3.1 C\n  - _Depends: 9_\n",
698        );
699        let codes: Vec<&str> = diags.iter().map(|d| d.code.as_str()).collect();
700        assert_eq!(
701            codes,
702            [
703                "tasks.duplicate-id",
704                "tasks.orphan",
705                "tasks.unknown-dependency"
706            ]
707        );
708    }
709
710    #[test]
711    fn steering_is_read_with_its_conditions() {
712        let dir = tempfile::tempdir().unwrap();
713        let w = |n: &str, t: &str| std::fs::write(dir.path().join(n), t).unwrap();
714        w("tech.md", "# Tech\n");
715        w("always.md", "---\ninclusion: always\n---\n\nAlways.\n");
716        w(
717            "ui.md",
718            "---\ninclusion: fileMatch\nfileMatchPattern: [\"**/*.ts\", '**/*.tsx']\n---\nUI.\n",
719        );
720        w(
721            "web.md",
722            "---\ninclusion: fileMatch\nfileMatchPattern:\n  - \"web/**\"\n---\nWeb.\n",
723        );
724        w(
725            "api.md",
726            "---\ninclusion: auto\nname: api\ndescription: REST handlers\n---\nAPI.\n",
727        );
728        w("notes.md", "---\ninclusion: manual\n---\nNotes.\n");
729        w("broken.md", "---\ninclusion: fileMatch\n---\nNo pattern.\n");
730        let s = steering(dir.path());
731        let rules: Vec<(&str, &When)> =
732            s.rules.iter().map(|r| (r.name.as_str(), &r.when)).collect();
733        assert_eq!(
734            rules,
735            [
736                ("always.md", &When::Always),
737                ("api.md", &When::About("REST handlers".to_owned())),
738                ("tech.md", &When::Always),
739                (
740                    "ui.md",
741                    &When::Files(vec!["**/*.ts".to_owned(), "**/*.tsx".to_owned()])
742                ),
743                ("web.md", &When::Files(vec!["web/**".to_owned()])),
744            ]
745        );
746        assert_eq!(s.rules[0].text, "Always.\n");
747        let left: Vec<&str> = s.left_out.iter().map(|(n, _)| n.as_str()).collect();
748        assert_eq!(left, ["broken.md", "notes.md"]);
749    }
750}