Skip to main content

mcd_core/
export.rs

1//! Export APIs.
2
3use indexmap::{IndexMap, IndexSet};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    Manifest, McdPackage,
8    annotations::{
9        AnnotationMetadata, AnnotationTarget, load_manifest_annotations,
10        validate_annotation_markers,
11    },
12    directives::{ImagePlacement, TableDisplay, TablePlacement},
13    document::{AnnotationRef, DocumentBlock, McdDocument, SourceSpan},
14    errors::{Diagnostic, McdError},
15    images::{ImageMetadata, ImageRole},
16    manifest::ExternalDataManifestEntry,
17    provenance::{ProvenanceMetadata, load_manifest_provenance},
18    schema::{ColumnType, ForeignKeySchema, TableColumnSchema},
19    table_view::{ChartEncoding, TableView, ViewColumn},
20    tables::{DataTable, TableRow, TypedValue},
21};
22
23/// Canonical JSON export for an MCD package.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct JsonExport {
26    /// Parsed manifest.
27    pub manifest: Manifest,
28    /// Parsed Markdown document.
29    pub document: McdDocument,
30    /// Loaded typed tables.
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub tables: Vec<DataTable>,
33    /// Loaded table and chart views.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub views: Vec<TableViewExport>,
36    /// Parsed image metadata objects.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub images: Vec<ImageMetadata>,
39    /// Parsed annotation metadata objects.
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub annotations: Vec<AnnotationMetadata>,
42    /// Parsed package-level provenance metadata.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub provenance: Option<ProvenanceMetadata>,
45    /// Chart placements with exact source table and view metadata.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub charts: Vec<ChartExportItem>,
48}
49
50/// Table extraction export.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct TableExport {
53    /// Loaded typed tables in manifest order.
54    pub tables: Vec<DataTable>,
55}
56
57/// Image metadata extraction export.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct ImageExport {
60    /// Image metadata objects in manifest order.
61    pub images: Vec<ImageMetadata>,
62}
63
64/// Annotation metadata extraction export.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct AnnotationExport {
67    /// Annotation metadata objects in manifest order.
68    pub annotations: Vec<AnnotationMetadata>,
69}
70
71/// External data reference extraction export.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ExternalDataExport {
75    /// External resources declared by the manifest.
76    pub external_data: Vec<ExternalDataManifestEntry>,
77}
78
79/// Provenance metadata extraction export.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct ProvenanceExport {
82    /// Package-level provenance metadata, if declared.
83    pub provenance: Option<ProvenanceMetadata>,
84}
85
86/// Loaded views for one table.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct TableViewExport {
90    /// Table id that owns the views.
91    pub table_id: String,
92    /// Views declared on the table.
93    pub views: Vec<TableView>,
94}
95
96/// Chart metadata and source-data export.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ChartExport {
99    /// Chart placements in document order.
100    pub charts: Vec<ChartExportItem>,
101}
102
103/// One chart placement backed by a table view.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct ChartExportItem {
107    /// Document block id for the chart placement.
108    pub block_id: String,
109    /// Optional placement ref from Markdown.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub placement_ref: Option<String>,
112    /// Source table id.
113    pub table_id: String,
114    /// Source chart view id.
115    pub view_id: String,
116    /// Placement caption, if any.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub caption: Option<String>,
119    /// Source span of the chart placement.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub source: Option<SourceSpan>,
122    /// Parsed chart view metadata.
123    pub view: TableView,
124    /// Exact typed source rows used by the chart.
125    pub rows: Vec<TableRow>,
126}
127
128/// Schema summary export.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SchemaSummaryExport {
131    /// Table schema summaries in manifest order.
132    pub schemas: Vec<TableSchemaSummary>,
133}
134
135/// Summary of one table schema.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct TableSchemaSummary {
139    /// Table id.
140    pub table_id: String,
141    /// Columns that uniquely identify table rows.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub primary_key: Vec<String>,
144    /// Foreign-key relationships declared by the table schema.
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub foreign_keys: Vec<ForeignKeySchema>,
147    /// Schema columns.
148    pub columns: Vec<TableColumnSchema>,
149}
150
151/// Agent-oriented context export.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct AgentContextExport {
155    /// Manifest title, if any.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub title: Option<String>,
158    /// Markdown entrypoint path.
159    pub source_path: String,
160    /// Canonical document block stream.
161    pub blocks: Vec<DocumentBlock>,
162    /// Table data and schemas.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub tables: Vec<DataTable>,
165    /// Chart placements with source table/view references.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub charts: Vec<AgentChartContext>,
168    /// Image metadata with semantic flags.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub images: Vec<AgentImageContext>,
171    /// Review annotations and proposed changes.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub annotations: Vec<AnnotationMetadata>,
174    /// External data resources referenced by this package.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub external_data: Vec<ExternalDataManifestEntry>,
177    /// Package-level provenance metadata.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub provenance: Option<ProvenanceMetadata>,
180}
181
182/// Agent context for one chart placement.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct AgentChartContext {
186    /// Document block id for the chart placement.
187    pub block_id: String,
188    /// Optional placement ref from Markdown.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub placement_ref: Option<String>,
191    /// Source table id.
192    pub table_id: String,
193    /// Source chart view id.
194    pub view_id: String,
195    /// Chart encoding metadata.
196    pub chart: serde_json::Value,
197    /// Optional style metadata.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub style: Option<serde_json::Value>,
200    /// Source span of the placement.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub source: Option<SourceSpan>,
203}
204
205/// Agent context for one image.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct AgentImageContext {
209    /// Image id.
210    pub id: String,
211    /// Asset path.
212    pub asset: String,
213    /// Image role.
214    pub role: ImageRole,
215    /// Whether agents should treat the image as non-semantic.
216    pub non_semantic: bool,
217    /// Optional alt text.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub alt: Option<String>,
220    /// Optional caption.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub caption: Option<String>,
223    /// Optional meaningful visual content declaration.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub meaningful_content: Option<crate::images::MeaningfulContent>,
226}
227
228/// Build the canonical JSON export for a package.
229pub fn json_export(package: &McdPackage) -> crate::Result<JsonExport> {
230    let manifest = package.manifest()?;
231    let document = McdDocument::from_package(package, &manifest)?;
232    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
233    let views = load_manifest_table_views(package, &manifest)?;
234    let images = crate::images::load_manifest_images(package, &manifest)?;
235    let annotations = load_manifest_annotations(package, &manifest, &document)?;
236    validate_annotation_markers(&document, &annotations)?;
237    let provenance = load_manifest_provenance(package, &manifest)?;
238    let charts = chart_export_from_parts(&document, &tables, &views)?.charts;
239    Ok(JsonExport {
240        manifest,
241        document,
242        tables: tables.into_values().collect(),
243        views: views
244            .into_iter()
245            .map(|(table_id, table_views)| TableViewExport {
246                table_id,
247                views: table_views.into_values().collect(),
248            })
249            .collect(),
250        images: images.into_values().collect(),
251        annotations: annotations.into_values().collect(),
252        provenance,
253        charts,
254    })
255}
256
257/// Export the original Markdown entrypoint.
258pub fn original_markdown_export(package: &McdPackage) -> crate::Result<String> {
259    let manifest = package.manifest()?;
260    package.read_to_string(&manifest.entrypoint)
261}
262
263/// Export expanded Markdown generated from canonical blocks, tables, views, and image metadata.
264pub fn expanded_markdown_export(package: &McdPackage) -> crate::Result<String> {
265    let manifest = package.manifest()?;
266    let document = McdDocument::from_package(package, &manifest)?;
267    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
268    let views = load_manifest_table_views(package, &manifest)?;
269    let images = crate::images::load_manifest_images(package, &manifest)?;
270    let annotations = load_manifest_annotations(package, &manifest, &document)?;
271    validate_annotation_markers(&document, &annotations)?;
272
273    let mut parts = Vec::new();
274    for block in &document.blocks {
275        parts.push(render_expanded_block(
276            block,
277            &document,
278            &tables,
279            &views,
280            &images,
281            &annotations,
282        )?);
283    }
284
285    Ok(parts
286        .into_iter()
287        .filter(|part| !part.trim().is_empty())
288        .collect::<Vec<_>>()
289        .join("\n\n"))
290}
291
292/// Build a typed table export for a package.
293pub fn table_export(package: &McdPackage) -> crate::Result<TableExport> {
294    let manifest = package.manifest()?;
295    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
296    Ok(TableExport {
297        tables: tables.into_values().collect(),
298    })
299}
300
301/// Build an image metadata export for a package.
302pub fn image_export(package: &McdPackage) -> crate::Result<ImageExport> {
303    let manifest = package.manifest()?;
304    let images = crate::images::load_manifest_images(package, &manifest)?
305        .into_values()
306        .collect();
307    Ok(ImageExport { images })
308}
309
310/// Build an annotation metadata export for a package.
311pub fn annotation_export(package: &McdPackage) -> crate::Result<AnnotationExport> {
312    let manifest = package.manifest()?;
313    let document = McdDocument::from_package(package, &manifest)?;
314    let annotations = load_manifest_annotations(package, &manifest, &document)?;
315    validate_annotation_markers(&document, &annotations)?;
316    Ok(AnnotationExport {
317        annotations: annotations.into_values().collect(),
318    })
319}
320
321/// Build an external data reference export for a package.
322pub fn external_data_export(package: &McdPackage) -> crate::Result<ExternalDataExport> {
323    let manifest = package.manifest()?;
324    Ok(ExternalDataExport {
325        external_data: manifest.external_data,
326    })
327}
328
329/// Build a provenance metadata export for a package.
330pub fn provenance_export(package: &McdPackage) -> crate::Result<ProvenanceExport> {
331    let manifest = package.manifest()?;
332    let provenance = load_manifest_provenance(package, &manifest)?;
333    Ok(ProvenanceExport { provenance })
334}
335
336/// Build a chart metadata export for a package.
337pub fn chart_export(package: &McdPackage) -> crate::Result<ChartExport> {
338    let manifest = package.manifest()?;
339    let document = McdDocument::from_package(package, &manifest)?;
340    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
341    let views = load_manifest_table_views(package, &manifest)?;
342    chart_export_from_parts(&document, &tables, &views)
343}
344
345/// Build a schema summary export for a package.
346pub fn schema_summary_export(package: &McdPackage) -> crate::Result<SchemaSummaryExport> {
347    let manifest = package.manifest()?;
348    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
349    Ok(SchemaSummaryExport {
350        schemas: manifest
351            .tables
352            .iter()
353            .filter_map(|entry| tables.get(&entry.id))
354            .map(|table| TableSchemaSummary {
355                table_id: table.id.clone(),
356                primary_key: table.schema.primary_key.clone(),
357                foreign_keys: table.schema.foreign_keys.clone(),
358                columns: table.schema.columns.clone(),
359            })
360            .collect(),
361    })
362}
363
364/// Build an agent context JSON export for a package.
365pub fn agent_context_export(package: &McdPackage) -> crate::Result<AgentContextExport> {
366    let manifest = package.manifest()?;
367    let document = McdDocument::from_package(package, &manifest)?;
368    let tables = crate::tables::load_manifest_tables(package, &manifest)?;
369    let views = load_manifest_table_views(package, &manifest)?;
370    let images = crate::images::load_manifest_images(package, &manifest)?;
371    let annotations = load_manifest_annotations(package, &manifest, &document)?;
372    validate_annotation_markers(&document, &annotations)?;
373    let provenance = load_manifest_provenance(package, &manifest)?;
374    let chart_export = chart_export_from_parts(&document, &tables, &views)?;
375
376    Ok(AgentContextExport {
377        title: manifest.title.clone(),
378        source_path: document.source_path.clone(),
379        blocks: document.blocks,
380        tables: tables.into_values().collect(),
381        charts: chart_export
382            .charts
383            .into_iter()
384            .map(|chart| AgentChartContext {
385                block_id: chart.block_id,
386                placement_ref: chart.placement_ref,
387                table_id: chart.table_id,
388                view_id: chart.view_id,
389                chart: serde_json::to_value(chart.view.chart).unwrap_or(serde_json::Value::Null),
390                style: chart.view.style,
391                source: chart.source,
392            })
393            .collect(),
394        images: images
395            .into_values()
396            .map(|image| AgentImageContext {
397                id: image.id,
398                asset: image.asset,
399                role: image.role,
400                non_semantic: image.role == ImageRole::Decorative,
401                alt: image.alt,
402                caption: image.caption,
403                meaningful_content: image.meaningful_content,
404            })
405            .collect(),
406        annotations: annotations.into_values().collect(),
407        external_data: manifest.external_data,
408        provenance,
409    })
410}
411
412/// Load all manifest-declared table and chart views in manifest order.
413pub fn load_manifest_table_views(
414    package: &McdPackage,
415    manifest: &Manifest,
416) -> crate::Result<IndexMap<String, IndexMap<String, TableView>>> {
417    let mut all_views = IndexMap::new();
418    for table in &manifest.tables {
419        let mut table_views = IndexMap::new();
420        for (view_id, path) in &table.views {
421            let view = TableView::from_package(package, path)?;
422            table_views.insert(view_id.clone(), view);
423        }
424        all_views.insert(table.id.clone(), table_views);
425    }
426    Ok(all_views)
427}
428
429fn chart_export_from_parts(
430    document: &McdDocument,
431    tables: &IndexMap<String, DataTable>,
432    views: &IndexMap<String, IndexMap<String, TableView>>,
433) -> crate::Result<ChartExport> {
434    let mut charts = Vec::new();
435    for block in &document.blocks {
436        let DocumentBlock::TableRef {
437            id,
438            placement,
439            source,
440        } = block
441        else {
442            continue;
443        };
444        if placement.display != TableDisplay::Chart {
445            continue;
446        }
447
448        let view_id = placement.view.as_deref().ok_or_else(|| {
449            export_error(
450                "export.chart.view.missing",
451                "Chart placement does not include a view id.",
452                document,
453                *source,
454            )
455        })?;
456        let table = tables.get(&placement.table).ok_or_else(|| {
457            export_error(
458                "export.chart.table.missing",
459                format!(
460                    "Chart placement references missing table '{}'.",
461                    placement.table
462                ),
463                document,
464                *source,
465            )
466        })?;
467        let view = views
468            .get(&placement.table)
469            .and_then(|table_views| table_views.get(view_id))
470            .ok_or_else(|| {
471                export_error(
472                    "export.chart.view.missing",
473                    format!(
474                        "Chart placement references missing view '{}' for table '{}'.",
475                        view_id, placement.table
476                    ),
477                    document,
478                    *source,
479                )
480            })?;
481
482        charts.push(ChartExportItem {
483            block_id: id.clone(),
484            placement_ref: placement.ref_id.clone(),
485            table_id: placement.table.clone(),
486            view_id: view_id.to_owned(),
487            caption: placement.caption.clone(),
488            source: *source,
489            view: view.clone(),
490            rows: table.rows.clone(),
491        });
492    }
493    Ok(ChartExport { charts })
494}
495
496fn render_expanded_block(
497    block: &DocumentBlock,
498    document: &McdDocument,
499    tables: &IndexMap<String, DataTable>,
500    views: &IndexMap<String, IndexMap<String, TableView>>,
501    images: &IndexMap<String, ImageMetadata>,
502    annotations: &IndexMap<String, AnnotationMetadata>,
503) -> crate::Result<String> {
504    let markdown: crate::Result<String> = match block {
505        DocumentBlock::Heading { level, text, .. } => Ok(format!(
506            "{} {}",
507            "#".repeat(usize::from(*level)),
508            render_annotated_markdown_text(text, block.annotation_refs(), annotations)
509        )),
510        DocumentBlock::Paragraph { text, .. } => Ok(render_annotated_markdown_text(
511            text,
512            block.annotation_refs(),
513            annotations,
514        )),
515        DocumentBlock::List { text, .. } => {
516            Ok(
517                render_annotated_markdown_text(text, block.annotation_refs(), annotations)
518                    .lines()
519                    .map(|line| format!("- {line}"))
520                    .collect::<Vec<_>>()
521                    .join("\n"),
522            )
523        }
524        DocumentBlock::CodeBlock { language, text, .. } => {
525            let mut parts = vec![format!(
526                "```{}\n{}\n```",
527                language.as_deref().unwrap_or_default(),
528                text.trim_end()
529            )];
530            parts.extend(block_annotation_markdown(
531                block.annotation_refs(),
532                annotations,
533            ));
534            Ok(parts.join("\n"))
535        }
536        DocumentBlock::Quote { text, .. } => {
537            Ok(
538                render_annotated_markdown_text(text, block.annotation_refs(), annotations)
539                    .lines()
540                    .map(|line| format!("> {line}"))
541                    .collect::<Vec<_>>()
542                    .join("\n"),
543            )
544        }
545        DocumentBlock::MathBlock { text, .. } => Ok(format!("$$\n{}\n$$", text.trim())),
546        DocumentBlock::TableRef { placement, .. } => {
547            let mut parts = vec![render_table_placement(placement, tables, views)?];
548            parts.extend(block_annotation_markdown(
549                block.annotation_refs(),
550                annotations,
551            ));
552            Ok(parts.join("\n\n"))
553        }
554        DocumentBlock::ImageRef { placement, .. } => {
555            let mut parts = vec![render_image_placement(placement, images)?];
556            parts.extend(block_annotation_markdown(
557                block.annotation_refs(),
558                annotations,
559            ));
560            Ok(parts.join("\n\n"))
561        }
562    };
563    let markdown = markdown?;
564
565    Ok(append_path_annotations(
566        markdown,
567        block,
568        document,
569        annotations,
570    ))
571}
572
573fn render_annotated_markdown_text(
574    text: &str,
575    refs: &[AnnotationRef],
576    annotations: &IndexMap<String, AnnotationMetadata>,
577) -> String {
578    let mut inline_refs = refs
579        .iter()
580        .filter_map(|annotation_ref| {
581            annotation_ref
582                .text_offset
583                .map(|offset| (offset, annotation_ref))
584        })
585        .collect::<Vec<_>>();
586    inline_refs.sort_by_key(|(offset, _)| *offset);
587
588    let mut markdown = String::new();
589    let mut cursor = 0;
590    for (offset, annotation_ref) in inline_refs {
591        if offset > text.len() || offset < cursor {
592            continue;
593        }
594        markdown.push_str(&text[cursor..offset]);
595        if let Some(annotation) = annotations.get(&annotation_ref.id) {
596            markdown.push_str(&annotation_markdown(annotation));
597        }
598        cursor = offset;
599    }
600    markdown.push_str(&text[cursor..]);
601
602    let block_annotations = refs
603        .iter()
604        .filter(|annotation_ref| annotation_ref.text_offset.is_none())
605        .filter_map(|annotation_ref| annotations.get(&annotation_ref.id))
606        .map(annotation_markdown)
607        .collect::<Vec<_>>();
608    if !block_annotations.is_empty() {
609        if !markdown.is_empty() {
610            markdown.push('\n');
611        }
612        markdown.push_str(&block_annotations.join("\n"));
613    }
614
615    markdown
616}
617
618fn block_annotation_markdown(
619    refs: &[AnnotationRef],
620    annotations: &IndexMap<String, AnnotationMetadata>,
621) -> Vec<String> {
622    refs.iter()
623        .filter_map(|annotation_ref| annotations.get(&annotation_ref.id))
624        .map(annotation_markdown)
625        .collect()
626}
627
628fn append_path_annotations(
629    mut markdown: String,
630    block: &DocumentBlock,
631    document: &McdDocument,
632    annotations: &IndexMap<String, AnnotationMetadata>,
633) -> String {
634    let path_annotations = annotations
635        .values()
636        .filter(|annotation| path_annotation_matches_block(annotation, block, document))
637        .map(annotation_markdown)
638        .collect::<Vec<_>>();
639    if path_annotations.is_empty() {
640        return markdown;
641    }
642    if !markdown.trim().is_empty() {
643        markdown.push('\n');
644    }
645    markdown.push_str(&path_annotations.join("\n"));
646    markdown
647}
648
649fn path_annotation_matches_block(
650    annotation: &AnnotationMetadata,
651    block: &DocumentBlock,
652    document: &McdDocument,
653) -> bool {
654    let AnnotationTarget::Path { path, source } = &annotation.target else {
655        return false;
656    };
657    if path != &document.source_path {
658        return false;
659    }
660    let Some(annotation_source) = source else {
661        return block_index_is_first(block);
662    };
663    let Some(block_source) = block_source(block) else {
664        return false;
665    };
666    spans_overlap(*annotation_source, block_source)
667}
668
669fn block_index_is_first(block: &DocumentBlock) -> bool {
670    matches!(
671        block
672            .id()
673            .strip_prefix("block-")
674            .and_then(|rest| rest.get(..4)),
675        Some("0001")
676    )
677}
678
679fn block_source(block: &DocumentBlock) -> Option<SourceSpan> {
680    match block {
681        DocumentBlock::Heading { source, .. }
682        | DocumentBlock::Paragraph { source, .. }
683        | DocumentBlock::List { source, .. }
684        | DocumentBlock::CodeBlock { source, .. }
685        | DocumentBlock::Quote { source, .. }
686        | DocumentBlock::MathBlock { source, .. }
687        | DocumentBlock::TableRef { source, .. }
688        | DocumentBlock::ImageRef { source, .. } => *source,
689    }
690}
691
692fn spans_overlap(left: SourceSpan, right: SourceSpan) -> bool {
693    left.start_line <= right.end_line && right.start_line <= left.end_line
694}
695
696fn annotation_markdown(annotation: &AnnotationMetadata) -> String {
697    format!(
698        "(@annotation: [{}])",
699        escape_annotation_text(&annotation.body)
700    )
701}
702
703fn escape_annotation_text(value: &str) -> String {
704    escape_markdown_text(value).replace(']', r"\]")
705}
706
707fn render_table_placement(
708    placement: &TablePlacement,
709    tables: &IndexMap<String, DataTable>,
710    views: &IndexMap<String, IndexMap<String, TableView>>,
711) -> crate::Result<String> {
712    let table = tables.get(&placement.table).ok_or_else(|| {
713        simple_export_error(
714            "export.table.missing",
715            format!("Table '{}' is not available for export.", placement.table),
716        )
717    })?;
718    let view = placement
719        .view
720        .as_deref()
721        .and_then(|view_id| views.get(&placement.table)?.get(view_id));
722
723    let mut parts = Vec::new();
724    if let Some(caption) = &placement.caption {
725        parts.push(format!("**{}**", escape_markdown_text(caption)));
726    }
727    if placement.display == TableDisplay::Chart {
728        let view_id = placement.view.as_deref().unwrap_or("default");
729        let chart_type = view
730            .and_then(|view| view.chart.as_ref())
731            .map(|chart| format!("{:?}", chart.chart_type).to_ascii_lowercase())
732            .unwrap_or_else(|| "chart".to_owned());
733        parts.push(format!(
734            "**Chart metadata:** table `{}`, view `{}`, type `{}`.",
735            placement.table, view_id, chart_type
736        ));
737    }
738    parts.push(markdown_table_for_placement(
739        table,
740        view,
741        placement.display,
742    )?);
743    Ok(parts.join("\n\n"))
744}
745
746fn render_image_placement(
747    placement: &ImagePlacement,
748    images: &IndexMap<String, ImageMetadata>,
749) -> crate::Result<String> {
750    let image = resolve_image_placement(placement, images).ok_or_else(|| {
751        simple_export_error(
752            "export.image.missing",
753            "Image placement does not resolve to metadata.",
754        )
755    })?;
756    let alt = placement.alt.as_ref().or(image.alt.as_ref());
757    let caption = placement.caption.as_ref().or(image.caption.as_ref());
758
759    let mut parts = Vec::new();
760    if image.role != ImageRole::Decorative {
761        parts.push(format!(
762            "![{}]({})",
763            escape_markdown_text(alt.map(String::as_str).unwrap_or_default()),
764            image.asset
765        ));
766    }
767    if let Some(caption) = caption {
768        parts.push(format!("*{}*", escape_markdown_text(caption)));
769    }
770    if let Some(alt) = alt {
771        parts.push(format!("Alt text: {}", escape_markdown_text(alt)));
772    }
773    Ok(parts.join("\n\n"))
774}
775
776fn markdown_table_for_placement(
777    table: &DataTable,
778    view: Option<&TableView>,
779    display: TableDisplay,
780) -> crate::Result<String> {
781    let columns = column_specs(table, view, display)?;
782    let headers = columns
783        .iter()
784        .map(|column| escape_table_cell(&column.label))
785        .collect::<Vec<_>>();
786    let alignments = columns
787        .iter()
788        .map(|column| alignment_marker(column.column_type))
789        .collect::<Vec<_>>();
790
791    let mut lines = Vec::new();
792    lines.push(format!("| {} |", headers.join(" | ")));
793    lines.push(format!("| {} |", alignments.join(" | ")));
794    for row in &table.rows {
795        let cells = columns
796            .iter()
797            .map(|column| {
798                row.cells
799                    .get(&column.name)
800                    .map(|value| escape_table_cell(&format_value(value, column)))
801                    .unwrap_or_default()
802            })
803            .collect::<Vec<_>>();
804        lines.push(format!("| {} |", cells.join(" | ")));
805    }
806    Ok(lines.join("\n"))
807}
808
809fn column_specs(
810    table: &DataTable,
811    view: Option<&TableView>,
812    display: TableDisplay,
813) -> crate::Result<Vec<ColumnExportSpec>> {
814    if display == TableDisplay::Chart
815        && let Some(view) = view
816        && let Some(chart) = &view.chart
817    {
818        let mut names = IndexSet::new();
819        names.insert(chart.x.column.clone());
820        names.insert(chart.y.column.clone());
821        if let Some(series) = &chart.series {
822            names.insert(series.column.clone());
823        }
824        if let Some(grouping) = &chart.grouping {
825            names.insert(grouping.column.clone());
826        }
827        if let Some(mark_labels) = &chart.mark_labels
828            && let Some(column) = &mark_labels.column
829        {
830            names.insert(column.clone());
831        }
832        return names
833            .into_iter()
834            .map(|name| {
835                let encoding = encoding_for_column(view, &name);
836                spec_from_schema(table, &name, encoding)
837            })
838            .collect();
839    }
840
841    if let Some(view) = view
842        && !view.columns.is_empty()
843    {
844        return view
845            .columns
846            .iter()
847            .map(|column| spec_from_view_column(table, column))
848            .collect();
849    }
850
851    Ok(table
852        .schema
853        .columns
854        .iter()
855        .map(|column| spec_from_column_schema(column, None, None, None, false))
856        .collect())
857}
858
859fn encoding_for_column<'a>(view: &'a TableView, name: &str) -> Option<&'a ChartEncoding> {
860    let chart = view.chart.as_ref()?;
861    [&chart.x, &chart.y]
862        .into_iter()
863        .chain(chart.series.as_ref())
864        .chain(chart.grouping.as_ref())
865        .find(|encoding| encoding.column == name)
866}
867
868fn spec_from_view_column(
869    table: &DataTable,
870    column: &ViewColumn,
871) -> crate::Result<ColumnExportSpec> {
872    let schema_column = table.schema.column(&column.name).ok_or_else(|| {
873        simple_export_error(
874            "export.view.column.missing",
875            format!("View references missing column '{}'.", column.name),
876        )
877    })?;
878    Ok(spec_from_column_schema(
879        schema_column,
880        column.label.as_deref(),
881        column.format.as_deref(),
882        column.currency.as_deref().or(column.unit_label.as_deref()),
883        column.percent,
884    ))
885}
886
887fn spec_from_schema(
888    table: &DataTable,
889    name: &str,
890    encoding: Option<&ChartEncoding>,
891) -> crate::Result<ColumnExportSpec> {
892    let schema_column = table.schema.column(name).ok_or_else(|| {
893        simple_export_error(
894            "export.chart.column.missing",
895            format!("Chart references missing column '{name}'."),
896        )
897    })?;
898    Ok(spec_from_column_schema(
899        schema_column,
900        encoding.and_then(|encoding| encoding.label.as_deref()),
901        encoding.and_then(|encoding| encoding.format.as_deref()),
902        encoding.and_then(|encoding| {
903            encoding
904                .currency
905                .as_deref()
906                .or(encoding.unit_label.as_deref())
907        }),
908        encoding.is_some_and(|encoding| encoding.percent),
909    ))
910}
911
912fn spec_from_column_schema(
913    column: &TableColumnSchema,
914    view_label: Option<&str>,
915    format: Option<&str>,
916    suffix_or_currency: Option<&str>,
917    percent: bool,
918) -> ColumnExportSpec {
919    ColumnExportSpec {
920        name: column.name.clone(),
921        label: view_label
922            .or(column.label.as_deref())
923            .unwrap_or(&column.name)
924            .to_owned(),
925        column_type: column.value_type,
926        format: format.map(ToOwned::to_owned),
927        suffix_or_currency: suffix_or_currency
928            .or_else(|| column.unit.as_ref().and_then(|unit| unit.display_label()))
929            .map(ToOwned::to_owned),
930        percent,
931    }
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
935struct ColumnExportSpec {
936    name: String,
937    label: String,
938    column_type: ColumnType,
939    format: Option<String>,
940    suffix_or_currency: Option<String>,
941    percent: bool,
942}
943
944fn format_value(value: &TypedValue, column: &ColumnExportSpec) -> String {
945    let raw = match value {
946        TypedValue::Null => return String::new(),
947        TypedValue::String(value)
948        | TypedValue::Decimal(value)
949        | TypedValue::Date(value)
950        | TypedValue::Datetime(value)
951        | TypedValue::Time(value)
952        | TypedValue::Enum(value) => value.clone(),
953        TypedValue::Integer(value) => value.to_string(),
954        TypedValue::Boolean(value) => value.to_string(),
955    };
956
957    match column.format.as_deref() {
958        Some("currency") => match &column.suffix_or_currency {
959            Some(currency) => format!("{currency} {raw}"),
960            None => raw,
961        },
962        Some("percent") => format!("{raw}%"),
963        Some("number" | "date" | "datetime" | "time" | "string") | None => {
964            if column.percent {
965                format!("{raw}%")
966            } else if let Some(unit) = &column.suffix_or_currency
967                && column.format.as_deref() != Some("currency")
968            {
969                format!("{raw} {unit}")
970            } else {
971                raw
972            }
973        }
974        Some(_) => raw,
975    }
976}
977
978fn alignment_marker(column_type: ColumnType) -> &'static str {
979    match column_type {
980        ColumnType::Integer | ColumnType::Decimal => "---:",
981        ColumnType::Boolean => ":---:",
982        ColumnType::String
983        | ColumnType::Date
984        | ColumnType::Datetime
985        | ColumnType::Time
986        | ColumnType::Enum => "---",
987    }
988}
989
990fn resolve_image_placement<'a>(
991    placement: &ImagePlacement,
992    images: &'a IndexMap<String, ImageMetadata>,
993) -> Option<&'a ImageMetadata> {
994    if let Some(image_id) = &placement.image {
995        return images.get(image_id);
996    }
997    let asset = placement.asset.as_deref()?;
998    images
999        .get(asset)
1000        .or_else(|| images.values().find(|image| image.asset == asset))
1001        .or_else(|| {
1002            images
1003                .values()
1004                .find(|image| image.asset.strip_prefix("assets/") == Some(asset))
1005        })
1006}
1007
1008fn escape_table_cell(value: &str) -> String {
1009    escape_markdown_text(value).replace('|', r"\|")
1010}
1011
1012fn escape_markdown_text(value: &str) -> String {
1013    value.replace('\n', " ")
1014}
1015
1016fn export_error(
1017    code: impl Into<String>,
1018    message: impl Into<String>,
1019    document: &McdDocument,
1020    source: Option<SourceSpan>,
1021) -> McdError {
1022    let source = source
1023        .map(|span| format!("{}:{span}", document.source_path))
1024        .unwrap_or_else(|| document.source_path.clone());
1025    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source))
1026}
1027
1028fn simple_export_error(code: impl Into<String>, message: impl Into<String>) -> McdError {
1029    McdError::from_diagnostic(Diagnostic::error(code, message))
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use std::io::{Cursor, Write};
1036    use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
1037
1038    #[test]
1039    fn expanded_markdown_renders_table_and_chart_source_data() {
1040        let package = package_with(
1041            "# Revenue\n\n:::table\ntable: revenue\nview: default\ncaption: Revenue table\n:::\n\n:::table\ntable: revenue\nview: chart\ndisplay: chart\ncaption: Revenue chart\n:::\n",
1042        );
1043
1044        let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1045
1046        assert!(markdown.contains("**Revenue table**"));
1047        assert!(markdown.contains("| Quarter | Revenue |"));
1048        assert!(markdown.contains("| Q1 | GBP 125000 |"));
1049        assert!(
1050            markdown.contains("**Chart metadata:** table `revenue`, view `chart`, type `bar`.")
1051        );
1052    }
1053
1054    #[test]
1055    fn expanded_markdown_embeds_annotation_metadata() {
1056        let package = package_with_annotations(
1057            "# Revenue\n\nRevenue[[annotation:review-intro]] increased.\n\n:::table\nref: revenue-table\ntable: revenue\nannotations: review-table\n:::\n",
1058        );
1059
1060        let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1061
1062        assert!(markdown.contains("Revenue(@annotation: [Review the opening copy.]) increased."));
1063        assert!(markdown.contains("(@annotation: [Line-level follow-up.])"));
1064        assert!(markdown.contains("(@annotation: [Review table totals.])"));
1065    }
1066
1067    #[test]
1068    fn expanded_markdown_preserves_inline_math() {
1069        let package = package_with("# Math\n\nInline $x^2 + y^2 = z^2$ remains math.\n");
1070
1071        let markdown = expanded_markdown_export(&package).expect("expanded markdown");
1072
1073        assert!(markdown.contains("Inline $x^2 + y^2 = z^2$ remains math."));
1074    }
1075
1076    #[test]
1077    fn chart_export_contains_exact_typed_rows_and_view_refs() {
1078        let package = package_with(
1079            ":::table\nref: revenue-chart\ntable: revenue\nview: chart\ndisplay: chart\n:::\n",
1080        );
1081
1082        let charts = chart_export(&package).expect("chart export");
1083
1084        assert_eq!(charts.charts.len(), 1);
1085        assert_eq!(charts.charts[0].table_id, "revenue");
1086        assert_eq!(charts.charts[0].view_id, "chart");
1087        assert_eq!(charts.charts[0].rows.len(), 1);
1088    }
1089
1090    #[test]
1091    fn agent_context_marks_decorative_images_non_semantic() {
1092        let package = McdPackage::from_bytes(&zip_bytes(&[
1093            ("mimetype", crate::package::MCD_MIMETYPE),
1094            (
1095                "manifest.json",
1096                r#"{"format":"MCD","version":"0.1","profile":"MCD-Core","entrypoint":"content/main.md","images":[{"id":"logo","metadata":"images/logo.image.json"}]}"#,
1097            ),
1098            ("content/main.md", ":::image\nimage: logo\n:::\n"),
1099            ("assets/logo.svg", r#"<svg xmlns="http://www.w3.org/2000/svg"/>"#),
1100            (
1101                "images/logo.image.json",
1102                r#"{"id":"logo","asset":"assets/logo.svg","mediaType":"image/svg+xml","role":"decorative","alt":""}"#,
1103            ),
1104        ]))
1105        .expect("package opens");
1106
1107        let context = agent_context_export(&package).expect("agent context");
1108
1109        assert_eq!(context.images.len(), 1);
1110        assert!(context.images[0].non_semantic);
1111    }
1112
1113    fn package_with(markdown: &str) -> McdPackage {
1114        McdPackage::from_bytes(&zip_bytes(&[
1115            ("mimetype", crate::package::MCD_MIMETYPE),
1116            ("manifest.json", manifest()),
1117            ("content/main.md", markdown),
1118            ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\n"),
1119            (
1120                "tables/revenue.schema.json",
1121                r#"{"id":"revenue","columns":[
1122                    {"name":"quarter","type":"string","label":"Quarter"},
1123                    {"name":"revenue_gbp","type":"decimal","label":"Revenue"}
1124                ]}"#,
1125            ),
1126            (
1127                "tables/revenue.view.json",
1128                r#"{"id":"default","table":"revenue","columns":[
1129                    {"name":"quarter","label":"Quarter"},
1130                    {"name":"revenue_gbp","label":"Revenue","format":"currency","currency":"GBP"}
1131                ]}"#,
1132            ),
1133            (
1134                "tables/revenue.chart.view.json",
1135                r#"{"id":"chart","table":"revenue","display":"chart","chart":{
1136                    "type":"bar",
1137                    "x":{"column":"quarter","label":"Quarter"},
1138                    "y":{"column":"revenue_gbp","label":"Revenue","format":"currency","currency":"GBP"}
1139                }}"#,
1140            ),
1141        ]))
1142        .expect("package opens")
1143    }
1144
1145    fn package_with_annotations(markdown: &str) -> McdPackage {
1146        McdPackage::from_bytes(&zip_bytes(&[
1147            ("mimetype", crate::package::MCD_MIMETYPE),
1148            ("manifest.json", annotation_manifest()),
1149            ("content/main.md", markdown),
1150            ("tables/revenue.csv", "quarter,revenue_gbp\nQ1,125000.00\n"),
1151            (
1152                "tables/revenue.schema.json",
1153                r#"{"id":"revenue","columns":[
1154                    {"name":"quarter","type":"string","label":"Quarter"},
1155                    {"name":"revenue_gbp","type":"decimal","label":"Revenue"}
1156                ]}"#,
1157            ),
1158            (
1159                "annotations/review-intro.annotation.json",
1160                r#"{"id":"review-intro","target":{"type":"document"},"kind":"comment","status":"open","body":"Review the opening copy."}"#,
1161            ),
1162            (
1163                "annotations/review-line.annotation.json",
1164                r#"{"id":"review-line","target":{"type":"path","path":"content/main.md","source":{"startLine":3,"startColumn":1,"endLine":3,"endColumn":1}},"kind":"comment","status":"open","body":"Line-level follow-up."}"#,
1165            ),
1166            (
1167                "annotations/review-table.annotation.json",
1168                r#"{"id":"review-table","target":{"type":"placement","ref":"revenue-table"},"kind":"comment","status":"open","body":"Review table totals."}"#,
1169            ),
1170        ]))
1171        .expect("package opens")
1172    }
1173
1174    fn manifest() -> &'static str {
1175        r#"{
1176            "format":"MCD",
1177            "version":"0.1",
1178            "profile":"MCD-Core",
1179            "entrypoint":"content/main.md",
1180            "tables":[{
1181                "id":"revenue",
1182                "data":"tables/revenue.csv",
1183                "schema":"tables/revenue.schema.json",
1184                "views":{
1185                    "default":"tables/revenue.view.json",
1186                    "chart":"tables/revenue.chart.view.json"
1187                }
1188            }]
1189        }"#
1190    }
1191
1192    fn annotation_manifest() -> &'static str {
1193        r#"{
1194            "format":"MCD",
1195            "version":"0.1",
1196            "profile":"MCD-Core",
1197            "entrypoint":"content/main.md",
1198            "tables":[{
1199                "id":"revenue",
1200                "data":"tables/revenue.csv",
1201                "schema":"tables/revenue.schema.json"
1202            }],
1203            "annotations":[
1204                {"id":"review-intro","metadata":"annotations/review-intro.annotation.json"},
1205                {"id":"review-line","metadata":"annotations/review-line.annotation.json"},
1206                {"id":"review-table","metadata":"annotations/review-table.annotation.json"}
1207            ]
1208        }"#
1209    }
1210
1211    fn zip_bytes(entries: &[(&str, &str)]) -> Vec<u8> {
1212        let cursor = Cursor::new(Vec::new());
1213        let mut writer = ZipWriter::new(cursor);
1214        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
1215
1216        for (path, content) in entries {
1217            writer.start_file(*path, options).expect("start file");
1218            writer.write_all(content.as_bytes()).expect("write file");
1219        }
1220
1221        writer.finish().expect("finish zip").into_inner()
1222    }
1223}