Skip to main content

coding_tools/
okfscript.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `ct-okf --script` engine: a batch of OKF mutations applied under the
5//! prepare/confirm/write standard.
6//!
7//! A `.ctb` block document (parsed by [`crate::blockdoc`]) lists `new`/`set`/
8//! `log`/`index`/`init` items. [`simulate`] runs the whole batch in memory over
9//! a [`Vfs`] overlay — in script order, under cascade, so a later `index` sees a
10//! concept an earlier `new` created and a later `set` edits an earlier one — and
11//! returns the complete set of pending writes. The caller writes them only when
12//! every op succeeded; any failing op aborts the batch with nothing written.
13//!
14//! The overlay reads through a [`Disk`] for files it has not (yet) written, so
15//! the engine is pure with respect to a supplied disk and can be unit-tested
16//! against an in-memory one.
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use crate::blockdoc::Item;
22use crate::okf;
23
24/// The directives that open an item in an okf script.
25pub const ITEM_NAMES: &[&str] = &["new", "set", "log", "index", "init"];
26
27/// The read surface the [`Vfs`] overlays writes on top of.
28pub trait Disk {
29    /// The file's text, or `None` if it does not exist / is unreadable.
30    fn read(&self, path: &Path) -> Option<String>;
31    /// Whether a file exists at `path`.
32    fn exists(&self, path: &Path) -> bool;
33    /// The `.md` files directly inside `dir` (non-recursive).
34    fn list_md(&self, dir: &Path) -> Vec<PathBuf>;
35}
36
37/// A real filesystem [`Disk`].
38pub struct FsDisk;
39
40impl Disk for FsDisk {
41    fn read(&self, path: &Path) -> Option<String> {
42        std::fs::read_to_string(path).ok()
43    }
44    fn exists(&self, path: &Path) -> bool {
45        path.exists()
46    }
47    fn list_md(&self, dir: &Path) -> Vec<PathBuf> {
48        let mut out = Vec::new();
49        if let Ok(rd) = std::fs::read_dir(dir) {
50            for entry in rd.flatten() {
51                let p = entry.path();
52                if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
53                    out.push(p);
54                }
55            }
56        }
57        out
58    }
59}
60
61/// A copy-on-write view of a [`Disk`]: reads fall through to disk, writes are
62/// held in an overlay until the batch is confirmed.
63struct Vfs<'a> {
64    disk: &'a dyn Disk,
65    overlay: BTreeMap<PathBuf, String>,
66}
67
68impl<'a> Vfs<'a> {
69    fn new(disk: &'a dyn Disk) -> Self {
70        Vfs {
71            disk,
72            overlay: BTreeMap::new(),
73        }
74    }
75    fn read(&self, path: &Path) -> Option<String> {
76        self.overlay
77            .get(path)
78            .cloned()
79            .or_else(|| self.disk.read(path))
80    }
81    fn exists(&self, path: &Path) -> bool {
82        self.overlay.contains_key(path) || self.disk.exists(path)
83    }
84    fn write(&mut self, path: PathBuf, content: String) {
85        self.overlay.insert(path, content);
86    }
87    /// `.md` files directly in `dir`, merging disk entries with overlay writes.
88    fn list_md(&self, dir: &Path) -> Vec<PathBuf> {
89        let mut set: BTreeSet<PathBuf> = self.disk.list_md(dir).into_iter().collect();
90        for key in self.overlay.keys() {
91            if key.parent() == Some(dir) && key.extension().and_then(|x| x.to_str()) == Some("md") {
92                set.insert(key.clone());
93            }
94        }
95        set.into_iter().collect()
96    }
97}
98
99/// One compiled okf mutation.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum OkfOp {
102    New {
103        file: String,
104        type_: String,
105        title: Option<String>,
106        description: Option<String>,
107        tags: Vec<String>,
108        body: Option<String>,
109    },
110    Set {
111        file: String,
112        field: String,
113        value: String,
114    },
115    Log {
116        base: Option<String>,
117        kind: String,
118        message: String,
119    },
120    Index {
121        base: Option<String>,
122    },
123    Init {
124        base: Option<String>,
125    },
126}
127
128/// One script op with its source position.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct OpSpec {
131    pub ordinal: usize,
132    pub line: usize,
133    pub op: OkfOp,
134}
135
136/// One simulated write or no-op, for reporting.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Action {
139    pub ordinal: usize,
140    pub verb: String,
141    /// Path relative to the engine base (for stable, portable reporting).
142    pub path: String,
143    /// `create` / `update` / `add` / `present`, etc.
144    pub effect: String,
145}
146
147/// The fully-simulated batch: per-op actions and the pending writes (each path
148/// relative to the engine base, paired with its final content).
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Plan {
151    pub actions: Vec<Action>,
152    pub writes: Vec<(PathBuf, String)>,
153}
154
155/// Validate an item's attributes/sections against the allowed vocabulary.
156fn check_vocab(
157    item: &Item,
158    ordinal: usize,
159    attrs: &[&str],
160    sections: &[&str],
161) -> Result<(), String> {
162    let at = |msg: String| format!("op {ordinal} (script line {}): {msg}", item.line);
163    for (k, _) in &item.attrs {
164        if !attrs.contains(&k.as_str()) {
165            return Err(at(format!(
166                "unknown attribute '{k}' for '{}' (allowed: {})",
167                item.directive,
168                attrs.join(", ")
169            )));
170        }
171    }
172    for (k, _) in &item.sections {
173        if !sections.contains(&k.as_str()) {
174            let allowed = if sections.is_empty() {
175                "none".to_string()
176            } else {
177                sections.join(", ")
178            };
179            return Err(at(format!(
180                "unknown section '{k}' for '{}' (allowed: {allowed})",
181                item.directive
182            )));
183        }
184    }
185    Ok(())
186}
187
188/// Compile parsed [`Item`]s into [`OpSpec`]s, validating each.
189pub fn compile(items: &[Item]) -> Result<Vec<OpSpec>, String> {
190    let mut specs = Vec::with_capacity(items.len());
191    for (i, item) in items.iter().enumerate() {
192        let ordinal = i + 1;
193        let at = |msg: String| format!("op {ordinal} (script line {}): {msg}", item.line);
194        let req_attr = |key: &str| {
195            item.attr(key)
196                .map(str::to_string)
197                .ok_or_else(|| at(format!("missing required '{key}='")))
198        };
199        let op = match item.directive.as_str() {
200            "new" => {
201                check_vocab(
202                    item,
203                    ordinal,
204                    &["file", "type", "title"],
205                    &["description", "tags", "body"],
206                )?;
207                let tags = item
208                    .section("tags")
209                    .map(|s| {
210                        s.lines()
211                            .map(str::trim)
212                            .filter(|l| !l.is_empty())
213                            .map(str::to_string)
214                            .collect()
215                    })
216                    .unwrap_or_default();
217                OkfOp::New {
218                    file: req_attr("file")?,
219                    type_: req_attr("type")?,
220                    title: item.attr("title").map(str::to_string),
221                    description: item
222                        .section("description")
223                        .map(|s| s.trim().to_string())
224                        .filter(|s| !s.is_empty()),
225                    tags,
226                    body: item.section("body").map(str::to_string),
227                }
228            }
229            "set" => {
230                check_vocab(item, ordinal, &["file", "field", "value"], &[])?;
231                OkfOp::Set {
232                    file: req_attr("file")?,
233                    field: req_attr("field")?,
234                    value: req_attr("value")?,
235                }
236            }
237            "log" => {
238                check_vocab(item, ordinal, &["base", "kind"], &["message"])?;
239                let message = item
240                    .section("message")
241                    .map(|s| s.trim().to_string())
242                    .filter(|s| !s.is_empty())
243                    .ok_or_else(|| at("missing 'message' section".to_string()))?;
244                OkfOp::Log {
245                    base: item.attr("base").map(str::to_string),
246                    kind: item.attr("kind").unwrap_or("Update").to_string(),
247                    message,
248                }
249            }
250            "index" => {
251                check_vocab(item, ordinal, &["base"], &[])?;
252                OkfOp::Index {
253                    base: item.attr("base").map(str::to_string),
254                }
255            }
256            "init" => {
257                check_vocab(item, ordinal, &["base"], &[])?;
258                OkfOp::Init {
259                    base: item.attr("base").map(str::to_string),
260                }
261            }
262            other => return Err(at(format!("unknown directive '{other}'"))),
263        };
264        specs.push(OpSpec {
265            ordinal,
266            line: item.line,
267            op,
268        });
269    }
270    Ok(specs)
271}
272
273/// Render a path relative to `base` for reporting (falls back to the full path).
274fn rel(base: &Path, path: &Path) -> String {
275    path.strip_prefix(base)
276        .unwrap_or(path)
277        .display()
278        .to_string()
279}
280
281/// Simulate the whole batch over `disk`, rooted at `base`, stamping `today` into
282/// new concepts and log entries. Returns the plan (actions + pending writes), or
283/// the first op's error — in which case the caller writes nothing.
284pub fn simulate(
285    base: &Path,
286    specs: &[OpSpec],
287    disk: &dyn Disk,
288    today: &str,
289) -> Result<Plan, String> {
290    let mut vfs = Vfs::new(disk);
291    let mut actions = Vec::with_capacity(specs.len());
292    for spec in specs {
293        let at = |msg: String| format!("op {} (script line {}): {msg}", spec.ordinal, spec.line);
294        match &spec.op {
295            OkfOp::New {
296                file,
297                type_,
298                title,
299                description,
300                tags,
301                body,
302            } => {
303                let target = base.join(file);
304                if vfs.exists(&target) {
305                    return Err(at(format!("{file} already exists; refusing to overwrite")));
306                }
307                let title = title.clone().unwrap_or_else(|| {
308                    target
309                        .file_stem()
310                        .map(|s| s.to_string_lossy().into_owned())
311                        .unwrap_or_default()
312                });
313                let content = okf::build_concept(
314                    type_,
315                    &title,
316                    description.as_deref(),
317                    tags,
318                    today,
319                    body.as_deref(),
320                );
321                actions.push(Action {
322                    ordinal: spec.ordinal,
323                    verb: "new".into(),
324                    path: rel(base, &target),
325                    effect: "create".into(),
326                });
327                vfs.write(target, content);
328            }
329            OkfOp::Set { file, field, value } => {
330                let target = base.join(file);
331                let text = vfs
332                    .read(&target)
333                    .ok_or_else(|| at(format!("no such concept: {file}")))?;
334                let (new_text, replaced) =
335                    okf::set_field(&text, field, value).map_err(|e| at(format!("{file}: {e}")))?;
336                actions.push(Action {
337                    ordinal: spec.ordinal,
338                    verb: "set".into(),
339                    path: rel(base, &target),
340                    effect: if replaced { "update" } else { "add" }.into(),
341                });
342                vfs.write(target, new_text);
343            }
344            OkfOp::Index { base: sub } => {
345                let dir = sub
346                    .as_ref()
347                    .map(|s| base.join(s))
348                    .unwrap_or(base.to_path_buf());
349                let mut entries: Vec<(String, String, String)> = Vec::new();
350                for p in vfs.list_md(&dir) {
351                    let name = p
352                        .file_name()
353                        .and_then(|n| n.to_str())
354                        .unwrap_or_default()
355                        .to_string();
356                    if okf::is_reserved(&name) {
357                        continue;
358                    }
359                    let fm = vfs.read(&p).and_then(|t| okf::parse(&t)).map(|x| x.fm);
360                    let title = fm
361                        .as_ref()
362                        .and_then(|f| f.title.clone())
363                        .unwrap_or_else(|| name.trim_end_matches(".md").to_string());
364                    let desc = fm.and_then(|f| f.description).unwrap_or_default();
365                    entries.push((name, title, desc));
366                }
367                let target = dir.join("index.md");
368                actions.push(Action {
369                    ordinal: spec.ordinal,
370                    verb: "index".into(),
371                    path: rel(base, &target),
372                    effect: format!("{} concept(s)", entries.len()),
373                });
374                vfs.write(target, okf::render_index(&entries));
375            }
376            OkfOp::Log {
377                base: sub,
378                kind,
379                message,
380            } => {
381                let dir = sub
382                    .as_ref()
383                    .map(|s| base.join(s))
384                    .unwrap_or(base.to_path_buf());
385                let target = dir.join("log.md");
386                let existing = vfs.read(&target).unwrap_or_default();
387                let updated = okf::log_entry(&existing, today, kind, message);
388                actions.push(Action {
389                    ordinal: spec.ordinal,
390                    verb: "log".into(),
391                    path: rel(base, &target),
392                    effect: kind.clone(),
393                });
394                vfs.write(target, updated);
395            }
396            OkfOp::Init { base: sub } => {
397                let dir = sub
398                    .as_ref()
399                    .map(|s| base.join(s))
400                    .unwrap_or(base.to_path_buf());
401                let target = dir.join("index.md");
402                if vfs.exists(&target) {
403                    actions.push(Action {
404                        ordinal: spec.ordinal,
405                        verb: "init".into(),
406                        path: rel(base, &target),
407                        effect: "present".into(),
408                    });
409                } else {
410                    actions.push(Action {
411                        ordinal: spec.ordinal,
412                        verb: "init".into(),
413                        path: rel(base, &target),
414                        effect: "create".into(),
415                    });
416                    vfs.write(target, "---\nokf_version: \"0.1\"\n---\n\n# Index\n".into());
417                }
418            }
419        }
420    }
421    let writes = vfs.overlay.into_iter().collect();
422    Ok(Plan { actions, writes })
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::blockdoc::{DEFAULT_FENCE, parse};
429    use std::cell::RefCell;
430
431    /// An in-memory disk for deterministic, filesystem-free tests.
432    #[derive(Default)]
433    struct MemDisk {
434        files: RefCell<BTreeMap<PathBuf, String>>,
435    }
436    impl MemDisk {
437        fn with(files: &[(&str, &str)]) -> Self {
438            let m = MemDisk::default();
439            for (p, c) in files {
440                m.files.borrow_mut().insert(PathBuf::from(p), c.to_string());
441            }
442            m
443        }
444    }
445    impl Disk for MemDisk {
446        fn read(&self, path: &Path) -> Option<String> {
447            self.files.borrow().get(path).cloned()
448        }
449        fn exists(&self, path: &Path) -> bool {
450            self.files.borrow().contains_key(path)
451        }
452        fn list_md(&self, dir: &Path) -> Vec<PathBuf> {
453            self.files
454                .borrow()
455                .keys()
456                .filter(|p| {
457                    p.parent() == Some(dir) && p.extension().and_then(|x| x.to_str()) == Some("md")
458                })
459                .cloned()
460                .collect()
461        }
462    }
463
464    fn plan(base: &str, doc: &str, disk: &dyn Disk) -> Result<Plan, String> {
465        let items = parse(doc, DEFAULT_FENCE, ITEM_NAMES)?;
466        let specs = compile(&items)?;
467        simulate(Path::new(base), &specs, disk, "2026-06-27")
468    }
469
470    #[test]
471    fn cascade_new_then_index_then_set_then_log() {
472        let disk = MemDisk::default();
473        let doc = "\
474#% new file=a.md type=Note title=A
475#% new file=b.md type=Note title=B
476#% description
477The B note.
478#% index
479#% set file=a.md field=timestamp value=2026-06-27
480#% log kind=Creation
481#% message
482added a and b
483";
484        let p = plan("/bundle", doc, &disk).unwrap();
485        let writes: BTreeMap<_, _> = p.writes.into_iter().collect();
486        // index.md sees both new concepts (cascade), with B's description.
487        let idx = &writes[&PathBuf::from("/bundle/index.md")];
488        assert!(idx.contains("[A](a.md)"), "{idx}");
489        assert!(idx.contains("[B](b.md) - The B note."), "{idx}");
490        // set edited the freshly-created a.md (timestamp added before fence).
491        let a = &writes[&PathBuf::from("/bundle/a.md")];
492        assert!(a.contains("timestamp: 2026-06-27"), "{a}");
493        // log.md got the dated entry.
494        let log = &writes[&PathBuf::from("/bundle/log.md")];
495        assert!(log.contains("**Creation**: added a and b"), "{log}");
496    }
497
498    #[test]
499    fn new_refuses_to_clobber_disk_or_overlay() {
500        let disk = MemDisk::with(&[("/b/exists.md", "---\ntype: X\n---\n")]);
501        // Clobbering an on-disk file aborts the whole batch.
502        let err = plan("/b", "#% new file=exists.md type=Note\n", &disk).unwrap_err();
503        assert!(err.contains("already exists"), "{err}");
504        // Clobbering a file created earlier in the same batch also aborts.
505        let dup = "#% new file=x.md type=Note\n#% new file=x.md type=Note\n";
506        assert!(
507            plan("/b", dup, &disk)
508                .unwrap_err()
509                .contains("already exists")
510        );
511    }
512
513    #[test]
514    fn set_on_a_missing_concept_aborts() {
515        let disk = MemDisk::default();
516        let err = plan("/b", "#% set file=ghost.md field=x value=y\n", &disk).unwrap_err();
517        assert!(err.contains("no such concept"), "{err}");
518    }
519
520    #[test]
521    fn unknown_attribute_is_rejected() {
522        let disk = MemDisk::default();
523        let err = plan("/b", "#% new file=a.md type=Note bogus=1\n", &disk).unwrap_err();
524        assert!(err.contains("unknown attribute 'bogus'"), "{err}");
525    }
526
527    #[test]
528    fn missing_required_attribute_is_rejected() {
529        let disk = MemDisk::default();
530        let err = plan("/b", "#% new title=A\n", &disk).unwrap_err();
531        assert!(err.contains("missing required 'file='"), "{err}");
532    }
533}