Skip to main content

poolster_core/
engine.rs

1//! Typed package composition for native and community generators.
2//!
3//! Contracts are package-local, immutable once published, and looked up by
4//! Rust type. Language implementations own package settings and shared state.
5
6use std::{
7    any::{Any, TypeId},
8    collections::{BTreeMap, BTreeSet, HashMap},
9    marker::PhantomData,
10    path::{Path, PathBuf},
11    sync::atomic::{AtomicU64, Ordering},
12};
13
14use anyhow::{Context, Result, bail};
15
16use crate::{
17    Api, GeneratedFile, GeneratedTree, SdkClientStyle, SdkSemantics, SecuritySchemeCatalog,
18    analyze_sdk_semantics,
19};
20
21/// Optional shared settings. Unset values remain unset until a plugin applies
22/// its defaults; false/flat/empty explicit values are never treated as absent.
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct Common {
25    pub client_name: Option<String>,
26    pub client_style: Option<SdkClientStyle>,
27    pub package_version: Option<String>,
28    pub layout: Option<crate::SourceLayout>,
29}
30
31impl Common {
32    pub fn client_name(mut self, name: impl Into<String>) -> Self {
33        self.client_name = Some(name.into());
34        self
35    }
36    pub fn client_style(mut self, style: SdkClientStyle) -> Self {
37        self.client_style = Some(style);
38        self
39    }
40    pub fn package_version(mut self, version: impl Into<String>) -> Self {
41        self.package_version = Some(version.into());
42        self
43    }
44    pub fn layout(mut self, layout: crate::SourceLayout) -> Self {
45        self.layout = Some(layout);
46        self
47    }
48    pub fn overlay(&self, local: &Self) -> Self {
49        Self {
50            client_name: local
51                .client_name
52                .clone()
53                .or_else(|| self.client_name.clone()),
54            client_style: local.client_style.or(self.client_style),
55            layout: local.layout.clone().or_else(|| self.layout.clone()),
56            package_version: local
57                .package_version
58                .clone()
59                .or_else(|| self.package_version.clone()),
60        }
61    }
62}
63
64/// Process-local identity. Never used in paths, output bytes, or ordering.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub struct InstanceId(u64);
67
68impl InstanceId {
69    fn fresh() -> Self {
70        static NEXT: AtomicU64 = AtomicU64::new(1);
71        Self(NEXT.fetch_add(1, Ordering::Relaxed))
72    }
73}
74
75/// Metadata belongs to an instance, not to a generator kind. Not Clone:
76/// construct another plugin to obtain a new identity.
77#[derive(Debug)]
78pub struct Meta {
79    id: InstanceId,
80    label: Option<String>,
81}
82
83impl Default for Meta {
84    fn default() -> Self {
85        Self {
86            id: InstanceId::fresh(),
87            label: None,
88        }
89    }
90}
91
92impl Meta {
93    pub fn new() -> Self {
94        Self::default()
95    }
96    pub fn label(mut self, label: impl Into<String>) -> Self {
97        self.label = Some(label.into());
98        self
99    }
100    pub fn handle<C: Contract>(&self) -> Handle<C> {
101        Handle {
102            id: self.id,
103            marker: PhantomData,
104        }
105    }
106}
107
108pub trait Contract: Any + Send + Sync {
109    const NAME: &'static str;
110}
111
112pub struct Handle<C: Contract> {
113    id: InstanceId,
114    marker: PhantomData<fn() -> C>,
115}
116impl<C: Contract> Copy for Handle<C> {}
117impl<C: Contract> Clone for Handle<C> {
118    fn clone(&self) -> Self {
119        *self
120    }
121}
122
123#[derive(Clone, Copy)]
124pub struct Provision {
125    type_id: TypeId,
126    name: &'static str,
127}
128impl Provision {
129    pub fn of<C: Contract>() -> Self {
130        Self {
131            type_id: TypeId::of::<C>(),
132            name: C::NAME,
133        }
134    }
135}
136
137pub struct Requirement {
138    contract: Provision,
139    provider: Option<InstanceId>,
140    optional: bool,
141}
142impl Requirement {
143    pub fn on<C: Contract>(handle: Option<Handle<C>>) -> Self {
144        Self {
145            contract: Provision::of::<C>(),
146            provider: handle.map(|h| h.id),
147            optional: false,
148        }
149    }
150    /// An absent automatic provider is allowed. Explicit missing handles and
151    /// ambiguous optional providers still fail. Optional edges also form cycles.
152    pub fn optional(mut self) -> Self {
153        self.optional = true;
154        self
155    }
156}
157
158pub trait Language: Send + Sync + Sized + 'static {
159    const NAME: &'static str;
160    type Settings: Default + Send + Sync;
161    type Workspace: Default + Send;
162    fn finalize(_cx: &mut FinalizeContext<'_, Self>) -> Result<()> {
163        Ok(())
164    }
165    /// Adapt assembled generated files for the target runtime after middleware
166    /// bundling and before explicit author source overlays. Default is unchanged.
167    fn finalize_files(_tree: &mut GeneratedTree) -> Result<()> {
168        Ok(())
169    }
170    /// Bundle SDK-author runtime middleware and register it in generated clients.
171    /// Called with package-relative output after finalization and post plugins.
172    fn bundle_middleware(
173        _tree: &mut GeneratedTree,
174        _middleware: &[crate::customization::BundledMiddleware],
175    ) -> Result<()> {
176        bail!("{} does not support bundled runtime middleware", Self::NAME)
177    }
178}
179
180/// A plugin's point in the package generation lifecycle.
181///
182/// Generation plugins run first. Post plugins run after every generation
183/// plugin and the language finalizer, so they can add derived artifacts after
184/// a package's normal output has been assembled.
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub enum PluginPhase {
187    #[default]
188    Generate,
189    Post,
190}
191
192/// Shorthand for selecting a plugin execution phase.
193///
194/// [`Enforce::Post`] is intended for plugins that consume or augment the
195/// completed generated package. Override [`Plugin::phase`] when selecting a
196/// phase dynamically is more appropriate than this shorthand.
197#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
198pub enum Enforce {
199    #[default]
200    Default,
201    Post,
202}
203
204impl Enforce {
205    pub const fn phase(self) -> PluginPhase {
206        match self {
207            Self::Default => PluginPhase::Generate,
208            Self::Post => PluginPhase::Post,
209        }
210    }
211}
212
213pub trait Plugin<L: Language>: Send + Sync + 'static {
214    fn kind(&self) -> &'static str;
215    fn meta(&self) -> &Meta;
216    /// Selects the plugin lifecycle phase. The default honors [`Self::enforce`]
217    /// so a post plugin only needs to override that shorthand.
218    fn phase(&self) -> PluginPhase {
219        self.enforce().phase()
220    }
221    /// Phase shorthand. Existing plugins remain generation plugins.
222    fn enforce(&self) -> Enforce {
223        Enforce::Default
224    }
225    /// Opt in when this plugin reads protocol contracts instead of the legacy HTTP context.
226    /// Native generation skips packages containing HTTP-only plugins before loading sources.
227    fn supports_native_input(&self) -> bool {
228        false
229    }
230    fn requires(&self) -> Vec<Requirement> {
231        Vec::new()
232    }
233    fn provides(&self) -> Vec<Provision> {
234        Vec::new()
235    }
236    fn generate(&self, cx: &mut PluginContext<'_, L>) -> Result<()>;
237}
238
239type Contracts = HashMap<(InstanceId, TypeId), Box<dyn Any + Send + Sync>>;
240type Bindings = HashMap<TypeId, Option<InstanceId>>;
241
242pub struct Inputs<'a> {
243    contracts: &'a Contracts,
244    bindings: &'a Bindings,
245}
246impl Inputs<'_> {
247    pub fn get<C: Contract>(&self) -> Result<&C> {
248        self.optional::<C>()?
249            .with_context(|| format!("no provider bound for {}", C::NAME))
250    }
251    /// Fails for undeclared reads, instead of silently returning None.
252    pub fn optional<C: Contract>(&self) -> Result<Option<&C>> {
253        let provider = self
254            .bindings
255            .get(&TypeId::of::<C>())
256            .with_context(|| format!("undeclared contract read: {}", C::NAME))?;
257        provider
258            .map(|id| {
259                self.contracts
260                    .get(&(id, TypeId::of::<C>()))
261                    .and_then(|value| value.downcast_ref::<C>())
262                    .with_context(|| format!("provider did not publish {}", C::NAME))
263            })
264            .transpose()
265    }
266}
267
268/// Package-relative emitter. Ownership is retained for collision diagnostics.
269pub struct Emitter<'a> {
270    tree: &'a mut GeneratedTree,
271    owners: &'a mut BTreeMap<PathBuf, String>,
272    owner: String,
273}
274impl Emitter<'_> {
275    pub fn emit(&mut self, file: GeneratedFile) -> Result<()> {
276        self.insert(file, false)
277    }
278    pub fn emit_custom(&mut self, file: GeneratedFile) -> Result<()> {
279        self.insert(file, true)
280    }
281    fn insert(&mut self, file: GeneratedFile, custom: bool) -> Result<()> {
282        // Normalize lexical aliases before collision detection.
283        let path = checked_path(&file.path)?;
284        if let Some(previous) = self.owners.get(&path) {
285            bail!(
286                "{} and {} both emit {}",
287                previous,
288                self.owner,
289                path.display()
290            );
291        }
292        let file = GeneratedFile::new(&path, file.contents)?;
293        if custom {
294            self.tree.insert_custom(file)?;
295        } else {
296            self.tree.insert(file)?;
297        }
298        self.tree.set_owner(&path, self.owner.clone())?;
299        self.owners.insert(path, self.owner.clone());
300        Ok(())
301    }
302    pub fn append(&mut self, tree: GeneratedTree) -> Result<()> {
303        for (file, custom, owner) in tree.into_owned_files() {
304            let path = file.path.clone();
305            self.insert(file, custom)?;
306            if let Some(owner) = owner {
307                self.tree.set_owner(path, owner)?;
308            }
309        }
310        Ok(())
311    }
312
313    /// Adapts a renderer with its own output prefix while retaining create-once files.
314    pub fn append_from(&mut self, tree: GeneratedTree, prefix: &Path) -> Result<()> {
315        for (file, custom, owner) in tree.into_owned_files() {
316            let relative = file.path.strip_prefix(prefix).with_context(|| {
317                format!(
318                    "renderer output {} is outside {}",
319                    file.path.display(),
320                    prefix.display()
321                )
322            })?;
323            let relative = relative.to_owned();
324            self.insert(GeneratedFile::new(&relative, file.contents)?, custom)?;
325            if let Some(owner) = owner {
326                self.tree.set_owner(relative, owner)?;
327            }
328        }
329        Ok(())
330    }
331}
332
333pub struct PluginContext<'a, L: Language> {
334    pub api: &'a Api,
335    pub semantics: &'a SdkSemantics,
336    pub security_schemes: Option<&'a SecuritySchemeCatalog>,
337    pub common: &'a Common,
338    pub settings: &'a L::Settings,
339    pub inputs: Inputs<'a>,
340    pub workspace: &'a mut L::Workspace,
341    pub files: Emitter<'a>,
342    publications: &'a mut HashMap<TypeId, Box<dyn Any + Send + Sync>>,
343    declared: &'a [Provision],
344}
345impl<L: Language> PluginContext<'_, L> {
346    pub fn publish<C: Contract>(&mut self, value: C) -> Result<()> {
347        let key = TypeId::of::<C>();
348        if !self.declared.iter().any(|p| p.type_id == key) {
349            bail!("undeclared contract publication: {}", C::NAME);
350        }
351        if self.publications.contains_key(&key) {
352            bail!("{} published twice", C::NAME);
353        }
354        self.publications.insert(key, Box::new(value));
355        Ok(())
356    }
357}
358
359pub struct FinalizeContext<'a, L: Language> {
360    pub api: &'a Api,
361    pub common: &'a Common,
362    pub settings: &'a L::Settings,
363    pub workspace: &'a mut L::Workspace,
364    pub files: Emitter<'a>,
365}
366
367pub struct Package<L: Language> {
368    dir: String,
369    settings: L::Settings,
370    common: Common,
371    plugins: Vec<Box<dyn Plugin<L>>>,
372    customizations: Vec<crate::customization::CodeCustomization>,
373    middleware: Vec<crate::customization::BundledMiddleware>,
374    idempotency: crate::idempotency::IdempotencyConfig,
375}
376
377impl<L: Language> Package<L> {
378    pub fn new(dir: impl Into<String>) -> Self {
379        Self {
380            dir: dir.into(),
381            settings: Default::default(),
382            common: Default::default(),
383            plugins: vec![],
384            customizations: vec![],
385            middleware: vec![],
386            idempotency: Default::default(),
387        }
388    }
389    pub fn with(mut self, plugin: impl Plugin<L>) -> Self {
390        self.plugins.push(Box::new(plugin));
391        self
392    }
393    /// Apply an explicit SDK-author source overlay after all plugins/finalizers.
394    pub fn customize(mut self, code: crate::customization::CodeCustomization) -> Self {
395        self.customizations.push(code);
396        self
397    }
398    /// Ship SDK-author runtime middleware enabled by default in this package.
399    pub fn middleware(mut self, middleware: crate::customization::BundledMiddleware) -> Self {
400        self.middleware.push(middleware);
401        self
402    }
403    /// Resolve explicit idempotency policy independently for this package.
404    pub fn idempotency(mut self, config: crate::idempotency::IdempotencyConfig) -> Self {
405        self.idempotency = config;
406        self
407    }
408    pub fn common(mut self, common: Common) -> Self {
409        self.common = common;
410        self
411    }
412    pub fn settings(mut self, settings: L::Settings) -> Self {
413        self.settings = settings;
414        self
415    }
416    pub fn settings_mut(&mut self) -> &mut L::Settings {
417        &mut self.settings
418    }
419}
420
421struct Plan {
422    order: Vec<usize>,
423    phases: Vec<PluginPhase>,
424    bindings: Vec<Bindings>,
425    provisions: Vec<Vec<Provision>>,
426}
427
428impl<L: Language> Package<L> {
429    fn label(&self, index: usize) -> String {
430        let plugin = &self.plugins[index];
431        format!(
432            "{} ({})",
433            plugin.kind(),
434            plugin.meta().label.as_deref().unwrap_or(&self.dir)
435        )
436    }
437
438    fn resolve(&self) -> Result<Plan> {
439        checked_path(Path::new(&self.dir))?;
440        let mut middleware_paths = BTreeSet::new();
441        for middleware in &self.middleware {
442            middleware.validate()?;
443            if !middleware_paths.insert(checked_path(&middleware.path)?) {
444                bail!(
445                    "duplicate bundled middleware path in {}: {}",
446                    self.dir,
447                    middleware.path.display()
448                );
449            }
450        }
451        let mut instances = BTreeMap::new();
452        let mut providers: HashMap<TypeId, Vec<usize>> = HashMap::new();
453        let mut phases = Vec::new();
454        let mut provisions = Vec::new();
455        for (index, plugin) in self.plugins.iter().enumerate() {
456            if instances.insert(plugin.meta().id, index).is_some() {
457                bail!("duplicate plugin instance: {}", self.label(index));
458            }
459            phases.push(plugin.phase());
460            let supplied = plugin.provides();
461            let mut seen = BTreeSet::new();
462            for contract in &supplied {
463                if !seen.insert(contract.type_id) {
464                    bail!("{} declares {} twice", self.label(index), contract.name);
465                }
466                providers.entry(contract.type_id).or_default().push(index);
467            }
468            provisions.push(supplied);
469        }
470        let mut dependencies = vec![BTreeSet::new(); self.plugins.len()];
471        let mut bindings = Vec::new();
472        for (index, plugin) in self.plugins.iter().enumerate() {
473            let mut bound = HashMap::new();
474            for req in plugin.requires() {
475                let candidates = providers
476                    .get(&req.contract.type_id)
477                    .map(Vec::as_slice)
478                    .unwrap_or(&[]);
479                let selected = if let Some(id) = req.provider {
480                    let selected = *instances.get(&id).with_context(|| {
481                        format!(
482                            "{}: handle for {} is not registered in this package",
483                            self.label(index),
484                            req.contract.name
485                        )
486                    })?;
487                    if !candidates.contains(&selected) {
488                        bail!(
489                            "{}: selected provider does not provide {}",
490                            self.label(index),
491                            req.contract.name
492                        );
493                    }
494                    Some(selected)
495                } else {
496                    match candidates {
497                        [] if req.optional => None,
498                        [] => bail!(
499                            "{} requires {}, but no provider is registered",
500                            self.label(index),
501                            req.contract.name
502                        ),
503                        [one] => Some(*one),
504                        many => bail!(
505                            "{} needs one {} provider; found {}: {}. Select a provider handle explicitly",
506                            self.label(index),
507                            req.contract.name,
508                            many.len(),
509                            many.iter()
510                                .map(|i| self.label(*i))
511                                .collect::<Vec<_>>()
512                                .join(", ")
513                        ),
514                    }
515                };
516                if bound
517                    .insert(
518                        req.contract.type_id,
519                        selected.map(|i| self.plugins[i].meta().id),
520                    )
521                    .is_some()
522                {
523                    bail!("{} requires {} twice", self.label(index), req.contract.name);
524                }
525                if let Some(selected) = selected {
526                    if phases[index] == PluginPhase::Generate
527                        && phases[selected] == PluginPhase::Post
528                    {
529                        bail!(
530                            "{} requires {} from {}, but generation plugins cannot depend on post plugins",
531                            self.label(index),
532                            req.contract.name,
533                            self.label(selected),
534                        );
535                    }
536                    dependencies[index].insert(selected);
537                }
538            }
539            bindings.push(bound);
540        }
541        // A post plugin observes a completed package. Make the phase boundary
542        // explicit in the dependency graph, while retaining normal contract
543        // ordering between post plugins themselves.
544        for (index, phase) in phases.iter().enumerate() {
545            if *phase == PluginPhase::Post {
546                dependencies[index].extend(phases.iter().enumerate().filter_map(
547                    |(other, phase)| (*phase == PluginPhase::Generate).then_some(other),
548                ));
549            }
550        }
551        let mut order = Vec::new();
552        let mut completed = BTreeSet::new();
553        while order.len() < self.plugins.len() {
554            let next = (0..self.plugins.len())
555                .find(|i| !completed.contains(i) && dependencies[*i].is_subset(&completed));
556            let Some(next) = next else {
557                bail!(
558                    "plugin dependency cycle in {}: {}",
559                    self.dir,
560                    (0..self.plugins.len())
561                        .filter(|i| !completed.contains(i))
562                        .map(|i| self.label(i))
563                        .collect::<Vec<_>>()
564                        .join(", ")
565                );
566            };
567            completed.insert(next);
568            order.push(next);
569        }
570        Ok(Plan {
571            order,
572            phases,
573            bindings,
574            provisions,
575        })
576    }
577
578    fn run(
579        &self,
580        api: &Api,
581        common: &Common,
582        catalog: Option<&SecuritySchemeCatalog>,
583    ) -> Result<GeneratedTree> {
584        let plan = self.resolve()?;
585        let common = common.overlay(&self.common);
586        let overridden = common.package_version.as_ref().map(|version| {
587            let mut copy = api.clone();
588            copy.version.clone_from(version);
589            copy
590        });
591        let api = overridden.as_ref().unwrap_or(api);
592        let prepared = crate::idempotency::prepare_api(api, &self.idempotency)?;
593        let api = &prepared;
594        let semantics = analyze_sdk_semantics(api, catalog);
595        let mut workspace = L::Workspace::default();
596        let mut tree = GeneratedTree::default();
597        let mut owners = BTreeMap::new();
598        let mut contracts = Contracts::new();
599        for phase in [PluginPhase::Generate, PluginPhase::Post] {
600            for &index in &plan.order {
601                if plan.phases[index] != phase {
602                    continue;
603                }
604                let plugin = &self.plugins[index];
605                let mut publications = HashMap::new();
606                let mut cx = PluginContext {
607                    api,
608                    semantics: &semantics,
609                    security_schemes: catalog,
610                    common: &common,
611                    settings: &self.settings,
612                    inputs: Inputs {
613                        contracts: &contracts,
614                        bindings: &plan.bindings[index],
615                    },
616                    workspace: &mut workspace,
617                    files: Emitter {
618                        tree: &mut tree,
619                        owners: &mut owners,
620                        owner: self.label(index),
621                    },
622                    publications: &mut publications,
623                    declared: &plan.provisions[index],
624                };
625                plugin
626                    .generate(&mut cx)
627                    .with_context(|| format!("generate {}", self.label(index)))?;
628                for provision in &plan.provisions[index] {
629                    if !publications.contains_key(&provision.type_id) {
630                        bail!(
631                            "{} did not publish declared contract {}",
632                            self.label(index),
633                            provision.name
634                        );
635                    }
636                }
637                for (key, value) in publications {
638                    contracts.insert((plugin.meta().id, key), value);
639                }
640            }
641            if phase == PluginPhase::Generate {
642                L::finalize(&mut FinalizeContext {
643                    api,
644                    common: &common,
645                    settings: &self.settings,
646                    workspace: &mut workspace,
647                    files: Emitter {
648                        tree: &mut tree,
649                        owners: &mut owners,
650                        owner: format!("{} finalizer", L::NAME),
651                    },
652                })?;
653            }
654        }
655        if !self.middleware.is_empty() {
656            let mut staged = tree.clone();
657            let middleware = self
658                .middleware
659                .iter()
660                .map(|item| {
661                    let mut item = item.clone();
662                    item.path = checked_path(&item.path)?;
663                    Ok(item)
664                })
665                .collect::<Result<Vec<_>>>()?;
666            L::bundle_middleware(&mut staged, &middleware).with_context(|| {
667                format!("bundle runtime middleware for {} ({})", self.dir, L::NAME)
668            })?;
669            for item in &middleware {
670                staged.set_owner(
671                    &item.path,
672                    format!("bundled-middleware:{}", item.path.display()),
673                )?;
674            }
675            tree = staged;
676        }
677        L::finalize_files(&mut tree)?;
678        crate::customization::apply_code_customizations(&mut tree, &self.customizations)?;
679        let mut output = GeneratedTree::default();
680        let dir = checked_path(Path::new(&self.dir))?;
681        for (file, custom, owner) in tree.into_owned_files() {
682            let file = GeneratedFile::new(dir.join(file.path), file.contents)?;
683            let path = file.path.clone();
684            if custom {
685                output.insert_custom(file)?;
686            } else {
687                output.insert(file)?;
688            }
689            if let Some(owner) = owner {
690                output.set_owner(path, format!("{}::{owner}", self.dir))?;
691            }
692        }
693        Ok(output)
694    }
695}
696
697/// Type erasure only at the release boundary; package builders remain typed.
698trait ErasedPackage: Send + Sync {
699    fn directory(&self) -> &str;
700    fn validate(&self) -> Result<()>;
701    fn native_incompatibility(&self) -> Option<String>;
702    fn validate_native(&self) -> Result<()>;
703    fn generate(
704        &self,
705        api: &Api,
706        common: &Common,
707        catalog: Option<&SecuritySchemeCatalog>,
708    ) -> Result<GeneratedTree>;
709}
710impl<L: Language> ErasedPackage for Package<L> {
711    fn directory(&self) -> &str {
712        &self.dir
713    }
714    fn validate(&self) -> Result<()> {
715        self.resolve().map(|_| ())
716    }
717    fn native_incompatibility(&self) -> Option<String> {
718        let plugins: Vec<_> = self
719            .plugins
720            .iter()
721            .enumerate()
722            .filter(|(_, plugin)| !plugin.supports_native_input())
723            .map(|(index, _)| self.label(index))
724            .collect();
725        if plugins.is_empty() {
726            None
727        } else {
728            Some(format!(
729                "{} requires an HTTP API and cannot consume native input contracts",
730                plugins.join(", ")
731            ))
732        }
733    }
734    fn validate_native(&self) -> Result<()> {
735        if !self.middleware.is_empty()
736            || (self.idempotency.defaults.is_some() || !self.idempotency.operations.is_empty())
737        {
738            bail!(
739                "native package {} cannot use HTTP middleware or idempotency policy",
740                self.dir
741            );
742        }
743        self.validate()
744    }
745    fn generate(
746        &self,
747        api: &Api,
748        common: &Common,
749        catalog: Option<&SecuritySchemeCatalog>,
750    ) -> Result<GeneratedTree> {
751        self.run(api, common, catalog)
752    }
753}
754
755/// An incompatible native package is skipped without running its input provider.
756#[derive(Clone, Debug, PartialEq, Eq)]
757pub struct NativeSkip {
758    pub package: String,
759    pub reason: String,
760}
761#[derive(Debug)]
762pub struct NativeGeneration {
763    pub tree: GeneratedTree,
764    pub skipped: Vec<NativeSkip>,
765}
766
767#[derive(Default)]
768pub struct Packages {
769    packages: Vec<Box<dyn ErasedPackage>>,
770    common: Common,
771}
772impl Packages {
773    pub fn new() -> Self {
774        Self::default()
775    }
776    pub fn common(mut self, common: Common) -> Self {
777        self.common = common;
778        self
779    }
780    pub fn package<L: Language>(mut self, package: Package<L>) -> Self {
781        self.packages.push(Box::new(package));
782        self
783    }
784    pub fn is_empty(&self) -> bool {
785        self.packages.is_empty()
786    }
787    /// Generate from explicitly declared native protocol contracts.
788    /// The empty legacy HTTP context preserves the plugin ABI; native operations
789    /// are carried only by typed requirements and are never mapped into `Api`.
790    pub fn generate_native(&self) -> Result<GeneratedTree> {
791        let report = self.generate_native_report()?;
792        for skipped in &report.skipped {
793            eprintln!(
794                "warning: skipped native package {}: {}",
795                skipped.package, skipped.reason
796            );
797        }
798        Ok(report.tree)
799    }
800    /// A structured report lets callers surface skips without treating them as errors.
801    /// An empty tree must not replace existing generated output when every package skips.
802    pub fn generate_native_report(&self) -> Result<NativeGeneration> {
803        let mut skipped = Vec::new();
804        let mut active = Vec::new();
805        let mut dirs: Vec<PathBuf> = Vec::new();
806        for package in &self.packages {
807            let dir = checked_path(Path::new(package.directory()))?;
808            if let Some(other) = dirs
809                .iter()
810                .find(|other| dir.starts_with(other) || other.starts_with(&dir))
811            {
812                bail!(
813                    "package directories overlap: {} and {}",
814                    other.display(),
815                    dir.display()
816                );
817            }
818            dirs.push(dir);
819            if let Some(reason) = package.native_incompatibility() {
820                skipped.push(NativeSkip {
821                    package: package.directory().to_owned(),
822                    reason,
823                });
824            } else {
825                package.validate_native()?;
826                active.push(package);
827            }
828        }
829        let mut tree = GeneratedTree::default();
830        for package in active {
831            tree.append(package.generate(&Api::default(), &self.common, None)?)?;
832        }
833        Ok(NativeGeneration { tree, skipped })
834    }
835    pub fn generate(
836        &self,
837        api: &Api,
838        catalog: Option<&SecuritySchemeCatalog>,
839    ) -> Result<GeneratedTree> {
840        for operation in &api.operations {
841            let parsed =
842                crate::HttpMethod::parse(operation.method.as_str()).map_err(anyhow::Error::msg)?;
843            if parsed != operation.method {
844                bail!("HTTP method variants must use their canonical representation");
845            }
846        }
847        let mut dirs: Vec<PathBuf> = Vec::new();
848        // Validate every package before executing any generator.
849        for package in &self.packages {
850            let dir = checked_path(Path::new(package.directory()))?;
851            if let Some(other) = dirs
852                .iter()
853                .find(|other| dir.starts_with(other) || other.starts_with(&dir))
854            {
855                bail!(
856                    "package directories overlap: {} and {}",
857                    other.display(),
858                    dir.display()
859                );
860            }
861            dirs.push(dir);
862            package.validate()?;
863        }
864        let mut output = GeneratedTree::default();
865        for package in &self.packages {
866            output.append(package.generate(api, &self.common, catalog)?)?;
867        }
868        Ok(output)
869    }
870}
871
872fn checked_path(path: &Path) -> Result<PathBuf> {
873    GeneratedFile::new(path, "")?;
874    let normalized: PathBuf = path
875        .components()
876        .filter(|component| !matches!(component, std::path::Component::CurDir))
877        .collect();
878    if normalized.as_os_str().is_empty() || normalized == Path::new(".") {
879        bail!("output path cannot be empty or '.'");
880    }
881    Ok(normalized)
882}