1use std::collections::HashSet;
4
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use crate::{
9 Manifest, McdPackage,
10 document::{DocumentBlock, McdDocument, SourceSpan},
11 errors::{Diagnostic, McdError, Result},
12 manifest::AnnotationManifestEntry,
13 package::validate_internal_path,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct AnnotationMetadata {
20 pub id: String,
22 pub target: AnnotationTarget,
24 pub kind: AnnotationKind,
26 pub status: AnnotationStatus,
28 pub body: String,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub author: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub created: Option<String>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub labels: Vec<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub proposed_change: Option<ProposedChange>,
42}
43
44impl AnnotationMetadata {
45 pub fn from_package(package: &McdPackage, path: &str) -> Result<Self> {
47 let bytes = package.read(path).map_err(|_| {
48 McdError::from_diagnostic(
49 Diagnostic::error(
50 "annotation.metadata.missing",
51 format!("Declared annotation metadata file '{path}' is missing."),
52 )
53 .with_source(path.to_owned()),
54 )
55 })?;
56 serde_json::from_slice::<Self>(bytes).map_err(McdError::from)
57 }
58
59 pub fn validate(
61 &self,
62 expected_id: &str,
63 manifest: &Manifest,
64 package: &McdPackage,
65 document: &McdDocument,
66 source: &str,
67 ) -> Result<()> {
68 if self.id != expected_id {
69 return Err(annotation_error(
70 "annotation.id.mismatch",
71 format!(
72 "Annotation metadata id '{}' does not match manifest annotation id '{}'.",
73 self.id, expected_id
74 ),
75 source,
76 ));
77 }
78 if self.id.trim().is_empty() {
79 return Err(annotation_error(
80 "annotation.id.empty",
81 "Annotation metadata id cannot be empty.",
82 source,
83 ));
84 }
85 if self.body.trim().is_empty() {
86 return Err(annotation_error(
87 "annotation.body.empty",
88 "Annotation body cannot be empty.",
89 source,
90 ));
91 }
92 for label in &self.labels {
93 if label.trim().is_empty() {
94 return Err(annotation_error(
95 "annotation.label.empty",
96 "Annotation labels cannot be empty.",
97 source,
98 ));
99 }
100 }
101
102 validate_target(&self.target, manifest, package, document, source)?;
103 if let Some(change) = &self.proposed_change {
104 change.validate(source)?;
105 }
106
107 Ok(())
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114pub enum AnnotationTarget {
115 Document,
117 Block {
119 id: String,
121 },
122 Placement {
124 #[serde(rename = "ref")]
126 ref_id: String,
127 },
128 Table {
130 id: String,
132 },
133 Image {
135 id: String,
137 },
138 Path {
140 path: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 source: Option<SourceSpan>,
145 },
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum AnnotationKind {
152 Comment,
154 Flag,
156 ProposedChange,
158 Question,
160 Todo,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum AnnotationStatus {
168 Open,
170 Accepted,
172 Rejected,
174 Resolved,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct ProposedChange {
182 pub path: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub replace: Option<SourceSpan>,
187 pub text: String,
189}
190
191impl ProposedChange {
192 fn validate(&self, source: &str) -> Result<()> {
193 validate_internal_path(&self.path).map_err(|_| {
194 annotation_error(
195 "annotation.proposed_change.path.invalid",
196 format!("Invalid proposed change path '{}'.", self.path),
197 source,
198 )
199 })?;
200 if self.text.trim().is_empty() {
201 return Err(annotation_error(
202 "annotation.proposed_change.text.empty",
203 "Proposed change text cannot be empty.",
204 source,
205 ));
206 }
207 Ok(())
208 }
209}
210
211pub fn load_manifest_annotations(
213 package: &McdPackage,
214 manifest: &Manifest,
215 document: &McdDocument,
216) -> Result<IndexMap<String, AnnotationMetadata>> {
217 let mut annotations = IndexMap::new();
218 for entry in &manifest.annotations {
219 let annotation = load_manifest_annotation(package, manifest, document, entry)?;
220 annotations.insert(entry.id.clone(), annotation);
221 }
222 Ok(annotations)
223}
224
225pub fn validate_annotation_markers(
227 document: &McdDocument,
228 annotations: &IndexMap<String, AnnotationMetadata>,
229) -> Result<()> {
230 for block in &document.blocks {
231 for annotation_ref in block.annotation_refs() {
232 if !annotations.contains_key(&annotation_ref.id) {
233 return Err(annotation_marker_error(
234 "annotation.marker.unresolved",
235 format!(
236 "Markdown annotation marker references undeclared annotation '{}'.",
237 annotation_ref.id
238 ),
239 document,
240 block_source(block),
241 ));
242 }
243 }
244 }
245 Ok(())
246}
247
248fn load_manifest_annotation(
249 package: &McdPackage,
250 manifest: &Manifest,
251 document: &McdDocument,
252 entry: &AnnotationManifestEntry,
253) -> Result<AnnotationMetadata> {
254 let annotation = AnnotationMetadata::from_package(package, &entry.metadata)?;
255 annotation.validate(&entry.id, manifest, package, document, &entry.metadata)?;
256 Ok(annotation)
257}
258
259fn validate_target(
260 target: &AnnotationTarget,
261 manifest: &Manifest,
262 package: &McdPackage,
263 document: &McdDocument,
264 source: &str,
265) -> Result<()> {
266 match target {
267 AnnotationTarget::Document => Ok(()),
268 AnnotationTarget::Block { id } => {
269 if document.blocks.iter().any(|block| block.id() == id) {
270 Ok(())
271 } else {
272 Err(annotation_error(
273 "annotation.target.block.unresolved",
274 format!("Annotation target references unknown block id '{id}'."),
275 source,
276 ))
277 }
278 }
279 AnnotationTarget::Placement { ref_id } => {
280 if placement_refs(document).contains(ref_id) {
281 Ok(())
282 } else {
283 Err(annotation_error(
284 "annotation.target.placement.unresolved",
285 format!("Annotation target references unknown placement ref '{ref_id}'."),
286 source,
287 ))
288 }
289 }
290 AnnotationTarget::Table { id } => {
291 if manifest.tables.iter().any(|table| table.id == *id) {
292 Ok(())
293 } else {
294 Err(annotation_error(
295 "annotation.target.table.unresolved",
296 format!("Annotation target references unknown table id '{id}'."),
297 source,
298 ))
299 }
300 }
301 AnnotationTarget::Image { id } => {
302 if manifest.images.iter().any(|image| image.id == *id) {
303 Ok(())
304 } else {
305 Err(annotation_error(
306 "annotation.target.image.unresolved",
307 format!("Annotation target references unknown image id '{id}'."),
308 source,
309 ))
310 }
311 }
312 AnnotationTarget::Path { path, .. } => {
313 validate_internal_path(path).map_err(|_| {
314 annotation_error(
315 "annotation.target.path.invalid",
316 format!("Invalid annotation target path '{path}'."),
317 source,
318 )
319 })?;
320 if package.contains(path) {
321 Ok(())
322 } else {
323 Err(annotation_error(
324 "annotation.target.path.missing",
325 format!("Annotation target path '{path}' is missing from the package."),
326 source,
327 ))
328 }
329 }
330 }
331}
332
333fn placement_refs(document: &McdDocument) -> HashSet<String> {
334 document
335 .blocks
336 .iter()
337 .filter_map(|block| match block {
338 DocumentBlock::TableRef { placement, .. } => placement.ref_id.clone(),
339 DocumentBlock::ImageRef { placement, .. } => placement.ref_id.clone(),
340 _ => None,
341 })
342 .collect()
343}
344
345fn block_source(block: &DocumentBlock) -> Option<SourceSpan> {
346 match block {
347 DocumentBlock::Heading { source, .. }
348 | DocumentBlock::Paragraph { source, .. }
349 | DocumentBlock::List { source, .. }
350 | DocumentBlock::CodeBlock { source, .. }
351 | DocumentBlock::Quote { source, .. }
352 | DocumentBlock::MathBlock { source, .. }
353 | DocumentBlock::TableRef { source, .. }
354 | DocumentBlock::ImageRef { source, .. } => *source,
355 }
356}
357
358fn annotation_marker_error(
359 code: impl Into<String>,
360 message: impl Into<String>,
361 document: &McdDocument,
362 source: Option<SourceSpan>,
363) -> McdError {
364 let source = source
365 .map(|span| format!("{}:{span}", document.source_path))
366 .unwrap_or_else(|| document.source_path.clone());
367 annotation_error(code, message, &source)
368}
369
370fn annotation_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
371 McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377 use std::io::{Cursor, Write};
378 use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
379
380 #[test]
381 fn validates_annotation_targeting_placement_ref() {
382 let package = package_with_annotation(
383 r#"{
384 "id":"review-revenue-chart",
385 "target":{"type":"placement","ref":"revenue-chart"},
386 "kind":"flag",
387 "status":"open",
388 "body":"Check whether Q1 revenue needs a footnote.",
389 "labels":["finance","review"]
390 }"#,
391 ":::table\nref: revenue-chart\ntable: revenue\n:::\n",
392 );
393 let manifest = package.manifest().expect("manifest");
394 let document = McdDocument::from_package(&package, &manifest).expect("document");
395 let annotations = load_manifest_annotations(&package, &manifest, &document)
396 .expect("annotations validate");
397
398 assert_eq!(annotations.len(), 1);
399 assert_eq!(
400 annotations["review-revenue-chart"].kind,
401 AnnotationKind::Flag
402 );
403 }
404
405 #[test]
406 fn rejects_unresolved_annotation_target() {
407 let package = package_with_annotation(
408 r#"{
409 "id":"review-revenue-chart",
410 "target":{"type":"placement","ref":"missing"},
411 "kind":"flag",
412 "status":"open",
413 "body":"Check this chart."
414 }"#,
415 ":::table\nref: revenue-chart\ntable: revenue\n:::\n",
416 );
417 let manifest = package.manifest().expect("manifest");
418 let document = McdDocument::from_package(&package, &manifest).expect("document");
419 let err = load_manifest_annotations(&package, &manifest, &document)
420 .expect_err("annotation target should fail");
421
422 assert_eq!(
423 err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
424 Some("annotation.target.placement.unresolved")
425 );
426 }
427
428 #[test]
429 fn rejects_unresolved_markdown_annotation_marker() {
430 let package = McdPackage::from_bytes(&zip_bytes(&[
431 ("mimetype", crate::package::MCD_MIMETYPE),
432 (
433 "manifest.json",
434 r#"{
435 "format":"MCD",
436 "version":"0.1",
437 "profile":"MCD-Core",
438 "entrypoint":"content/main.md"
439 }"#,
440 ),
441 (
442 "content/main.md",
443 "Revenue[[annotation:missing-note]] increased.\n",
444 ),
445 ]))
446 .expect("package opens");
447 let manifest = package.manifest().expect("manifest");
448 let document = McdDocument::from_package(&package, &manifest).expect("document");
449 let annotations = load_manifest_annotations(&package, &manifest, &document)
450 .expect("empty annotation set loads");
451 let err =
452 validate_annotation_markers(&document, &annotations).expect_err("marker should fail");
453
454 assert_eq!(
455 err.diagnostic().map(|diagnostic| diagnostic.code.as_str()),
456 Some("annotation.marker.unresolved")
457 );
458 }
459
460 fn package_with_annotation(annotation_json: &str, markdown: &str) -> McdPackage {
461 McdPackage::from_bytes(&zip_bytes(&[
462 ("mimetype", crate::package::MCD_MIMETYPE),
463 ("manifest.json", manifest()),
464 ("content/main.md", markdown),
465 ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\n"),
466 (
467 "tables/revenue.schema.json",
468 r#"{"id":"revenue","columns":[{"name":"quarter","type":"string"},{"name":"revenue_gbp","type":"decimal"}]}"#,
469 ),
470 (
471 "annotations/review-revenue-chart.annotation.json",
472 annotation_json,
473 ),
474 ]))
475 .expect("package opens")
476 }
477
478 fn manifest() -> &'static str {
479 r#"{
480 "format":"MCD",
481 "version":"0.1",
482 "profile":"MCD-Core",
483 "entrypoint":"content/main.md",
484 "tables":[{"id":"revenue","data":"tables/revenue.csv","schema":"tables/revenue.schema.json"}],
485 "annotations":[{"id":"review-revenue-chart","metadata":"annotations/review-revenue-chart.annotation.json"}]
486 }"#
487 }
488
489 fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
490 let cursor = Cursor::new(Vec::new());
491 let mut writer = ZipWriter::new(cursor);
492 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
493
494 for (path, content) in entries {
495 writer.start_file(*path, options).expect("start file");
496 writer.write_all(content.as_bytes()).expect("write file");
497 }
498
499 writer.finish().expect("finish zip").into_inner()
500 }
501}