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