Skip to main content

expectation_docs/
outline.rs

1//! The manual's outline: the order the reader meets the chapters in, grouped into parts, with
2//! hand-written pages (install, controls, reference, troubleshooting) among the generated
3//! ones. The sections stay as the specs have them, because their setups inherit from their
4//! parents; the outline only says where each top-level chapter goes.
5//!
6//! `outline.txt` in the suite's directory:
7//!
8//! ```text
9//! Part: Start here
10//!   Page: installing          manual/installing.md
11//!   Page: the-installer [desktop]
12//! Part: Brewing
13//!   Section: The kettle
14//! ```
15//!
16//! A page's title is its first `# ` line. `[desktop]` limits a page to one platform. A
17//! top-level section that no part names goes into a last part, "More".
18
19use 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
39/// A page written by hand, in Markdown, with `[[links]]` into the generated pages.
40pub struct Authored {
41    pub slug: String,
42    pub title: String,
43    pub source: String,
44    /// The part it is in.
45    pub part: usize,
46}
47
48impl Authored {
49    /// The first paragraph, for contents lists.
50    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
67/// Up to the first full stop that ends a sentence.
68pub 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    /// The outline in `dir`, if it has one. Problems (an unknown section, a missing page) are
77    /// returned as warnings; the outline then leaves that entry out.
78    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                    // A section below the top level moves, with its subsections, to here.
97                    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        // Chapters nobody placed.
135        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    /// Whether the outline places this section itself (rather than under its parent).
160    pub fn listed(&self, id: SectionId) -> bool {
161        self.parts.iter().any(|p| p.items.contains(&Item::Section(id)))
162    }
163
164    /// The part a section is in, and the chapter it is under: the section itself or its
165    /// nearest ancestor that the outline places.
166    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
174/// The suite's outline, with its problems on stderr.
175pub 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}