Skip to main content

mcd_core/
annotations.rs

1//! Annotation metadata parsing and validation.
2
3use 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/// Parsed annotation metadata object.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct AnnotationMetadata {
20    /// Stable annotation id.
21    pub id: String,
22    /// Annotation target.
23    pub target: AnnotationTarget,
24    /// Annotation kind.
25    pub kind: AnnotationKind,
26    /// Review lifecycle status.
27    pub status: AnnotationStatus,
28    /// Human/agent-readable annotation body.
29    pub body: String,
30    /// Optional author identifier.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub author: Option<String>,
33    /// Optional creation timestamp string.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub created: Option<String>,
36    /// Optional version-control-friendly labels.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub labels: Vec<String>,
39    /// Optional proposed textual change.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub proposed_change: Option<ProposedChange>,
42}
43
44impl AnnotationMetadata {
45    /// Parse annotation metadata from a package entry.
46    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    /// Validate annotation metadata, target references, and proposed changes.
60    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/// Supported annotation targets.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114pub enum AnnotationTarget {
115    /// Whole-document annotation.
116    Document,
117    /// Generated canonical block id target.
118    Block {
119        /// Canonical block id.
120        id: String,
121    },
122    /// Stable placement ref target from a table or image directive.
123    Placement {
124        /// Placement ref.
125        #[serde(rename = "ref")]
126        ref_id: String,
127    },
128    /// Manifest-declared table id target.
129    Table {
130        /// Table id.
131        id: String,
132    },
133    /// Manifest-declared image id target.
134    Image {
135        /// Image id.
136        id: String,
137    },
138    /// Package path target, optionally with a source span.
139    Path {
140        /// Package path.
141        path: String,
142        /// Optional span within the path.
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        source: Option<SourceSpan>,
145    },
146}
147
148/// Annotation kind.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum AnnotationKind {
152    /// General comment.
153    Comment,
154    /// Review flag.
155    Flag,
156    /// Proposed edit.
157    ProposedChange,
158    /// Question for a human or agent.
159    Question,
160    /// Follow-up task.
161    Todo,
162}
163
164/// Annotation review status.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum AnnotationStatus {
168    /// Open annotation.
169    Open,
170    /// Accepted annotation or change.
171    Accepted,
172    /// Rejected annotation or change.
173    Rejected,
174    /// Resolved annotation.
175    Resolved,
176}
177
178/// Textual proposed change metadata.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct ProposedChange {
182    /// Package path to modify.
183    pub path: String,
184    /// Optional replacement span.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub replace: Option<SourceSpan>,
187    /// Proposed replacement or insertion text.
188    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
211/// Load and validate all manifest-declared annotations.
212pub 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
225/// Validate all Markdown annotation markers against declared annotation metadata.
226pub 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}