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;
16
17use lean_ctx_hive::HiveError;
18use lean_ctx_hive::docs;
19use lean_ctx_hive::knowledge::screening::Screen;
20
21#[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 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
42pub 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
102pub 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
223pub 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
238fn 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 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
279pub 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 ¤t.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;