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};
13
14use crate::anchor::{self, Anchor, AnchorKind, AnchorMode, AnchorRole};
15use crate::links::{self, Target};
16use crate::model::{Note, Page, PageStatus};
17use crate::sections;
18
19/// One emspace space, as an export program writes it.
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
21pub struct Export {
22    pub space: String,
23    pub pages: Vec<ExportPage>,
24    #[serde(default)]
25    pub tasks: Vec<ExportTask>,
26    #[serde(default)]
27    pub boards: Vec<ExportBoard>,
28}
29
30#[derive(Debug, Clone, Default, Deserialize, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct ExportPage {
33    pub slug: String,
34    #[serde(rename = "type")]
35    pub kind: String,
36    pub status: String,
37    pub body: String,
38    #[serde(default)]
39    pub retired: bool,
40    #[serde(default)]
41    pub sources: Vec<ExportSource>,
42    #[serde(default)]
43    pub notes: Vec<ExportNote>,
44}
45
46#[derive(Debug, Clone, Default, Deserialize, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct ExportSource {
49    pub locator: String,
50    /// `derived` or `cites`.
51    pub role: String,
52    /// `content` (default) or `shape`.
53    #[serde(default)]
54    pub mode: Option<String>,
55    #[serde(default)]
56    pub pinned_hash: String,
57    #[serde(default)]
58    pub pinned_ref: Option<String>,
59    /// The target changed since it was pinned.
60    #[serde(default)]
61    pub moved: bool,
62}
63
64#[derive(Debug, Clone, Default, Deserialize, Serialize)]
65pub struct ExportNote {
66    pub body: String,
67    /// Locators of the sources the note covers.
68    #[serde(default)]
69    pub covers: Vec<String>,
70}
71
72#[derive(Debug, Clone, Default, Deserialize, Serialize)]
73pub struct ExportTask {
74    pub id: String,
75    pub title: String,
76    #[serde(default)]
77    pub body: String,
78    pub status: String,
79    #[serde(default)]
80    pub priority: Option<String>,
81    /// Slugs of the boards the task is filed on.
82    #[serde(default)]
83    pub boards: Vec<String>,
84    /// The page the task is about.
85    #[serde(default)]
86    pub page: Option<String>,
87}
88
89#[derive(Debug, Clone, Default, Deserialize, Serialize)]
90pub struct ExportBoard {
91    pub slug: String,
92    pub title: String,
93}
94
95/// A page of the plan: the page and how each anchor stood in emspace.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct PagePlan {
98    pub page: Page,
99    /// Parallel to `page.anchors`: the source had moved in emspace.
100    pub moved: Vec<bool>,
101}
102
103/// A task of the plan. Boards become labels.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
105pub struct TaskPlan {
106    pub source_id: String,
107    pub title: String,
108    pub body: String,
109    pub status: String,
110    pub priority: String,
111    pub labels: Vec<String>,
112    pub page: Option<String>,
113}
114
115/// What the plan could not carry over unchanged.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
117pub struct Report {
118    pub pages: usize,
119    pub sections: usize,
120    pub anchors: usize,
121    pub notes: usize,
122    pub tasks: usize,
123    pub retired_skipped: usize,
124    /// `(page, target)` for links to a slug no migrated page has.
125    pub dangling: Vec<(String, String)>,
126    /// `(page, locator)` for sources outside the mapped directories.
127    pub unmapped_sources: Vec<(String, String)>,
128    /// Pages whose body does not survive split + join byte for byte.
129    pub not_lossless: Vec<String>,
130    /// `(page, status)` for statuses this model does not know.
131    pub unknown_status: Vec<(String, String)>,
132}
133
134/// The pages, tasks and report of a migration.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
136pub struct Plan {
137    pub pages: Vec<PagePlan>,
138    pub tasks: Vec<TaskPlan>,
139    pub report: Report,
140}
141
142/// Plans the move of `export`. `dirs` maps an emspace directory name (the
143/// `repo` of `dir:repo/rust/src`) to its path prefix in the target
144/// repository (`""` for the root); `is_dir` says whether a mapped path is a
145/// directory of the target checkout. Retired pages are left in the archive.
146pub fn plan(
147    export: &Export,
148    dirs: &BTreeMap<String, String>,
149    is_dir: &dyn Fn(&str) -> bool,
150) -> Plan {
151    let mut report = Report::default();
152    let live: Vec<&ExportPage> = export
153        .pages
154        .iter()
155        .filter(|p| !p.retired && p.status != "retired")
156        .collect();
157    report.retired_skipped = export.pages.len() - live.len();
158    let slugs: BTreeSet<&str> = live.iter().map(|p| p.slug.as_str()).collect();
159
160    let mut pages = Vec::with_capacity(live.len());
161    for p in live {
162        let (title, secs) = sections::split(&p.body);
163        if sections::join(&title, &secs) != p.body {
164            report.not_lossless.push(p.slug.clone());
165        }
166        let status = PageStatus::parse(&p.status).unwrap_or_else(|| {
167            report
168                .unknown_status
169                .push((p.slug.clone(), p.status.clone()));
170            PageStatus::Draft
171        });
172        for t in links::targets(&p.body) {
173            if let Target::Page { slug, .. } = links::classify(t) {
174                if !slugs.contains(slug) {
175                    report.dangling.push((p.slug.clone(), t.to_owned()));
176                }
177            }
178        }
179        let mut anchors = Vec::new();
180        let mut moved = Vec::new();
181        let mut by_locator = BTreeMap::new();
182        for s in &p.sources {
183            match to_anchor(s, dirs, is_dir) {
184                Some(a) => {
185                    by_locator.insert(s.locator.as_str(), a.path.clone());
186                    anchors.push(a);
187                    moved.push(s.moved);
188                }
189                None => report
190                    .unmapped_sources
191                    .push((p.slug.clone(), s.locator.clone())),
192            }
193        }
194        let notes: Vec<Note> = p
195            .notes
196            .iter()
197            .map(|n| Note {
198                body: n.body.clone(),
199                covers: n
200                    .covers
201                    .iter()
202                    .filter_map(|l| by_locator.get(l.as_str()).cloned())
203                    .collect(),
204            })
205            .collect();
206        report.sections += secs.len();
207        report.anchors += anchors.len();
208        report.notes += notes.len();
209        pages.push(PagePlan {
210            page: Page {
211                slug: p.slug.clone(),
212                title,
213                kind: p.kind.clone(),
214                status,
215                sections: secs,
216                anchors,
217                notes,
218            },
219            moved,
220        });
221    }
222    report.pages = pages.len();
223
224    let tasks: Vec<TaskPlan> = export
225        .tasks
226        .iter()
227        .map(|t| TaskPlan {
228            source_id: t.id.clone(),
229            title: t.title.clone(),
230            body: t.body.clone(),
231            status: t.status.clone(),
232            priority: t.priority.clone().unwrap_or_else(|| "normal".to_owned()),
233            labels: t.boards.iter().map(|b| format!("board:{b}")).collect(),
234            page: t.page.clone().filter(|s| slugs.contains(s.as_str())),
235        })
236        .collect();
237    report.tasks = tasks.len();
238    Plan {
239        pages,
240        tasks,
241        report,
242    }
243}
244
245/// An emspace `dir:<name>/<path>` source as an anchor, if `<name>` is mapped.
246fn to_anchor(
247    s: &ExportSource,
248    dirs: &BTreeMap<String, String>,
249    is_dir: &dyn Fn(&str) -> bool,
250) -> Option<Anchor> {
251    let rest = s.locator.strip_prefix("dir:")?;
252    let (name, path) = rest.split_once('/').unwrap_or((rest, ""));
253    let prefix = dirs.get(name)?;
254    let path = match (prefix.trim_end_matches('/'), path) {
255        ("", p) => p.to_owned(),
256        (pre, "") => pre.to_owned(),
257        (pre, p) => format!("{pre}/{p}"),
258    };
259    let kind = if anchor::is_glob(&path) {
260        AnchorKind::Glob
261    } else if is_dir(&path) {
262        AnchorKind::Dir
263    } else {
264        AnchorKind::File
265    };
266    let mode = if s.mode.as_deref() == Some("shape") {
267        AnchorMode::Shape
268    } else {
269        AnchorMode::Content
270    };
271    let role = if s.role == "cites" {
272        AnchorRole::Cites
273    } else {
274        AnchorRole::Derived
275    };
276    // emspace hashes with its own algorithm: the migrator re-pins the digest
277    // at `commit` (emspace's pinnedRef) before anything is written.
278    Some(Anchor {
279        kind,
280        mode,
281        role,
282        path,
283        digest: String::new(),
284        commit: s.pinned_ref.clone(),
285    })
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn source(locator: &str, role: &str, moved: bool) -> ExportSource {
293        ExportSource {
294            locator: locator.into(),
295            role: role.into(),
296            moved,
297            pinned_hash: "h".into(),
298            ..Default::default()
299        }
300    }
301
302    fn export() -> Export {
303        Export {
304            space: "s".into(),
305            pages: vec![
306                ExportPage {
307                    slug: "a".into(),
308                    kind: "reference".into(),
309                    status: "canonical".into(),
310                    body: "# A\n## One\nsee [B](/b) and [X](/gone)\n".into(),
311                    sources: vec![
312                        source("dir:repo/rust/src/core", "derived", true),
313                        source("dir:repo/rust/src/**/*.rs", "cites", false),
314                        source("url:https://x", "cites", false),
315                    ],
316                    notes: vec![ExportNote {
317                        body: "moved".into(),
318                        covers: vec!["dir:repo/rust/src/core".into()],
319                    }],
320                    ..Default::default()
321                },
322                ExportPage {
323                    slug: "b".into(),
324                    kind: "design".into(),
325                    status: "draft".into(),
326                    body: "# B\n".into(),
327                    ..Default::default()
328                },
329                ExportPage {
330                    slug: "old".into(),
331                    kind: "x".into(),
332                    status: "canonical".into(),
333                    retired: true,
334                    body: "# O\n".into(),
335                    ..Default::default()
336                },
337            ],
338            tasks: vec![ExportTask {
339                id: "t1".into(),
340                title: "Do It".into(),
341                status: "todo".into(),
342                boards: vec!["w".into()],
343                page: Some("old".into()),
344                ..Default::default()
345            }],
346            boards: vec![ExportBoard {
347                slug: "w".into(),
348                title: "Work".into(),
349            }],
350        }
351    }
352
353    #[test]
354    fn plans_pages_anchors_notes_and_tasks() {
355        let dirs = BTreeMap::from([("repo".to_owned(), String::new())]);
356        let plan = plan(&export(), &dirs, &|p| p == "rust/src/core");
357        let r = &plan.report;
358        assert_eq!((r.pages, r.retired_skipped, r.tasks, r.notes), (2, 1, 1, 1));
359        assert_eq!(r.dangling, [("a".to_owned(), "/gone".to_owned())]);
360        assert_eq!(
361            r.unmapped_sources,
362            [("a".to_owned(), "url:https://x".to_owned())]
363        );
364        assert!(r.not_lossless.is_empty());
365
366        let a = &plan.pages[0];
367        assert_eq!(a.page.title, "A");
368        assert_eq!(a.page.status, PageStatus::Canonical);
369        assert_eq!(a.page.anchors[0].kind, AnchorKind::Dir);
370        assert_eq!(a.page.anchors[0].path, "rust/src/core");
371        assert_eq!(a.page.anchors[1].kind, AnchorKind::Glob);
372        assert_eq!(a.page.anchors[1].role, AnchorRole::Cites);
373        assert_eq!(a.moved, [true, false]);
374        assert_eq!(a.page.notes[0].covers, ["rust/src/core"]);
375        assert_eq!(a.page.body(), export().pages[0].body);
376
377        let t = &plan.tasks[0];
378        assert_eq!(t.priority, "normal");
379        assert_eq!(t.labels, ["board:w"]);
380        assert_eq!(t.page, None, "a task about a retired page loses the link");
381    }
382
383    #[test]
384    fn a_mapped_prefix_is_prepended() {
385        let dirs = BTreeMap::from([("hive".to_owned(), "services/hive".to_owned())]);
386        let s = source("dir:hive/crates/x.rs", "derived", false);
387        assert_eq!(
388            to_anchor(&s, &dirs, &|_| false).map(|a| a.reference()),
389            Some("file:services/hive/crates/x.rs".to_owned())
390        );
391        assert!(to_anchor(&source("dir:other/x", "derived", false), &dirs, &|_| false).is_none());
392    }
393}