1use 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#[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 pub anchors: &'a [String],
39 pub to: Option<&'a str>,
41 pub rev: Option<u64>,
43 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
54pub 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 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
112fn 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
136pub 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
162fn 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
190pub 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
313fn 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 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
354pub 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 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 ¤t.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;