Skip to main content

speccoder_runtime/
documents.rs

1//! A feature's planning documents, rendered for an interface: the
2//! spec, the plan and the task list as HTML. Raw HTML in them is shown as
3//! text, never passed through: the documents are the repository's, and an
4//! interface that holds a token must not run what they contain.
5
6use std::path::Path;
7
8use pulldown_cmark::{Event, Options, Parser, html};
9use serde::Serialize;
10
11/// One rendered document.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
13pub struct Document {
14    /// `spec`, `plan`, `tasks`, …
15    pub name: String,
16    /// Repository-relative path.
17    pub path: String,
18    /// The rendered HTML.
19    pub html: String,
20}
21
22/// Renders Markdown; raw HTML becomes escaped text. No heading attributes
23/// (`# x {style=…}`): a file the model wrote would set styles and ids on the
24/// page that shows it.
25#[must_use]
26pub fn render(markdown: &str) -> String {
27    let options =
28        Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_STRIKETHROUGH;
29    let events = Parser::new_ext(markdown, options).map(|e| match e {
30        Event::Html(t) | Event::InlineHtml(t) => Event::Text(t),
31        other => other,
32    });
33    let mut out = String::new();
34    html::push_html(&mut out, events);
35    out
36}
37
38/// The current feature's planning documents that exist, in reading order:
39/// (name, path relative to the project, text).
40#[must_use]
41pub fn sources(path: &Path, feature: Option<&str>) -> Vec<(String, String, String)> {
42    let format = crate::format::at(path);
43    let Some(snap) = format.open(path, feature) else {
44        return Vec::new();
45    };
46    let Some(f) = snap.feature.as_ref() else {
47        return Vec::new();
48    };
49    let root = Path::new(&snap.root);
50    format
51        .documents()
52        .iter()
53        .filter_map(|(name, file)| {
54            let rel = format!("{}/{file}", f.feature.dir);
55            std::fs::read_to_string(root.join(&rel))
56                .ok()
57                .map(|text| ((*name).to_owned(), rel, text))
58        })
59        .collect()
60}
61
62/// The current feature's planning documents, rendered.
63#[must_use]
64pub fn of_feature(path: &Path, feature: Option<&str>) -> Vec<Document> {
65    sources(path, feature)
66        .into_iter()
67        .map(|(name, path, text)| Document {
68            name,
69            path,
70            html: render(&text),
71        })
72        .collect()
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn a_file_cannot_style_or_script_the_page() {
81        let html = render(
82            "# Hi {style=position:fixed;inset:0 #top .x}\n\n<script>alert(1)</script>\n\n<b onclick=x>b</b>\n",
83        );
84        // The heading has no attributes; the braces stay text.
85        assert!(html.starts_with("<h1>Hi {style="), "{html}");
86        assert!(!html.contains("<h1 "), "{html}");
87        assert!(!html.contains("<script"), "{html}");
88        assert!(!html.contains("<b "), "{html}");
89    }
90
91    #[test]
92    fn markdown_renders_and_raw_html_is_text() {
93        let html = render("# Spec\n\n- [ ] T001 a\n\n<script>alert(1)</script>\n\nok <b>x</b>\n");
94        assert!(html.contains("<h1>Spec</h1>"), "{html}");
95        assert!(html.contains("type=\"checkbox\""), "{html}");
96        assert!(
97            !html.contains("<script>") && html.contains("&lt;script&gt;"),
98            "{html}"
99        );
100        assert!(!html.contains("<b>"), "{html}");
101    }
102}