1use speccoder_core::snapshot::{
6 DependencyMode, Diagnostic, ParsedTasks, Phase, Severity, Task, Totals,
7};
8
9use crate::hash::sha256_hex;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Criterion {
14 pub id: String,
16 pub text: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Requirement {
23 pub number: String,
25 pub title: String,
27 pub text: String,
29 pub criteria: Vec<Criterion>,
31}
32
33impl Requirement {
34 #[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#[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 c.text.push(' ');
103 c.text.push_str(trimmed);
104 }
105 }
106 out
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Parent {
112 pub id: String,
114 pub line: Option<([usize; 2], String)>,
116 pub done: bool,
118 pub children: Vec<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Tasks {
126 pub parsed: ParsedTasks,
128 pub parents: Vec<Parent>,
130 pub optional: Vec<String>,
132}
133
134struct 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 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
181fn 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 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
202fn 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
215fn parent_of(id: &str) -> Option<&str> {
217 id.rsplit_once('.').map(|(p, _)| p)
218}
219
220#[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
449pub enum When {
450 Always,
452 Files(Vec<String>),
454 About(String),
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct Rule {
461 pub name: String,
463 pub when: When,
465 pub text: String,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Default)]
471pub struct Steering {
472 pub rules: Vec<Rule>,
474 pub left_out: Vec<(String, String)>,
476}
477
478fn unquote(v: &str) -> String {
480 v.trim().trim_matches(['"', '\'']).to_owned()
481}
482
483fn 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
510fn 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#[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}