001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2023-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.cli;
019
020import com.google.common.collect.ImmutableList;
021
022import dev.enola.common.context.TLC;
023import dev.enola.common.io.iri.URIs;
024import dev.enola.common.io.resource.FileDescriptorResource;
025import dev.enola.common.io.resource.stream.GlobResolvers;
026import dev.enola.core.EnolaServiceProvider;
027import dev.enola.core.grpc.EnolaGrpcClientProvider;
028import dev.enola.core.grpc.EnolaGrpcInProcess;
029import dev.enola.core.grpc.ServiceProvider;
030import dev.enola.core.proto.EnolaServiceGrpc.EnolaServiceBlockingStub;
031import dev.enola.data.ProviderFromIRI;
032import dev.enola.data.Trigger;
033import dev.enola.data.iri.NamespaceConverter;
034import dev.enola.datatype.DatatypeRepository;
035import dev.enola.infer.rdf.RDFSPropertyTrigger;
036import dev.enola.thing.impl.MutableThing;
037import dev.enola.thing.java.ProxyTBF;
038import dev.enola.thing.java.TBF;
039import dev.enola.thing.message.AlwaysThingProviderAdapter;
040import dev.enola.thing.metadata.ThingMetadataProvider;
041import dev.enola.thing.proto.Thing;
042import dev.enola.thing.repo.*;
043import dev.enola.thing.template.TemplateThingRepository;
044import dev.enola.thing.validation.LoggingCollector;
045import dev.enola.thing.validation.Validators;
046
047import org.jspecify.annotations.Nullable;
048
049import picocli.CommandLine;
050import picocli.CommandLine.ArgGroup;
051import picocli.CommandLine.Model.CommandSpec;
052import picocli.CommandLine.Option;
053import picocli.CommandLine.Spec;
054
055import java.net.URI;
056import java.util.List;
057import java.util.stream.Stream;
058
059public abstract class CommandWithModel extends CommandWithResourceProviderAndLoader {
060
061    protected EnolaServiceProvider esp;
062
063    @Spec CommandSpec spec;
064    @ArgGroup @Nullable ModelOrServer group;
065
066    @CommandLine.Option(
067            names = {"--validate"},
068            negatable = true,
069            required = true,
070            defaultValue = "false", // TODO Enable Model validation by default
071            showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
072            description = "Whether validation errors in loaded models should stop & exit")
073    boolean validate;
074
075    private EnolaServiceBlockingStub gRPCService;
076
077    // TODO Turn remote service encapsulation upside down (as-is this "exception" is strange)
078    protected TemplateThingRepository templateService;
079
080    @Override
081    public final void run() throws Exception {
082        super.run();
083        try (var ctx1 = TLC.open()) {
084            setup(ctx1);
085
086            if (group == null) {
087                group = new ModelOrServer();
088                group.load = List.of();
089            }
090
091            // TODO Move elsewhere for continuous ("shell") mode, as this is "expensive".
092            ServiceProvider grpc = null;
093            if (group.load != null) {
094                ImmutableList<Trigger<? extends dev.enola.thing.Thing>> triggers =
095                        ImmutableList.of(new RDFSPropertyTrigger());
096                ThingMemoryRepositoryROBuilder store = new ThingMemoryRepositoryROBuilder(triggers);
097                for (var trigger : triggers) {
098                    ((ThingTrigger<?>) trigger).setRepo(store);
099                }
100
101                try (var ctx2 = TLC.open()) {
102                    ctx2.push(ThingProvider.class, new AlwaysThingRepositoryStore(store));
103                    ctx2.push(TBF.class, new ProxyTBF(MutableThing.FACTORY));
104                    var loader = loader();
105                    var fgrp = new GlobResolvers();
106                    for (var globIRI : group.load) {
107                        try (var stream = fgrp.get(globIRI)) {
108                            loader.convertIntoOrThrow(stream, store);
109                        }
110                    }
111                }
112                var repo = store.build();
113
114                if (validate) {
115                    var c = new LoggingCollector();
116                    var v = new Validators(repo);
117                    v.validate(repo, c);
118                    if (c.hasMessages()) {
119                        System.err.println(
120                                "Loaded models have validation errors; use -v to show them (or use"
121                                        + " --no-validate to disable)");
122                        System.exit(7);
123                    }
124                }
125
126                TemplateThingRepository templateThingRepository = new TemplateThingRepository(repo);
127                templateService = templateThingRepository;
128                ThingsProvider thingsProvider =
129                        new ThingsProvider() {
130                            @Override
131                            public Stream<dev.enola.thing.Thing> getThings(String iri) {
132                                throw new UnsupportedOperationException("TODO");
133                            }
134                        };
135                esp = new EnolaServiceProvider(thingsProvider, templateThingRepository, rp);
136                var enolaService = esp.getEnolaService();
137                grpc = new EnolaGrpcInProcess(esp, enolaService, false); // direct, single-threaded!
138                gRPCService = grpc.get();
139
140            } else if (group.server != null) {
141                grpc = new EnolaGrpcClientProvider(group.server, false); // direct, single-threaded!
142                gRPCService = grpc.get();
143            }
144
145            try {
146                run(gRPCService);
147            } finally {
148                if (grpc != null) grpc.close();
149            }
150        }
151    }
152
153    // TODO Move this to class EnolaProvider?
154    protected ThingMetadataProvider getMetadataProvider(ProviderFromIRI<Thing> thingProvider) {
155        return new ThingMetadataProvider(
156                new AlwaysThingProviderAdapter(thingProvider, DatatypeRepository.CTX),
157                NamespaceConverter.CTX);
158    }
159
160    protected abstract void run(EnolaServiceBlockingStub service) throws Exception;
161
162    static class LoadableModelURIs {
163
164        @Option(
165                names = {"--load", "-l"},
166                description = "URI Glob of Models to load (e.g. file:\"**.ttl\")")
167        /* TODO @Nullable */ java.util.List<String> load;
168    }
169
170    static class ModelOrServer extends LoadableModelURIs {
171
172        @Option(
173                names = {"--server", "-s"},
174                required = true,
175                description = "Target of an Enola gRPC Server (e.g. localhost:7070)")
176        @Nullable String server;
177    }
178
179    static class Output {
180        // Default command output destination is STDOUT.
181        // NB: "fd:1" normally (in ResourceProviders) is FileDescriptorResource,
182        // but CommandWithIRI "hacks" this and uses WriterResource, for "testability".
183        static final URI DEFAULT_OUTPUT_URI = FileDescriptorResource.STDOUT_URI;
184
185        @Option(
186                names = {"--output", "-o"},
187                required = true,
188                defaultValue = FileDescriptorResource.STDOUT,
189                showDefaultValue = CommandLine.Help.Visibility.ALWAYS,
190                description =
191                        "URI (base) of where to write output/s; e.g. file:/tmp or "
192                                + FileDescriptorResource.STDOUT)
193        URI output;
194
195        static URI get(Output output) {
196            if (output == null) return FileDescriptorResource.STDOUT_URI;
197            if (output != null && output.output == null) return FileDescriptorResource.STDOUT_URI;
198            return URIs.absolutify(output.output);
199        }
200    }
201}