1use std::collections::BTreeMap;
4
5const KINDS: [&str; 5] = [
6 "functional",
7 "non-functional",
8 "persisted-format",
9 "ordering",
10 "security",
11];
12const FIELDS: [&str; 4] = ["must", "why", "kind", "status"];
13
14pub struct Obligation {
15 pub kind: String,
16 pub retired: bool,
17}
18
19struct Block<'a> {
20 id: u32,
21 line: usize,
22 fields: BTreeMap<&'a str, &'a str>,
23}
24
25pub fn obligations(readme: &str, file: &str) -> Result<BTreeMap<u32, Obligation>, Vec<String>> {
29 let mut found = BTreeMap::new();
30 let mut problems = Vec::new();
31 let mut in_section = false;
32 let mut in_fence = false;
33 let mut block = None;
34 for (i, line) in readme.lines().enumerate() {
35 let n = i + 1;
36 let line = line.trim_end();
37 if line.trim_start().starts_with("```") {
38 in_fence = !in_fence;
39 continue;
40 }
41 if in_fence {
42 continue;
43 }
44 if line.starts_with("## ") {
45 finish(block.take(), &mut found, &mut problems);
46 in_section = line == "## Obligations";
47 continue;
48 }
49 if !in_section {
50 if line.starts_with("### A") || line.starts_with("- **A") {
51 problems.push((n, "an obligation outside ## Obligations".to_string()));
52 }
53 continue;
54 }
55 if line.is_empty() {
56 continue;
57 }
58 if let Some(rest) = line.strip_prefix("### ") {
59 finish(block.take(), &mut found, &mut problems);
60 match heading(rest) {
61 Some(id) => {
62 block = Some(Block {
63 id,
64 line: n,
65 fields: BTreeMap::new(),
66 })
67 }
68 None => problems.push((n, format!("malformed obligation heading `{line}`"))),
69 }
70 continue;
71 }
72 match (field(line), block.as_mut()) {
73 (Some((name, _)), Some(_)) if !FIELDS.contains(&name) => {
74 problems.push((n, format!("unknown field `{name}`")))
75 }
76 (Some((name, value)), Some(block)) => {
77 if block.fields.insert(name, value).is_some() {
78 problems.push((n, format!("`{name}` is given twice")));
79 }
80 }
81 _ => problems.push((n, format!("`{line}` is not part of an obligation block"))),
82 }
83 }
84 finish(block, &mut found, &mut problems);
85 if !problems.is_empty() {
86 problems.sort_by_key(|(n, _)| *n);
87 return Err(problems
88 .into_iter()
89 .map(|(n, problem)| format!("{file}:{n}: {problem}"))
90 .collect());
91 }
92 if found.is_empty() {
93 return Err(vec![format!("{file}: declares no obligations")]);
94 }
95 Ok(found)
96}
97
98fn heading(rest: &str) -> Option<u32> {
100 let (num, title) = rest.strip_prefix('A')?.split_once(' ')?;
101 title
102 .strip_prefix("— ")
103 .or_else(|| title.strip_prefix("- "))?;
104 num.parse().ok()
105}
106
107fn field(line: &str) -> Option<(&str, &str)> {
109 line.strip_prefix("- **")?.split_once(":** ")
110}
111
112fn finish(
113 block: Option<Block>,
114 found: &mut BTreeMap<u32, Obligation>,
115 problems: &mut Vec<(usize, String)>,
116) {
117 let Some(Block { id, line, fields }) = block else {
118 return;
119 };
120 for name in ["must", "kind", "status"] {
121 if !fields.contains_key(name) {
122 problems.push((line, format!("A{id} has no `{name}`")));
123 }
124 }
125 if let Some(kind) = fields.get("kind")
126 && !KINDS.contains(kind)
127 {
128 let kinds = KINDS.join(", ");
129 problems.push((line, format!("A{id} has kind `{kind}`, not one of {kinds}")));
130 }
131 let retired = match fields.get("status") {
132 Some(&"retired") => true,
133 Some(&"active") | None => false,
134 Some(status) => {
135 problems.push((
136 line,
137 format!("A{id} has status `{status}`, not active or retired"),
138 ));
139 false
140 }
141 };
142 let kind = fields.get("kind").unwrap_or(&"").to_string();
143 if found.insert(id, Obligation { kind, retired }).is_some() {
144 problems.push((line, format!("A{id} is declared twice")));
145 }
146}