001package gudusoft.gsqlparser.lineage2.cli;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ir.semantic.catalog.Catalog;
005import gudusoft.gsqlparser.ir.semantic.catalog.CatalogTable;
006import gudusoft.gsqlparser.lineage2.api.LineageAnalyzer;
007import gudusoft.gsqlparser.lineage2.api.LineageConfig;
008import gudusoft.gsqlparser.util.json.JSON;
009
010import java.io.File;
011import java.io.IOException;
012import java.nio.charset.StandardCharsets;
013import java.nio.file.Files;
014import java.util.List;
015import java.util.Map;
016
017/**
018 * Command-line entry point for the lineage2 engine — the invocation
019 * contract pinned by the verification harness
020 * ({@code scripts/lineage2/gold/check_gold.py} and {@code compare.sh}):
021 *
022 * <pre>
023 * java -cp gsqlparser.jar gudusoft.gsqlparser.lineage2.cli.Lineage2Cli \
024 *     --sql &lt;file&gt; [--vendor mssql] [--artifact-locator &lt;name&gt;] \
025 *     [--workspace &lt;ws&gt;] [--default-database &lt;db&gt;] \
026 *     [--default-schema &lt;schema&gt;] [--catalog &lt;catalog.json&gt;]
027 * </pre>
028 *
029 * <p>Prints the contract JSON document on stdout (one trailing newline)
030 * and exits 0. SQL-level failures degrade into document diagnostics per
031 * contract §2 and still exit 0; usage and I/O errors print to stderr and
032 * exit 2.
033 *
034 * <p>The {@code --catalog} sidecar uses the gold-fixture format:
035 * {@code {"defaultDatabase", "defaultSchema", "objects": [{"name", "kind",
036 * "columns": [...]}], "primaryKeys"?, "synonyms"?}}. Object names and
037 * columns are loaded into the Semantic IR {@link Catalog} DTO;
038 * {@code kind}/{@code primaryKeys}/{@code synonyms} are parsed but not yet
039 * consumed (they feed the BindingGate and LOOKUP stories, US-025+).
040 *
041 * <p>Internal API: not part of the public gsqlparser surface.
042 */
043public final class Lineage2Cli {
044
045    private Lineage2Cli() {
046        // entry point — no instances
047    }
048
049    public static void main(String[] args) {
050        try {
051            System.out.println(run(args));
052        } catch (UsageException | IOException e) {
053            System.err.println("lineage2: " + e.getMessage());
054            System.exit(2);
055        }
056    }
057
058    /**
059     * Thrown on bad command-line arguments (exit code 2).
060     *
061     * <p>Public alongside {@link #run(String[])}: it is part of that method's
062     * behaviour contract, so a caller catching it needs the type to keep its
063     * name in the obfuscated jar.</p>
064     *
065     * @since 4.2.6
066     */
067    public static final class UsageException extends RuntimeException {
068        UsageException(String message) {
069            super(message);
070        }
071    }
072
073    /**
074     * Embeddable core of {@link #main}: parses arguments, runs the analyzer,
075     * and RETURNS the contract JSON (without trailing newline) instead of
076     * printing it and terminating the JVM.
077     *
078     * <p>Public because this is the form a caller actually wants when driving
079     * the tool in-process: {@link #main} writes to stdout and calls
080     * {@link System#exit}, which would take the host application down with it.
081     * It is also what makes the CLI testable against the shipped
082     * (ProGuard-obfuscated) jar - a package-private method is renamed there and
083     * cannot be reached, by a test or by anyone else.</p>
084     *
085     * @param args the same arguments {@link #main} accepts
086     * @return the contract JSON produced for those arguments
087     * @throws IOException if the SQL or artifact input cannot be read
088     * @since 4.2.6
089     */
090    public static String run(String[] args) throws IOException {
091        String sqlPath = null;
092        String vendorName = "mssql";
093        String artifactLocator = null;
094        String workspace = "";
095        String defaultDatabase = "";
096        String defaultSchema = "";
097        String catalogPath = null;
098        for (int i = 0; i < args.length; i++) {
099            String arg = args[i];
100            if (!arg.startsWith("--")) {
101                throw new UsageException("unexpected argument: " + arg);
102            }
103            if (i + 1 >= args.length) {
104                throw new UsageException(arg + " needs a value");
105            }
106            String value = args[++i];
107            switch (arg) {
108                case "--sql":
109                    sqlPath = value;
110                    break;
111                case "--vendor":
112                    vendorName = value;
113                    break;
114                case "--artifact-locator":
115                    artifactLocator = value;
116                    break;
117                case "--workspace":
118                    workspace = value;
119                    break;
120                case "--default-database":
121                    defaultDatabase = value;
122                    break;
123                case "--default-schema":
124                    defaultSchema = value;
125                    break;
126                case "--catalog":
127                    catalogPath = value;
128                    break;
129                default:
130                    throw new UsageException("unknown option: " + arg);
131            }
132        }
133        if (sqlPath == null) {
134            throw new UsageException("--sql <file> is required");
135        }
136        File sqlFile = new File(sqlPath);
137        if (!sqlFile.isFile()) {
138            throw new UsageException("not a readable file: " + sqlPath);
139        }
140
141        EDbVendor vendor = resolveVendor(vendorName);
142        Catalog catalog = (catalogPath == null) ? null : loadCatalog(catalogPath);
143        LineageConfig config = LineageConfig.builder()
144                .workspaceId(workspace)
145                .defaultDatabase(defaultDatabase)
146                .defaultSchema(defaultSchema)
147                .artifactLocator(artifactLocator == null ? "" : artifactLocator)
148                .build();
149        return LineageAnalyzer.analyze(sqlFile, vendor, catalog, config).toJson();
150    }
151
152    /** Contract vendor string → {@link EDbVendor} (e.g. "mssql" → dbvmssql). */
153    static EDbVendor resolveVendor(String name) {
154        for (EDbVendor v : EDbVendor.values()) {
155            String enumName = v.name();
156            String bare = enumName.startsWith("dbv")
157                    ? enumName.substring(3) : enumName;
158            if (bare.equalsIgnoreCase(name)) {
159                return v;
160            }
161        }
162        throw new UsageException("unknown vendor: " + name);
163    }
164
165    /** Load a gold-fixture-format catalog sidecar into the Catalog DTO. */
166    /**
167     * Reads a catalog sidecar JSON file into a {@link Catalog}.
168     *
169     * <p>Public for the same reason as {@link #run(String[])}: it is a useful
170     * entry point for a caller assembling the same inputs the CLI takes, and a
171     * package-private method is renamed in the shipped obfuscated jar.</p>
172     *
173     * @param path path to the catalog sidecar JSON
174     * @return the parsed catalog
175     * @throws IOException if the file cannot be read or parsed
176     * @since 4.2.6
177     */
178    public static Catalog loadCatalog(String path) throws IOException {
179        String text = new String(
180                Files.readAllBytes(new File(path).toPath()),
181                StandardCharsets.UTF_8);
182        Object parsed = JSON.parseObject(text);
183        if (!(parsed instanceof Map)) {
184            throw new UsageException("catalog file is not a JSON object: " + path);
185        }
186        Map<?, ?> root = (Map<?, ?>) parsed;
187        Object objects = root.get("objects");
188        Catalog.Builder builder = Catalog.builder();
189        if (objects instanceof List) {
190            for (Object entry : (List<?>) objects) {
191                if (!(entry instanceof Map)) {
192                    throw new UsageException(
193                            "catalog objects[] entry is not an object: " + path);
194                }
195                Map<?, ?> object = (Map<?, ?>) entry;
196                Object name = object.get("name");
197                if (!(name instanceof String)) {
198                    throw new UsageException(
199                            "catalog object without a name: " + path);
200                }
201                CatalogTable.Builder table =
202                        CatalogTable.builder((String) name);
203                Object columns = object.get("columns");
204                if (columns instanceof List) {
205                    for (Object column : (List<?>) columns) {
206                        if (column instanceof String) {
207                            table.addColumn((String) column);
208                        }
209                    }
210                }
211                builder.addTable(table.build());
212            }
213        }
214        return builder.build();
215    }
216}