Skip to main content

nova_doc/
write.rs

1//! Writing the site to `target/doc/` (spec §4.3, §6.1).
2
3use std::path::{Path, PathBuf};
4
5use crate::model::Site;
6use crate::{render, search, CSS, JS};
7
8/// The file every site holds, so that `nova doc` replaces only a
9/// directory it wrote (spec §4.3).
10pub const MARKER: &str = ".nova-doc";
11
12const MARKER_TEXT: &str =
13    "This directory was written by nova doc, which replaces it on its next run.\n";
14
15/// Why a site was not written.
16#[derive(Debug, thiserror::Error)]
17pub enum WriteError {
18    /// The directory exists, holds files, and has no marker.
19    #[error("{} exists and was not written by nova doc; remove it, and nova doc will write its site there", .0.display())]
20    NotOurs(PathBuf),
21    /// A file or directory could not be removed, read or written.
22    #[error("cannot write {}: {}", .0.display(), .1)]
23    Io(PathBuf, std::io::Error),
24}
25
26/// Write `site` to `dir`, replacing a site written there before (spec
27/// §4.3). Returns the start page's path.
28///
29/// The marker is never removed, and is written before any page, so a
30/// write cut short leaves a directory the next `nova doc` replaces.
31pub fn write_site(site: &Site, dir: &Path) -> Result<PathBuf, WriteError> {
32    let io = |path: &Path| {
33        let path = path.to_path_buf();
34        move |e| WriteError::Io(path, e)
35    };
36    if dir.exists() {
37        if dir.join(MARKER).is_file() {
38            for entry in std::fs::read_dir(dir).map_err(io(dir))? {
39                let entry = entry.map_err(io(dir))?;
40                if entry.file_name() == MARKER {
41                    continue;
42                }
43                let path = entry.path();
44                // `file_type` does not follow a symlink: a link to a
45                // directory is removed, not the directory.
46                let removed = if entry.file_type().map_err(io(&path))?.is_dir() {
47                    std::fs::remove_dir_all(&path)
48                } else {
49                    std::fs::remove_file(&path)
50                };
51                removed.map_err(io(&path))?;
52            }
53        } else if std::fs::read_dir(dir).map_err(io(dir))?.next().is_some() {
54            return Err(WriteError::NotOurs(dir.to_path_buf()));
55        }
56    }
57    for (path, text) in &files(site) {
58        let full = under(dir, path);
59        if let Some(parent) = full.parent() {
60            std::fs::create_dir_all(parent).map_err(io(parent))?;
61        }
62        std::fs::write(&full, text.replace("\r\n", "\n")).map_err(io(&full))?;
63    }
64    Ok(under(dir, &site.start))
65}
66
67/// Every file of `site`'s directory, by its path from the site's root,
68/// the marker first.
69fn files(site: &Site) -> Vec<(String, String)> {
70    let mut files = vec![(MARKER.to_string(), MARKER_TEXT.to_string())];
71    files.extend(render::pages(site));
72    files.push(("nova.css".to_string(), CSS.to_string()));
73    files.push(("nova.js".to_string(), JS.to_string()));
74    files.push(("search-index.js".to_string(), search::index(site)));
75    files
76}
77
78/// `relative`, a `/`-separated path, under `dir`.
79fn under(dir: &Path, relative: &str) -> PathBuf {
80    relative
81        .split('/')
82        .fold(dir.to_path_buf(), |path, part| path.join(part))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::model::sample_site;
89
90    fn fresh(name: &str) -> PathBuf {
91        let dir = std::env::temp_dir().join(format!("nova-doc-write-{name}"));
92        let _ = std::fs::remove_dir_all(&dir);
93        std::fs::create_dir_all(&dir).unwrap();
94        dir
95    }
96
97    #[test]
98    fn the_sample_site_is_written_whole_and_its_links_hold() {
99        let dir = fresh("whole").join("doc");
100        let start = write_site(&sample_site(), &dir).unwrap();
101        assert_eq!(start, dir.join("app").join("index.html"));
102        for file in [
103            MARKER,
104            "index.html",
105            "nova.css",
106            "nova.js",
107            "search-index.js",
108            "app/index.html",
109            "app/util/index.html",
110            "std/index.html",
111            "std/core/index.html",
112            "std/primitive.Int.html",
113        ] {
114            assert!(dir.join(file).is_file(), "{file}");
115        }
116        assert_eq!(crate::check_links(&dir), Vec::new());
117    }
118
119    #[test]
120    fn a_site_it_wrote_is_replaced_and_another_directory_is_refused() {
121        let base = fresh("replace");
122        let dir = base.join("doc");
123        write_site(&sample_site(), &dir).unwrap();
124        std::fs::write(dir.join("stale.html"), "old").unwrap();
125        write_site(&sample_site(), &dir).unwrap();
126        assert!(!dir.join("stale.html").exists());
127        let other = base.join("other");
128        std::fs::create_dir_all(&other).unwrap();
129        std::fs::write(other.join("keep.txt"), "keep").unwrap();
130        assert!(matches!(
131            write_site(&sample_site(), &other),
132            Err(WriteError::NotOurs(_))
133        ));
134        assert!(other.join("keep.txt").is_file());
135        let empty = base.join("empty");
136        std::fs::create_dir_all(&empty).unwrap();
137        write_site(&sample_site(), &empty).unwrap();
138    }
139
140    #[test]
141    fn the_marker_is_written_before_any_page() {
142        // A write cut short still leaves a directory `nova doc` replaces.
143        let files = files(&sample_site());
144        assert_eq!(files[0].0, MARKER);
145        assert_eq!(files.iter().filter(|(p, _)| p == MARKER).count(), 1);
146    }
147
148    #[test]
149    fn the_marker_stays_while_the_old_site_is_removed() {
150        // A hard link to the marker sees the new text only if the marker
151        // is the same file: it was written over, never removed.
152        let base = fresh("marker");
153        let dir = base.join("doc");
154        write_site(&sample_site(), &dir).unwrap();
155        std::fs::write(dir.join(MARKER), "old").unwrap();
156        std::fs::hard_link(dir.join(MARKER), base.join("link")).unwrap();
157        write_site(&sample_site(), &dir).unwrap();
158        assert_eq!(
159            std::fs::read_to_string(base.join("link")).unwrap(),
160            MARKER_TEXT
161        );
162    }
163
164    #[test]
165    fn a_marker_and_a_partial_site_are_replaced_cleanly() {
166        let dir = fresh("partial").join("doc");
167        std::fs::create_dir_all(dir.join("app").join("old")).unwrap();
168        std::fs::write(dir.join(MARKER), MARKER_TEXT).unwrap();
169        std::fs::write(dir.join("app").join("index.html"), "<p>half").unwrap();
170        std::fs::write(dir.join("app").join("old").join("index.html"), "x").unwrap();
171        std::fs::write(dir.join("stale.html"), "old").unwrap();
172        write_site(&sample_site(), &dir).unwrap();
173        assert!(!dir.join("stale.html").exists());
174        assert!(!dir.join("app").join("old").exists());
175        assert!(dir.join(MARKER).is_file());
176        let page = std::fs::read_to_string(dir.join("app").join("index.html")).unwrap();
177        assert!(page.ends_with("</html>\n"), "{page}");
178        assert_eq!(crate::check_links(&dir), Vec::new());
179    }
180
181    #[test]
182    fn every_file_ends_its_lines_with_a_line_feed() {
183        let dir = fresh("endings").join("doc");
184        write_site(&sample_site(), &dir).unwrap();
185        for file in [
186            "index.html",
187            "nova.css",
188            "nova.js",
189            "search-index.js",
190            "app/index.html",
191        ] {
192            let text = std::fs::read_to_string(dir.join(file)).unwrap();
193            assert!(!text.contains('\r'), "{file}");
194        }
195    }
196}