expectation_docs/
outline.rs1use expectation_model::text::slug;
20use expectation_model::{SectionId, Suite};
21use std::path::Path;
22
23pub struct Outline {
24 pub parts: Vec<Part>,
25 pub pages: Vec<Authored>,
26}
27
28pub struct Part {
29 pub name: String,
30 pub items: Vec<Item>,
31}
32
33#[derive(Clone, Copy, PartialEq)]
34pub enum Item {
35 Section(SectionId),
36 Page(usize),
37}
38
39pub struct Authored {
41 pub slug: String,
42 pub title: String,
43 pub source: String,
44 pub part: usize,
46}
47
48impl Authored {
49 pub fn about(&self) -> String {
51 let mut para: Vec<&str> = vec![];
52 for line in self.source.lines().skip(1) {
53 let line = line.trim();
54 if line.is_empty() || line.starts_with('#') || line.starts_with("```") || line.starts_with('|') {
55 if para.is_empty() {
56 continue;
57 }
58 break;
59 }
60 para.push(line);
61 }
62 let text = para.join(" ");
63 first_sentence(&text)
64 }
65}
66
67pub fn first_sentence(text: &str) -> String {
69 match text.find(". ") {
70 Some(i) => text[..=i].to_string(),
71 None => text.to_string(),
72 }
73}
74
75impl Outline {
76 pub fn load(suite: &Suite, dir: &Path) -> Option<(Outline, Vec<String>)> {
79 let text = std::fs::read_to_string(dir.join("outline.txt")).ok()?;
80 let mut warnings = vec![];
81 let mut out = Outline { parts: vec![], pages: vec![] };
82 for (n, raw) in text.lines().enumerate() {
83 let line = raw.split('#').next().unwrap_or("").trim();
84 if line.is_empty() {
85 continue;
86 }
87 let Some((key, value)) = line.split_once(':') else {
88 warnings.push(format!("outline.txt:{}: expected `Part:`, `Section:` or `Page:`", n + 1));
89 continue;
90 };
91 let value = value.trim();
92 match key.trim() {
93 "Part" => out.parts.push(Part { name: value.to_string(), items: vec![] }),
94 "Section" => {
95 let found = suite.sections.iter().find(|s| s.parent.is_some() && s.name.eq_ignore_ascii_case(value));
96 match (found, out.parts.last_mut()) {
98 (Some(s), Some(part)) => part.items.push(Item::Section(s.id)),
99 (None, _) => warnings.push(format!("outline.txt:{}: no section named «{value}»", n + 1)),
100 (_, None) => warnings.push(format!("outline.txt:{}: a section before any part", n + 1)),
101 }
102 }
103 "Page" => {
104 let (name, platform) = match value.split_once('[') {
105 Some((a, b)) => (a.trim(), Some(b.trim_end_matches(']').trim())),
106 None => (value, None),
107 };
108 if let (Some(p), Some(current)) = (platform, suite.platform.as_deref())
109 && !p.eq_ignore_ascii_case(current)
110 {
111 continue;
112 }
113 let path = dir.join("manual").join(format!("{name}.md"));
114 let Ok(source) = std::fs::read_to_string(&path) else {
115 warnings.push(format!("outline.txt:{}: cannot read {}", n + 1, path.display()));
116 continue;
117 };
118 let title = source
119 .lines()
120 .find_map(|l| l.strip_prefix("# "))
121 .map(|t| t.trim().to_string())
122 .unwrap_or_else(|| name.to_string());
123 let Some(part) = out.parts.len().checked_sub(1) else {
124 warnings.push(format!("outline.txt:{}: a page before any part", n + 1));
125 continue;
126 };
127 out.pages.push(Authored { slug: slug(name), title, source, part });
128 let i = out.pages.len() - 1;
129 out.parts[part].items.push(Item::Page(i));
130 }
131 other => warnings.push(format!("outline.txt:{}: unknown entry «{other}»", n + 1)),
132 }
133 }
134 let placed: Vec<SectionId> = out
136 .parts
137 .iter()
138 .flat_map(|p| p.items.iter())
139 .filter_map(|i| if let Item::Section(s) = i { Some(*s) } else { None })
140 .collect();
141 let rest: Vec<Item> = suite
142 .section(SectionId(0))
143 .children
144 .iter()
145 .filter(|c| !placed.contains(c))
146 .map(|&c| Item::Section(c))
147 .collect();
148 if !rest.is_empty() {
149 for item in &rest {
150 if let Item::Section(s) = item {
151 warnings.push(format!("outline.txt: «{}» is in no part", suite.section(*s).name));
152 }
153 }
154 out.parts.push(Part { name: "More".into(), items: rest });
155 }
156 Some((out, warnings))
157 }
158
159 pub fn listed(&self, id: SectionId) -> bool {
161 self.parts.iter().any(|p| p.items.contains(&Item::Section(id)))
162 }
163
164 pub fn anchor(&self, suite: &Suite, id: SectionId) -> Option<(usize, SectionId)> {
167 suite.chain(id).into_iter().rev().find_map(|s| {
168 let part = self.parts.iter().position(|p| p.items.contains(&Item::Section(s.id)))?;
169 Some((part, s.id))
170 })
171 }
172}
173
174pub fn of(d: &crate::Docs) -> Option<Outline> {
176 let (outline, warnings) = Outline::load(d.suite, d.suite_dir?)?;
177 for w in warnings {
178 eprintln!("warning: {w}");
179 }
180 Some(outline)
181}