1use std::path::{Path, PathBuf};
4
5use crate::model::Site;
6use crate::{render, search, CSS, JS};
7
8pub 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#[derive(Debug, thiserror::Error)]
17pub enum WriteError {
18 #[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 #[error("cannot write {}: {}", .0.display(), .1)]
23 Io(PathBuf, std::io::Error),
24}
25
26pub 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 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
67fn 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
78fn 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 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 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}