Skip to main content

compose_lens/loader/
mod.rs

1//! Ordered, caller-supplied Compose project loading.
2
3mod include_paths;
4
5use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
6use crate::interpolation::{
7    DocumentInterpolation, EnvironmentProvider, InterpolationOptions, interpolate_document_with_options,
8};
9use crate::merge::{MergedProject, merge_project};
10use crate::model::{
11    ComposeDocument, ConfigDefinition, IncludeItem, Located, ModelDefinition, ModelParse, NetworkDefinition,
12    SecretDefinition, VolumeDefinition,
13};
14use crate::project::{ProjectResource, ProjectService, ProjectView, build_project_view};
15use crate::source::{SourceId, SourceSpan};
16use crate::syntax::{SyntaxDocument, SyntaxParseError};
17use std::collections::BTreeMap;
18use std::error::Error;
19use std::fmt;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23pub use include_paths::{
24    INCLUDE_PROJECT_DIRECTORY_UNRESOLVED, IncludeProjectDirectoryEntry, IncludeProjectDirectoryPlan,
25    IncludeProjectDirectoryRequest, IncludeProjectDirectoryResolution, IncludeProjectDirectoryResolveError,
26    IncludeProjectDirectoryResolver, IncludeProjectDirectoryStatus,
27};
28
29/// An effective include declaration could not be modeled safely enough to authorize loading.
30pub const INCLUDE_UNMODELED: DiagnosticCode = DiagnosticCode::new("compose.include.unmodeled");
31/// The caller denied an include request.
32pub const INCLUDE_LOADER_DENIED: DiagnosticCode = DiagnosticCode::new("compose.include.loader-denied");
33/// The caller's include loader failed to supply a project.
34pub const INCLUDE_LOADER_FAILED: DiagnosticCode = DiagnosticCode::new("compose.include.loader-failed");
35/// The caller supplied no documents for an included project.
36pub const INCLUDE_EMPTY_RESULT: DiagnosticCode = DiagnosticCode::new("compose.include.empty-result");
37/// An included identity appears again on the active traversal stack.
38pub const INCLUDE_CYCLE: DiagnosticCode = DiagnosticCode::new("compose.include.cycle");
39/// A source identifier was reused anywhere in an include traversal.
40pub const INCLUDE_DUPLICATE_SOURCE_ID: DiagnosticCode = DiagnosticCode::new("compose.include.duplicate-source-id");
41/// A caller-supplied include project could not enter the existing ordered loader.
42pub const INCLUDE_PROJECT_LOAD_FAILED: DiagnosticCode = DiagnosticCode::new("compose.include.project-load-failed");
43/// The root input contains no documents.
44pub const INCLUDE_EMPTY_ROOT: DiagnosticCode = DiagnosticCode::new("compose.include.empty-root");
45/// An included definition collides with an already selected parent definition.
46pub const INCLUDE_RESOURCE_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.include.resource-conflict");
47
48/// The caller-defined location of one Compose document.
49///
50/// The label is for display and may be a path, URI, or synthetic name. The document directory is
51/// retained verbatim for later path-resolution decisions; `ComposeLens` does not canonicalize it or
52/// access the file system.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DocumentOrigin {
55    label: String,
56    directory: PathBuf,
57}
58
59impl DocumentOrigin {
60    /// Creates an explicit document origin.
61    #[must_use]
62    pub fn new(label: impl Into<String>, directory: impl Into<PathBuf>) -> Self {
63        Self {
64            label: label.into(),
65            directory: directory.into(),
66        }
67    }
68
69    /// Returns the caller-defined display label.
70    #[must_use]
71    pub fn label(&self) -> &str {
72        &self.label
73    }
74
75    /// Returns the caller-supplied directory associated with this document.
76    #[must_use]
77    pub fn directory(&self) -> &Path {
78        &self.directory
79    }
80}
81
82/// One source document supplied to the project loader.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DocumentInput {
85    source_id: SourceId,
86    origin: DocumentOrigin,
87    source: Arc<str>,
88}
89
90impl DocumentInput {
91    /// Creates an input without reading the file system or process environment.
92    #[must_use]
93    pub fn new(source_id: SourceId, origin: DocumentOrigin, source: impl Into<Arc<str>>) -> Self {
94        Self {
95            source_id,
96            origin,
97            source: source.into(),
98        }
99    }
100
101    /// Returns the caller-managed source identifier.
102    #[must_use]
103    pub const fn source_id(&self) -> SourceId {
104        self.source_id
105    }
106
107    /// Returns the caller-defined document origin.
108    #[must_use]
109    pub const fn origin(&self) -> &DocumentOrigin {
110        &self.origin
111    }
112
113    /// Returns the supplied source text.
114    #[must_use]
115    pub fn source_text(&self) -> &str {
116        &self.source
117    }
118}
119
120/// A caller-defined canonical identity for one includable Compose project.
121///
122/// The caller, not `ComposeLens`, establishes identity equivalence. In particular, this type does
123/// not open, join, normalize, or canonicalize paths.
124#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub struct IncludeIdentity(String);
126
127impl IncludeIdentity {
128    /// Creates an opaque identity whose canonical form is owned by the caller.
129    #[must_use]
130    pub fn new(canonical: impl Into<String>) -> Self {
131        Self(canonical.into())
132    }
133
134    /// Returns the caller-defined canonical identity text.
135    #[must_use]
136    pub fn as_str(&self) -> &str {
137        &self.0
138    }
139}
140
141impl fmt::Display for IncludeIdentity {
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        formatter.write_str(&self.0)
144    }
145}
146
147/// A caller-created ordered project input returned by an [`IncludeLoader`].
148///
149/// This is equally suitable for the traversal root. `ComposeLens` keeps every origin and source
150/// text supplied here, but it never discovers documents from the identity or include paths.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct IncludedProjectInput {
153    identity: IncludeIdentity,
154    documents: Vec<DocumentInput>,
155}
156
157impl IncludedProjectInput {
158    /// Creates one project input from caller-created documents in merge order.
159    #[must_use]
160    pub fn new(identity: IncludeIdentity, documents: impl IntoIterator<Item = DocumentInput>) -> Self {
161        Self {
162            identity,
163            documents: documents.into_iter().collect(),
164        }
165    }
166
167    /// Returns the caller-defined canonical project identity.
168    #[must_use]
169    pub const fn identity(&self) -> &IncludeIdentity {
170        &self.identity
171    }
172
173    /// Returns documents in caller-selected merge order.
174    #[must_use]
175    pub fn documents(&self) -> &[DocumentInput] {
176        &self.documents
177    }
178}
179
180/// One effective, caller-authorized include request.
181///
182/// Every path-like value remains raw source data in declared order. The loader receives this
183/// complete context and alone decides whether and how any document may be obtained.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct IncludeRequest {
186    parent_identity: IncludeIdentity,
187    parent_base_directory: PathBuf,
188    declaration_span: SourceSpan,
189    declaration_origin: Option<DocumentOrigin>,
190    item: IncludeItem,
191    paths: Vec<Located<String>>,
192    env_files: Vec<Located<String>>,
193    project_directory: Option<Located<String>>,
194}
195
196impl IncludeRequest {
197    fn from_item(
198        parent_identity: IncludeIdentity,
199        parent_base_directory: PathBuf,
200        declaration_origin: Option<DocumentOrigin>,
201        item: IncludeItem,
202    ) -> Option<Self> {
203        let declaration_span = include_item_span(&item)?;
204        let (paths, env_files, project_directory) = match &item {
205            IncludeItem::Short(path) => (vec![path.clone()], Vec::new(), None),
206            IncludeItem::Long(include) if include.unmodeled_fields().is_empty() && !include.paths().is_empty() => (
207                include.paths().to_vec(),
208                include.env_files().to_vec(),
209                include.project_directory().cloned(),
210            ),
211            IncludeItem::Long(_) | IncludeItem::Unmodeled => return None,
212        };
213        Some(Self {
214            parent_identity,
215            parent_base_directory,
216            declaration_span,
217            declaration_origin,
218            item,
219            paths,
220            env_files,
221            project_directory,
222        })
223    }
224
225    /// Returns the identity of the project that declared this request.
226    #[must_use]
227    pub const fn parent_identity(&self) -> &IncludeIdentity {
228        &self.parent_identity
229    }
230
231    /// Returns the parent project's first-document directory exactly as supplied by the caller.
232    #[must_use]
233    pub fn parent_base_directory(&self) -> &Path {
234        &self.parent_base_directory
235    }
236
237    /// Returns the complete span of the effective declaration.
238    #[must_use]
239    pub const fn declaration_span(&self) -> SourceSpan {
240        self.declaration_span
241    }
242
243    /// Returns the source identifier containing the effective declaration.
244    #[must_use]
245    pub const fn declaration_source_id(&self) -> SourceId {
246        self.declaration_span.source_id()
247    }
248
249    /// Returns the origin of the document containing the effective declaration, when available.
250    #[must_use]
251    pub const fn declaration_origin(&self) -> Option<&DocumentOrigin> {
252        self.declaration_origin.as_ref()
253    }
254
255    /// Returns the complete effective typed include item without interpolation.
256    #[must_use]
257    pub const fn item(&self) -> &IncludeItem {
258        &self.item
259    }
260
261    /// Returns raw include paths in declared order.
262    #[must_use]
263    pub fn paths(&self) -> &[Located<String>] {
264        &self.paths
265    }
266
267    /// Returns raw include environment-file declarations in declared order.
268    #[must_use]
269    pub fn env_files(&self) -> &[Located<String>] {
270        &self.env_files
271    }
272
273    /// Returns the raw optional project-directory declaration.
274    #[must_use]
275    pub const fn project_directory(&self) -> Option<&Located<String>> {
276        self.project_directory.as_ref()
277    }
278}
279
280/// The only authorization and I/O boundary used by recursive include traversal.
281pub trait IncludeLoader {
282    /// Authorizes and loads one requested included project.
283    ///
284    /// Implementations may use files, editor buffers, archives, URIs, or another caller policy.
285    /// `ComposeLens` itself performs none of those operations.
286    ///
287    /// # Errors
288    ///
289    /// Returns an [`IncludeLoadError`] when caller policy denies the request or the selected
290    /// external source cannot supply a project input.
291    fn load_include(&self, request: &IncludeRequest) -> Result<IncludedProjectInput, IncludeLoadError>;
292}
293
294/// A caller-controlled include loading outcome that prevents traversal of one requested edge.
295#[derive(Debug, Clone, PartialEq, Eq)]
296#[non_exhaustive]
297pub enum IncludeLoadError {
298    /// The caller's policy denied the request.
299    Denied(String),
300    /// The caller could not load an authorized request.
301    Failed(String),
302}
303
304impl IncludeLoadError {
305    /// Creates a policy-denial result without exposing it through diagnostics by default.
306    #[must_use]
307    pub fn denied(message: impl Into<String>) -> Self {
308        Self::Denied(message.into())
309    }
310
311    /// Creates a loader-failure result without exposing it through diagnostics by default.
312    #[must_use]
313    pub fn failed(message: impl Into<String>) -> Self {
314        Self::Failed(message.into())
315    }
316}
317
318impl fmt::Display for IncludeLoadError {
319    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320        match self {
321            Self::Denied(message) | Self::Failed(message) => formatter.write_str(message),
322        }
323    }
324}
325
326impl Error for IncludeLoadError {}
327
328/// One parsed document in an ordered Compose project.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct LoadedDocument {
331    origin: DocumentOrigin,
332    syntax: SyntaxDocument,
333    syntax_diagnostics: Vec<Diagnostic>,
334    model: ModelParse,
335}
336
337impl LoadedDocument {
338    /// Returns the document's source identifier.
339    #[must_use]
340    pub const fn source_id(&self) -> SourceId {
341        self.syntax.source_id()
342    }
343
344    /// Returns the explicit origin retained for this document.
345    #[must_use]
346    pub const fn origin(&self) -> &DocumentOrigin {
347        &self.origin
348    }
349
350    /// Returns the loss-aware syntax document.
351    #[must_use]
352    pub const fn syntax(&self) -> &SyntaxDocument {
353        &self.syntax
354    }
355
356    /// Returns recoverable YAML syntax diagnostics.
357    #[must_use]
358    pub fn syntax_diagnostics(&self) -> &[Diagnostic] {
359        &self.syntax_diagnostics
360    }
361
362    /// Returns the recoverable typed-model parse result.
363    #[must_use]
364    pub const fn model(&self) -> &ModelParse {
365        &self.model
366    }
367
368    /// Reports whether syntax and typed-model parsing emitted no error diagnostics.
369    #[must_use]
370    pub fn is_valid(&self) -> bool {
371        self.syntax_diagnostics
372            .iter()
373            .chain(self.model.diagnostics())
374            .all(|diagnostic| diagnostic.severity() != Severity::Error)
375    }
376}
377
378/// An ordered set of parsed Compose documents and their path origins.
379///
380/// File order is semantically significant. The first document supplies the project directory used
381/// by Compose's multi-file relative-path rules, while every document retains its own origin for
382/// provenance and future `include` support.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct LoadedProject {
385    documents: Vec<LoadedDocument>,
386    base_directory: PathBuf,
387    diagnostics: Vec<Diagnostic>,
388}
389
390impl LoadedProject {
391    /// Parses ordered, caller-supplied documents into a loaded project.
392    ///
393    /// Recoverable YAML and typed-model problems remain available in [`Self::diagnostics`]. No
394    /// interpolation or merge is performed by this operation.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`ProjectLoadError`] when no document is supplied, a source identifier is reused, or
399    /// one source exceeds the syntax tree's byte-offset capacity.
400    pub fn load(inputs: impl IntoIterator<Item = DocumentInput>) -> Result<Self, ProjectLoadError> {
401        let inputs: Vec<_> = inputs.into_iter().collect();
402        let Some(first) = inputs.first() else {
403            return Err(ProjectLoadError::EmptyProject);
404        };
405
406        let mut source_origins = BTreeMap::new();
407        for input in &inputs {
408            if let Some(first_origin) = source_origins.insert(input.source_id, input.origin.label.clone()) {
409                return Err(ProjectLoadError::DuplicateSourceId {
410                    source_id: input.source_id,
411                    first_origin,
412                    duplicate_origin: input.origin.label.clone(),
413                });
414            }
415        }
416
417        let base_directory = first.origin.directory.clone();
418        let mut documents = Vec::with_capacity(inputs.len());
419        let mut diagnostics = Vec::new();
420        for input in inputs {
421            let syntax = SyntaxDocument::parse(input.source_id, input.source).map_err(|error| {
422                ProjectLoadError::SyntaxCapacity {
423                    origin: input.origin.clone(),
424                    error,
425                }
426            })?;
427            let (syntax, syntax_diagnostics) = syntax.into_parts();
428            let model = ComposeDocument::parse(&syntax);
429            diagnostics.extend(syntax_diagnostics.iter().cloned());
430            diagnostics.extend(model.diagnostics().iter().cloned());
431            documents.push(LoadedDocument {
432                origin: input.origin,
433                syntax,
434                syntax_diagnostics,
435                model,
436            });
437        }
438
439        Ok(Self {
440            documents,
441            base_directory,
442            diagnostics,
443        })
444    }
445
446    /// Returns documents in caller-supplied merge order.
447    #[must_use]
448    pub fn documents(&self) -> &[LoadedDocument] {
449        &self.documents
450    }
451
452    /// Finds a document by its unique source identifier.
453    #[must_use]
454    pub fn document(&self, source_id: SourceId) -> Option<&LoadedDocument> {
455        self.documents.iter().find(|document| document.source_id() == source_id)
456    }
457
458    /// Returns the project directory inherited from the first document.
459    #[must_use]
460    pub fn base_directory(&self) -> &Path {
461        &self.base_directory
462    }
463
464    /// Returns aggregated syntax and typed-model diagnostics in document order.
465    #[must_use]
466    pub fn diagnostics(&self) -> &[Diagnostic] {
467        &self.diagnostics
468    }
469
470    /// Reports whether loading emitted no error diagnostics.
471    #[must_use]
472    pub fn is_valid(&self) -> bool {
473        self.diagnostics
474            .iter()
475            .all(|diagnostic| diagnostic.severity() != Severity::Error)
476    }
477
478    /// Interpolates each document independently, in file order, without modifying the project.
479    #[must_use]
480    pub fn interpolate(&self, environment: &dyn EnvironmentProvider) -> ProjectInterpolation {
481        self.interpolate_with_options(environment, InterpolationOptions::default())
482    }
483
484    /// Interpolates each document independently with explicit options.
485    #[must_use]
486    pub fn interpolate_with_options(
487        &self,
488        environment: &dyn EnvironmentProvider,
489        options: InterpolationOptions,
490    ) -> ProjectInterpolation {
491        let documents: Vec<_> = self
492            .documents
493            .iter()
494            .map(|document| interpolate_document_with_options(document.syntax(), environment, options))
495            .collect();
496        let diagnostics = documents
497            .iter()
498            .flat_map(|document| document.diagnostics().iter().cloned())
499            .collect();
500        ProjectInterpolation { documents, diagnostics }
501    }
502}
503
504/// One visited project occurrence in an [`IncludeResolution`].
505///
506/// A repeated identity is retained as a separate occurrence when it is reached by distinct
507/// non-cyclic edges. Traversal intentionally does not cache those diamonds.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct IncludeNode {
510    index: usize,
511    identity: IncludeIdentity,
512    inputs: IncludedProjectInput,
513    origins: Vec<DocumentOrigin>,
514    loaded_project: Option<LoadedProject>,
515    merged_project: Option<MergedProject>,
516    project_view: Option<ProjectView>,
517}
518
519impl IncludeNode {
520    /// Returns this occurrence's stable index within [`IncludeResolution::nodes`].
521    #[must_use]
522    pub const fn index(&self) -> usize {
523        self.index
524    }
525
526    /// Returns this occurrence's caller-defined identity.
527    #[must_use]
528    pub const fn identity(&self) -> &IncludeIdentity {
529        &self.identity
530    }
531
532    /// Returns the complete caller-created input retained for this occurrence.
533    #[must_use]
534    pub const fn inputs(&self) -> &IncludedProjectInput {
535        &self.inputs
536    }
537
538    /// Returns source origins in the input's merge order.
539    #[must_use]
540    pub fn origins(&self) -> &[DocumentOrigin] {
541        &self.origins
542    }
543
544    /// Returns the ordered loaded project when no fatal load boundary failed.
545    #[must_use]
546    pub const fn loaded_project(&self) -> Option<&LoadedProject> {
547        self.loaded_project.as_ref()
548    }
549
550    /// Returns the authored, no-interpolation merge when a mapping-root project was available.
551    #[must_use]
552    pub const fn merged_project(&self) -> Option<&MergedProject> {
553        self.merged_project.as_ref()
554    }
555
556    /// Returns the effective typed project view used to discover child include declarations.
557    #[must_use]
558    pub const fn project_view(&self) -> Option<&ProjectView> {
559        self.project_view.as_ref()
560    }
561}
562
563/// One requested include edge in traversal order.
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct IncludeEdge {
566    parent: IncludeIdentity,
567    child: IncludeIdentity,
568    parent_node_index: usize,
569    child_node_index: usize,
570    request_index: usize,
571    cycle: bool,
572}
573
574impl IncludeEdge {
575    /// Returns the identity that declared the include.
576    #[must_use]
577    pub const fn parent(&self) -> &IncludeIdentity {
578        &self.parent
579    }
580
581    /// Returns the identity supplied by the caller's loader for the child.
582    #[must_use]
583    pub const fn child(&self) -> &IncludeIdentity {
584        &self.child
585    }
586
587    /// Returns the occurrence index that declared this edge.
588    #[must_use]
589    pub const fn parent_node_index(&self) -> usize {
590        self.parent_node_index
591    }
592
593    /// Returns the retained target occurrence index.
594    ///
595    /// A cycle targets its existing active occurrence; every other successful edge targets the
596    /// retained occurrence loaded for that request.
597    #[must_use]
598    pub const fn child_node_index(&self) -> usize {
599        self.child_node_index
600    }
601
602    /// Returns the matching [`IncludeResolution::requests`] index.
603    #[must_use]
604    pub const fn request_index(&self) -> usize {
605        self.request_index
606    }
607
608    /// Reports whether this edge targets an already active occurrence.
609    #[must_use]
610    pub const fn is_cycle(&self) -> bool {
611        self.cycle
612    }
613}
614
615/// One of the six top-level definition namespaces composed from includes.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
617#[non_exhaustive]
618pub enum IncludeResourceNamespace {
619    /// Compose services.
620    Services,
621    /// Compose networks.
622    Networks,
623    /// Compose volumes.
624    Volumes,
625    /// Compose configs.
626    Configs,
627    /// Compose secrets.
628    Secrets,
629    /// Individual Compose model definitions.
630    Models,
631}
632
633impl IncludeResourceNamespace {
634    const fn as_str(self) -> &'static str {
635        match self {
636            Self::Services => "service",
637            Self::Networks => "network",
638            Self::Volumes => "volume",
639            Self::Configs => "config",
640            Self::Secrets => "secret",
641            Self::Models => "model",
642        }
643    }
644}
645
646/// Source and occurrence evidence for one selected or conflicting definition.
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct IncludeDefinitionEvidence {
649    occurrence_index: usize,
650    identity: IncludeIdentity,
651    source: Option<SourceSpan>,
652    source_label: Option<String>,
653}
654
655impl IncludeDefinitionEvidence {
656    /// Returns the retained project occurrence index.
657    #[must_use]
658    pub const fn occurrence_index(&self) -> usize {
659        self.occurrence_index
660    }
661
662    /// Returns the caller-defined identity of the retained project occurrence.
663    #[must_use]
664    pub const fn identity(&self) -> &IncludeIdentity {
665        &self.identity
666    }
667
668    /// Returns the effective definition source, when the definition had one.
669    #[must_use]
670    pub const fn source(&self) -> Option<SourceSpan> {
671        self.source
672    }
673
674    /// Returns the caller-defined label for [`Self::source`], when available.
675    #[must_use]
676    pub fn source_label(&self) -> Option<&str> {
677        self.source_label.as_deref()
678    }
679}
680
681/// One typed definition selected during include composition.
682#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct IncludeDefinition<T> {
684    name: String,
685    definition: T,
686    evidence: IncludeDefinitionEvidence,
687}
688
689impl<T> IncludeDefinition<T> {
690    /// Returns the Compose definition name.
691    #[must_use]
692    pub fn name(&self) -> &str {
693        &self.name
694    }
695
696    /// Returns the typed effective definition.
697    #[must_use]
698    pub const fn definition(&self) -> &T {
699        &self.definition
700    }
701
702    /// Returns occurrence and source evidence for this selected definition.
703    #[must_use]
704    pub const fn evidence(&self) -> &IncludeDefinitionEvidence {
705        &self.evidence
706    }
707}
708
709/// A same-name conflict that prevented an included definition from being imported.
710#[derive(Debug, Clone, PartialEq, Eq)]
711pub struct IncludeResourceConflict {
712    namespace: IncludeResourceNamespace,
713    name: String,
714    edge_index: usize,
715    incoming: IncludeDefinitionEvidence,
716    incumbent: IncludeDefinitionEvidence,
717}
718
719impl IncludeResourceConflict {
720    /// Returns the colliding Compose namespace.
721    #[must_use]
722    pub const fn namespace(&self) -> IncludeResourceNamespace {
723        self.namespace
724    }
725
726    /// Returns the shared definition name.
727    #[must_use]
728    pub fn name(&self) -> &str {
729        &self.name
730    }
731
732    /// Returns the include-edge index through which the incoming candidate was considered.
733    #[must_use]
734    pub const fn edge_index(&self) -> usize {
735        self.edge_index
736    }
737
738    /// Returns the child-side candidate that was not imported.
739    #[must_use]
740    pub const fn incoming(&self) -> &IncludeDefinitionEvidence {
741        &self.incoming
742    }
743
744    /// Returns the already selected parent-side candidate.
745    #[must_use]
746    pub const fn incumbent(&self) -> &IncludeDefinitionEvidence {
747        &self.incumbent
748    }
749}
750
751/// The typed definitions selected for one retained include occurrence.
752#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct IncludeComposition {
754    node_index: usize,
755    services: Vec<IncludeDefinition<ProjectService>>,
756    networks: Vec<IncludeDefinition<NetworkDefinition>>,
757    volumes: Vec<IncludeDefinition<VolumeDefinition>>,
758    configs: Vec<IncludeDefinition<ConfigDefinition>>,
759    secrets: Vec<IncludeDefinition<SecretDefinition>>,
760    models: Vec<IncludeDefinition<ModelDefinition>>,
761}
762
763impl IncludeComposition {
764    /// Returns the retained occurrence represented by this composition.
765    #[must_use]
766    pub const fn node_index(&self) -> usize {
767        self.node_index
768    }
769
770    /// Returns selected services in local-then-depth-first import order.
771    #[must_use]
772    pub fn services(&self) -> &[IncludeDefinition<ProjectService>] {
773        &self.services
774    }
775
776    /// Finds a selected service by name.
777    #[must_use]
778    pub fn service(&self, name: &str) -> Option<&IncludeDefinition<ProjectService>> {
779        self.services.iter().find(|definition| definition.name == name)
780    }
781
782    /// Returns selected networks in local-then-depth-first import order.
783    #[must_use]
784    pub fn networks(&self) -> &[IncludeDefinition<NetworkDefinition>] {
785        &self.networks
786    }
787
788    /// Finds a selected network by name.
789    #[must_use]
790    pub fn network(&self, name: &str) -> Option<&IncludeDefinition<NetworkDefinition>> {
791        self.networks.iter().find(|definition| definition.name == name)
792    }
793
794    /// Returns selected volumes in local-then-depth-first import order.
795    #[must_use]
796    pub fn volumes(&self) -> &[IncludeDefinition<VolumeDefinition>] {
797        &self.volumes
798    }
799
800    /// Finds a selected volume by name.
801    #[must_use]
802    pub fn volume(&self, name: &str) -> Option<&IncludeDefinition<VolumeDefinition>> {
803        self.volumes.iter().find(|definition| definition.name == name)
804    }
805
806    /// Returns selected configs in local-then-depth-first import order.
807    #[must_use]
808    pub fn configs(&self) -> &[IncludeDefinition<ConfigDefinition>] {
809        &self.configs
810    }
811
812    /// Finds a selected config by name.
813    #[must_use]
814    pub fn config(&self, name: &str) -> Option<&IncludeDefinition<ConfigDefinition>> {
815        self.configs.iter().find(|definition| definition.name == name)
816    }
817
818    /// Returns selected secrets in local-then-depth-first import order.
819    #[must_use]
820    pub fn secrets(&self) -> &[IncludeDefinition<SecretDefinition>] {
821        &self.secrets
822    }
823
824    /// Finds a selected secret by name.
825    #[must_use]
826    pub fn secret(&self, name: &str) -> Option<&IncludeDefinition<SecretDefinition>> {
827        self.secrets.iter().find(|definition| definition.name == name)
828    }
829
830    /// Returns selected individual model definitions in local-then-depth-first import order.
831    #[must_use]
832    pub fn models(&self) -> &[IncludeDefinition<ModelDefinition>] {
833        &self.models
834    }
835
836    /// Finds a selected model definition by name.
837    #[must_use]
838    pub fn model(&self, name: &str) -> Option<&IncludeDefinition<ModelDefinition>> {
839        self.models.iter().find(|definition| definition.name == name)
840    }
841}
842
843/// The I/O-free outcome of composing an [`IncludeResolution`].
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct IncludeCompositionResult {
846    compositions: Vec<IncludeComposition>,
847    diagnostics: Vec<Diagnostic>,
848    conflicts: Vec<IncludeResourceConflict>,
849}
850
851impl IncludeCompositionResult {
852    /// Returns the root composition, when the root occurrence was retained.
853    #[must_use]
854    pub fn root(&self) -> Option<&IncludeComposition> {
855        self.compositions.first()
856    }
857
858    /// Returns compositions indexed by [`IncludeNode::index`].
859    #[must_use]
860    pub fn compositions(&self) -> &[IncludeComposition] {
861        &self.compositions
862    }
863
864    /// Finds one retained occurrence's composition by node index.
865    #[must_use]
866    pub fn composition(&self, node_index: usize) -> Option<&IncludeComposition> {
867        self.compositions.get(node_index)
868    }
869
870    /// Returns traversal diagnostics followed by composition conflict warnings.
871    #[must_use]
872    pub fn diagnostics(&self) -> &[Diagnostic] {
873        &self.diagnostics
874    }
875
876    /// Returns every non-imported same-name candidate in deterministic import order.
877    #[must_use]
878    pub fn conflicts(&self) -> &[IncludeResourceConflict] {
879        &self.conflicts
880    }
881
882    /// Reports whether traversal and composition emitted no error diagnostic.
883    #[must_use]
884    pub fn is_valid(&self) -> bool {
885        self.diagnostics
886            .iter()
887            .all(|diagnostic| diagnostic.severity() != Severity::Error)
888    }
889
890    /// Reports whether traversal completed without errors and no include resource conflicted.
891    #[must_use]
892    pub fn is_complete(&self) -> bool {
893        self.is_valid() && self.conflicts.is_empty()
894    }
895}
896
897/// A partial or complete depth-first traversal of caller-authorized Compose includes.
898#[derive(Debug, Clone, PartialEq, Eq)]
899pub struct IncludeResolution {
900    root: IncludeIdentity,
901    nodes: Vec<IncludeNode>,
902    edges: Vec<IncludeEdge>,
903    requests: Vec<IncludeRequest>,
904    diagnostics: Vec<Diagnostic>,
905}
906
907impl IncludeResolution {
908    /// Loads and traverses one root project in depth-first effective-include order.
909    ///
910    /// Each node uses the established ordered loader, then an authored no-interpolation merge and
911    /// native project view before child declarations are considered. The returned graph remains
912    /// inspectable after loader denials, loader failures, malformed declarations, duplicate source
913    /// identifiers, cycles, or malformed project inputs. Resources from children are never merged
914    /// or imported into their parents.
915    #[must_use]
916    pub fn load(root: IncludedProjectInput, loader: &dyn IncludeLoader) -> Self {
917        let root_identity = root.identity().clone();
918        let mut resolution = Self {
919            root: root_identity.clone(),
920            nodes: Vec::new(),
921            edges: Vec::new(),
922            requests: Vec::new(),
923            diagnostics: Vec::new(),
924        };
925        let mut active = Vec::new();
926        let mut source_origins = BTreeMap::new();
927        visit_project(root, loader, &mut active, &mut source_origins, &mut resolution, None);
928        resolution
929    }
930
931    /// Returns the root identity selected by the caller.
932    #[must_use]
933    pub const fn root(&self) -> &IncludeIdentity {
934        &self.root
935    }
936
937    /// Returns visited project occurrences in depth-first traversal order.
938    #[must_use]
939    pub fn nodes(&self) -> &[IncludeNode] {
940        &self.nodes
941    }
942
943    /// Returns successful loader edges in request order.
944    #[must_use]
945    pub fn edges(&self) -> &[IncludeEdge] {
946        &self.edges
947    }
948
949    /// Returns every effective declaration submitted to the caller's loader in order.
950    #[must_use]
951    pub fn requests(&self) -> &[IncludeRequest] {
952        &self.requests
953    }
954
955    /// Returns diagnostics from every reached loading, merge, view, and traversal boundary.
956    #[must_use]
957    pub fn diagnostics(&self) -> &[Diagnostic] {
958        &self.diagnostics
959    }
960
961    /// Composes already loaded child occurrences without authorizing further loading or performing I/O.
962    ///
963    /// The pass first composes every non-cycle child recursively, then imports absent definitions
964    /// into each parent after that parent's ordinary multi-file merge. Same-namespace same-name
965    /// candidates remain separate conflict records and never enter ordinary Compose merge rules.
966    #[must_use]
967    pub fn compose(&self) -> IncludeCompositionResult {
968        let mut diagnostics = self.diagnostics.clone();
969        let mut conflicts = Vec::new();
970        let mut compositions = vec![None; self.nodes.len()];
971        for node_index in 0..self.nodes.len() {
972            let _ = compose_node(node_index, self, &mut compositions, &mut diagnostics, &mut conflicts);
973        }
974        IncludeCompositionResult {
975            compositions: compositions.into_iter().flatten().collect(),
976            diagnostics,
977            conflicts,
978        }
979    }
980
981    /// Plans effective include project directories through an explicit caller-owned resolver.
982    ///
983    /// Only explicit `project_directory` declarations invoke the resolver. Root and child defaults
984    /// reuse the caller-supplied first-document directories already retained by the traversal.
985    #[must_use]
986    pub fn plan_project_directories(
987        &self,
988        resolver: &dyn IncludeProjectDirectoryResolver,
989    ) -> IncludeProjectDirectoryPlan {
990        include_paths::plan_project_directories(self, resolver)
991    }
992
993    /// Reports whether traversal reached no error diagnostic.
994    #[must_use]
995    pub fn is_valid(&self) -> bool {
996        self.diagnostics
997            .iter()
998            .all(|diagnostic| diagnostic.severity() != Severity::Error)
999    }
1000}
1001
1002fn visit_project(
1003    input: IncludedProjectInput,
1004    include_loader: &dyn IncludeLoader,
1005    active: &mut Vec<(IncludeIdentity, usize)>,
1006    source_origins: &mut BTreeMap<SourceId, DocumentOrigin>,
1007    resolution: &mut IncludeResolution,
1008    request_span: Option<SourceSpan>,
1009) -> usize {
1010    if input.documents().is_empty() {
1011        resolution.diagnostics.push(include_diagnostic(
1012            if request_span.is_some() {
1013                INCLUDE_EMPTY_RESULT
1014            } else {
1015                INCLUDE_EMPTY_ROOT
1016            },
1017            "included Compose project contains no documents",
1018            request_span,
1019        ));
1020        retain_unloaded_node(input, resolution);
1021        return resolution.nodes.len() - 1;
1022    }
1023
1024    if !register_source_ids(input.documents(), source_origins, resolution, request_span) {
1025        retain_unloaded_node(input, resolution);
1026        return resolution.nodes.len() - 1;
1027    }
1028
1029    let Some(prepared) = load_include_node(input, resolution, request_span) else {
1030        return resolution.nodes.len() - 1;
1031    };
1032    let PreparedIncludeNode {
1033        identity,
1034        base_directory,
1035        items,
1036        node_index,
1037    } = prepared;
1038    active.push((identity.clone(), node_index));
1039
1040    visit_includes(
1041        items,
1042        &identity,
1043        &base_directory,
1044        node_index,
1045        include_loader,
1046        (active, source_origins, resolution),
1047    );
1048
1049    let popped = active.pop();
1050    debug_assert_eq!(popped.as_ref().map(|(identity, _)| identity), Some(&identity));
1051    node_index
1052}
1053
1054fn visit_includes(
1055    items: Vec<IncludeItem>,
1056    identity: &IncludeIdentity,
1057    base_directory: &Path,
1058    node_index: usize,
1059    include_loader: &dyn IncludeLoader,
1060    state: (
1061        &mut Vec<(IncludeIdentity, usize)>,
1062        &mut BTreeMap<SourceId, DocumentOrigin>,
1063        &mut IncludeResolution,
1064    ),
1065) {
1066    let (active, source_origins, resolution) = state;
1067    for item in items {
1068        let declaration_span = include_item_span(&item);
1069        let declaration_origin = declaration_span.and_then(|span| {
1070            resolution.nodes[node_index]
1071                .inputs()
1072                .documents()
1073                .iter()
1074                .find(|document| document.source_id() == span.source_id())
1075                .map(|document| document.origin().clone())
1076        });
1077        let Some(request) =
1078            IncludeRequest::from_item(identity.clone(), base_directory.to_path_buf(), declaration_origin, item)
1079        else {
1080            resolution.diagnostics.push(include_diagnostic(
1081                INCLUDE_UNMODELED,
1082                "effective include declaration is malformed or contains unmodeled members",
1083                declaration_span,
1084            ));
1085            continue;
1086        };
1087        let request_index = resolution.requests.len();
1088        let request_span = request.declaration_span();
1089        let child = match include_loader.load_include(&request) {
1090            Ok(child) => child,
1091            Err(IncludeLoadError::Denied(_)) => {
1092                resolution.diagnostics.push(include_diagnostic(
1093                    INCLUDE_LOADER_DENIED,
1094                    "include loader denied this declaration",
1095                    Some(request_span),
1096                ));
1097                resolution.requests.push(request);
1098                continue;
1099            }
1100            Err(IncludeLoadError::Failed(_)) => {
1101                resolution.diagnostics.push(include_diagnostic(
1102                    INCLUDE_LOADER_FAILED,
1103                    "include loader failed to supply this declaration",
1104                    Some(request_span),
1105                ));
1106                resolution.requests.push(request);
1107                continue;
1108            }
1109        };
1110        let child_identity = child.identity().clone();
1111        resolution.requests.push(request);
1112        let edge_index = resolution.edges.len();
1113        if let Some((_, active_node_index)) = active
1114            .iter()
1115            .find(|(active_identity, _)| active_identity == &child_identity)
1116        {
1117            resolution.edges.push(IncludeEdge {
1118                parent: identity.clone(),
1119                child: child_identity,
1120                parent_node_index: node_index,
1121                child_node_index: *active_node_index,
1122                request_index,
1123                cycle: true,
1124            });
1125            resolution.diagnostics.push(
1126                include_diagnostic(
1127                    INCLUDE_CYCLE,
1128                    "include identity is already active in this traversal",
1129                    Some(request_span),
1130                )
1131                .with_note("the caller controls identity canonicalization"),
1132            );
1133            continue;
1134        }
1135        let child_node_index = visit_project(
1136            child,
1137            include_loader,
1138            active,
1139            source_origins,
1140            resolution,
1141            Some(request_span),
1142        );
1143        resolution.edges.insert(
1144            edge_index,
1145            IncludeEdge {
1146                parent: identity.clone(),
1147                child: child_identity,
1148                parent_node_index: node_index,
1149                child_node_index,
1150                request_index,
1151                cycle: false,
1152            },
1153        );
1154    }
1155}
1156
1157struct PreparedIncludeNode {
1158    identity: IncludeIdentity,
1159    base_directory: PathBuf,
1160    items: Vec<IncludeItem>,
1161    node_index: usize,
1162}
1163
1164fn load_include_node(
1165    input: IncludedProjectInput,
1166    resolution: &mut IncludeResolution,
1167    request_span: Option<SourceSpan>,
1168) -> Option<PreparedIncludeNode> {
1169    let identity = input.identity().clone();
1170    let origins = input
1171        .documents()
1172        .iter()
1173        .map(|document| document.origin().clone())
1174        .collect();
1175    let Ok(project) = LoadedProject::load(input.documents().iter().cloned()) else {
1176        resolution.diagnostics.push(include_diagnostic(
1177            INCLUDE_PROJECT_LOAD_FAILED,
1178            "included Compose project could not enter the ordered loader",
1179            request_span,
1180        ));
1181        resolution.nodes.push(IncludeNode {
1182            index: resolution.nodes.len(),
1183            identity,
1184            inputs: input,
1185            origins,
1186            loaded_project: None,
1187            merged_project: None,
1188            project_view: None,
1189        });
1190        return None;
1191    };
1192
1193    let merge = merge_project(&project, None);
1194    resolution.diagnostics.extend(merge.diagnostics().iter().cloned());
1195    let merged_project = merge.project().cloned();
1196    let (project_view, view_diagnostics) = match merged_project.as_ref() {
1197        Some(merged) => build_project_view(merged, None).into_parts(),
1198        None => (None, Vec::new()),
1199    };
1200    resolution.diagnostics.extend(view_diagnostics);
1201    let base_directory = project.base_directory().to_path_buf();
1202    let items = project_view
1203        .as_ref()
1204        .and_then(ProjectView::include)
1205        .map(|includes| includes.value().items().to_vec())
1206        .unwrap_or_default();
1207    let node_index = resolution.nodes.len();
1208    resolution.nodes.push(IncludeNode {
1209        index: node_index,
1210        identity: identity.clone(),
1211        inputs: input,
1212        origins,
1213        loaded_project: Some(project),
1214        merged_project,
1215        project_view,
1216    });
1217    Some(PreparedIncludeNode {
1218        identity,
1219        base_directory,
1220        items,
1221        node_index,
1222    })
1223}
1224
1225fn retain_unloaded_node(input: IncludedProjectInput, resolution: &mut IncludeResolution) {
1226    let origins = input
1227        .documents()
1228        .iter()
1229        .map(|document| document.origin().clone())
1230        .collect();
1231    resolution.nodes.push(IncludeNode {
1232        index: resolution.nodes.len(),
1233        identity: input.identity().clone(),
1234        inputs: input,
1235        origins,
1236        loaded_project: None,
1237        merged_project: None,
1238        project_view: None,
1239    });
1240}
1241
1242fn register_source_ids(
1243    documents: &[DocumentInput],
1244    source_origins: &mut BTreeMap<SourceId, DocumentOrigin>,
1245    resolution: &mut IncludeResolution,
1246    request_span: Option<SourceSpan>,
1247) -> bool {
1248    let mut accepted = true;
1249    for document in documents {
1250        match source_origins.entry(document.source_id()) {
1251            std::collections::btree_map::Entry::Occupied(_) => {
1252                resolution.diagnostics.push(include_diagnostic(
1253                    INCLUDE_DUPLICATE_SOURCE_ID,
1254                    "include traversal reused a caller-managed source identifier",
1255                    request_span,
1256                ));
1257                accepted = false;
1258            }
1259            std::collections::btree_map::Entry::Vacant(entry) => {
1260                entry.insert(document.origin().clone());
1261            }
1262        }
1263    }
1264    accepted
1265}
1266
1267fn include_item_span(item: &IncludeItem) -> Option<SourceSpan> {
1268    match item {
1269        IncludeItem::Short(path) => Some(path.span()),
1270        IncludeItem::Long(include) => Some(include.span()),
1271        IncludeItem::Unmodeled => None,
1272    }
1273}
1274
1275fn include_diagnostic(code: DiagnosticCode, message: &'static str, span: Option<SourceSpan>) -> Diagnostic {
1276    let diagnostic = Diagnostic::new(code, Severity::Error, message);
1277    match span {
1278        Some(span) => diagnostic.with_label(DiagnosticLabel::primary(span, "include declaration")),
1279        None => diagnostic,
1280    }
1281}
1282
1283fn compose_node(
1284    node_index: usize,
1285    resolution: &IncludeResolution,
1286    compositions: &mut [Option<IncludeComposition>],
1287    diagnostics: &mut Vec<Diagnostic>,
1288    conflicts: &mut Vec<IncludeResourceConflict>,
1289) -> IncludeComposition {
1290    if let Some(composition) = &compositions[node_index] {
1291        return composition.clone();
1292    }
1293
1294    let mut composition = local_composition(node_index, resolution);
1295    for (edge_index, edge) in resolution.edges.iter().enumerate() {
1296        if edge.parent_node_index != node_index || edge.cycle {
1297            continue;
1298        }
1299        let child = compose_node(edge.child_node_index, resolution, compositions, diagnostics, conflicts);
1300        import_definitions(
1301            IncludeResourceNamespace::Services,
1302            edge_index,
1303            &mut composition.services,
1304            &child.services,
1305            edge,
1306            diagnostics,
1307            conflicts,
1308        );
1309        import_definitions(
1310            IncludeResourceNamespace::Networks,
1311            edge_index,
1312            &mut composition.networks,
1313            &child.networks,
1314            edge,
1315            diagnostics,
1316            conflicts,
1317        );
1318        import_definitions(
1319            IncludeResourceNamespace::Volumes,
1320            edge_index,
1321            &mut composition.volumes,
1322            &child.volumes,
1323            edge,
1324            diagnostics,
1325            conflicts,
1326        );
1327        import_definitions(
1328            IncludeResourceNamespace::Configs,
1329            edge_index,
1330            &mut composition.configs,
1331            &child.configs,
1332            edge,
1333            diagnostics,
1334            conflicts,
1335        );
1336        import_definitions(
1337            IncludeResourceNamespace::Secrets,
1338            edge_index,
1339            &mut composition.secrets,
1340            &child.secrets,
1341            edge,
1342            diagnostics,
1343            conflicts,
1344        );
1345        import_definitions(
1346            IncludeResourceNamespace::Models,
1347            edge_index,
1348            &mut composition.models,
1349            &child.models,
1350            edge,
1351            diagnostics,
1352            conflicts,
1353        );
1354    }
1355    compositions[node_index] = Some(composition.clone());
1356    composition
1357}
1358
1359fn local_composition(node_index: usize, resolution: &IncludeResolution) -> IncludeComposition {
1360    let node = &resolution.nodes[node_index];
1361    let Some(view) = node.project_view() else {
1362        return IncludeComposition {
1363            node_index,
1364            services: Vec::new(),
1365            networks: Vec::new(),
1366            volumes: Vec::new(),
1367            configs: Vec::new(),
1368            secrets: Vec::new(),
1369            models: Vec::new(),
1370        };
1371    };
1372
1373    IncludeComposition {
1374        node_index,
1375        services: view
1376            .services()
1377            .iter()
1378            .map(|service| {
1379                local_definition(
1380                    service.name().value(),
1381                    service.clone(),
1382                    node_index,
1383                    node,
1384                    service
1385                        .provenance()
1386                        .effective_source()
1387                        .or_else(|| service.name().effective_source()),
1388                )
1389            })
1390            .collect(),
1391        networks: local_resources(view.networks(), node_index, node),
1392        volumes: local_resources(view.volumes(), node_index, node),
1393        configs: local_resources(view.configs(), node_index, node),
1394        secrets: local_resources(view.secrets(), node_index, node),
1395        models: view
1396            .models()
1397            .into_iter()
1398            .flat_map(|models| models.value().definitions())
1399            .map(|model| local_definition(model.key().value(), model.clone(), node_index, node, Some(model.span())))
1400            .collect(),
1401    }
1402}
1403
1404fn local_resources<T: Clone>(
1405    resources: &[ProjectResource<T>],
1406    node_index: usize,
1407    node: &IncludeNode,
1408) -> Vec<IncludeDefinition<T>> {
1409    resources
1410        .iter()
1411        .map(|resource| {
1412            local_definition(
1413                resource.name().value(),
1414                resource.definition().value().clone(),
1415                node_index,
1416                node,
1417                resource
1418                    .definition()
1419                    .effective_source()
1420                    .or_else(|| resource.name().effective_source()),
1421            )
1422        })
1423        .collect()
1424}
1425
1426fn local_definition<T>(
1427    name: &str,
1428    definition: T,
1429    node_index: usize,
1430    node: &IncludeNode,
1431    source: Option<SourceSpan>,
1432) -> IncludeDefinition<T> {
1433    let source_label = source.and_then(|source| {
1434        node.origins()
1435            .iter()
1436            .zip(node.inputs().documents())
1437            .find(|(_, input)| input.source_id() == source.source_id())
1438            .map(|(origin, _)| origin.label().to_owned())
1439    });
1440    IncludeDefinition {
1441        name: name.to_owned(),
1442        definition,
1443        evidence: IncludeDefinitionEvidence {
1444            occurrence_index: node_index,
1445            identity: node.identity().clone(),
1446            source,
1447            source_label,
1448        },
1449    }
1450}
1451
1452fn import_definitions<T: Clone>(
1453    namespace: IncludeResourceNamespace,
1454    edge_index: usize,
1455    parent: &mut Vec<IncludeDefinition<T>>,
1456    child: &[IncludeDefinition<T>],
1457    edge: &IncludeEdge,
1458    diagnostics: &mut Vec<Diagnostic>,
1459    conflicts: &mut Vec<IncludeResourceConflict>,
1460) {
1461    for incoming in child {
1462        if let Some(incumbent) = parent.iter().find(|candidate| candidate.name == incoming.name) {
1463            let conflict = IncludeResourceConflict {
1464                namespace,
1465                name: incoming.name.clone(),
1466                edge_index,
1467                incoming: incoming.evidence.clone(),
1468                incumbent: incumbent.evidence.clone(),
1469            };
1470            diagnostics.push(include_resource_conflict_diagnostic(&conflict, edge));
1471            conflicts.push(conflict);
1472        } else {
1473            parent.push(incoming.clone());
1474        }
1475    }
1476}
1477
1478fn include_resource_conflict_diagnostic(conflict: &IncludeResourceConflict, edge: &IncludeEdge) -> Diagnostic {
1479    let mut diagnostic = Diagnostic::new(
1480        INCLUDE_RESOURCE_CONFLICT,
1481        Severity::Warning,
1482        format!(
1483            "included {} `{}` conflicts with an already selected definition",
1484            conflict.namespace.as_str(),
1485            conflict.name
1486        ),
1487    );
1488    if let Some(source) = conflict.incoming.source {
1489        diagnostic = diagnostic.with_label(DiagnosticLabel::primary(
1490            source,
1491            format!(
1492                "incoming {} `{}` from {}",
1493                conflict.namespace.as_str(),
1494                conflict.name,
1495                conflict.incoming.source_label().unwrap_or("included occurrence")
1496            ),
1497        ));
1498    }
1499    if let Some(source) = conflict.incumbent.source {
1500        diagnostic = diagnostic.with_label(DiagnosticLabel::secondary(
1501            source,
1502            format!(
1503                "incumbent {} `{}` from {}",
1504                conflict.namespace.as_str(),
1505                conflict.name,
1506                conflict.incumbent.source_label().unwrap_or("parent occurrence")
1507            ),
1508        ));
1509    }
1510    diagnostic.with_note(format!(
1511        "declaring include edge #{}, occurrence #{} ({}) -> #{} ({})",
1512        conflict.edge_index, edge.parent_node_index, edge.parent, edge.child_node_index, edge.child
1513    ))
1514}
1515
1516/// Per-file interpolation overlays for one loaded project.
1517#[derive(Debug, Clone, PartialEq, Eq)]
1518pub struct ProjectInterpolation {
1519    documents: Vec<DocumentInterpolation>,
1520    diagnostics: Vec<Diagnostic>,
1521}
1522
1523impl ProjectInterpolation {
1524    /// Returns document overlays in file order.
1525    #[must_use]
1526    pub fn documents(&self) -> &[DocumentInterpolation] {
1527        &self.documents
1528    }
1529
1530    /// Finds a document overlay by source identifier.
1531    #[must_use]
1532    pub fn document(&self, source_id: SourceId) -> Option<&DocumentInterpolation> {
1533        self.documents.iter().find(|document| document.source_id() == source_id)
1534    }
1535
1536    /// Returns aggregated interpolation diagnostics in file order.
1537    #[must_use]
1538    pub fn diagnostics(&self) -> &[Diagnostic] {
1539        &self.diagnostics
1540    }
1541
1542    /// Reports whether interpolation emitted no error diagnostics.
1543    #[must_use]
1544    pub fn is_valid(&self) -> bool {
1545        self.diagnostics
1546            .iter()
1547            .all(|diagnostic| diagnostic.severity() != Severity::Error)
1548    }
1549}
1550
1551/// A fatal project-loading failure.
1552#[derive(Debug, Clone, PartialEq, Eq)]
1553pub enum ProjectLoadError {
1554    /// At least one Compose document is required to establish ordering and a base directory.
1555    EmptyProject,
1556    /// Two inputs reused a caller-managed source identifier.
1557    DuplicateSourceId {
1558        /// The reused identifier.
1559        source_id: SourceId,
1560        /// The first document's display label.
1561        first_origin: String,
1562        /// The later document's display label.
1563        duplicate_origin: String,
1564    },
1565    /// A document exceeded the syntax tree's byte-offset capacity.
1566    SyntaxCapacity {
1567        /// The rejected document's origin.
1568        origin: DocumentOrigin,
1569        /// The underlying parser capacity error.
1570        error: SyntaxParseError,
1571    },
1572}
1573
1574impl fmt::Display for ProjectLoadError {
1575    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1576        match self {
1577            Self::EmptyProject => formatter.write_str("a Compose project requires at least one document"),
1578            Self::DuplicateSourceId {
1579                source_id,
1580                first_origin,
1581                duplicate_origin,
1582            } => write!(
1583                formatter,
1584                "{source_id} is assigned to both `{first_origin}` and `{duplicate_origin}`"
1585            ),
1586            Self::SyntaxCapacity { origin, error } => write!(formatter, "{}: {error}", origin.label),
1587        }
1588    }
1589}
1590
1591impl Error for ProjectLoadError {
1592    fn source(&self) -> Option<&(dyn Error + 'static)> {
1593        match self {
1594            Self::SyntaxCapacity { error, .. } => Some(error),
1595            Self::EmptyProject | Self::DuplicateSourceId { .. } => None,
1596        }
1597    }
1598}