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;
16
17use lean_ctx_hive::HiveError;
18use lean_ctx_hive::docs;
19use lean_ctx_hive::knowledge::screening::Screen;
20
21/// The arguments of one call.
22#[derive(Debug, Default)]
23pub struct Args<'a> {
24    pub action: &'a str,
25    pub slug: Option<&'a str>,
26    pub section: Option<&'a str>,
27    pub query: Option<&'a str>,
28    pub content: Option<&'a str>,
29    pub find: Option<&'a str>,
30    pub replace: Option<&'a str>,
31    pub kind: Option<&'a str>,
32    pub status: Option<&'a str>,
33    pub outline: bool,
34    /// write: the code the page derives from (`file:`, `dir:`, `glob:`, `shape:`).
35    pub anchors: &'a [String],
36}
37
38const USAGE: &str = "ctx_doc actions: list [slug=prefix] | show slug [section=heading-slug] [outline=true] | \
39search query | status | write slug content (whole page, `# Title` first) [type] [status=draft|canonical] | \
40edit slug find replace | note slug content | confirm slug (re-pin after checking the page)";
41
42/// Runs one call for the checkout at `root`.
43pub fn handle(root: &str, args: &Args<'_>) -> String {
44    let earlier = lean_ctx_hive::background::earlier("ctx_doc");
45    match run(root, args) {
46        Ok(out) => earlier.present(out),
47        Err(e) => format!("ctx_doc: {e}"),
48    }
49}
50
51fn run(root: &str, args: &Args<'_>) -> Result<String, HiveError> {
52    match args.action {
53        "list" | "show" | "search" | "status" => {
54            docs::with_vault(root, |v| Ok(read(&docs::space(v.target.replica)?, args)))
55        }
56        "write" | "edit" | "note" | "confirm" => {
57            docs::require_docs_protocol(&lean_ctx_hive::HiveStore::open()?)?;
58            let queued = docs::with_vault(root, |v| {
59                let space = docs::space(v.target.replica)?;
60                let pinned = pins(root, &space, args)?;
61                let specs = plan_write(&space, args, &pinned)?;
62                let n = specs.len();
63                if n == 0 {
64                    return Ok(format!("{}: nothing changed", v.label));
65                }
66                let now = chrono::Utc::now().timestamp_millis().unsigned_abs();
67                let mut screen = Screen::for_installation();
68                docs::queue_specs(&v.target, specs, &mut screen, now)?;
69                Ok(format!(
70                    "{}: queued {n} event(s); they are sent in the background",
71                    v.label
72                ))
73            })?;
74            let root = root.to_owned();
75            lean_ctx_hive::background::submit_once("ctx_doc", &format!("push:{root}"), move || {
76                Some(match docs::push_queued(&root) {
77                    Ok(line) => line,
78                    Err(e) if e.is_transient() => format!(
79                        "docs: not sent yet ({e}); the next `lean-ctx hive sync` sends them"
80                    ),
81                    Err(e) => format!("docs: push failed ({e})"),
82                })
83            });
84            Ok(queued)
85        }
86        _ => Err(HiveError::Config(USAGE.into())),
87    }
88}
89
90fn state_word(p: &PageView) -> &'static str {
91    match p.state.state {
92        DocState::Fresh => "fresh",
93        DocState::Noted => "noted",
94        DocState::Stale => "stale",
95    }
96}
97
98fn slug_of(space: &DocSpace) -> impl Fn(&str) -> Option<String> + '_ {
99    |id: &str| space.pages.get(id).map(|p| p.slug().to_owned())
100}
101
102/// The read actions, from the replica's view alone.
103pub fn read(space: &DocSpace, args: &Args<'_>) -> String {
104    match args.action {
105        "list" => list(space, args.slug.unwrap_or("")),
106        "status" => status(space),
107        "search" => search(space, args.query.unwrap_or("")),
108        _ => match args.slug.and_then(|s| space.page_by_slug(s)) {
109            Some(p) => show(space, p, args.section, args.outline),
110            None => format!(
111                "no page {}; try action=list",
112                args.slug.unwrap_or("(slug missing)")
113            ),
114        },
115    }
116}
117
118fn list(space: &DocSpace, prefix: &str) -> String {
119    let mut pages: Vec<&PageView> = space
120        .pages
121        .values()
122        .filter(|p| p.slug().starts_with(prefix))
123        .collect();
124    pages.sort_by(|a, b| a.slug().cmp(b.slug()));
125    if pages.is_empty() {
126        return "no pages".into();
127    }
128    let mut out = format!("{} page(s)\n", pages.len());
129    for p in pages {
130        let notes = p.notes.iter().filter(|n| n.open).count();
131        out.push_str(&format!("/{} — {} [{}", p.slug(), p.title(), state_word(p)));
132        if notes > 0 {
133            out.push_str(&format!(", {notes} note(s)"));
134        }
135        out.push_str("]\n");
136    }
137    out
138}
139
140fn status(space: &DocSpace) -> String {
141    let mut behind: Vec<&PageView> = space
142        .pages
143        .values()
144        .filter(|p| p.state.state != DocState::Fresh)
145        .collect();
146    behind.sort_by(|a, b| a.slug().cmp(b.slug()));
147    if behind.is_empty() {
148        return format!("{} page(s), all fresh", space.pages.len());
149    }
150    let mut out = format!(
151        "{} of {} page(s) behind their code\n",
152        behind.len(),
153        space.pages.len()
154    );
155    for p in behind {
156        out.push_str(&format!("/{} [{}]", p.slug(), state_word(p)));
157        if !p.state.uncovered.is_empty() {
158            out.push_str(&format!(" moved: {}", p.state.uncovered.join(", ")));
159        }
160        out.push('\n');
161    }
162    out
163}
164
165fn show(space: &DocSpace, p: &PageView, section: Option<&str>, outline: bool) -> String {
166    let md = links::to_slugs(&p.markdown(), slug_of(space));
167    let mut out = format!("/{} [{}]\n", p.slug(), state_word(p));
168    if !p.state.uncovered.is_empty() {
169        out.push_str(&format!(
170            "stale: {} moved since the page was written\n",
171            p.state.uncovered.join(", ")
172        ));
173    }
174    for n in p.notes.iter().filter(|n| n.open) {
175        out.push_str(&format!("note: {}\n", n.body));
176    }
177    if outline {
178        for h in links::headings(&md) {
179            out.push_str(&format!(
180                "{} {}  (#{})\n",
181                "#".repeat(h.level),
182                h.text,
183                h.slug
184            ));
185        }
186        return out;
187    }
188    match section {
189        Some(want) => match links::section_of(&md, want) {
190            Some(text) => out.push_str(text),
191            None => out.push_str(&format!("no section #{want}; try outline=true\n")),
192        },
193        None => out.push_str(&md),
194    }
195    out
196}
197
198fn search(space: &DocSpace, query: &str) -> String {
199    let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
200    if words.is_empty() {
201        return "search needs a query".into();
202    }
203    let mut hits: Vec<(usize, &PageView)> = space
204        .pages
205        .values()
206        .filter_map(|p| {
207            let text = format!("{}\n{}", p.title(), p.markdown()).to_lowercase();
208            let score: usize = words.iter().map(|w| text.matches(w.as_str()).count()).sum();
209            (words.iter().all(|w| text.contains(w.as_str())) && score > 0).then_some((score, p))
210        })
211        .collect();
212    hits.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.slug().cmp(b.1.slug())));
213    if hits.is_empty() {
214        return format!("no page mentions {query}");
215    }
216    let mut out = String::new();
217    for (score, p) in hits.into_iter().take(20) {
218        out.push_str(&format!("/{} — {} ({score})\n", p.slug(), p.title()));
219    }
220    out
221}
222
223/// C0 2.2 slug: `seg(/seg){0,7}`, seg `[a-z0-9]+(-[a-z0-9]+)*`, ≤ 128 bytes.
224pub fn valid_slug(slug: &str) -> bool {
225    let segs: Vec<&str> = slug.split('/').collect();
226    slug.len() <= 128
227        && segs.len() <= 8
228        && segs.iter().all(|s| {
229            !s.is_empty()
230                && !s.starts_with('-')
231                && !s.ends_with('-')
232                && !s.contains("--")
233                && s.bytes()
234                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
235        })
236}
237
238/// The anchors a write pins: the `anchors` argument for `write`, the page's
239/// own anchors re-pinned for `confirm`; none otherwise.
240fn pins(
241    root: &str,
242    space: &DocSpace,
243    args: &Args<'_>,
244) -> Result<Vec<lean_ctx_doc::Anchor>, HiveError> {
245    let wanted: Vec<lean_ctx_doc::Anchor> = match args.action {
246        "write" if !args.anchors.is_empty() => Vec::new(),
247        "confirm" => match args.slug.and_then(|s| space.page_by_slug(s)) {
248            Some(p) => docs::page_anchors(p),
249            None => return Ok(Vec::new()),
250        },
251        _ => return Ok(Vec::new()),
252    };
253    let files = docs::tracked_files(root)
254        .ok_or_else(|| HiveError::Config("anchors need a git checkout".into()))?;
255    if args.action == "write" {
256        return args
257            .anchors
258            .iter()
259            .map(|r| docs::pin(root, &files, r))
260            .collect();
261    }
262    // confirm: the same refs and roles at today's digests; a gone target
263    // cannot be confirmed (edit the page's anchors instead).
264    wanted
265        .into_iter()
266        .map(|mut a| {
267            a.digest = docs::digest_now(root, &files, &a).ok_or_else(|| {
268                HiveError::Config(format!(
269                    "{} is gone: write the page with its new anchors",
270                    a.reference()
271                ))
272            })?;
273            a.commit = docs::head_commit(root);
274            Ok(a)
275        })
276        .collect()
277}
278
279/// The events of a write action against the current `space`; `pinned` are
280/// the anchors `pins` resolved against the checkout.
281pub fn plan_write(
282    space: &DocSpace,
283    args: &Args<'_>,
284    pinned: &[lean_ctx_doc::Anchor],
285) -> Result<Vec<write::EventSpec>, HiveError> {
286    let refuse = |m: &str| HiveError::Config(m.to_owned());
287    let slug = args.slug.ok_or_else(|| refuse("slug is required"))?;
288    let id_of = |s: &str| space.by_slug.get(s).cloned();
289    let mut new_id = docs::new_id;
290    let verified_at = chrono::Utc::now().timestamp_millis().unsigned_abs();
291    match args.action {
292        "confirm" => {
293            let page = space
294                .page_by_slug(slug)
295                .ok_or_else(|| refuse("no such page"))?;
296            if pinned.is_empty() {
297                return Err(refuse("the page has no anchors to confirm"));
298            }
299            Ok(write::confirm(page, pinned, verified_at))
300        }
301        "note" => {
302            let page = space
303                .page_by_slug(slug)
304                .ok_or_else(|| refuse("no such page"))?;
305            let body = args.content.ok_or_else(|| refuse("content is required"))?;
306            Ok(write::add_note(&page.page.id, body, &[], &mut new_id))
307        }
308        "edit" => {
309            let page = space
310                .page_by_slug(slug)
311                .ok_or_else(|| refuse("no such page"))?;
312            let (find, replace) = match (args.find, args.replace) {
313                (Some(f), Some(r)) if !f.is_empty() => (f, r),
314                _ => return Err(refuse("edit needs find and replace")),
315            };
316            let current = links::to_slugs(&page.markdown(), slug_of(space));
317            match current.matches(find).count() {
318                1 => {}
319                0 => {
320                    return Err(refuse(
321                        "find matches nothing: the page moved on, re-read it",
322                    ));
323                }
324                n => {
325                    return Err(refuse(&format!(
326                        "find matches {n} times: extend it until it is unique"
327                    )));
328                }
329            }
330            revise(
331                space,
332                page,
333                &current.replacen(find, replace, 1),
334                &mut new_id,
335            )
336        }
337        _ => {
338            let content = args.content.ok_or_else(|| refuse("content is required"))?;
339            if let Some(page) = space.page_by_slug(slug) {
340                let mut specs = revise(space, page, content, &mut new_id)?;
341                if !pinned.is_empty() {
342                    specs.extend(write::confirm(page, pinned, verified_at));
343                }
344                return Ok(specs);
345            }
346            if !valid_slug(slug) {
347                return Err(refuse(
348                    "slug must be lowercase words joined by - and /, at most 8 segments",
349                ));
350            }
351            let (stored, _dangling) = links::to_storage(content, id_of);
352            let (title, sections) = sections::split(&stored);
353            if title.is_empty() {
354                return Err(refuse("a new page starts with its `# Title` line"));
355            }
356            let status = match args.status {
357                Some("canonical") => PageStatus::Canonical,
358                _ => PageStatus::Draft,
359            };
360            let page = Page {
361                slug: slug.to_owned(),
362                title,
363                kind: args.kind.unwrap_or("reference").to_owned(),
364                status,
365                sections,
366                anchors: pinned.to_vec(),
367                notes: Vec::new(),
368            };
369            let (specs, _) = write::create_page(&page, &link_targets(&stored), &mut new_id);
370            Ok(specs)
371        }
372    }
373}
374
375fn link_targets(stored: &str) -> Vec<String> {
376    let mut out: Vec<String> = links::targets(stored)
377        .into_iter()
378        .filter_map(|t| match links::classify(t) {
379            links::Target::Node { id, .. } => Some(id.to_owned()),
380            _ => None,
381        })
382        .collect();
383    out.sort();
384    out.dedup();
385    out
386}
387
388fn revise(
389    space: &DocSpace,
390    page: &PageView,
391    markdown: &str,
392    new_id: write::NewId<'_>,
393) -> Result<Vec<write::EventSpec>, HiveError> {
394    let (stored, _) = links::to_storage(markdown, |s| space.by_slug.get(s).cloned());
395    let now = space.links.get(&page.page.id).cloned().unwrap_or_default();
396    Ok(write::revise_page(
397        page,
398        &stored,
399        &now,
400        &link_targets(&stored),
401        new_id,
402    ))
403}
404
405#[cfg(test)]
406#[path = "ctx_doc_tests.rs"]
407mod tests;