Skip to main content

stack_engine/
lib.rs

1//! Pure operation facade and output contract for Stack diagrams.
2//!
3//! The facade accepts source bytes and a validated, versioned theme catalog. It
4//! never reads the filesystem, network, process environment, clock, locale, or
5//! host font APIs. Invalid user source is returned as ordered diagnostics in a
6//! successful operation result; [`OperationalError`] is reserved for failures
7//! in supplied execution inputs or violated internal pipeline invariants.
8//!
9//! ```
10//! use stack_engine::Engine;
11//!
12//! let engine = Engine::bundled();
13//! let output = engine.check(b"stack 1.0 diagram \"API\" { node api \"API\" }")?;
14//! assert!(output.diagnostics.is_empty());
15//! assert_eq!(output.metadata.language_version.map(|version| version.major), Some(1));
16//! # Ok::<(), stack_engine::OperationalError>(())
17//! ```
18
19#![forbid(unsafe_code)]
20#![deny(missing_docs)]
21
22use std::error::Error;
23use std::fmt;
24
25use stack_compiler::diagnostic as compiler_diagnostic;
26
27mod resources;
28mod routing;
29mod scene;
30mod svg;
31
32mod language;
33mod provider;
34pub use language::{
35    CompletionItem, CompletionKind, CompletionOutput, Hover, HoverKind, HoverOutput,
36    LANGUAGE_INTELLIGENCE_SCHEMA_VERSION, TextEdit,
37};
38pub use provider::{ProviderAsset, ProviderPack};
39
40/// Version of the Rust engine facade.
41pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
42
43/// Result channel for failures outside user-authored Stack source.
44pub type OperationResult<T> = Result<T, OperationalError>;
45
46/// Pure engine facade backed by one validated catalog and revision.
47#[derive(Debug, Clone, Copy)]
48pub struct Engine<'catalog> {
49    catalog: &'catalog stack_theme::Catalog,
50    catalog_revision: &'catalog str,
51    provider_packs: &'catalog [ProviderPack],
52}
53
54#[derive(Debug)]
55struct PreparedScene<'catalog> {
56    scene: scene::Scene,
57    resources: resources::Resources<'catalog>,
58    diagnostics: Vec<Diagnostic>,
59}
60
61impl Engine<'static> {
62    /// Creates an engine backed by the catalog embedded in `stack-theme`.
63    #[must_use]
64    pub fn bundled() -> Self {
65        Self {
66            catalog: stack_theme::catalog(),
67            catalog_revision: stack_theme::CATALOG_REVISION,
68            provider_packs: &[],
69        }
70    }
71}
72
73impl Default for Engine<'static> {
74    fn default() -> Self {
75        Self::bundled()
76    }
77}
78
79impl<'catalog> Engine<'catalog> {
80    /// Creates an engine backed by the bundled catalog and validated provider packs.
81    ///
82    /// Provider assets remain caller-owned in-memory data. The engine performs
83    /// no discovery, file access, download, upload, caching, or terms decision.
84    pub fn with_provider_packs(provider_packs: &'catalog [ProviderPack]) -> OperationResult<Self> {
85        Self::with_catalog_and_provider_packs(
86            stack_theme::catalog(),
87            stack_theme::CATALOG_REVISION,
88            provider_packs,
89        )
90    }
91
92    /// Creates an engine from a previously validated catalog and its content revision.
93    ///
94    /// The catalog document must already have passed the public `stack-theme`
95    /// schema and asset validator. This constructor checks the fallback records
96    /// needed by every engine operation and rejects invalid execution input as an
97    /// operational error rather than a user-source diagnostic.
98    pub fn with_catalog(
99        catalog: &'catalog stack_theme::Catalog,
100        catalog_revision: &'catalog str,
101    ) -> OperationResult<Self> {
102        Self::with_catalog_and_provider_packs(catalog, catalog_revision, &[])
103    }
104
105    /// Creates an engine from a validated catalog and validated provider packs.
106    ///
107    /// Provider namespaces must be unique. Provider icons cannot replace core
108    /// catalog identifiers because [`ProviderPack::new`] requires namespaced IDs.
109    pub fn with_catalog_and_provider_packs(
110        catalog: &'catalog stack_theme::Catalog,
111        catalog_revision: &'catalog str,
112        provider_packs: &'catalog [ProviderPack],
113    ) -> OperationResult<Self> {
114        if !valid_catalog_revision(catalog_revision) {
115            return Err(OperationalError::InvalidCatalog {
116                reason: "catalog revision must be a lowercase sha256 digest",
117            });
118        }
119        if !catalog
120            .themes
121            .iter()
122            .any(|theme| theme.id == catalog.fallbacks.missing_theme_id)
123        {
124            return Err(OperationalError::InvalidCatalog {
125                reason: "missing-theme fallback does not reference an active theme",
126            });
127        }
128        if catalog.themes.iter().any(|theme| {
129            !theme
130                .icons
131                .iter()
132                .any(|icon| icon.id == catalog.fallbacks.missing_icon_id)
133        }) {
134            return Err(OperationalError::InvalidCatalog {
135                reason: "missing-icon fallback is not present in every theme",
136            });
137        }
138
139        if provider_packs.len() > 32 {
140            return Err(OperationalError::InvalidProviderPack {
141                reason: "an engine may contain at most 32 provider packs",
142            });
143        }
144        for (index, pack) in provider_packs.iter().enumerate() {
145            if provider_packs[..index]
146                .iter()
147                .any(|candidate| candidate.manifest().provider.id == pack.manifest().provider.id)
148            {
149                return Err(OperationalError::InvalidProviderPack {
150                    reason: "provider namespaces must be unique",
151                });
152            }
153        }
154
155        Ok(Self {
156            catalog,
157            catalog_revision,
158            provider_packs,
159        })
160    }
161
162    /// Formats source while preserving compiler diagnostics in authored order.
163    pub fn format(&self, source: &[u8]) -> OperationResult<FormatOutput> {
164        let formatted = stack_formatter::format_bytes(source);
165        Ok(FormatOutput {
166            formatted_source: formatted.source,
167            diagnostics: portable_diagnostics(formatted.diagnostics),
168            metadata: self.metadata(declared_language_version(source)),
169        })
170    }
171
172    /// Runs compiler, theme, layout, and routing validation without producing SVG.
173    pub fn check(&self, source: &[u8]) -> OperationResult<CheckOutput> {
174        let compiled = stack_compiler::compile_bytes_with_source_map(source);
175        let mut diagnostics = portable_diagnostics(compiled.diagnostics);
176        if let Some(diagram) = &compiled.diagram {
177            let source_map = compiled.source_map.as_ref().ok_or(
178                OperationalError::InvalidIntermediateRepresentation {
179                    reason: "compiler omitted the source map for normalized IR",
180                },
181            )?;
182            diagnostics.extend(self.prepare_scene(diagram, source_map)?.diagnostics);
183        }
184        Ok(CheckOutput {
185            diagnostics,
186            metadata: self.metadata(declared_language_version(source)),
187        })
188    }
189
190    /// Produces a deterministic standalone SVG from valid Stack source.
191    ///
192    /// Invalid source returns a normal [`RenderOutput`] with ordered diagnostics
193    /// and no SVG. Resource and layout warnings preserve a fallback SVG, while
194    /// invalid catalog or intermediate pipeline state uses [`OperationalError`].
195    pub fn render(&self, source: &[u8]) -> OperationResult<RenderOutput> {
196        let compiled = stack_compiler::compile_bytes_with_source_map(source);
197        let metadata = self.metadata(declared_language_version(source));
198        if compiled.diagram.is_none() {
199            return Ok(RenderOutput {
200                svg: None,
201                diagnostics: portable_diagnostics(compiled.diagnostics),
202                metadata,
203                provider_notices: Vec::new(),
204            });
205        }
206
207        let diagram = compiled.diagram.as_ref().ok_or(
208            OperationalError::InvalidIntermediateRepresentation {
209                reason: "compiler omitted normalized IR after successful compilation",
210            },
211        )?;
212        let source_map = compiled.source_map.as_ref().ok_or(
213            OperationalError::InvalidIntermediateRepresentation {
214                reason: "compiler omitted the source map for normalized IR",
215            },
216        )?;
217        let prepared = self.prepare_scene(diagram, source_map)?;
218        let mut diagnostics = portable_diagnostics(compiled.diagnostics);
219        diagnostics.extend(prepared.diagnostics);
220        let svg = svg::render(diagram, &prepared.scene, &prepared.resources, &metadata).map_err(
221            |error| OperationalError::InvalidIntermediateRepresentation {
222                reason: error.reason(),
223            },
224        )?;
225        Ok(RenderOutput {
226            svg: Some(svg),
227            diagnostics,
228            metadata,
229            provider_notices: prepared.resources.provider_notices(),
230        })
231    }
232
233    fn metadata(&self, language_version: Option<LanguageVersion>) -> EngineMetadata {
234        EngineMetadata {
235            engine_version: ENGINE_VERSION.to_owned(),
236            language_version,
237            theme_catalog_version: self.catalog.catalog_version.clone(),
238            theme_catalog_revision: self.catalog_revision.to_owned(),
239        }
240    }
241
242    fn prepare_scene(
243        &self,
244        diagram: &stack_compiler::ir::Diagram,
245        source_map: &stack_compiler::source_map::SourceMap,
246    ) -> OperationResult<PreparedScene<'catalog>> {
247        let resources = resources::Resources::resolve(diagram, self.catalog, self.provider_packs)
248            .map_err(|error| OperationalError::InvalidCatalog {
249            reason: error.reason(),
250        })?;
251        let scene = scene::layout(diagram, self.catalog).map_err(|error| {
252            OperationalError::InvalidIntermediateRepresentation {
253                reason: error.reason(),
254            }
255        })?;
256        if !scene.geometry_is_valid() {
257            return Err(OperationalError::InvalidIntermediateRepresentation {
258                reason: "layout produced invalid containment or overlap geometry",
259            });
260        }
261        let mut diagnostics = resources
262            .warnings
263            .iter()
264            .map(|warning| resource_diagnostic(warning, source_map))
265            .collect::<OperationResult<Vec<_>>>()?;
266        diagnostics.extend(
267            scene
268                .unsatisfied_orders
269                .iter()
270                .map(|scope| order_diagnostic(scope, source_map))
271                .collect::<OperationResult<Vec<_>>>()?,
272        );
273        Ok(PreparedScene {
274            scene,
275            resources,
276            diagnostics,
277        })
278    }
279}
280
281fn resource_diagnostic(
282    warning: &resources::ResourceWarning,
283    source_map: &stack_compiler::source_map::SourceMap,
284) -> OperationResult<Diagnostic> {
285    let (code, message, help, origin) = match warning {
286        resources::ResourceWarning::MissingTheme(identifier) => (
287            "STK6001",
288            format!("theme '{identifier}' is unavailable; default theme was used"),
289            "Install the requested theme or select an available theme.",
290            source_map.theme(),
291        ),
292        resources::ResourceWarning::MissingIcon { node_id, icon_id } => (
293            "STK5001",
294            format!("icon '{icon_id}' is unavailable; the missing-icon fallback was used"),
295            "Install the icon in the effective theme or remove the icon property.",
296            source_map.node_icon(node_id).ok_or(
297                OperationalError::InvalidIntermediateRepresentation {
298                    reason: "source map omitted a normalized node",
299                },
300            )?,
301        ),
302    };
303    let span = origin
304        .span()
305        .ok_or(OperationalError::InvalidIntermediateRepresentation {
306            reason: "source map omitted an authored resource identifier",
307        })?;
308    Ok(Diagnostic {
309        code: code.to_owned(),
310        severity: Severity::Warning,
311        message,
312        range: SourceRange::from(span),
313        expected: Vec::new(),
314        help: Some(help.to_owned()),
315        related: Vec::new(),
316    })
317}
318
319fn order_diagnostic(
320    scope: &scene::SceneScope,
321    source_map: &stack_compiler::source_map::SourceMap,
322) -> OperationResult<Diagnostic> {
323    let origin = match scope {
324        scene::SceneScope::Diagram => source_map.diagram_order(),
325        scene::SceneScope::Group(identifier) => source_map.group_order(identifier).ok_or(
326            OperationalError::InvalidIntermediateRepresentation {
327                reason: "source map omitted a normalized group",
328            },
329        )?,
330    };
331    let span = origin
332        .span()
333        .ok_or(OperationalError::InvalidIntermediateRepresentation {
334            reason: "source map omitted an authored order hint",
335        })?;
336    Ok(Diagnostic {
337        code: "STK4001".to_owned(),
338        severity: Severity::Warning,
339        message: "order hint could not be satisfied by deterministic layout".to_owned(),
340        range: SourceRange::from(span),
341        expected: Vec::new(),
342        help: Some("Adjust the order hint or same-rank constraints.".to_owned()),
343        related: Vec::new(),
344    })
345}
346
347/// Failure in execution inputs or internal pipeline invariants, not in Stack source.
348#[derive(Debug, Clone, PartialEq, Eq)]
349#[non_exhaustive]
350pub enum OperationalError {
351    /// A provided catalog violates an invariant required by pure execution.
352    InvalidCatalog {
353        /// Stable explanation of the violated catalog invariant.
354        reason: &'static str,
355    },
356    /// A provider pack violates safe deterministic rendering invariants.
357    InvalidProviderPack {
358        /// Stable explanation of the violated provider-pack invariant.
359        reason: &'static str,
360    },
361    /// A language-intelligence request violates its stateless input contract.
362    InvalidLanguageIntelligenceInput {
363        /// Stable explanation of the invalid source position or completion catalog.
364        reason: &'static str,
365    },
366    /// Compiler or layout data violates an invariant required by pure execution.
367    InvalidIntermediateRepresentation {
368        /// Stable explanation of the violated invariant.
369        reason: &'static str,
370    },
371}
372
373impl fmt::Display for OperationalError {
374    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match self {
376            Self::InvalidCatalog { reason } => write!(formatter, "invalid theme catalog: {reason}"),
377            Self::InvalidProviderPack { reason } => {
378                write!(formatter, "invalid provider pack: {reason}")
379            }
380            Self::InvalidLanguageIntelligenceInput { reason } => {
381                write!(formatter, "invalid language-intelligence input: {reason}")
382            }
383            Self::InvalidIntermediateRepresentation { reason } => {
384                write!(formatter, "invalid intermediate representation: {reason}")
385            }
386        }
387    }
388}
389
390impl Error for OperationalError {}
391
392/// Version metadata attached to every operation output.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct EngineMetadata {
395    /// Semantic version of `stack-engine`.
396    pub engine_version: String,
397    /// Authored language version, absent when decoding or syntax parsing fails.
398    pub language_version: Option<LanguageVersion>,
399    /// Semantic version of the selected theme catalog.
400    pub theme_catalog_version: String,
401    /// Content revision of the selected theme catalog and icon bytes.
402    pub theme_catalog_revision: String,
403}
404
405/// Authored Stack language version when decoding and syntax parsing succeed.
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct LanguageVersion {
408    /// Authored major language version.
409    pub major: u32,
410    /// Authored minor language version.
411    pub minor: u32,
412}
413
414/// Result of the format operation.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct FormatOutput {
417    /// Canonical source, absent after encoding, lexical, or syntax failure.
418    pub formatted_source: Option<String>,
419    /// Compiler diagnostics in deterministic authored order.
420    pub diagnostics: Vec<Diagnostic>,
421    /// Versions that identify the exact operation implementation and inputs.
422    pub metadata: EngineMetadata,
423}
424
425/// Result of the check operation.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct CheckOutput {
428    /// Compiler, theme, and layout diagnostics in deterministic order.
429    pub diagnostics: Vec<Diagnostic>,
430    /// Versions that identify the exact operation implementation and inputs.
431    pub metadata: EngineMetadata,
432}
433
434/// Result of the render operation.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct RenderOutput {
437    /// Standalone SVG, absent whenever an error diagnostic prevents rendering.
438    pub svg: Option<String>,
439    /// Compiler, theme, and layout diagnostics in deterministic order.
440    pub diagnostics: Vec<Diagnostic>,
441    /// Versions that identify the exact operation implementation and inputs.
442    pub metadata: EngineMetadata,
443    /// Provider-specific notices for the exact assets embedded in this output.
444    pub provider_notices: Vec<ProviderNotice>,
445}
446
447/// Notice and provenance for one provider pack used by a rendered artifact.
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct ProviderNotice {
450    /// Stable provider namespace.
451    pub provider_id: String,
452    /// Human-readable provider name.
453    pub provider_name: String,
454    /// Provider-pack semantic version.
455    pub pack_version: String,
456    /// Deterministic hash of the manifest and processed asset bytes.
457    pub pack_revision: String,
458    /// Audited upstream release identifier.
459    pub source_release: String,
460    /// Complete official source archive SHA-256.
461    pub archive_sha256: String,
462    /// Provider terms reviewed for this pack.
463    pub terms_url: String,
464    /// Every audited archive that contributed an icon to this pack.
465    pub sources: Vec<ProviderNoticeSource>,
466    /// User-visible attribution text.
467    pub attribution: String,
468    /// User-visible terms summary.
469    pub terms_summary: String,
470    /// User-visible non-endorsement statement.
471    pub non_endorsement: String,
472    /// Exact provider icons embedded in the output.
473    pub icons: Vec<ProviderNoticeIcon>,
474}
475
476/// One provider icon listed in a rendered-artifact notice.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct ProviderNoticeIcon {
479    /// Namespaced provider icon identifier.
480    pub id: String,
481    /// Official provider product name.
482    pub product_name: String,
483    /// Rights-owner source for this brand icon, when the archive is multi-brand.
484    pub brand_source_url: Option<String>,
485    /// Rights-owner usage guidelines for this brand icon, when available.
486    pub brand_guidelines_url: Option<String>,
487    /// Pack-local source ID, or `primary` for the primary source.
488    pub source_id: String,
489}
490
491/// One audited archive listed in a rendered-artifact notice.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ProviderNoticeSource {
494    /// Pack-local source ID. The primary source always uses `primary`.
495    pub id: String,
496    /// Official source page.
497    pub page_url: String,
498    /// Audited upstream release identifier.
499    pub release: String,
500    /// Complete official source archive SHA-256.
501    pub archive_sha256: String,
502    /// Terms reviewed for this source.
503    pub terms_url: String,
504}
505
506/// Engine-owned portable diagnostic shared by native and future WASM outputs.
507#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct Diagnostic {
509    /// Stable Stack diagnostic identifier.
510    pub code: String,
511    /// Whether the diagnostic prevents an artifact from being produced.
512    pub severity: Severity,
513    /// Concise human-readable diagnostic description.
514    pub message: String,
515    /// Primary end-exclusive source range.
516    pub range: SourceRange,
517    /// Ordered source values or constructs valid at the primary range.
518    pub expected: Vec<String>,
519    /// Optional corrective guidance.
520    pub help: Option<String>,
521    /// Other source locations involved in the diagnostic.
522    pub related: Vec<RelatedInformation>,
523}
524
525/// Severity of one portable diagnostic.
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum Severity {
528    /// Prevents normalized or rendered output as defined by the operation.
529    Error,
530    /// Preserves successful output while reporting actionable information.
531    Warning,
532}
533
534/// Additional source context related to a diagnostic.
535#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct RelatedInformation {
537    /// Description of the related source location.
538    pub message: String,
539    /// End-exclusive related source range.
540    pub range: SourceRange,
541}
542
543/// End-exclusive source range.
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub struct SourceRange {
546    /// Inclusive source position.
547    pub start: SourcePosition,
548    /// Exclusive source position.
549    pub end: SourcePosition,
550}
551
552/// One-based line and column with a zero-based UTF-8 byte offset.
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub struct SourcePosition {
555    /// Zero-based UTF-8 byte offset.
556    pub byte_offset: u64,
557    /// One-based source line.
558    pub line: u64,
559    /// One-based Unicode scalar column.
560    pub column: u64,
561}
562
563fn valid_catalog_revision(revision: &str) -> bool {
564    revision.strip_prefix("sha256:").is_some_and(|digest| {
565        digest.len() == 64
566            && digest
567                .bytes()
568                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
569    })
570}
571
572fn declared_language_version(source: &[u8]) -> Option<LanguageVersion> {
573    stack_compiler::parse_bytes(source)
574        .document
575        .map(|document| LanguageVersion {
576            major: document.version.major,
577            minor: document.version.minor,
578        })
579}
580
581fn portable_diagnostics(diagnostics: Vec<compiler_diagnostic::Diagnostic>) -> Vec<Diagnostic> {
582    diagnostics.into_iter().map(Diagnostic::from).collect()
583}
584
585impl From<compiler_diagnostic::Diagnostic> for Diagnostic {
586    fn from(diagnostic: compiler_diagnostic::Diagnostic) -> Self {
587        Self {
588            code: diagnostic.code.to_owned(),
589            severity: match diagnostic.severity {
590                compiler_diagnostic::Severity::Error => Severity::Error,
591                compiler_diagnostic::Severity::Warning => Severity::Warning,
592            },
593            message: diagnostic.message,
594            range: SourceRange::from(diagnostic.span),
595            expected: diagnostic.expected,
596            help: diagnostic.help,
597            related: diagnostic
598                .related
599                .into_iter()
600                .map(|related| RelatedInformation {
601                    message: related.message,
602                    range: SourceRange::from(related.span),
603                })
604                .collect(),
605        }
606    }
607}
608
609impl From<compiler_diagnostic::Span> for SourceRange {
610    fn from(span: compiler_diagnostic::Span) -> Self {
611        Self {
612            start: SourcePosition::from(span.start),
613            end: SourcePosition::from(span.end),
614        }
615    }
616}
617
618impl From<compiler_diagnostic::SourcePosition> for SourcePosition {
619    fn from(position: compiler_diagnostic::SourcePosition) -> Self {
620        Self {
621            byte_offset: position.byte_offset as u64,
622            line: position.line as u64,
623            column: position.column as u64,
624        }
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use std::error::Error;
631
632    use stack_compiler::diagnostic as compiler_diagnostic;
633
634    use super::{
635        Diagnostic, ENGINE_VERSION, Engine, LanguageVersion, OperationalError, Severity,
636        SourcePosition,
637    };
638
639    const VALID_SOURCE: &[u8] = b"stack 1.0 diagram \"API\" { node api \"API\" }";
640
641    #[test]
642    fn bundled_engine_reports_all_version_metadata() {
643        let engine = Engine::bundled();
644        let result = engine.check(VALID_SOURCE);
645        assert!(result.is_ok());
646        if let Ok(output) = result {
647            assert!(output.diagnostics.is_empty());
648            assert_eq!(output.metadata.engine_version, ENGINE_VERSION);
649            assert_eq!(
650                output.metadata.language_version,
651                Some(LanguageVersion { major: 1, minor: 0 })
652            );
653            assert_eq!(output.metadata.theme_catalog_version, "0.5.0");
654            assert_eq!(
655                output.metadata.theme_catalog_revision,
656                stack_theme::CATALOG_REVISION
657            );
658            assert_eq!(Engine::default().check(VALID_SOURCE), Ok(output));
659        }
660    }
661
662    #[test]
663    fn bundled_catalog_resolves_explicit_core_icons() -> Result<(), Box<dyn Error>> {
664        let expected_icons = [
665            ("api", "Application programming interface"),
666            ("web", "Web application"),
667            ("mobile", "Mobile application"),
668            ("desktop", "Desktop application"),
669            ("server", "Server host"),
670            ("container", "Application container"),
671            ("cluster", "Compute cluster"),
672            ("cloud", "Cloud environment"),
673            ("scheduler", "Scheduled execution"),
674            ("webhook", "Webhook endpoint"),
675            ("identity", "Identity and access"),
676            ("observability", "Observability system"),
677            ("gateway", "Network gateway"),
678            ("load-balancer", "Load balancer"),
679            ("dns", "Domain name service"),
680            ("cdn", "Content delivery network"),
681            ("firewall", "Network firewall"),
682            ("network", "Network topology"),
683            ("event", "Discrete event"),
684            ("stream", "Event stream"),
685            ("search", "Search service"),
686            ("analytics", "Analytics system"),
687            ("repository", "Source code repository"),
688            ("pipeline", "Delivery pipeline"),
689            ("secret", "Secret or credential"),
690            ("document", "Document or knowledge base"),
691            ("task", "Task or issue tracker"),
692            ("chat", "Chat or messaging tool"),
693            ("email", "Email delivery"),
694            ("ai", "Artificial intelligence system"),
695        ];
696        let catalog = stack_theme::catalog();
697        assert_eq!(catalog.catalog_version, "0.5.0");
698        assert_eq!(
699            stack_theme::CATALOG_REVISION,
700            "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53"
701        );
702        for theme in &catalog.themes {
703            for (identifier, subject) in expected_icons {
704                let icon = theme
705                    .icons
706                    .iter()
707                    .find(|icon| icon.id == identifier)
708                    .ok_or("core icon is unavailable in a bundled theme")?;
709                assert_eq!(icon.subject, subject);
710                assert_eq!(icon.asset.path, format!("assets/core/{identifier}.svg"));
711            }
712        }
713
714        let source = b"stack 1.0 diagram \"Core icon\" { theme dark node gateway \"Gateway\" { kind service detail \"Public API\" icon \"gateway\" } }";
715        let checked = Engine::bundled().check(source)?;
716        let rendered = Engine::bundled().render(source)?;
717        assert!(checked.diagnostics.is_empty());
718        assert!(rendered.diagnostics.is_empty());
719        assert_eq!(rendered.metadata.theme_catalog_version, "0.5.0");
720        assert_eq!(
721            rendered.metadata.theme_catalog_revision,
722            stack_theme::CATALOG_REVISION
723        );
724        let svg = rendered.svg.ok_or("explicit icon render produced no SVG")?;
725        assert!(svg.contains("data-icon-id=\"gateway\""));
726        assert!(!svg.contains("data-icon-id=\"kind-external\""));
727        Ok(())
728    }
729
730    #[test]
731    fn format_preserves_semantic_diagnostics_but_not_syntax_failures() {
732        let engine = Engine::bundled();
733        let semantic_error = b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" }";
734        let semantic_result = engine.format(semantic_error);
735        assert!(semantic_result.is_ok());
736        if let Ok(semantic) = semantic_result {
737            assert!(semantic.formatted_source.is_some());
738            assert!(!semantic.diagnostics.is_empty());
739        }
740
741        let encoding_result = engine.format(b"stack 1.0\n\xff");
742        assert!(encoding_result.is_ok());
743        if let Ok(encoding) = encoding_result {
744            assert!(encoding.formatted_source.is_none());
745            assert_eq!(encoding.diagnostics[0].code, "STK1001");
746            assert_eq!(encoding.metadata.language_version, None);
747        }
748    }
749
750    #[test]
751    fn check_keeps_compiler_diagnostic_order_and_positions() {
752        let source =
753            b"stack 1.0 diagram \"API\" { node api \"A\" node api \"B\" edge api -> missing }";
754        let expected = stack_compiler::compile_bytes(source)
755            .diagnostics
756            .into_iter()
757            .map(|diagnostic| diagnostic.code)
758            .collect::<Vec<_>>();
759        let result = Engine::bundled().check(source);
760        assert!(result.is_ok());
761        if let Ok(output) = result {
762            assert_eq!(
763                output
764                    .diagnostics
765                    .iter()
766                    .map(|diagnostic| diagnostic.code.as_str())
767                    .collect::<Vec<_>>(),
768                expected
769            );
770            assert!(
771                output
772                    .diagnostics
773                    .windows(2)
774                    .all(|pair| pair[0].range.start.byte_offset <= pair[1].range.start.byte_offset)
775            );
776        }
777    }
778
779    #[test]
780    fn check_emits_order_warning_at_the_authored_statement() -> Result<(), Box<dyn Error>> {
781        let source = "stack 1.0 diagram \"Order\" { layout { direction right order [b, a] } node a \"A\" node b \"B\" }";
782        let output = Engine::bundled().check(source.as_bytes())?;
783        assert_eq!(output.diagnostics.len(), 1);
784        let diagnostic = &output.diagnostics[0];
785        assert_eq!(diagnostic.code, "STK4001");
786        assert_eq!(diagnostic.severity, Severity::Warning);
787        let start = source
788            .find("order [b, a]")
789            .ok_or("missing order statement")?;
790        let end = start + "order [b, a]".len();
791        assert_eq!(diagnostic.range.start.byte_offset, start as u64);
792        assert_eq!(diagnostic.range.end.byte_offset, end as u64);
793        assert_eq!(diagnostic.range.start.line, 1);
794        assert_eq!(diagnostic.range.start.column, start as u64 + 1);
795        assert_eq!(diagnostic.range.end.column, end as u64 + 1);
796        Ok(())
797    }
798
799    #[test]
800    fn check_omits_order_warning_when_rank_placement_satisfies_it() -> Result<(), Box<dyn Error>> {
801        let source = b"stack 1.0 diagram \"Order\" { layout { direction right rank same [a, b] order [b, a] } node a \"A\" node b \"B\" }";
802        let output = Engine::bundled().check(source)?;
803        assert!(output.diagnostics.is_empty());
804        Ok(())
805    }
806
807    #[test]
808    fn group_order_warning_uses_the_group_source_map_entry() -> Result<(), Box<dyn Error>> {
809        let source = "stack 1.0 diagram \"Group order\" { group pair \"Pair\" { layout { direction down order [b, a] } node a \"A\" node b \"B\" } }";
810        let output = Engine::bundled().check(source.as_bytes())?;
811        assert_eq!(output, Engine::bundled().check(source.as_bytes())?);
812        assert_eq!(
813            output
814                .diagnostics
815                .iter()
816                .map(|diagnostic| diagnostic.code.as_str())
817                .collect::<Vec<_>>(),
818            vec!["STK4001"]
819        );
820        let start = source
821            .find("order [b, a]")
822            .ok_or("missing order statement")?;
823        assert_eq!(output.diagnostics[0].range.start.byte_offset, start as u64);
824        Ok(())
825    }
826
827    #[test]
828    fn layout_warnings_follow_compiler_warnings() -> Result<(), Box<dyn Error>> {
829        let mut source = String::from(
830            "stack 1.0 diagram \"Warnings\" { layout { direction right order [n1, n0] } node hub \"Hub\" ",
831        );
832        for index in 0..13 {
833            source.push_str(&format!(
834                "node n{index} \"N {index}\" edge hub -> n{index} "
835            ));
836        }
837        source.push('}');
838        let output = Engine::bundled().check(source.as_bytes())?;
839        assert_eq!(
840            output
841                .diagnostics
842                .iter()
843                .map(|diagnostic| diagnostic.code.as_str())
844                .collect::<Vec<_>>(),
845            vec!["STK4002", "STK4001"]
846        );
847        Ok(())
848    }
849
850    #[test]
851    fn resource_fallbacks_report_authored_ranges_and_render_svg() -> Result<(), Box<dyn Error>> {
852        let source = "stack 1.0 diagram \"Fallbacks\" { theme neon layout { direction right order [b, a] } node a \"A\" { icon \"missing\" } node b \"B\" }";
853        let checked = Engine::bundled().check(source.as_bytes())?;
854        let rendered = Engine::bundled().render(source.as_bytes())?;
855        assert_eq!(checked.diagnostics, rendered.diagnostics);
856        assert_eq!(
857            rendered
858                .diagnostics
859                .iter()
860                .map(|diagnostic| diagnostic.code.as_str())
861                .collect::<Vec<_>>(),
862            vec!["STK6001", "STK5001", "STK4001"]
863        );
864
865        let theme_start = source.find("neon").ok_or("missing theme identifier")?;
866        assert_eq!(
867            rendered.diagnostics[0].range.start.byte_offset,
868            theme_start as u64
869        );
870        assert_eq!(
871            rendered.diagnostics[0].range.end.byte_offset,
872            (theme_start + "neon".len()) as u64
873        );
874        let icon_start = source.find("\"missing\"").ok_or("missing icon string")?;
875        assert_eq!(
876            rendered.diagnostics[1].range.start.byte_offset,
877            icon_start as u64
878        );
879        assert_eq!(
880            rendered.diagnostics[1].range.end.byte_offset,
881            (icon_start + "\"missing\"".len()) as u64
882        );
883        let svg = rendered.svg.ok_or("render produced no SVG")?;
884        assert!(svg.contains("data-theme-id=\"default\""));
885        assert!(svg.contains("data-icon-id=\"kind-external\""));
886        Ok(())
887    }
888
889    #[test]
890    fn render_is_repeatable_and_escapes_source_text() -> Result<(), Box<dyn Error>> {
891        let source = b"stack 1.0 diagram \"<script>&\" { node client \"\\\" onload=\\\"alert(1)<&>\" edge client -> api \"javascript:alert(1)\" node api \"API\" }";
892        let first = Engine::bundled().render(source)?;
893        let second = Engine::bundled().render(source)?;
894        assert_eq!(first, second);
895        let svg = first.svg.ok_or("render produced no SVG")?;
896        assert!(svg.contains("&lt;script&gt;&amp;"));
897        assert!(svg.contains("&quot; onload=&quot;alert(1)&lt;&amp;&gt;"));
898        assert!(!svg.contains("<script"));
899        assert!(!svg.contains("href="));
900        Ok(())
901    }
902
903    #[cfg(feature = "conformance")]
904    #[test]
905    fn canonical_valid_fixtures_render_standalone_svg() -> Result<(), Box<dyn Error>> {
906        let specification = std::env::var("STACK_SPECIFICATION_DIR")?;
907        let valid_root = std::path::Path::new(&specification).join("conformance/valid");
908        let mut cases = std::fs::read_dir(&valid_root)?.collect::<Result<Vec<_>, _>>()?;
909        cases.sort_by_key(|entry| entry.file_name());
910        if cases.is_empty() {
911            return Err(format!("no valid fixtures found in {}", valid_root.display()).into());
912        }
913
914        for case in cases {
915            let source_path = case.path().join("source.stack");
916            if !source_path.is_file() {
917                continue;
918            }
919            let output = Engine::bundled().render(&std::fs::read(&source_path)?)?;
920            if output
921                .diagnostics
922                .iter()
923                .any(|diagnostic| diagnostic.severity == Severity::Error)
924            {
925                return Err(
926                    format!("{} produced an error diagnostic", source_path.display()).into(),
927                );
928            }
929            let svg = output
930                .svg
931                .ok_or_else(|| format!("{} produced no standalone SVG", source_path.display()))?;
932            assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
933            assert!(svg.ends_with("</svg>\n"));
934        }
935        Ok(())
936    }
937
938    #[test]
939    fn render_separates_invalid_input_from_success() -> Result<(), Box<dyn Error>> {
940        let engine = Engine::bundled();
941        let result = engine.render(b"\xff");
942        assert!(result.is_ok());
943        if let Ok(output) = result {
944            assert!(output.svg.is_none());
945            assert_eq!(output.diagnostics[0].code, "STK1001");
946            assert_eq!(output.metadata.language_version, None);
947        }
948
949        let output = engine.render(VALID_SOURCE)?;
950        assert!(output.diagnostics.is_empty());
951        assert!(
952            output
953                .svg
954                .as_deref()
955                .is_some_and(|svg| svg.contains("<svg"))
956        );
957        Ok(())
958    }
959
960    #[test]
961    fn provided_catalog_requires_usable_fallbacks_and_revision() {
962        let catalog = stack_theme::catalog().clone();
963        assert!(Engine::with_catalog(&catalog, stack_theme::CATALOG_REVISION).is_ok());
964        assert!(matches!(
965            Engine::with_catalog(&catalog, "sha256:NOT-A-DIGEST"),
966            Err(OperationalError::InvalidCatalog { .. })
967        ));
968
969        let mut missing_theme = catalog.clone();
970        missing_theme.fallbacks.missing_theme_id = "missing".to_owned();
971        assert!(matches!(
972            Engine::with_catalog(&missing_theme, stack_theme::CATALOG_REVISION),
973            Err(OperationalError::InvalidCatalog { .. })
974        ));
975
976        let mut missing_icon = catalog;
977        missing_icon.fallbacks.missing_icon_id = "missing".to_owned();
978        assert!(matches!(
979            Engine::with_catalog(&missing_icon, stack_theme::CATALOG_REVISION),
980            Err(OperationalError::InvalidCatalog { .. })
981        ));
982    }
983
984    #[test]
985    fn diagnostic_conversion_keeps_expected_help_and_related_ranges() {
986        let start = compiler_diagnostic::SourcePosition {
987            byte_offset: 3,
988            line: 2,
989            column: 4,
990        };
991        let end = compiler_diagnostic::SourcePosition {
992            byte_offset: 7,
993            line: 2,
994            column: 8,
995        };
996        let diagnostic = compiler_diagnostic::Diagnostic {
997            code: "STK4002",
998            severity: compiler_diagnostic::Severity::Warning,
999            message: "warning".to_owned(),
1000            span: compiler_diagnostic::Span { start, end },
1001            expected: vec!["right".to_owned(), "down".to_owned()],
1002            help: Some("help".to_owned()),
1003            related: vec![compiler_diagnostic::RelatedInformation {
1004                message: "related".to_owned(),
1005                span: compiler_diagnostic::Span::point(start),
1006            }],
1007        };
1008
1009        let portable = Diagnostic::from(diagnostic);
1010        assert_eq!(portable.severity, Severity::Warning);
1011        assert_eq!(portable.expected, ["right", "down"]);
1012        assert_eq!(portable.help.as_deref(), Some("help"));
1013        assert_eq!(portable.related[0].message, "related");
1014        assert_eq!(
1015            portable.range.start,
1016            SourcePosition {
1017                byte_offset: 3,
1018                line: 2,
1019                column: 4,
1020            }
1021        );
1022    }
1023
1024    #[test]
1025    fn operational_error_messages_are_stable() {
1026        assert_eq!(
1027            OperationalError::InvalidCatalog { reason: "reason" }.to_string(),
1028            "invalid theme catalog: reason"
1029        );
1030        assert_eq!(
1031            OperationalError::InvalidIntermediateRepresentation { reason: "reason" }.to_string(),
1032            "invalid intermediate representation: reason"
1033        );
1034        assert_eq!(
1035            OperationalError::InvalidProviderPack { reason: "reason" }.to_string(),
1036            "invalid provider pack: reason"
1037        );
1038        assert_eq!(
1039            OperationalError::InvalidLanguageIntelligenceInput { reason: "reason" }.to_string(),
1040            "invalid language-intelligence input: reason"
1041        );
1042    }
1043}