1use 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#[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 pub role: String,
52 #[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 #[serde(default)]
61 pub moved: bool,
62}
63
64#[derive(Debug, Clone, Default, Deserialize, Serialize)]
65pub struct ExportNote {
66 pub body: String,
67 #[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 #[serde(default)]
83 pub boards: Vec<String>,
84 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct PagePlan {
98 pub page: Page,
99 pub moved: Vec<bool>,
101}
102
103#[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#[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 pub dangling: Vec<(String, String)>,
126 pub unmapped_sources: Vec<(String, String)>,
128 pub not_lossless: Vec<String>,
130 pub unknown_status: Vec<(String, String)>,
132}
133
134#[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
142pub 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
245fn 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 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 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}