1use std::io::Write as _;
16use std::path::{Path, PathBuf};
17
18use crate::{CATEGORY_FILE, Frontmatter, Library, MAX_DEPTH, Source};
19
20pub const MAX_WRITE_BYTES: usize = 256 * 1024;
22const MAX_PATH: usize = 240;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Written {
28 pub path: String,
30 pub file: PathBuf,
32 pub created: bool,
34 pub bytes: usize,
36 pub replaces_built_in: bool,
38}
39
40pub fn check_path(path: &str) -> Result<(String, Vec<String>), String> {
42 let p = path.trim().trim_start_matches("docs://");
43 if p.is_empty() {
44 return Err("give a path such as `memory/style.md`".into());
45 }
46 if p.len() > MAX_PATH {
47 return Err(format!("the path is longer than {MAX_PATH} characters"));
48 }
49 if p.starts_with('/') || p.starts_with('\\') || p.starts_with('~') || p.contains(':') || p.contains('\\') {
50 return Err(format!("`{p}` must be a path relative to your documentation folder, with `/` between folders"));
51 }
52 let parts: Vec<String> = p.split('/').map(str::to_string).collect();
53 if parts.len() > MAX_DEPTH {
54 return Err(format!("`{p}` is nested deeper than {MAX_DEPTH} folders"));
55 }
56 for part in &parts {
57 let plain = !part.is_empty() && !part.starts_with('.') && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
58 if !plain {
59 return Err(format!(
60 "`{p}`: `{part}` is not allowed (use letters, digits, `.`, `_` and `-`; no empty parts, `..` or names starting with a dot)"
61 ));
62 }
63 }
64 if !p.ends_with(".md") {
65 return Err(format!("`{p}` must be a markdown file ending in `.md`"));
66 }
67 Ok((parts.join("/"), parts))
68}
69
70fn built_in(lib: &Library, path: &str) -> bool {
72 let (dir, name) = path.rsplit_once('/').unwrap_or(("", path));
73 if name == CATEGORY_FILE {
74 return lib.described().iter().any(|c| c.path == dir && c.source == Source::BuiltIn);
75 }
76 lib.docs().iter().any(|d| d.path == path && d.source == Source::BuiltIn)
77}
78
79fn file_for(dir: &Path, parts: &[String]) -> Result<PathBuf, String> {
81 std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
82 let mut at = dir.to_path_buf();
83 let (name, folders) = parts.split_last().ok_or("empty path")?;
84 for f in folders {
85 at.push(f);
86 match std::fs::symlink_metadata(&at) {
87 Ok(m) if m.file_type().is_symlink() => return Err(format!("`{f}` is a symbolic link; pages are only written inside the folder")),
88 Ok(m) if !m.is_dir() => return Err(format!("`{f}` is a file, not a folder")),
89 Ok(_) => {}
90 Err(_) => std::fs::create_dir(&at).map_err(|e| format!("cannot create {}: {e}", at.display()))?,
91 }
92 }
93 at.push(name);
94 if let Ok(m) = std::fs::symlink_metadata(&at)
95 && (m.file_type().is_symlink() || !m.is_file())
96 {
97 return Err(format!("`{name}` is not a regular file; it is left alone"));
98 }
99 Ok(at)
100}
101
102fn prepare(path: &str, content: &str) -> Result<String, String> {
104 let front = Frontmatter::parse(content);
105 if front.title.is_none() || front.description.is_none() {
106 return Err(format!(
107 "`{path}` needs frontmatter with `title` and `description` (when to read it), e.g. \"---\\ntitle: Editing style\\ndescription: Read before cutting\\n---\\n…\""
108 ));
109 }
110 let text = with_updated(content, &today());
111 if text.len() > MAX_WRITE_BYTES {
112 return Err(format!("`{path}` is {} bytes; a page holds at most {MAX_WRITE_BYTES} (keep it short: replace old notes)", text.len()));
113 }
114 Ok(text)
115}
116
117fn write_atomic(file: &Path, text: &str) -> Result<(), String> {
119 let parent = file.parent().ok_or("no folder")?;
120 let name = file.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
121 let tmp = parent.join(format!(".{name}.{}.tmp", std::process::id()));
122 let result = (|| {
123 let mut f = std::fs::File::create(&tmp)?;
124 f.write_all(text.as_bytes())?;
125 f.sync_all()?;
126 std::fs::rename(&tmp, file)
127 })();
128 if result.is_err() {
129 let _ = std::fs::remove_file(&tmp);
130 }
131 result.map_err(|e| format!("cannot write {}: {e}", file.display()))
132}
133
134pub fn save(dir: &Path, lib: &Library, path: &str, content: &str, replace_built_in: bool) -> Result<Written, String> {
138 let (path, parts) = check_path(path)?;
139 let shadows = built_in(lib, &path);
140 if shadows && !replace_built_in {
141 return Err(format!(
142 "`{path}` is a built-in page; save under another path (your notes go in `memory/`), or ask to replace it, which hides the built-in page until yours is deleted"
143 ));
144 }
145 let text = prepare(&path, content)?;
146 let file = file_for(dir, &parts)?;
147 let created = !file.exists();
148 write_atomic(&file, &text)?;
149 Ok(Written { path, file, created, bytes: text.len(), replaces_built_in: shadows })
150}
151
152pub fn append(dir: &Path, path: &str, text: &str) -> Result<Written, String> {
154 let (path, parts) = check_path(path)?;
155 let file = file_for(dir, &parts)?;
156 let old =
157 std::fs::read_to_string(&file).map_err(|_| format!("`{path}` is not one of your pages yet; save it first (with title and description)"))?;
158 let addition = text.trim_matches('\n');
159 if addition.trim().is_empty() {
160 return Err("nothing to append".into());
161 }
162 let joined = format!("{}\n\n{addition}\n", old.trim_end());
163 let text = prepare(&path, &joined)?;
164 write_atomic(&file, &text)?;
165 Ok(Written { path, file, created: false, bytes: text.len(), replaces_built_in: false })
166}
167
168pub fn delete(dir: &Path, lib: &Library, path: &str) -> Result<Written, String> {
170 let (path, parts) = check_path(path)?;
171 let file = file_for(dir, &parts)?;
172 if !file.is_file() {
173 let why = if built_in(lib, &path) { "it is a built-in page, which cannot be deleted" } else { "there is no such page of yours" };
174 return Err(format!("`{path}` was not deleted: {why}"));
175 }
176 std::fs::remove_file(&file).map_err(|e| format!("cannot delete {}: {e}", file.display()))?;
177 Ok(Written { replaces_built_in: built_in(lib, &path), path, file, created: false, bytes: 0 })
178}
179
180fn with_updated(content: &str, date: &str) -> String {
182 let body_start = content.strip_prefix('\u{feff}').unwrap_or(content);
183 let Some(rest) = body_start.strip_prefix("---\n") else { return content.to_string() };
184 let mut out = String::from("---\n");
185 let mut done = false;
186 let mut lines = rest.split_inclusive('\n');
187 for line in lines.by_ref() {
188 let trimmed = line.trim_end();
189 if trimmed == "---" || trimmed == "..." {
190 if !done {
191 out.push_str(&format!("updated: {date}\n"));
192 }
193 out.push_str(line);
194 break;
195 }
196 if line.split_once(':').is_some_and(|(k, _)| k.trim() == "updated") {
197 if !done {
198 out.push_str(&format!("updated: {date}\n"));
199 done = true;
200 }
201 continue;
202 }
203 out.push_str(line);
204 }
205 for line in lines {
206 out.push_str(line);
207 }
208 out
209}
210
211fn today() -> String {
213 let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
214 let days = i64::try_from(secs / 86_400).unwrap_or(0);
215 let (y, m, d) = civil(days);
216 format!("{y:04}-{m:02}-{d:02}")
217}
218
219fn civil(days: i64) -> (i64, u32, u32) {
221 let z = days.saturating_add(719_468);
222 let era = z.div_euclid(146_097);
223 let doe = z.rem_euclid(146_097);
224 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
225 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
226 let mp = (5 * doy + 2) / 153;
227 let d = doy - (153 * mp + 2) / 5 + 1;
228 let m = if mp < 10 { mp + 3 } else { mp - 9 };
229 let y = yoe + era * 400 + i64::from(m <= 2);
230 (y, u32::try_from(m).unwrap_or(1), u32::try_from(d).unwrap_or(1))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn dates() {
239 assert_eq!(civil(0), (1970, 1, 1));
240 assert_eq!(civil(20_384), (2025, 10, 23));
241 assert_eq!(civil(11_016), (2000, 2, 29));
242 assert_eq!(today().len(), 10);
243 }
244
245 #[test]
246 fn updated_is_set_once() {
247 let a = with_updated("---\ntitle: T\ndescription: D\n---\nbody\n", "2026-09-14");
248 assert_eq!(a, "---\ntitle: T\ndescription: D\nupdated: 2026-09-14\n---\nbody\n");
249 let b = with_updated("---\ntitle: T\nupdated: 2020-01-01\ndescription: D\nupdated: x\n---\nbody\n", "2026-09-14");
250 assert_eq!(b, "---\ntitle: T\nupdated: 2026-09-14\ndescription: D\n---\nbody\n");
251 }
252}