1use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21use crate::blockdoc::Item;
22use crate::okf;
23
24pub const ITEM_NAMES: &[&str] = &["new", "set", "log", "index", "init"];
26
27pub trait Disk {
29 fn read(&self, path: &Path) -> Option<String>;
31 fn exists(&self, path: &Path) -> bool;
33 fn list_md(&self, dir: &Path) -> Vec<PathBuf>;
35}
36
37pub 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
61struct 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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct OpSpec {
131 pub ordinal: usize,
132 pub line: usize,
133 pub op: OkfOp,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Action {
139 pub ordinal: usize,
140 pub verb: String,
141 pub path: String,
143 pub effect: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Plan {
151 pub actions: Vec<Action>,
152 pub writes: Vec<(PathBuf, String)>,
153}
154
155fn 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
188pub 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
273fn rel(base: &Path, path: &Path) -> String {
275 path.strip_prefix(base)
276 .unwrap_or(path)
277 .display()
278 .to_string()
279}
280
281pub 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 #[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 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 let a = &writes[&PathBuf::from("/bundle/a.md")];
492 assert!(a.contains("timestamp: 2026-06-27"), "{a}");
493 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 let err = plan("/b", "#% new file=exists.md type=Note\n", &disk).unwrap_err();
503 assert!(err.contains("already exists"), "{err}");
504 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}