Skip to main content

ashlar_docs/
write.rs

1//! Writing pages into the user's own folder (the one [`Library::overlay_dir`] reads), so an agent
2//! can keep notes that later sessions find: save a page, append to one, delete one.
3//!
4//! Only markdown files strictly inside that folder are touched: a path must be relative, made of
5//! plain names (letters, digits, `.`, `_`, `-`, no leading dot), end in `.md` and stay within
6//! [`MAX_DEPTH`] folders; no part of it may be a symbolic link. A page needs `title` and
7//! `description` frontmatter (so it lists like any other), is at most [`MAX_WRITE_BYTES`], and is
8//! written atomically (a temporary file in the same folder, then a rename). Each write sets an
9//! `updated: YYYY-MM-DD` frontmatter line.
10//!
11//! A path that names a built-in page (or a built-in category's `_category.md`) is refused unless
12//! the caller asks to replace it: the user's page then hides the built-in one, as any overlay
13//! page does. Built-in pages cannot be deleted; deleting the user's copy brings the built-in back.
14
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17
18use crate::{CATEGORY_FILE, Frontmatter, Library, MAX_DEPTH, Source};
19
20/// Largest page [`save`] and [`append`] write.
21pub const MAX_WRITE_BYTES: usize = 256 * 1024;
22/// Longest accepted path.
23const MAX_PATH: usize = 240;
24
25/// What a write did.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Written {
28    /// The page's path in the library, e.g. `memory/style.md`.
29    pub path: String,
30    /// The file on disk.
31    pub file: PathBuf,
32    /// Whether the file is new.
33    pub created: bool,
34    /// Bytes written.
35    pub bytes: usize,
36    /// Whether it now hides a built-in page of the same path.
37    pub replaces_built_in: bool,
38}
39
40/// Check a page path for writing; the normalized path and its parts.
41pub 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
70/// Whether `path` is a page (or a described category's `_category.md`) built into `lib`.
71fn 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
79/// The file for `parts` under `dir`, creating its folders; refuses symbolic links on the way.
80fn 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
102/// Check frontmatter and size, and set `updated`.
103fn 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
117/// Write `text` to `file` through a temporary file in the same folder.
118fn 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
134/// Save `content` as the page `path` in the user's folder `dir` (replacing the user's own page
135/// of that path). `lib` is the library the folder overlays: a built-in path is refused unless
136/// `replace_built_in`.
137pub 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
152/// Add `text` to the end of the user's page `path` (it must exist: [`save`] makes it).
153pub 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
168/// Delete the user's page `path`. A built-in page of the same path shows again.
169pub 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
180/// `content` with its frontmatter's `updated` line set to `date` (added when missing).
181fn 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
211/// Today's date (UTC) as `YYYY-MM-DD`.
212fn 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
219/// Days since 1970-01-01 → (year, month, day), proleptic Gregorian.
220fn 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}