Skip to main content

lean_ctx_doc/
emspace.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Reading an emspace export into pages, for the move of a space to Hive.
4//!
5//! The export is the JSON an emspace program writes (`Export`): pages with
6//! their sources and open notes, tasks and boards. [`plan`] turns it into
7//! pages of this model and a report that says what could not be carried over
8//! as it was, so the migration is checked before anything is written.
9
10use 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/// One emspace space, as an export program writes it.
21#[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    /// The one-line page description ([`Description`]); a value that is
40    /// neither text nor `null` is an error. [`Page`] has no field for it
41    /// yet, so the plan counts it ([`Report::descriptions`]) and flags a page
42    /// without the key ([`Report::without_description_key`]): the import
43    /// fails closed rather than lose descriptions it did not see.
44    #[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    /// Keys of the page record this reader does not map. The plan flags
53    /// the description-like ones ([`Report::description_like_keys`]).
54    #[serde(flatten)]
55    pub extra: BTreeMap<String, Value>,
56}
57
58/// How a page record carries its `description` key.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
60pub enum Description {
61    /// The record has no `description` key.
62    #[default]
63    Missing,
64    /// `"description": null`.
65    Null,
66    Text(String),
67}
68
69/// A present key: only text or `null` is accepted.
70fn 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/// A key that may carry a page description under another name.
75#[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
86/// Unmapped page record keys known to hold no description: any other
87/// unmapped key makes an `--apply` refuse (it could hold one). Kept in step
88/// with the list in the internal emspace-to-hive migration notes; check it
89/// against the first real export. A key beside `page` is reported as
90/// `record.<key>` and checked by its bare name ([`no_description_key`]).
91///
92/// `title`: the importer takes a page's title from its body's `# heading`
93/// (byte-lossless split + join), so a `title` field adds nothing to read.
94pub 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
109/// The prefix of an unmapped key that sits beside `page` in a
110/// `pages.jsonl` record, so it never shadows a `page` key of the same name.
111pub const RECORD_KEY_PREFIX: &str = "record.";
112
113/// Whether unmapped `key` (bare, or with [`RECORD_KEY_PREFIX`]) is on
114/// [`NO_DESCRIPTION_KEYS`].
115#[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
121/// Every description-like key ([`description_like`]) below `value`, as a
122/// path from `prefix` (`meta.description`, `blocks[0].summary`).
123fn 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    /// `derived` or `cites`.
148    pub role: String,
149    /// `content` (default) or `shape`.
150    #[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    /// The target changed since it was pinned.
157    #[serde(default)]
158    pub moved: bool,
159}
160
161#[derive(Debug, Clone, Default, Deserialize, Serialize)]
162pub struct ExportNote {
163    pub body: String,
164    /// Locators of the sources the note covers.
165    #[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    /// Slugs of the boards the task is filed on.
179    #[serde(default)]
180    pub boards: Vec<String>,
181    /// The page the task is about.
182    #[serde(default)]
183    pub page: Option<String>,
184    /// Further pages the task is about (`emspace export` names all of them).
185    #[serde(default)]
186    pub pages: Vec<String>,
187}
188
189/// The task statuses emspace counts as open work (`backlog → todo → doing`);
190/// `done` and `dropped` are closed and stay behind.
191pub 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/// A page of the plan: the page and how each anchor stood in emspace.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201pub struct PagePlan {
202    pub page: Page,
203    /// Parallel to `page.anchors`: the source had moved in emspace.
204    pub moved: Vec<bool>,
205}
206
207/// A task of the plan. Boards become labels.
208#[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    /// The migrated pages the task is about (a retired page drops out).
217    pub pages: Vec<String>,
218}
219
220/// What the plan could not carry over unchanged.
221#[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    /// Migrated pages that carry a description: the page model has no
230    /// field for it, so an import would lose these descriptions.
231    pub descriptions: usize,
232    /// Migrated pages whose record has no `description` key: the export may
233    /// carry their descriptions where this reader does not look.
234    pub without_description_key: Vec<String>,
235    /// `(page, key path)` for keys of a migrated page record that look like
236    /// a description under another name ([`description_like`]), nested
237    /// ones included (`meta.description`).
238    pub description_like_keys: Vec<(String, String)>,
239    /// `(page, key)` for every unmapped key of a migrated page record.
240    pub unmapped_keys: Vec<(String, String)>,
241    /// `(page, key)` for unmapped keys outside [`NO_DESCRIPTION_KEYS`]:
242    /// they could hold a description, so an import fails closed on them.
243    pub unknown_keys: Vec<(String, String)>,
244    /// Done or dropped tasks, left behind (only open work moves).
245    pub closed_tasks_skipped: usize,
246    /// Slugs that are not C0 2.2 slugs ([`crate::model::valid_slug`]):
247    /// Hive refuses these pages, so they need a new slug first.
248    pub invalid_slugs: Vec<String>,
249    /// `(page, target)` for links to a slug no migrated page has.
250    pub dangling: Vec<(String, String)>,
251    /// `(page, locator)` for sources outside the mapped directories.
252    pub unmapped_sources: Vec<(String, String)>,
253    /// Pages whose body does not survive split + join byte for byte.
254    pub not_lossless: Vec<String>,
255    /// `(page, status)` for statuses this model does not know.
256    pub unknown_status: Vec<(String, String)>,
257}
258
259/// The pages, tasks and report of a migration.
260#[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
267/// Records in `report` how page `p` carries its description and every key
268/// the reader did not map (fail closed: an import must not lose a
269/// description it did not see).
270fn 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        // Deterministic whatever order serde_json keeps object keys in.
287        paths.sort();
288        report
289            .description_like_keys
290            .extend(paths.into_iter().map(|path| (p.slug.clone(), path)));
291    }
292}
293
294/// Plans the move of `export`. `dirs` maps an emspace directory name (the
295/// `repo` of `dir:repo/rust/src`) to its path prefix in the target
296/// repository (`""` for the root); `is_dir` says whether a mapped path is a
297/// directory of the target checkout. Retired pages are left in the archive.
298pub 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
390/// The open tasks ([`OPEN_TASK_STATUSES`]) as task plans; a page link
391/// survives only to a page in `slugs` (retired pages drop out).
392fn 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
416/// An emspace `dir:<name>/<path>` source as an anchor, if `<name>` is mapped.
417fn 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    // emspace hashes with its own algorithm: the migrator re-pins the digest
448    // at `commit` (emspace's pinnedRef) before anything is written.
449    Some(Anchor {
450        kind,
451        mode,
452        role,
453        path,
454        digest: String::new(),
455        commit: s.pinned_ref.clone(),
456    })
457}
458
459/// Reads what `emspace export <dir>` writes: the text of its `pages.jsonl`,
460/// `tasks.jsonl` and `journal.jsonl`. The export carries no notes of its
461/// own, so the open notes come from the journal (`jsonl_notes`).
462///
463/// # Errors
464///
465/// A line that is not JSON (the error names its file and line), or a
466/// `pages.jsonl` record without `page`.
467pub 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        // In `page`; tolerated beside it as well. Both are read: text in
479        // either one counts, two different texts are refused.
480        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            // A record key never replaces a `page` key of the same name.
499            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
554/// The description of a `pages.jsonl` record, read from `page` and from
555/// beside it. Text in either one wins over `null` or a missing key (blank
556/// text only over those); two different non-blank texts are an error, as
557/// is a value that is neither text nor `null`.
558fn 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
592/// The open notes of a journal, by page id: `note.add` and `note.update`
593/// set a note's body, `note.resolve` closes it for good (a later update
594/// does not reopen it). Notes keep note-id order (emspace ids sort by time).
595fn 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
623/// The JSON records of one JSONL file with their (1-based) line; an error
624/// names `name` and the line.
625fn 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![
680                ExportPage {
681                    slug: "a".into(),
682                    kind: "reference".into(),
683                    status: "canonical".into(),
684                    body: "# A\n## One\nsee [B](/b) 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    /// The JSONL of `emspace export <dir>`: retired pages, sources, open
789    /// notes from the journal (a resolved note stays behind, even when an
790    /// update follows its resolve) and tasks with their board.
791    #[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    /// Review 05.11.: the description check must fail closed. `null` is a
841    /// present key, a description under another name is kept as an extra
842    /// key (and flagged by the plan), a non-text one is an error naming its
843    /// line, in both the JSONL and the JSON form.
844    #[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    /// Review 05.11. (third round): unknown keys fail closed. Every unmapped
892    /// key is reported; one outside [`NO_DESCRIPTION_KEYS`] is unknown, and
893    /// a description-like key nested in another field is flagged by path.
894    #[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    /// Review 05.11. (fourth round, F2c): both `description` fields are
937    /// read. Text in either one counts, two different texts are refused,
938    /// and a non-text record-level value is an error too.
939    #[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    /// Review 05.11. (fourth round, F2d): a record key never replaces the
989    /// `page` key of the same name, so a nested description in either is
990    /// flagged.
991    #[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}