using GisConverter.Lib.Converters; using GisConverter.Lib.Factories; using GisConverter.Lib.Licensing; using GisConverter.Lib.Logging; using GisConverter.Lib.Models; namespace GisConverter.ConsoleApp { /// /// Entry point and CLI dispatch for the GisConverter console application. /// /// /// Responsibilities /// - Exposes the single top-level command gis_convert which routes an input GIS artifact to a target format converter. /// - Performs host-level concerns only: argument parsing, basic validation, logging initialization, license application /// and converter selection. Conversion work is delegated to and . /// /// Design notes /// - The class keeps behavior deterministic and minimal because tests invoke /// directly and assert on return codes and console output. /// - Exit codes (see ) are the stable contract with callers and automation. /// - Keep user-facing messages concise and stable; tests and automation rely on predictable tokens and exit codes. /// public class Program { // Exit codes for the CLI private enum ExitCode : int { Success = 0, // Successful execution AppError = 1, // invalid args, wrong command ConversionFailed = 2, // converter returned Failure or null Unexpected = 3, // unexpected exception } private const int MIN_REQUIRED_ARGS = 5; private const int LOG_ARGS_START_INDEX = 5; /// /// Process entry point. Sets up basic host concerns and delegates to . /// /// Command-line arguments supplied by the caller. /// /// - Enables UTF-8 console output so international characters and emoji render correctly. /// - In DEBUG builds uses to supply developer scenarios via /// the GISCONVERTER_DEBUG_TARGET environment variable. /// - Calls and returns its numeric exit code; this method is intentionally minimal /// and non-throwing so test harnesses and hosts can rely on the returned value. /// private static int Main(string[] args) { // Enable Unicode (UTF-8) output for emoji and international characters Console.OutputEncoding = System.Text.Encoding.UTF8; args = EnsureDebugArgs(args); return Run(args); } /// /// In DEBUG builds optionally replaces supplied args with a developer scenario selected via environment variables. /// /// Original command-line arguments (may be null or incomplete). /// /// - In DEBUG builds: either the original args (if sufficient), a selected debug scenario args array, or an empty /// args array when no scenario is selected (which causes the app to print available scenarios). /// - In RELEASE builds: returns the original args or an empty array if null. /// /// /// Purpose /// - Provide an ergonomic mechanism for developers to run repeatable end-to-end scenarios without editing code. /// /// Behavior /// - When running under DEBUG and no sufficient args are provided the method probes the environment variable /// __GISCONVERTER_DEBUG_TARGET__ and returns the matching prebuilt scenario from . /// - Supports a special value of "all" which will execute every scenario in sequence and then exit the process. /// /// Test guidance /// - Tests should not rely on this behavior. Unit and integration tests invoke directly /// with explicit args. This helper is strictly aimed at developer convenience during interactive debugging. /// private static string[] EnsureDebugArgs(string[] args) { #if DEBUG if (args != null && args.Length >= MIN_REQUIRED_ARGS) return args; var baseDir = Environment.GetEnvironmentVariable("GISCONVERTER_TEST_BASE") ?? @"D:\GisConverter\Tests"; var scenarios = BuildDebugScenarios(baseDir); var debugTarget = Environment.GetEnvironmentVariable("GISCONVERTER_DEBUG_TARGET")?.Trim().ToLowerInvariant(); // Special case: run all scenarios in sequence if (debugTarget == "all") { Console.WriteLine("Running all debug scenarios:"); Console.WriteLine(); foreach (var kvp in scenarios.OrderBy(s => s.Key)) { Console.WriteLine($"Executing scenario: {kvp.Key}"); Console.WriteLine(new string('-', 60)); var exitCode = Run(kvp.Value); Console.WriteLine($"Result: {(ExitCode)exitCode}"); Console.WriteLine(); } Environment.Exit(0); } // Try to return the requested single scenario if (!string.IsNullOrWhiteSpace(debugTarget) && scenarios.TryGetValue(debugTarget, out var selectedArgs)) { Console.WriteLine($"Debug mode: using scenario '{debugTarget}'"); Console.WriteLine(); return selectedArgs; } // If no valid scenario was specified, show available options if (!string.IsNullOrWhiteSpace(debugTarget)) { Console.WriteLine($"Warning: Unknown scenario: '{debugTarget}'"); } Console.WriteLine("Available scenarios (set GISCONVERTER_DEBUG_TARGET):"); foreach (var key in scenarios.Keys.OrderBy(k => k)) { Console.WriteLine($" - {key}"); } Console.WriteLine(" - all (runs all scenarios)"); Console.WriteLine(); return Array.Empty(); #else return args ?? Array.Empty(); #endif } /// /// Builds a dictionary of debug scenarios to developer-friendly argument arrays. /// /// Root folder where test scenario subfolders live (contains Input/Output/Temp/Log folders). /// Dictionary mapping scenario key to a complete args array that can be passed to . /// /// - Each scenario returns an args array following the CLI contract: /// [ "gis_convert", inputPath, format, outputFolder, tempFolder, "Log", logFilePath ] /// - The helper centralizes sample naming and path composition to keep debug scenarios consistent. /// - This method is used only for developer debugging scenarios and is not relied upon by automated tests. /// private static Dictionary BuildDebugScenarios(string baseDir) { var scenarios = new Dictionary(StringComparer.OrdinalIgnoreCase); void AddScenario(string scenarioKey, string format, string inputFileName) { var formatFolder = format; scenarios[scenarioKey] = new[] { "gis_convert", Path.Combine(baseDir, formatFolder, "Input", inputFileName), format, Path.Combine(baseDir, formatFolder, "Output", scenarioKey), Path.Combine(baseDir, formatFolder, "Temp", scenarioKey), "Log", Path.Combine(baseDir, formatFolder, "Log", $"{scenarioKey.ToLowerInvariant()}_log.txt") }; } // Csv test cases - note the different extensions AddScenario("csv1", "Csv", "features.7z"); AddScenario("csv2", "Csv", "features.csv"); AddScenario("csv3", "Csv", "features.zip"); // Gml test cases AddScenario("gml1", "Gml", "gml_with_attribute_collection_schema.7z"); AddScenario("gml2", "Gml", "gml_with_attribute_collection_schema.zip"); AddScenario("gml3", "Gml", "gml_without_attribute_collection_schema.7z"); AddScenario("gml4", "Gml", "gml_without_attribute_collection_schema.gml"); AddScenario("gml5", "Gml", "gml_without_attribute_collection_schema.zip"); // Shapefile test cases AddScenario("shapefile1", "Shapefile", "ShapeFiles.7z"); AddScenario("shapefile2", "Shapefile", "Vector.7z"); AddScenario("shapefile3", "Shapefile", "לא סטטוטורי - גבול מחוז מאוחד.7z"); AddScenario("shapefile4", "Shapefile", "שכבות מידע (Arc View).7z"); return scenarios; } /// /// Main dispatcher for command-line operations. /// /// Command-line arguments passed to the application. /// Integer exit code describing result (see ). /// /// - Recognizes top-level help flags and the single supported command gis_convert. /// - Applies licensing via before performing other work; /// when licensing fails a friendly error is written to stderr and the method returns an application error. /// - Catches unexpected exceptions and returns while printing the exception message /// for diagnostics. The method itself avoids throwing to keep callers (tests, CI) deterministic. /// public static int Run(string[] args) { // Apply license FIRST before doing anything else if (!AsposeLicenseManager.ApplyLicense()) { Console.Error.WriteLine("⚠️ Failed to apply aspose license."); Console.Error.WriteLine("the program cannot continue without a valid license."); return (int)ExitCode.AppError; } if (args.Length == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h") { ShowHelp(); return (int)ExitCode.Success; } if (args[0] == "--help-formats") { ShowHelpFormats(); return (int)ExitCode.Success; } // Extract the command from the first argument. string command = args[0].ToLowerInvariant(); try { if (!args[0].Equals("gis_convert", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"❌ Unknown command: {args[0]}\n"); ShowHelp(); return (int)ExitCode.AppError; } return RunGisConverter(args); } catch (Exception ex) { Console.WriteLine("❌ Error:"); Console.WriteLine(ex.Message); return (int)ExitCode.Unexpected; } } /// /// Executes the gis_convert command and returns an exit code. /// /// Command-line arguments where: /// args[1] = input path, args[2] = target format, args[3] = output folder, args[4] = temp folder, /// optional args starting at index 5 may contain logging options. /// Exit code representing the command outcome. /// /// Responsibilities and behavior: /// - Validates argument count and prints usage when insufficient arguments are supplied. /// - Parses optional logging arguments via and initializes logging /// with (best-effort — logging failures do not abort conversion). /// - Resolves an appropriate converter using and /// invokes its method. /// - Maps converter outcomes to stable exit codes: /// - on success, /// - when the converter reports failure or returns null, /// - for validation/usage/detection issues. /// - Defensive: catches unexpected exceptions from converters and reports them while returning . /// /// Notes for tests and consumers: /// - Tests may rely on specific console tokens and exit codes; prefer case-insensitive substring checks when asserting messages. /// - The method intentionally writes concise, human-readable diagnostics to stdout/stderr to aid developers and CI diagnostics. /// - When integrating with automation, prefer checking the numeric exit code first and then capture console output for richer diagnostics. /// private static int RunGisConverter(string[] args) { // Require at least: command (gis_convert), gis input file path, gis target format option, output folder path, out temp folder. if (args.Length < MIN_REQUIRED_ARGS) { Console.WriteLine("Usage: gis_convert [Log [level]]"); return (int)ExitCode.AppError; } var gisInputFilePath = args[1]; var gisTargetFormatOption = args[2]; var outputFolderPath = args[3]; var tempFolderPath = args[4]; if (!File.Exists(gisInputFilePath) && !Directory.Exists(gisInputFilePath)) { Console.WriteLine($"❌ Input path not found: {gisInputFilePath}"); Console.WriteLine("Note: wrap paths containing spaces in quotes"); return (int)ExitCode.AppError; } var (logFilePath, logLevel) = GetOptionalLogArguments(args, LOG_ARGS_START_INDEX); SetLogger(logFilePath, logLevel); #if DEBUG Console.WriteLine($"Args: input='{gisInputFilePath}', format='{gisTargetFormatOption}', output='{outputFolderPath}', temp='{tempFolderPath}', logPath='{logFilePath ?? ""}', logLevel='{logLevel ?? ""}'"); #endif var factory = new ConverterFactory(); if (!ConverterFactoryInputExtensions.TryCreateForInput(factory, gisInputFilePath, out var converter, out var detectedSourceFormat, out var detectReason) || converter == null) { // Detection failed or ambiguous; report detect reason and exit. Console.WriteLine("❌ Unable to auto-detect converter from input."); if (!string.IsNullOrWhiteSpace(detectReason)) Console.WriteLine($"Detector reason: {detectReason}"); Console.WriteLine("Please use 'help' for supported options."); return (int)ExitCode.AppError; } // Converter resolved by TryCreateForInput — invoke it. try { ConversionResult result = converter.Convert(gisInputFilePath, detectedSourceFormat, gisTargetFormatOption, outputFolderPath, tempFolderPath); if (result == null) { Console.WriteLine("❌ Conversion finished with no result returned."); return (int)ExitCode.ConversionFailed; } else { // Always present timestamps to the user in local time, keep UTC for diagnostics. var utc = result.TimestampUtc; var timeLine = ConverterUtils.FormatTimestampForDisplay(utc); if (result.IsSuccess) { Console.WriteLine("✅ Conversion finished successfully."); Console.WriteLine(timeLine); if (!string.IsNullOrWhiteSpace(result.Message)) Console.WriteLine(result.Message); return (int)ExitCode.Success; } else { Console.WriteLine("❌ Conversion failed."); Console.WriteLine(timeLine); if (!string.IsNullOrWhiteSpace(result.Message)) Console.WriteLine(result.Message); return (int)ExitCode.ConversionFailed; } } } catch (Exception ex) { // Defensive: unexpected exceptions from converters Console.WriteLine("❌ Conversion encountered an unexpected error:"); Console.WriteLine(ex.Message); return (int)ExitCode.Unexpected; } } /// /// Writes usage and command information to the console. /// /// /// - Intended for interactive users and automation that inspects help output. /// - Keep wording stable; tests and automation reference specific tokens (Usage, Arguments, Options). /// - Use --help-formats to show the detailed list of supported target options. /// static void ShowHelp() { Console.WriteLine("GIS Converter CLI Tool"); Console.WriteLine("----------------------"); Console.WriteLine(); Console.WriteLine("Usage:"); Console.WriteLine(" GISConverter.Cli.exe gis_convert [Log [level]]"); Console.WriteLine(); Console.WriteLine("Arguments:"); Console.WriteLine(" - GIS input file (single file or archive)"); Console.WriteLine(" Note: Paths with spaces can be unquoted, but quoting is recommended"); Console.WriteLine(" (e.g., \"C:\\My Input\\input.zip\")"); Console.WriteLine(); Console.WriteLine(" - Target GIS format. Use --help-formats for full list"); Console.WriteLine(); Console.WriteLine(" - Output folder where converted files will be saved"); Console.WriteLine(" The system creates output files inside this folder"); Console.WriteLine(); Console.WriteLine(" - Temporary folder used during processing"); Console.WriteLine(); Console.WriteLine(" [Log [level]]"); Console.WriteLine(" - Optional. To enable logging include 'Log' followed by log file path and log level"); Console.WriteLine(" Log levels: ALL, DEBUG, INFO, WARN, ERROR, FATAL, OFF"); Console.WriteLine(" Note: Paths with spaces can be unquoted, but quoting is recommended"); Console.WriteLine(" (e.g., Log \"C:\\My Logs\\run.log\" DEBUG)"); Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" help, --help, -h Show this help"); Console.WriteLine(" --help-formats Show detailed format descriptions"); } /// /// Writes the detailed supported formats help to the console. /// /// /// - This output is intended to be relatively static; tests and documentation refer to these descriptions. /// - Keep the individual format blocks short and factual (purpose, required files). /// - Avoid embedding environment-specific paths or examples that may not be portable. /// static void ShowHelpFormats() { Console.WriteLine("Supported GIS Formats"); Console.WriteLine("---------------------"); Console.WriteLine(); Console.WriteLine(" - Available GIS target conversion format options:"); Console.WriteLine(); Console.WriteLine(" Csv:"); Console.WriteLine(" Purpose:Plain text format for tabular data."); Console.WriteLine(" Files needed: a single .csv file."); Console.WriteLine(); Console.WriteLine(" EsriJson:"); Console.WriteLine(" Purpose:a JSON format used by Esri ArcGis software to reprsent geographic features."); Console.WriteLine(" Similar to GeoJson but with Esri-specific attributes."); Console.WriteLine(" Files needed: a single .esrijson or .json file."); Console.WriteLine(); Console.WriteLine(" Gdb:"); Console.WriteLine(" Purpose:a folder-based database format from Esri storing multiple feature classes and tables."); Console.WriteLine(" Files needed: FileGdb can be represent as a dataset or just as one table."); Console.WriteLine(" In case of dataset - the entire .gdb folder as a single file."); Console.WriteLine(" In case of one table - .gdbtable and .gdbtablx as an archive file."); Console.WriteLine(); Console.WriteLine(" GeoJson:"); Console.WriteLine(" Purpose:a widely used open standard format for encoding variety of geographic data structures."); Console.WriteLine(" Files needed: a single .geojson or .json file."); Console.WriteLine(); Console.WriteLine(" GeoJsonSeq:"); Console.WriteLine(" Purpose:GeoJson sequence, where each line is an individual GeoJson feature(streaming format)."); Console.WriteLine(" It’s json format with special structure."); Console.WriteLine(" Files needed: a single .json or .jsonl or .ndjson file."); Console.WriteLine(); Console.WriteLine(" GeoPackage:"); Console.WriteLine(" Purpose:SQLite-based OGC standard for vector and raster data - modern, compact, and widely support."); Console.WriteLine(" Files needed: a single .gpkg file."); Console.WriteLine(); Console.WriteLine(" Gml:"); Console.WriteLine(" Purpose:Xml based Geography markup Language, often used in government or standards-based data."); Console.WriteLine(" Files needed: a single .gml file or .gml file (sometimes with .xsd schema) as an archive file."); Console.WriteLine(); Console.WriteLine(" Gpx:"); Console.WriteLine(" Purpose:Xml schema for storing GPs track, waypoint and route data."); Console.WriteLine(" Files needed: a single .gpx file."); Console.WriteLine(); Console.WriteLine(" Kml:"); Console.WriteLine(" Purpose:XML based format used by Goggle Earth for geographic annotation and visualization."); Console.WriteLine(" Files needed: a single .kml file."); Console.WriteLine(); Console.WriteLine(" Kmz:"); Console.WriteLine(" Purpose:XML based format used by Goggle Earth for geographic annotation and visualization."); Console.WriteLine(" .kmz is a zipped of .kml."); Console.WriteLine(" Files needed: a single .kmz file."); Console.WriteLine(); Console.WriteLine(" MapInfoInterchange:"); Console.WriteLine(" Purpose:Text based format used by MapInfo system."); Console.WriteLine(" Files needed: .mif mandatory, .mid - contains attribue info and not mandatory."); Console.WriteLine(" If both files are supplied give it as an archive file, otherwise a single .mif file is needed."); Console.WriteLine(); Console.WriteLine(" MapInfoTab:"); Console.WriteLine(" Purpose:Native MapInfo vector format."); Console.WriteLine(" Files needed: an archive file that contains .tab, .dat, .map, .id."); Console.WriteLine(" tab — main file (links others)."); Console.WriteLine(); Console.WriteLine(" Osm:"); Console.WriteLine(" Purpose:Xml format used by OpenStreetMap to store nodes, ways, and relations."); Console.WriteLine(" Files needed: a single .osm file."); Console.WriteLine(); Console.WriteLine(" Shapefile:"); Console.WriteLine(" Purpose:Traditional Esri vector format for storing geographic features."); Console.WriteLine(" Files needed:an archive file that contains .shp (geometry), .shx (shape index), .dbf (attribute table)."); Console.WriteLine(" Optional:.prj, .cpg, .sbn, .sbx."); Console.WriteLine(" TopoJson:"); Console.WriteLine(" Purpose:Extension of GeoJson that encodes topology, reducing redundancy and file size."); Console.WriteLine(" Files needed: a single .json or .topojson file."); Console.WriteLine(); } /// /// Parse optional logging arguments starting at a given index. /// /// Argument array to inspect. /// Index to begin searching for the Log token. /// /// Tuple of log file path and log level. Both values may be null when logging is not configured. /// /// /// Behavior /// - Scans the arguments starting at for the case-insensitive token Log. /// - Gathers subsequent tokens as the log path until a known log level token is reached. /// - Known log levels: DEBUG, INFO, WARN, ERROR, FATAL, ALL, OFF (case-insensitive). /// /// Parsing rules /// - Tokens following the Log token are joined with a single space to support unquoted paths containing spaces. /// - If no path tokens are present or the assembled path is whitespace the method returns (null, null). /// - If a recognized level token is found it is returned upper-cased; otherwise the level part of the tuple is null. /// /// Examples /// - args: [..., "Log", "C:\\Logs\\run.log", "DEBUG"] → returns ("C:\\Logs\\run.log", "DEBUG") /// - args: [..., "Log", "C:\\My Logs\\run.log"] → returns ("C:\\My Logs\\run.log", null) /// /// Notes /// - This parser is intentionally tolerant to support simple unquoted scenarios used by CI recipes and developers. /// - Tests should pass full quoted paths where necessary; callers may prefer to wrap paths with spaces in quotes. /// static (string? logPath, string? logLevel) GetOptionalLogArguments(string[] args, int searchFromIndex) { if (args == null || args.Length < searchFromIndex + 2) return (null, null); for (int i = searchFromIndex; i < args.Length - 1; i++) { if (string.Equals(args[i], "Log", StringComparison.OrdinalIgnoreCase)) { var knownLevels = new HashSet(StringComparer.OrdinalIgnoreCase) { "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "ALL", "OFF" }; var pathTokens = new List(); string? detectedLevel = null; // Explicitly nullable for (int j = i + 1; j < args.Length; j++) { var token = args[j]; if (knownLevels.Contains(token)) { detectedLevel = token.ToUpperInvariant(); break; } pathTokens.Add(token); } var logPath = string.Join(" ", pathTokens).Trim(); if (string.IsNullOrWhiteSpace(logPath)) return (null, null); return (logPath, detectedLevel); } } return (null, null); } /// /// Configure global logging to write to the specified file at the requested level. /// /// Path to the log file. When null or whitespace logging will be disabled. /// Optional log level token (case-insensitive). When null defaults to All. /// /// Behavior and resilience /// - When is null or whitespace the method disables global logging via . /// - When a path is supplied the method trims quotes and attempts to initialize file logging using . /// - Logging initialization is best-effort: any exception raised during initialization is caught, a warning is written to stderr, /// and logging is disabled to avoid impacting conversion logic. /// /// Threading and global state /// - The method affects process-global logging state. Callers should ensure tests restore or disable logging in teardown to avoid leaks. /// /// Examples /// - SetLogger("C:\\logs\\run.log", "Info") → tries to configure file logging at Info level and enable it. /// - SetLogger(null, null) → disables logging. /// private static void SetLogger(string? logFilePath, string? logLevel = null) { if (string.IsNullOrWhiteSpace(logFilePath)) { Log.Disable(); return; } var path = logFilePath.Trim().Trim('"'); try { Log.SetFile(path, logLevel ?? "All"); // explicitly use "All" if null Log.Enable(); } catch (Exception ex) { Console.Error.WriteLine($"⚠️ Warning: logging disabled — could not initialize file logger: {ex.Message}"); Log.Disable(); } } } }