Skip to main content

lean_ctx_server/tools/
ctx_doc.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_doc`: the project's documentation in its Hive vault (C0 2.2 docs).
3//!
4//! Reads come from the local replica, so they work offline and cost no
5//! request. Writes are queued as signed events in the replica and sent in
6//! the background ([`lean_ctx_hive::background`]); a write never waits
7//! for Hive. Links are `/slug#heading` for agents and `hive:<id>` on the
8//! wire ([`lean_ctx_doc::links`]).
9
10use lean_ctx_doc::links;
11use lean_ctx_doc::model::{Page, PageStatus};
12use lean_ctx_doc::replica::{DocSpace, PageView};
13use lean_ctx_doc::sections;
14use lean_ctx_doc::state::DocState;
15use lean_ctx_doc::write;
16use lean_ctx_doc::write::field_set as doc_field_set;
17
18use lean_ctx_doc::local::{self, Author};
19use lean_ctx_hive::HiveError;
20use lean_ctx_hive::docs;
21use lean_ctx_hive::knowledge::screening::Screen;
22use lean_ctx_hive::local_docs;
23
24/// The arguments of one call.
25#[derive(Debug, Default)]
26pub struct Args<'a> {
27    pub action: &'a str,
28    pub slug: Option<&'a str>,
29    pub section: Option<&'a str>,
30    pub query: Option<&'a str>,
31    pub content: Option<&'a str>,
32    pub find: Option<&'a str>,
33    pub replace: Option<&'a str>,
34    pub kind: Option<&'a str>,
35    pub status: Option<&'a str>,
36    pub outline: bool,
37    /// write: the code the page derives from (`file:`, `dir:`, `glob:`, `shape:`).
38    pub anchors: &'a [String],
39    /// move: the new slug.
40    pub to: Option<&'a str>,
41    /// show/list (local docs): the pages as of this revision.
42    pub rev: Option<u64>,
43    /// The author of a local revision (default: an unnamed agent).
44    pub author: Option<Author>,
45}
46
47const USAGE: &str = "ctx_doc actions: list [slug=prefix] | show slug [section=heading-slug] [outline=true] | \
48search query | status | write slug content (whole page, `# Title` first) [type] [status=draft|canonical] | \
49edit slug find replace | note slug content | confirm slug (re-pin after checking the page) | \
50local docs (no vault linked) also: history slug | show slug rev=N | move slug to=new-slug | delete slug";
51
52const LOCAL_ONLY: [&str; 3] = ["history", "move", "delete"];
53
54/// Runs one call for the checkout at `root`.
55pub fn handle(root: &str, args: &Args<'_>) -> String {
56    let earlier = lean_ctx_hive::background::earlier("ctx_doc");
57    match run(root, args) {
58        Ok(out) => earlier.present(out),
59        Err(e) => format!("ctx_doc: {e}"),
60    }
61}
62
63fn run(root: &str, args: &Args<'_>) -> Result<String, HiveError> {
64    // A link that cannot be read refuses: a linked checkout never writes local.
65    if !local_docs::is_linked(root).map_err(HiveError::Local)? {
66        return run_local(root, args);
67    }
68    if LOCAL_ONLY.contains(&args.action) || args.rev.is_some() {
69        return Err(HiveError::Config(format!(
70            "action={} and rev apply to local docs; this checkout's docs are in its vault",
71            args.action
72        )));
73    }
74    match args.action {
75        "list" | "show" | "search" | "status" => {
76            docs::with_vault(root, |v| Ok(read(&docs::space(v.target.replica)?, args)))
77        }
78        "write" | "edit" | "note" | "confirm" => {
79            docs::require_docs_protocol(&lean_ctx_hive::HiveStore::open()?)?;
80            let queued = docs::with_vault(root, |v| {
81                let space = docs::space(v.target.replica)?;
82                let pinned = pins(root, &space, args)?;
83                let specs = plan_write(&space, args, &pinned)?;
84                let n = specs.len();
85                if n == 0 {
86                    return Ok(format!("{}: nothing changed", v.label));
87                }
88                let now = chrono::Utc::now().timestamp_millis().unsigned_abs();
89                let mut screen = Screen::for_installation();
90                docs::queue_specs(&v.target, specs, &mut screen, now)?;
91                Ok(format!(
92                    "{}: queued {n} event(s); they are sent in the background",
93                    v.label
94                ))
95            })?;
96            let root = root.to_owned();
97            lean_ctx_hive::background::submit_once("ctx_doc", &format!("push:{root}"), move || {
98                Some(match docs::push_queued(&root) {
99                    Ok(line) => line,
100                    Err(e) if e.is_transient() => format!(
101                        "docs: not sent yet ({e}); the next `lean-ctx hive sync` sends them"
102                    ),
103                    Err(e) => format!("docs: push failed ({e})"),
104                })
105            });
106            Ok(queued)
107        }
108        _ => Err(HiveError::Config(USAGE.into())),
109    }
110}
111
112/// The docs of a checkout no vault is linked to: the project's local store
113/// on this Instance ([`local_docs`]). A write is one revision, saved at once.
114fn run_local(root: &str, args: &Args<'_>) -> Result<String, HiveError> {
115    let store = local_docs::store(root)?;
116    match args.action {
117        "list" | "show" | "search" | "status" => {
118            let (_, space) = local_docs::read(root, &store, args.rev)?;
119            Ok(read(&space, args))
120        }
121        "history" => history_text(&store.entries()?, args.slug),
122        "write" | "edit" | "note" | "confirm" | "move" | "delete" => {
123            let done = commit_local(root, args)?;
124            Ok(match done.rev {
125                None => "local docs: nothing changed".to_owned(),
126                Some(rev) => format!(
127                    "local docs: saved revision {rev} ({} event(s)); no vault is linked, so the page stays on this Instance",
128                    done.events
129                ),
130            })
131        }
132        _ => Err(HiveError::Config(USAGE.into())),
133    }
134}
135
136/// One local write action (`write`, `edit`, `note`, `confirm`, `move`,
137/// `delete`) as one revision of `root`'s local store, planned under its lock.
138pub fn commit_local(root: &str, args: &Args<'_>) -> Result<local::Committed, HiveError> {
139    if !WRITES.contains(&args.action) {
140        return Err(HiveError::Config(USAGE.into()));
141    }
142    if args.rev.is_some() {
143        return Err(HiveError::Config("rev is for reads".into()));
144    }
145    let author = args
146        .author
147        .clone()
148        .unwrap_or_else(|| Author::agent("agent"));
149    local_docs::store(root)?.commit::<HiveError>(
150        &author,
151        &local_docs::now(),
152        args.action,
153        |space| {
154            let pinned = pins(root, space, args)?;
155            plan_write(space, args, &pinned)
156        },
157    )
158}
159
160const WRITES: [&str; 6] = ["write", "edit", "note", "confirm", "move", "delete"];
161
162/// The revisions of the local page `slug` (also a moved or deleted one).
163fn history_text(entries: &[local::Entry], slug: Option<&str>) -> Result<String, HiveError> {
164    let slug = slug.ok_or_else(|| HiveError::Config("slug is required".into()))?;
165    let id = local::page_id_of(entries, slug)
166        .ok_or_else(|| HiveError::Config(format!("no page {slug}; try action=list")))?;
167    let revs = local::history(entries, &id);
168    let mut out = format!("/{slug}: {} revision(s)\n", revs.len());
169    for r in revs {
170        out.push_str(&format!(
171            "rev {} {} {} {} {}\n",
172            r.rev, r.at, r.author.kind, r.author.label, r.action
173        ));
174    }
175    Ok(out)
176}
177
178fn state_word(p: &PageView) -> &'static str {
179    match p.state.state {
180        DocState::Fresh => "fresh",
181        DocState::Noted => "noted",
182        DocState::Stale => "stale",
183    }
184}
185
186fn slug_of(space: &DocSpace) -> impl Fn(&str) -> Option<String> + '_ {
187    |id: &str| space.pages.get(id).map(|p| p.slug().to_owned())
188}
189
190/// The read actions, from the replica's view alone.
191pub fn read(space: &DocSpace, args: &Args<'_>) -> String {
192    match args.action {
193        "list" => list(space, args.slug.unwrap_or("")),
194        "status" => status(space),
195        "search" => search(space, args.query.unwrap_or("")),
196        _ => match args.slug.and_then(|s| space.page_by_slug(s)) {
197            Some(p) => show(space, p, args.section, args.outline),
198            None => format!(
199                "no page {}; try action=list",
200                args.slug.unwrap_or("(slug missing)")
201            ),
202        },
203    }
204}
205
206fn list(space: &DocSpace, prefix: &str) -> String {
207    let mut pages: Vec<&PageView> = space
208        .pages
209        .values()
210        .filter(|p| p.slug().starts_with(prefix))
211        .collect();
212    pages.sort_by(|a, b| a.slug().cmp(b.slug()));
213    if pages.is_empty() {
214        return "no pages".into();
215    }
216    let mut out = format!("{} page(s)\n", pages.len());
217    for p in pages {
218        let notes = p.notes.iter().filter(|n| n.open).count();
219        out.push_str(&format!("/{} — {} [{}", p.slug(), p.title(), state_word(p)));
220        if notes > 0 {
221            out.push_str(&format!(", {notes} note(s)"));
222        }
223        out.push_str("]\n");
224    }
225    out
226}
227
228fn status(space: &DocSpace) -> String {
229    let mut behind: Vec<&PageView> = space
230        .pages
231        .values()
232        .filter(|p| p.state.state != DocState::Fresh)
233        .collect();
234    behind.sort_by(|a, b| a.slug().cmp(b.slug()));
235    if behind.is_empty() {
236        return format!("{} page(s), all fresh", space.pages.len());
237    }
238    let mut out = format!(
239        "{} of {} page(s) behind their code\n",
240        behind.len(),
241        space.pages.len()
242    );
243    for p in behind {
244        out.push_str(&format!("/{} [{}]", p.slug(), state_word(p)));
245        if !p.state.uncovered.is_empty() {
246            out.push_str(&format!(" moved: {}", p.state.uncovered.join(", ")));
247        }
248        out.push('\n');
249    }
250    out
251}
252
253fn show(space: &DocSpace, p: &PageView, section: Option<&str>, outline: bool) -> String {
254    let md = links::to_slugs(&p.markdown(), slug_of(space));
255    let mut out = format!("/{} [{}]\n", p.slug(), state_word(p));
256    if !p.state.uncovered.is_empty() {
257        out.push_str(&format!(
258            "stale: {} moved since the page was written\n",
259            p.state.uncovered.join(", ")
260        ));
261    }
262    for n in p.notes.iter().filter(|n| n.open) {
263        out.push_str(&format!("note: {}\n", n.body));
264    }
265    if outline {
266        for h in links::headings(&md) {
267            out.push_str(&format!(
268                "{} {}  (#{})\n",
269                "#".repeat(h.level),
270                h.text,
271                h.slug
272            ));
273        }
274        return out;
275    }
276    match section {
277        Some(want) => match links::section_of(&md, want) {
278            Some(text) => out.push_str(text),
279            None => out.push_str(&format!("no section #{want}; try outline=true\n")),
280        },
281        None => out.push_str(&md),
282    }
283    out
284}
285
286fn search(space: &DocSpace, query: &str) -> String {
287    let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
288    if words.is_empty() {
289        return "search needs a query".into();
290    }
291    let mut hits: Vec<(usize, &PageView)> = space
292        .pages
293        .values()
294        .filter_map(|p| {
295            let text = format!("{}\n{}", p.title(), p.markdown()).to_lowercase();
296            let score: usize = words.iter().map(|w| text.matches(w.as_str()).count()).sum();
297            (words.iter().all(|w| text.contains(w.as_str())) && score > 0).then_some((score, p))
298        })
299        .collect();
300    hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.slug().cmp(b.1.slug())));
301    if hits.is_empty() {
302        return format!("no page mentions {query}");
303    }
304    let mut out = String::new();
305    for (score, p) in hits.into_iter().take(20) {
306        out.push_str(&format!("/{} — {} ({score})\n", p.slug(), p.title()));
307    }
308    out
309}
310
311pub use lean_ctx_doc::model::valid_slug;
312
313/// The anchors a write pins: the `anchors` argument for `write`, the page's
314/// own anchors re-pinned for `confirm`; none otherwise.
315fn pins(
316    root: &str,
317    space: &DocSpace,
318    args: &Args<'_>,
319) -> Result<Vec<lean_ctx_doc::Anchor>, HiveError> {
320    let wanted: Vec<lean_ctx_doc::Anchor> = match args.action {
321        "write" if !args.anchors.is_empty() => Vec::new(),
322        "confirm" => match args.slug.and_then(|s| space.page_by_slug(s)) {
323            Some(p) => docs::page_anchors(p),
324            None => return Ok(Vec::new()),
325        },
326        _ => return Ok(Vec::new()),
327    };
328    let files = docs::tracked_files(root)
329        .ok_or_else(|| HiveError::Config("anchors need a git checkout".into()))?;
330    if args.action == "write" {
331        return args
332            .anchors
333            .iter()
334            .map(|r| docs::pin(root, &files, r))
335            .collect();
336    }
337    // confirm: the same refs and roles at today's digests; a gone target
338    // cannot be confirmed (edit the page's anchors instead).
339    wanted
340        .into_iter()
341        .map(|mut a| {
342            a.digest = docs::digest_now(root, &files, &a).ok_or_else(|| {
343                HiveError::Config(format!(
344                    "{} is gone: write the page with its new anchors",
345                    a.reference()
346                ))
347            })?;
348            a.commit = docs::head_commit(root);
349            Ok(a)
350        })
351        .collect()
352}
353
354/// The events of a write action against the current `space`; `pinned` are
355/// the anchors `pins` resolved against the checkout.
356pub fn plan_write(
357    space: &DocSpace,
358    args: &Args<'_>,
359    pinned: &[lean_ctx_doc::Anchor],
360) -> Result<Vec<write::EventSpec>, HiveError> {
361    let refuse = |m: &str| HiveError::Config(m.to_owned());
362    let slug = args.slug.ok_or_else(|| refuse("slug is required"))?;
363    let id_of = |s: &str| space.by_slug.get(s).cloned();
364    let mut new_id = docs::new_id;
365    let verified_at = chrono::Utc::now().timestamp_millis().unsigned_abs();
366    match args.action {
367        "move" => {
368            let page = space
369                .page_by_slug(slug)
370                .ok_or_else(|| refuse("no such page"))?;
371            let to = args.to.ok_or_else(|| refuse("move needs to=<new slug>"))?;
372            if !valid_slug(to) {
373                return Err(refuse(
374                    "slug must be lowercase words joined by - and /, at most 8 segments",
375                ));
376            }
377            if to == slug {
378                return Ok(Vec::new());
379            }
380            if space.by_slug.contains_key(to) {
381                return Err(refuse(&format!("/{to} is taken")));
382            }
383            // A doc page's field event (lean-ctx-doc), not a C0 fact's
384            // `maintain::field_set` that the C0 field guard checks.
385            Ok(vec![doc_field_set(
386                &page.page.id,
387                "slug",
388                serde_json::Value::from(to),
389                &page.page.head,
390            )])
391        }
392        "delete" => {
393            let page = space
394                .page_by_slug(slug)
395                .ok_or_else(|| refuse("no such page"))?;
396            Ok(vec![write::retract(&page.page.id, "page deleted")])
397        }
398        "confirm" => {
399            let page = space
400                .page_by_slug(slug)
401                .ok_or_else(|| refuse("no such page"))?;
402            if pinned.is_empty() {
403                return Err(refuse("the page has no anchors to confirm"));
404            }
405            Ok(write::confirm(page, pinned, verified_at))
406        }
407        "note" => {
408            let page = space
409                .page_by_slug(slug)
410                .ok_or_else(|| refuse("no such page"))?;
411            let body = args.content.ok_or_else(|| refuse("content is required"))?;
412            Ok(write::add_note(&page.page.id, body, &[], &mut new_id))
413        }
414        "edit" => {
415            let page = space
416                .page_by_slug(slug)
417                .ok_or_else(|| refuse("no such page"))?;
418            let (find, replace) = match (args.find, args.replace) {
419                (Some(f), Some(r)) if !f.is_empty() => (f, r),
420                _ => return Err(refuse("edit needs find and replace")),
421            };
422            let current = links::to_slugs(&page.markdown(), slug_of(space));
423            match current.matches(find).count() {
424                1 => {}
425                0 => {
426                    return Err(refuse(
427                        "find matches nothing: the page moved on, re-read it",
428                    ));
429                }
430                n => {
431                    return Err(refuse(&format!(
432                        "find matches {n} times: extend it until it is unique"
433                    )));
434                }
435            }
436            revise(
437                space,
438                page,
439                &current.replacen(find, replace, 1),
440                &mut new_id,
441            )
442        }
443        _ => {
444            let content = args.content.ok_or_else(|| refuse("content is required"))?;
445            if let Some(page) = space.page_by_slug(slug) {
446                let mut specs = revise(space, page, content, &mut new_id)?;
447                if !pinned.is_empty() {
448                    specs.extend(write::confirm(page, pinned, verified_at));
449                }
450                return Ok(specs);
451            }
452            if !valid_slug(slug) {
453                return Err(refuse(
454                    "slug must be lowercase words joined by - and /, at most 8 segments",
455                ));
456            }
457            let (stored, _dangling) = links::to_storage(content, id_of);
458            let (title, sections) = sections::split(&stored);
459            if title.is_empty() {
460                return Err(refuse("a new page starts with its `# Title` line"));
461            }
462            let status = match args.status {
463                Some("canonical") => PageStatus::Canonical,
464                _ => PageStatus::Draft,
465            };
466            let page = Page {
467                slug: slug.to_owned(),
468                title,
469                kind: args.kind.unwrap_or("reference").to_owned(),
470                status,
471                sections,
472                anchors: pinned.to_vec(),
473                notes: Vec::new(),
474            };
475            let (specs, _) = write::create_page(&page, &link_targets(&stored), &mut new_id);
476            Ok(specs)
477        }
478    }
479}
480
481fn link_targets(stored: &str) -> Vec<String> {
482    let mut out: Vec<String> = links::targets(stored)
483        .into_iter()
484        .filter_map(|t| match links::classify(t) {
485            links::Target::Node { id, .. } => Some(id.to_owned()),
486            _ => None,
487        })
488        .collect();
489    out.sort();
490    out.dedup();
491    out
492}
493
494fn revise(
495    space: &DocSpace,
496    page: &PageView,
497    markdown: &str,
498    new_id: write::NewId<'_>,
499) -> Result<Vec<write::EventSpec>, HiveError> {
500    let (stored, _) = links::to_storage(markdown, |s| space.by_slug.get(s).cloned());
501    let now = space.links.get(&page.page.id).cloned().unwrap_or_default();
502    Ok(write::revise_page(
503        page,
504        &stored,
505        &now,
506        &link_targets(&stored),
507        new_id,
508    ))
509}
510
511#[cfg(test)]
512#[path = "ctx_doc_tests.rs"]
513mod tests;