1use std::collections::{BTreeMap, BTreeSet};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::anchor::{self, Anchor, AnchorKind, AnchorMode, AnchorRole};
16use crate::links::{self, Target};
17use crate::model::{Note, Page, PageStatus};
18use crate::sections;
19
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
22pub struct Export {
23 pub space: String,
24 pub pages: Vec<ExportPage>,
25 #[serde(default)]
26 pub tasks: Vec<ExportTask>,
27 #[serde(default)]
28 pub boards: Vec<ExportBoard>,
29}
30
31#[derive(Debug, Clone, Default, Deserialize, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct ExportPage {
34 pub slug: String,
35 #[serde(rename = "type")]
36 pub kind: String,
37 pub status: String,
38 pub body: String,
39 #[serde(default, deserialize_with = "present")]
45 pub description: Description,
46 #[serde(default)]
47 pub retired: bool,
48 #[serde(default)]
49 pub sources: Vec<ExportSource>,
50 #[serde(default)]
51 pub notes: Vec<ExportNote>,
52 #[serde(flatten)]
55 pub extra: BTreeMap<String, Value>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
60pub enum Description {
61 #[default]
63 Missing,
64 Null,
66 Text(String),
67}
68
69fn present<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Description, D::Error> {
71 Option::<String>::deserialize(d).map(|o| o.map_or(Description::Null, Description::Text))
72}
73
74#[must_use]
76pub fn description_like(key: &str) -> bool {
77 let k = key.to_ascii_lowercase();
78 [
79 "desc", "summary", "abstract", "blurb", "tagline", "subtitle", "excerpt", "synopsis",
80 "teaser", "intro", "lede", "overview",
81 ]
82 .iter()
83 .any(|w| k.contains(w))
84}
85
86pub const NO_DESCRIPTION_KEYS: [&str; 12] = [
95 "title",
96 "spaceId",
97 "createdAt",
98 "updatedAt",
99 "createdBy",
100 "updatedBy",
101 "revision",
102 "revisedAt",
103 "lsn",
104 "bodyHash",
105 "edges",
106 "links",
107];
108
109pub const RECORD_KEY_PREFIX: &str = "record.";
112
113#[must_use]
116pub fn no_description_key(key: &str) -> bool {
117 let bare = key.strip_prefix(RECORD_KEY_PREFIX).unwrap_or(key);
118 NO_DESCRIPTION_KEYS.contains(&bare)
119}
120
121fn nested_description_keys(prefix: &str, value: &Value, out: &mut Vec<String>) {
124 match value {
125 Value::Object(map) => {
126 for (k, v) in map {
127 let path = format!("{prefix}.{k}");
128 if description_like(k) {
129 out.push(path.clone());
130 }
131 nested_description_keys(&path, v, out);
132 }
133 }
134 Value::Array(items) => {
135 for (i, v) in items.iter().enumerate() {
136 nested_description_keys(&format!("{prefix}[{i}]"), v, out);
137 }
138 }
139 _ => {}
140 }
141}
142
143#[derive(Debug, Clone, Default, Deserialize, Serialize)]
144#[serde(rename_all = "camelCase")]
145pub struct ExportSource {
146 pub locator: String,
147 pub role: String,
149 #[serde(default)]
151 pub mode: Option<String>,
152 #[serde(default)]
153 pub pinned_hash: String,
154 #[serde(default)]
155 pub pinned_ref: Option<String>,
156 #[serde(default)]
158 pub moved: bool,
159}
160
161#[derive(Debug, Clone, Default, Deserialize, Serialize)]
162pub struct ExportNote {
163 pub body: String,
164 #[serde(default)]
166 pub covers: Vec<String>,
167}
168
169#[derive(Debug, Clone, Default, Deserialize, Serialize)]
170pub struct ExportTask {
171 pub id: String,
172 pub title: String,
173 #[serde(default)]
174 pub body: String,
175 pub status: String,
176 #[serde(default)]
177 pub priority: Option<String>,
178 #[serde(default)]
180 pub boards: Vec<String>,
181 #[serde(default)]
183 pub page: Option<String>,
184 #[serde(default)]
186 pub pages: Vec<String>,
187}
188
189pub const OPEN_TASK_STATUSES: [&str; 3] = ["backlog", "todo", "doing"];
192
193#[derive(Debug, Clone, Default, Deserialize, Serialize)]
194pub struct ExportBoard {
195 pub slug: String,
196 pub title: String,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201pub struct PagePlan {
202 pub page: Page,
203 pub moved: Vec<bool>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
209pub struct TaskPlan {
210 pub source_id: String,
211 pub title: String,
212 pub body: String,
213 pub status: String,
214 pub priority: String,
215 pub labels: Vec<String>,
216 pub pages: Vec<String>,
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
222pub struct Report {
223 pub pages: usize,
224 pub sections: usize,
225 pub anchors: usize,
226 pub notes: usize,
227 pub tasks: usize,
228 pub retired_skipped: usize,
229 pub descriptions: usize,
232 pub without_description_key: Vec<String>,
235 pub description_like_keys: Vec<(String, String)>,
239 pub unmapped_keys: Vec<(String, String)>,
241 pub unknown_keys: Vec<(String, String)>,
244 pub closed_tasks_skipped: usize,
246 pub invalid_slugs: Vec<String>,
249 pub dangling: Vec<(String, String)>,
251 pub unmapped_sources: Vec<(String, String)>,
253 pub not_lossless: Vec<String>,
255 pub unknown_status: Vec<(String, String)>,
257}
258
259#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
261pub struct Plan {
262 pub pages: Vec<PagePlan>,
263 pub tasks: Vec<TaskPlan>,
264 pub report: Report,
265}
266
267fn check_description_keys(p: &ExportPage, report: &mut Report) {
271 match &p.description {
272 Description::Missing => report.without_description_key.push(p.slug.clone()),
273 Description::Text(d) if !d.trim().is_empty() => report.descriptions += 1,
274 Description::Null | Description::Text(_) => {}
275 }
276 for (k, v) in &p.extra {
277 report.unmapped_keys.push((p.slug.clone(), k.clone()));
278 if !no_description_key(k) {
279 report.unknown_keys.push((p.slug.clone(), k.clone()));
280 }
281 let mut paths = Vec::new();
282 if description_like(k) {
283 paths.push(k.clone());
284 }
285 nested_description_keys(k, v, &mut paths);
286 paths.sort();
288 report
289 .description_like_keys
290 .extend(paths.into_iter().map(|path| (p.slug.clone(), path)));
291 }
292}
293
294pub fn plan(
299 export: &Export,
300 dirs: &BTreeMap<String, String>,
301 is_dir: &dyn Fn(&str) -> bool,
302) -> Plan {
303 let mut report = Report::default();
304 let live: Vec<&ExportPage> = export
305 .pages
306 .iter()
307 .filter(|p| !p.retired && p.status != "retired")
308 .collect();
309 report.retired_skipped = export.pages.len() - live.len();
310 let slugs: BTreeSet<&str> = live.iter().map(|p| p.slug.as_str()).collect();
311
312 let mut pages = Vec::with_capacity(live.len());
313 for p in live {
314 check_description_keys(p, &mut report);
315 if !crate::model::valid_slug(&p.slug) {
316 report.invalid_slugs.push(p.slug.clone());
317 }
318 let (title, secs) = sections::split(&p.body);
319 if sections::join(&title, &secs) != p.body {
320 report.not_lossless.push(p.slug.clone());
321 }
322 let status = PageStatus::parse(&p.status).unwrap_or_else(|| {
323 report
324 .unknown_status
325 .push((p.slug.clone(), p.status.clone()));
326 PageStatus::Draft
327 });
328 for t in links::targets(&p.body) {
329 if let Target::Page { slug, .. } = links::classify(t) {
330 if !slugs.contains(slug) {
331 report.dangling.push((p.slug.clone(), t.to_owned()));
332 }
333 }
334 }
335 let mut anchors = Vec::new();
336 let mut moved = Vec::new();
337 let mut by_locator = BTreeMap::new();
338 for s in &p.sources {
339 match to_anchor(s, dirs, is_dir) {
340 Some(a) => {
341 by_locator.insert(s.locator.as_str(), a.path.clone());
342 anchors.push(a);
343 moved.push(s.moved);
344 }
345 None => report
346 .unmapped_sources
347 .push((p.slug.clone(), s.locator.clone())),
348 }
349 }
350 let notes: Vec<Note> = p
351 .notes
352 .iter()
353 .map(|n| Note {
354 body: n.body.clone(),
355 covers: n
356 .covers
357 .iter()
358 .filter_map(|l| by_locator.get(l.as_str()).cloned())
359 .collect(),
360 })
361 .collect();
362 report.sections += secs.len();
363 report.anchors += anchors.len();
364 report.notes += notes.len();
365 pages.push(PagePlan {
366 page: Page {
367 slug: p.slug.clone(),
368 title,
369 kind: p.kind.clone(),
370 status,
371 sections: secs,
372 anchors,
373 notes,
374 },
375 moved,
376 });
377 }
378 report.pages = pages.len();
379
380 let tasks = plan_tasks(&export.tasks, &slugs);
381 report.tasks = tasks.len();
382 report.closed_tasks_skipped = export.tasks.len() - tasks.len();
383 Plan {
384 pages,
385 tasks,
386 report,
387 }
388}
389
390fn plan_tasks(tasks: &[ExportTask], slugs: &BTreeSet<&str>) -> Vec<TaskPlan> {
393 tasks
394 .iter()
395 .filter(|t| OPEN_TASK_STATUSES.contains(&t.status.as_str()))
396 .map(|t| {
397 let mut about: Vec<String> = Vec::new();
398 for s in t.page.iter().chain(&t.pages) {
399 if slugs.contains(s.as_str()) && !about.contains(s) {
400 about.push(s.clone());
401 }
402 }
403 TaskPlan {
404 source_id: t.id.clone(),
405 title: t.title.clone(),
406 body: t.body.clone(),
407 status: t.status.clone(),
408 priority: t.priority.clone().unwrap_or_else(|| "normal".to_owned()),
409 labels: t.boards.iter().map(|b| format!("board:{b}")).collect(),
410 pages: about,
411 }
412 })
413 .collect()
414}
415
416fn to_anchor(
418 s: &ExportSource,
419 dirs: &BTreeMap<String, String>,
420 is_dir: &dyn Fn(&str) -> bool,
421) -> Option<Anchor> {
422 let rest = s.locator.strip_prefix("dir:")?;
423 let (name, path) = rest.split_once('/').unwrap_or((rest, ""));
424 let prefix = dirs.get(name)?;
425 let path = match (prefix.trim_end_matches('/'), path) {
426 ("", p) => p.to_owned(),
427 (pre, "") => pre.to_owned(),
428 (pre, p) => format!("{pre}/{p}"),
429 };
430 let kind = if anchor::is_glob(&path) {
431 AnchorKind::Glob
432 } else if is_dir(&path) {
433 AnchorKind::Dir
434 } else {
435 AnchorKind::File
436 };
437 let mode = if s.mode.as_deref() == Some("shape") {
438 AnchorMode::Shape
439 } else {
440 AnchorMode::Content
441 };
442 let role = if s.role == "cites" {
443 AnchorRole::Cites
444 } else {
445 AnchorRole::Derived
446 };
447 Some(Anchor {
450 kind,
451 mode,
452 role,
453 path,
454 digest: String::new(),
455 commit: s.pinned_ref.clone(),
456 })
457}
458
459pub fn from_jsonl(space: &str, pages: &str, tasks: &str, journal: &str) -> Result<Export, String> {
468 let mut notes_of = jsonl_notes(journal)?;
469 let mut export = Export {
470 space: space.to_owned(),
471 ..Export::default()
472 };
473 for r in records("pages.jsonl", pages) {
474 let (line, r) = r?;
475 let p = r
476 .get("page")
477 .ok_or_else(|| format!("pages.jsonl:{line}: a record without `page`"))?;
478 let description = record_description(p, &r)
481 .map_err(|why| format!("pages.jsonl:{line}: page {}: {why}", text(p, "slug")))?;
482 let mut extra = BTreeMap::new();
483 for (obj, known) in [
484 (
485 p,
486 &[
487 "id",
488 "slug",
489 "type",
490 "status",
491 "body",
492 "description",
493 "retiredAt",
494 ][..],
495 ),
496 (&r, &["page", "sources", "description"][..]),
497 ] {
498 let prefix = if std::ptr::eq(obj, p) {
500 ""
501 } else {
502 RECORD_KEY_PREFIX
503 };
504 if let Some(map) = obj.as_object() {
505 for (k, v) in map.iter().filter(|(k, _)| !known.contains(&k.as_str())) {
506 extra.insert(format!("{prefix}{k}"), v.clone());
507 }
508 }
509 }
510 let sources = r
511 .get("sources")
512 .and_then(Value::as_array)
513 .map(|a| {
514 a.iter()
515 .map(|s| ExportSource {
516 locator: text(s, "locator"),
517 role: text(s, "role"),
518 mode: opt(s, "mode"),
519 pinned_hash: text(s, "pinnedHash"),
520 pinned_ref: opt(s, "pinnedRef"),
521 moved: s.get("moved").and_then(Value::as_bool).unwrap_or(false),
522 })
523 .collect()
524 })
525 .unwrap_or_default();
526 export.pages.push(ExportPage {
527 slug: text(p, "slug"),
528 kind: text(p, "type"),
529 status: text(p, "status"),
530 body: text(p, "body"),
531 description,
532 retired: p.get("retiredAt").is_some_and(|v| !v.is_null()),
533 sources,
534 notes: notes_of.remove(&text(p, "id")).unwrap_or_default(),
535 extra,
536 });
537 }
538 for t in records("tasks.jsonl", tasks) {
539 let (_, t) = t?;
540 export.tasks.push(ExportTask {
541 id: text(&t, "id"),
542 title: text(&t, "title"),
543 body: text(&t, "body"),
544 status: text(&t, "status"),
545 priority: opt(&t, "priority"),
546 boards: opt(&t, "board").into_iter().collect(),
547 page: None,
548 pages: strs(&t, "pages"),
549 });
550 }
551 Ok(export)
552}
553
554fn record_description(page: &Value, record: &Value) -> Result<Description, String> {
559 fn read(v: Option<&Value>, at: &str) -> Result<Description, String> {
560 match v {
561 None => Ok(Description::Missing),
562 Some(Value::Null) => Ok(Description::Null),
563 Some(Value::String(s)) => Ok(Description::Text(s.clone())),
564 Some(_) => Err(format!("`{at}` is not text")),
565 }
566 }
567 fn rank(d: &Description) -> u8 {
568 match d {
569 Description::Missing => 0,
570 Description::Null => 1,
571 Description::Text(t) if t.trim().is_empty() => 2,
572 Description::Text(_) => 3,
573 }
574 }
575 let inner = read(page.get("description"), "description")?;
576 let outer = read(record.get("description"), "record.description")?;
577 if let (Description::Text(a), Description::Text(b)) = (&inner, &outer) {
578 if rank(&inner) == 3 && rank(&outer) == 3 && a != b {
579 return Err(
580 "`description` in `page` and `record.description` differ: refused rather than lose one"
581 .to_owned(),
582 );
583 }
584 }
585 Ok(if rank(&outer) > rank(&inner) {
586 outer
587 } else {
588 inner
589 })
590}
591
592fn jsonl_notes(journal: &str) -> Result<BTreeMap<String, Vec<ExportNote>>, String> {
596 let mut open: BTreeMap<String, (String, String)> = BTreeMap::new();
597 let mut resolved: BTreeSet<String> = BTreeSet::new();
598 for e in records("journal.jsonl", journal) {
599 let (_, e) = e?;
600 let id = text(&e, "objectId");
601 let payload = e.get("payload").unwrap_or(&Value::Null);
602 match e.get("op").and_then(Value::as_str) {
603 Some("note.add" | "note.update") if !resolved.contains(&id) => {
604 open.insert(id, (text(payload, "pageId"), text(payload, "body")));
605 }
606 Some("note.resolve") => {
607 open.remove(&id);
608 resolved.insert(id);
609 }
610 _ => {}
611 }
612 }
613 let mut notes_of: BTreeMap<String, Vec<ExportNote>> = BTreeMap::new();
614 for (page, body) in open.into_values() {
615 notes_of.entry(page).or_default().push(ExportNote {
616 body,
617 covers: Vec::new(),
618 });
619 }
620 Ok(notes_of)
621}
622
623fn records<'a>(
626 name: &'a str,
627 text: &'a str,
628) -> impl Iterator<Item = Result<(usize, Value), String>> + 'a {
629 text.lines()
630 .enumerate()
631 .filter(|(_, l)| !l.trim().is_empty())
632 .map(move |(i, l)| {
633 serde_json::from_str(l)
634 .map(|v| (i + 1, v))
635 .map_err(|e| format!("{name}:{}: {e}", i + 1))
636 })
637}
638
639fn text(v: &Value, key: &str) -> String {
640 v.get(key)
641 .and_then(Value::as_str)
642 .unwrap_or_default()
643 .to_owned()
644}
645
646fn opt(v: &Value, key: &str) -> Option<String> {
647 v.get(key).and_then(Value::as_str).map(str::to_owned)
648}
649
650fn strs(v: &Value, key: &str) -> Vec<String> {
651 v.get(key)
652 .and_then(Value::as_array)
653 .map(|a| {
654 a.iter()
655 .filter_map(Value::as_str)
656 .map(str::to_owned)
657 .collect()
658 })
659 .unwrap_or_default()
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn source(locator: &str, role: &str, moved: bool) -> ExportSource {
667 ExportSource {
668 locator: locator.into(),
669 role: role.into(),
670 moved,
671 pinned_hash: "h".into(),
672 ..Default::default()
673 }
674 }
675
676 fn export() -> Export {
677 Export {
678 space: "s".into(),
679 pages: vec and [X](/gone)\n".into(),
685 sources: vec![
686 source("dir:repo/rust/src/core", "derived", true),
687 source("dir:repo/rust/src/**/*.rs", "cites", false),
688 source("url:https://x", "cites", false),
689 ],
690 notes: vec![ExportNote {
691 body: "moved".into(),
692 covers: vec!["dir:repo/rust/src/core".into()],
693 }],
694 ..Default::default()
695 },
696 ExportPage {
697 slug: "b".into(),
698 kind: "design".into(),
699 status: "draft".into(),
700 body: "# B\n".into(),
701 description: Description::Text("The B page.".into()),
702 extra: BTreeMap::from([("summary".to_owned(), Value::from("B in short"))]),
703 ..Default::default()
704 },
705 ExportPage {
706 slug: "old".into(),
707 kind: "x".into(),
708 status: "canonical".into(),
709 retired: true,
710 body: "# O\n".into(),
711 description: Description::Text("Retired, stays behind.".into()),
712 ..Default::default()
713 },
714 ],
715 tasks: vec![
716 ExportTask {
717 id: "t1".into(),
718 title: "Do It".into(),
719 status: "todo".into(),
720 boards: vec!["w".into()],
721 page: Some("old".into()),
722 pages: vec!["b".into(), "old".into()],
723 ..Default::default()
724 },
725 ExportTask {
726 id: "t2".into(),
727 title: "Done Already".into(),
728 status: "done".into(),
729 ..Default::default()
730 },
731 ],
732 boards: vec![ExportBoard {
733 slug: "w".into(),
734 title: "Work".into(),
735 }],
736 }
737 }
738
739 #[test]
740 fn plans_pages_anchors_notes_and_tasks() {
741 let dirs = BTreeMap::from([("repo".to_owned(), String::new())]);
742 let plan = plan(&export(), &dirs, &|p| p == "rust/src/core");
743 let r = &plan.report;
744 assert_eq!((r.pages, r.retired_skipped, r.tasks, r.notes), (2, 1, 1, 1));
745 assert_eq!(
746 r.descriptions, 1,
747 "a retired page's description stays behind"
748 );
749 assert_eq!(
750 r.without_description_key,
751 ["a"],
752 "a page record without the key fails closed"
753 );
754 assert_eq!(
755 r.description_like_keys,
756 [("b".to_owned(), "summary".to_owned())]
757 );
758 assert_eq!(r.dangling, [("a".to_owned(), "/gone".to_owned())]);
759 assert_eq!(
760 r.unmapped_sources,
761 [("a".to_owned(), "url:https://x".to_owned())]
762 );
763 assert!(r.not_lossless.is_empty());
764
765 let a = &plan.pages[0];
766 assert_eq!(a.page.title, "A");
767 assert_eq!(a.page.status, PageStatus::Canonical);
768 assert_eq!(a.page.anchors[0].kind, AnchorKind::Dir);
769 assert_eq!(a.page.anchors[0].path, "rust/src/core");
770 assert_eq!(a.page.anchors[1].kind, AnchorKind::Glob);
771 assert_eq!(a.page.anchors[1].role, AnchorRole::Cites);
772 assert_eq!(a.moved, [true, false]);
773 assert_eq!(a.page.notes[0].covers, ["rust/src/core"]);
774 assert_eq!(a.page.body(), export().pages[0].body);
775
776 assert_eq!(plan.tasks.len(), 1, "only open work moves");
777 assert_eq!(r.closed_tasks_skipped, 1);
778 let t = &plan.tasks[0];
779 assert_eq!(t.priority, "normal");
780 assert_eq!(t.labels, ["board:w"]);
781 assert_eq!(
782 t.pages,
783 ["b"],
784 "a task about a retired page loses that link"
785 );
786 }
787
788 #[test]
792 fn reads_the_jsonl_of_an_export_dir() {
793 let pages = concat!(
794 r##"{"page":{"id":"p1","slug":"a","type":"guide","status":"canonical","body":"# A\n","description":"What A is."},"sources":[{"locator":"dir:repo/x.rs","role":"derived","pinnedHash":"h","pinnedRef":"c1","moved":true}]}"##,
795 "\n",
796 r##"{"page":{"id":"p2","slug":"gone","type":"x","status":"draft","body":"# G\n","retiredAt":"2026-10-30T00:00:00Z"},"sources":[]}"##,
797 "\n"
798 );
799 let journal = concat!(
800 r#"{"op":"note.add","objectId":"n1","payload":{"pageId":"p1","body":"first"}}"#,
801 "\n",
802 r#"{"op":"note.update","objectId":"n1","payload":{"pageId":"p1","body":"open"}}"#,
803 "\n",
804 r#"{"op":"note.add","objectId":"n2","payload":{"pageId":"p1","body":"closed"}}"#,
805 "\n",
806 r#"{"op":"note.resolve","objectId":"n2","payload":{"pageId":"p1"}}"#,
807 "\n",
808 r#"{"op":"note.update","objectId":"n2","payload":{"pageId":"p1","body":"late"}}"#,
809 "\n",
810 r#"{"op":"page.revise","objectId":"p1","payload":{}}"#,
811 "\n"
812 );
813 let tasks = r#"{"id":"t1","title":"Fix It Now","body":"why","status":"backlog","priority":"high","board":"w","pages":["a"]}"#;
814 let e = from_jsonl("s", pages, tasks, journal).expect("a valid export");
815 assert_eq!(e.pages.len(), 2);
816 let a = &e.pages[0];
817 assert_eq!((a.kind.as_str(), a.retired), ("guide", false));
818 assert!(e.pages[1].retired);
819 assert_eq!(a.description, Description::Text("What A is.".to_owned()));
820 assert_eq!(
821 e.pages[1].description,
822 Description::Missing,
823 "no key, not an empty text"
824 );
825 assert!(a.extra.is_empty(), "{:?}", a.extra);
826 assert_eq!(a.sources[0].pinned_ref.as_deref(), Some("c1"));
827 assert!(a.sources[0].moved);
828 let notes: Vec<&str> = a.notes.iter().map(|n| n.body.as_str()).collect();
829 assert_eq!(notes, ["open"]);
830 let t = &e.tasks[0];
831 assert_eq!(
832 (t.priority.as_deref(), t.boards.as_slice()),
833 (Some("high"), &["w".to_owned()][..])
834 );
835 assert_eq!(t.pages, ["a"]);
836 let err = from_jsonl("s", "{not json", "", "").expect_err("not JSON");
837 assert!(err.starts_with("pages.jsonl:1:"), "{err}");
838 }
839
840 #[test]
845 fn descriptions_are_read_strictly() {
846 let pages = concat!(
847 r##"{"page":{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n","description":null}}"##,
848 "\n",
849 r##"{"page":{"id":"p2","slug":"b","type":"g","status":"draft","body":"# B\n","pageDescription":"B"}}"##,
850 "\n",
851 );
852 let e = from_jsonl("s", pages, "", "").expect("a valid export");
853 assert_eq!(e.pages[0].description, Description::Null);
854 assert_eq!(e.pages[1].description, Description::Missing);
855 let dirs = BTreeMap::new();
856 let r = plan(&e, &dirs, &|_| false).report;
857 assert_eq!(
858 (r.descriptions, r.without_description_key.as_slice()),
859 (0, &["b".to_owned()][..])
860 );
861 assert_eq!(
862 r.description_like_keys,
863 [("b".to_owned(), "pageDescription".to_owned())]
864 );
865
866 let bad = concat!(
867 r##"{"page":{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n","description":"ok"}}"##,
868 "\n",
869 r##"{"page":{"id":"p2","slug":"b","type":"g","status":"draft","body":"# B\n","description":{"text":"B"}}}"##,
870 );
871 let err = from_jsonl("s", bad, "", "").expect_err("not text");
872 assert_eq!(err, "pages.jsonl:2: page b: `description` is not text");
873
874 let json = |d: &str| {
875 serde_json::from_str::<ExportPage>(&format!(
876 r##"{{"slug":"a","type":"g","status":"draft","body":"# A\n"{d}}}"##
877 ))
878 };
879 assert_eq!(json("").expect("no key").description, Description::Missing);
880 assert_eq!(
881 json(r#","description":null"#).expect("null").description,
882 Description::Null
883 );
884 assert_eq!(
885 json(r#","summary":"S""#).expect("extra").extra["summary"],
886 "S"
887 );
888 assert!(json(r#","description":3"#).is_err(), "non-text is refused");
889 }
890
891 #[test]
895 fn unmapped_keys_are_reported_and_unknown_ones_flagged() {
896 let pages = concat!(
897 r##"{"page":{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n","description":null,"title":"A","createdAt":"2026-11-01"}}"##,
898 "\n",
899 r##"{"page":{"id":"p2","slug":"b","type":"g","status":"draft","body":"# B\n","description":null,"meta":{"tags":[{"lede":"x"}],"description":"lost"}}}"##,
900 "\n",
901 r##"{"page":{"id":"p3","slug":"c","type":"g","status":"draft","body":"# C\n","description":null},"intro":"C"}"##,
902 "\n",
903 );
904 let e = from_jsonl("s", pages, "", "").expect("a valid export");
905 let r = plan(&e, &BTreeMap::new(), &|_| false).report;
906 let pairs = |v: &[(&str, &str)]| -> Vec<(String, String)> {
907 v.iter()
908 .map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
909 .collect()
910 };
911 assert_eq!(
912 r.unmapped_keys,
913 pairs(&[
914 ("a", "createdAt"),
915 ("a", "title"),
916 ("b", "meta"),
917 ("c", "record.intro")
918 ])
919 );
920 assert_eq!(
921 r.unknown_keys,
922 pairs(&[("b", "meta"), ("c", "record.intro")]),
923 "title and createdAt are on the no-description list"
924 );
925 assert_eq!(
926 r.description_like_keys,
927 pairs(&[
928 ("b", "meta.description"),
929 ("b", "meta.tags[0].lede"),
930 ("c", "record.intro")
931 ])
932 );
933 assert!(r.without_description_key.is_empty() && r.descriptions == 0);
934 }
935
936 #[test]
940 fn both_description_fields_are_read() {
941 let line = |page: &str, record: &str| {
942 format!(
943 r##"{{"page":{{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n"{page}}}{record}}}"##
944 )
945 };
946 let read = |page: &str, record: &str| {
947 from_jsonl("s", &line(page, record), "", "").map(|e| e.pages[0].description.clone())
948 };
949 let text = |s: &str| Ok(Description::Text(s.to_owned()));
950 assert_eq!(
951 read(r#","description":null"#, r#","description":"R""#),
952 text("R")
953 );
954 assert_eq!(read("", r#","description":"R""#), text("R"));
955 assert_eq!(
956 read(r#","description":"P""#, r#","description":null"#),
957 text("P")
958 );
959 assert_eq!(
960 read(r#","description":"P""#, r#","description":"P""#),
961 text("P")
962 );
963 assert_eq!(
964 read(r#","description":" ""#, r#","description":"R""#),
965 text("R")
966 );
967 assert_eq!(read("", r#","description":null"#), Ok(Description::Null));
968 assert_eq!(
969 read(r#","description":"P""#, r#","description":"R""#),
970 Err("pages.jsonl:1: page a: `description` in `page` and `record.description` differ: refused rather than lose one".to_owned())
971 );
972 assert_eq!(
973 read(r#","description":null"#, r#","description":5"#),
974 Err("pages.jsonl:1: page a: `record.description` is not text".to_owned())
975 );
976
977 let e = from_jsonl(
978 "s",
979 &line(r#","description":null"#, r#","description":"R""#),
980 "",
981 "",
982 )
983 .expect("a valid export");
984 let r = plan(&e, &BTreeMap::new(), &|_| false).report;
985 assert_eq!(r.descriptions, 1, "the record-level text is counted");
986 }
987
988 #[test]
992 fn record_keys_do_not_shadow_page_keys() {
993 let pages = r##"{"page":{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n","description":null,"links":[{"summary":"x"}]},"links":[],"title":"A"}"##;
994 let e = from_jsonl("s", pages, "", "").expect("a valid export");
995 let r = plan(&e, &BTreeMap::new(), &|_| false).report;
996 let pairs = |v: &[&str]| -> Vec<(String, String)> {
997 v.iter()
998 .map(|k| ("a".to_owned(), (*k).to_owned()))
999 .collect()
1000 };
1001 assert_eq!(
1002 r.unmapped_keys,
1003 pairs(&["links", "record.links", "record.title"])
1004 );
1005 assert!(
1006 r.unknown_keys.is_empty(),
1007 "checked by the bare name: {:?}",
1008 r.unknown_keys
1009 );
1010 assert_eq!(r.description_like_keys, pairs(&["links[0].summary"]));
1011
1012 let swapped = r##"{"page":{"id":"p1","slug":"a","type":"g","status":"draft","body":"# A\n","description":null,"links":[]},"links":[{"summary":"x"}]}"##;
1013 let e = from_jsonl("s", swapped, "", "").expect("a valid export");
1014 let r = plan(&e, &BTreeMap::new(), &|_| false).report;
1015 assert_eq!(r.description_like_keys, pairs(&["record.links[0].summary"]));
1016 assert!(no_description_key("record.links") && !no_description_key("record.intro"));
1017 }
1018
1019 #[test]
1020 fn a_mapped_prefix_is_prepended() {
1021 let dirs = BTreeMap::from([("hive".to_owned(), "services/hive".to_owned())]);
1022 let s = source("dir:hive/crates/x.rs", "derived", false);
1023 assert_eq!(
1024 to_anchor(&s, &dirs, &|_| false).map(|a| a.reference()),
1025 Some("file:services/hive/crates/x.rs".to_owned())
1026 );
1027 assert!(to_anchor(&source("dir:other/x", "derived", false), &dirs, &|_| false).is_none());
1028 }
1029}