using GitConverter.Lib.Converters; using GitConverter.Lib.Factories; using GitConverter.Lib.Licensing; using GitConverter.Lib.Logging; using GitConverter.Lib.Models; namespace GitConverter.ConsoleApp { /// /// Entry point and CLI dispatch for the GitConverter console application. /// /// /// - Exposes a single command: gis_convert, which converts an input GIS file/archive to a target format. /// - This class is intentionally small: parsing, validation and host-level concerns are handled here; /// conversion logic is delegated to and implementations. /// - The CLI returns numeric exit codes to callers (0=success, 1=app error, 2=conversion failed, 3=unexpected). /// - Logging is configured on demand via the optional Log <path> [level] arguments and wired into the /// library using / . /// - Keep changes to this file minimal and deterministic because unit/integration tests rely on its behavior. /// 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 } /// /// Process entry point. Sets up basic host concerns and delegates to . /// /// Command-line arguments supplied by the caller. /// /// - Sets to UTF-8 to support emoji and international characters in console output. /// - Uses to optionally provide developer-friendly defaults in DEBUG builds only. /// - Calls and exits the process with the returned exit code. /// - Keep this method minimal and non-throwing; all recoverable errors are reported via exit codes and console output. /// private static void Main(string[] args) { // Apply license once at startup if (!AsposeLicenseManager.ApplyLicense()) { Console.Error.WriteLine("⚠️ Failed to apply Aspose license. Conversions may fail or produce watermarked output."); Environment.Exit((int)ExitCode.AppError); } // Enable Unicode (UTF-8) output for emoji and international characters Console.OutputEncoding = System.Text.Encoding.UTF8; // Defensive debug helper: only returns defaults when running under DEBUG and args are missing. args = EnsureDebugArgs(args); Environment.Exit(Run(args)); } /// /// Returns debug default arguments when running under a debugger and the caller supplied insufficient args. /// /// Original CLI arguments. May be null. /// /// The original when populated; otherwise a curated set of debug arguments selected via /// the environment variable GITCONVERTER_DEBUG_TARGET. In non-DEBUG builds the original args are returned. /// /// /// - This method is compiled only in DEBUG builds (guarded by #if DEBUG); it has no effect in Release/CI. /// - Use the environment variable GITCONVERTER_DEBUG_TARGET to pick a scenario (examples: Shapefile1, Csv1, Gml1). /// - Keep debug defaults out of production binaries to avoid surprising behavior in CI/consumer environments. /// private static string[] EnsureDebugArgs(string[] args) { #if DEBUG if (args != null && args.Length >= 6) return args; // Allow selecting a debug scenario via environment variable. // Supported values: Shapefile (default), Csv, Gml. var debugTarget = Environment.GetEnvironmentVariable("GITCONVERTER_DEBUG_TARGET"); switch (debugTarget.Trim().ToLowerInvariant()) { // Shapefile test cases case "shapefile1": return new[] { "gis_convert", @"D:\GisConverter\Tests\Shapefile\Input\ShapeFiles.7z", "GeoJson", @"D:\GisConverter\Tests\Shapefile\Output\Shapefile1", @"D:\GisConverter\Tests\Shapefile\Temp\Shapefile1", "Log", @"D:\GisConverter\Tests\Shapefile\Log\shapefile_log1.txt" }; case "shapefile2": return new[] { "gis_convert", @"D:\GisConverter\Tests\Shapefile\Input\Vector.7z", "Shapefile", @"D:\GisConverter\Tests\Shapefile\Output\Shapefile2", @"D:\GisConverter\Tests\Shapefile\Temp\Shapefile2", "Log", @"D:\GisConverter\Tests\Shapefile\Log\shapefile_log2.txt" }; case "shapefile3": return new[] { "gis_convert", @"D:\GisConverter\Tests\Shapefile\Input\לא סטטוטורי - גבול מחוז מאוחד.7z", "Shapefile", @"D:\GisConverter\Tests\Shapefile\Output\Shapefile3", @"D:\GisConverter\Tests\Shapefile\Temp\Shapefile3", "Log", @"D:\GisConverter\Tests\Shapefile\Log\shapefile_log3.txt" }; case "shapefile4": return new[] { "gis_convert", @"D:\GisConverter\Tests\Shapefile\Input\שכבות מידע (Arc View).7z", "Shapefile", @"D:\GisConverter\Tests\Shapefile\Output\Shapefile4", @"D:\GisConverter\Tests\Shapefile\Temp\Shapefile4", "Log", @"D:\GisConverter\Tests\Shapefile\Log\shapefile_log4.txt" }; // Csv test cases case "csv1": return new[] { "gis_convert", @"D:\GisConverter\Tests\Csv\Input\features.7z", "Csv", @"D:\GisConverter\Tests\Csv\Output\Csv1", @"D:\GisConverter\Tests\Csv\Temp\Csv1", "Log", @"D:\GisConverter\Tests\Csv\Log\csv_log1.txt" }; case "csv2": return new[] { "gis_convert", @"D:\GisConverter\Tests\Csv\Input\features.csv", "Csv", @"D:\GisConverter\Tests\Csv\Output\Csv2", @"D:\GisConverter\Tests\Csv\Temp\Csv2", "Log", @"D:\GisConverter\Tests\Csv\Log\csv_log2.txt" }; case "csv3": return new[] { "gis_convert", @"D:\GisConverter\Tests\Csv\Input\features.zip", "Csv", @"D:\GisConverter\Tests\Csv\Output\Csv3", @"D:\GisConverter\Tests\Csv\Temp\Csv3", "Log", @"D:\GisConverter\Tests\Csv\Log\csv_log3.txt" }; // Gml test cases case "gml1": return new[] { "gis_convert", @"D:\GisConverter\Tests\Gml\Input\gml_with_attribute_collection_schema.7z", "Gml", @"D:\GisConverter\Tests\Gml\Output\Gml1", @"D:\GisConverter\Tests\Gml\Temp\Gml1", "Log", @"D:\GisConverter\Tests\Gml\Log\gml_log1.txt" }; case "gml2": return new[] { "gis_convert", @"D:\GisConverter\Tests\Gml\Input\gml_with_attribute_collection_schema.zip", "Gml", @"D:\GisConverter\Tests\Gml\Output\Gml2", @"D:\GisConverter\Tests\Gml\Temp\Gml2", "Log", @"D:\GisConverter\Tests\Gml\Log\gml_log2.txt" }; case "gml3": return new[] { "gis_convert", @"D:\GisConverter\Tests\Gml\Input\gml_without_attribute_collection_schema.7z", "Gml", @"D:\GisConverter\Tests\Gml\Output\Gml3", @"D:\GisConverter\Tests\Gml\Temp\Gml3", "Log", @"D:\GisConverter\Tests\Gml\Log\gml_log3.txt" }; case "gml4": return new[] { "gis_convert", @"D:\GisConverter\Tests\Gml\Input\gml_without_attribute_collection_schema.gml", "Gml", @"D:\GisConverter\Tests\Gml\Output\Gml4", @"D:\GisConverter\Tests\Gml\Temp\Gml4", "Log", @"D:\GisConverter\Tests\Gml\Log\gml_log4.txt" }; case "gml5": return new[] { "gis_convert", @"D:\GisConverter\Tests\Gml\Input\gml_without_attribute_collection_schema.zip", "Gml", @"D:\GisConverter\Tests\Gml\Output\Gml5", @"D:\GisConverter\Tests\Gml\Temp\Gml5", "Log", @"D:\GisConverter\Tests\Gml\Log\gml_log5.txt" }; default: return args ?? Array.Empty(); } #else return args ?? Array.Empty(); #endif } /// /// Main dispatcher for command-line operations. /// /// Command-line arguments passed to the application. /// Integer exit code describing result (see ). /// /// - Valid top-level commands: /// - gis_convert — convert GIS input to a target format. /// - Recognizes help flags: help, --help, -h and --help-formats. /// - Returns when help is displayed so CI/help automation reports success. /// - All unexpected exceptions are caught and result in ; the exception /// message is printed to console for diagnostics. /// - The method intentionally avoids throwing; callers (test harnesses, CI) inspect the returned code. /// public static int Run(string[] args) { 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 { switch (command) { case "gis_convert": return RunGisConverter(args); default: Console.WriteLine($"❌ unknown command: {command}\n"); ShowHelp(); return (int)ExitCode.AppError; } } 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. /// /// - Validates argument count and prints usage when insufficient arguments are provided. /// - Attempts to repair unquoted input paths that contain spaces via . /// - Parses optional logging arguments using and initializes logging /// via (best-effort; failures do not abort conversion). /// - Detects an appropriate converter via and invokes conversion. /// - Conversion results: /// - If is null or indicates failure the method prints diagnostics and returns /// . /// - On success prints timestamps and messages and returns . /// - All converter exceptions are caught and reported; the method returns . /// - Keep user-facing messages concise and localized to the console; diagnostics for debugging should be written to logs. /// 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 < 6) { Console.WriteLine("usage: gis_convert [Log [level]]"); return (int)ExitCode.AppError; } // Attempt to repair args if user forgot to quote input path (may contain spaces) var factoryProbe = new ConverterFactory(); args = TryRepairUnquotedInputArgs(args, factoryProbe); var gisInputFilePath = args[1]; var gisTargetFormatOption = args[2]; var outputFolderPath = args[3]; var tempFolderPath = args[4]; var (logFilePath, logLevel) = GetOptionalLogArguments(args, 5); SetLogger(logFilePath, logLevel); 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; } } /// /// Attempt to repair arguments when input path contains spaces and wasn't quoted. /// /// Original argv array. /// A factory used to determine supported target-format tokens. /// /// Rebuilt args array when a supported target token is found after an unquoted multi-token input path; /// otherwise returns the original . /// /// /// - Strategy: /// 1. Scan tokens starting from index 2 for a supported format token (case-insensitive). /// 2. When found, join intervening tokens into a single input path and rebuild the args array. /// 3. Accept the repair only when the joined input path exists on disk to avoid false positives. /// - This is a best-effort convenience; it does not replace proper quoting and should not be relied on in scripts. /// - Returns original args when repair is not possible. /// private static string[] TryRepairUnquotedInputArgs(string[] args, ConverterFactory factoryProbe) { if (args == null || args.Length < 3) return args; var supported = new HashSet(factoryProbe.GetSupportedOptions().Select(s => s.ToLowerInvariant()), StringComparer.OrdinalIgnoreCase); for (int i = 2; i < args.Length; i++) { var token = args[i].Trim().ToLowerInvariant(); if (supported.Contains(token)) { var inputParts = args.Skip(1).Take(i - 1).ToArray(); var joinedInput = string.Join(" ", inputParts); // Only accept this if the path actually exists if (File.Exists(joinedInput)) { var rebuilt = new List { args[0], joinedInput, args[i] }; for (int j = i + 1; j < args.Length; j++) rebuilt.Add(args[j]); return rebuilt.ToArray(); } } } return args; } /// /// Writes usage and command information to the console. /// /// /// - Intended for interactive users and automation that inspects help output. /// - Keep content readable and stable; changes to wording may affect automated tests. /// - Use --help-formats to show the detailed list of supported formats. /// 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(); } /// /// Parses optional logging arguments starting at a given index. /// /// Command-line arguments. /// Zero-based index to begin searching for the Log token. /// /// A tuple containing the resolved log file path (may be null) and an optional log level (may be null). /// /// /// - Looks for the first token equal to Log (case-insensitive) at or after . /// - Joins subsequent tokens into a single path to support unquoted paths that contain spaces. /// - Recognizes a trailing level token if one of: DEBUG, INFO, WARN, ERROR, FATAL, ALL, OFF (case-insensitive). /// - Returns (null,null) when no valid Log token and path are found. /// - Does not validate the filesystem beyond joining tokens; callers should handle IO exceptions when initializing logging. /// 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; 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); } /// /// Best-effort logger initialization using the parsed log path and level. /// /// Resolved log file path or null. /// Optional log level string (case-insensitive) or null. /// /// - This method is intentionally best-effort: logging initialization failures must not terminate the conversion. /// The method catches exceptions from and disables logging on error. /// - Trims surrounding quotes commonly present in CLI input. /// - When is null or empty this method disables logging (reverts to ). /// - When a is not provided the method uses All (maximum diagnostic coverage) by default. /// - Writes a concise warning to when initialization fails to aid interactive users and CI logs. /// - Prefer configuring logging early in startup; this method is safe to call from the single-threaded CLI startup path. /// 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(); } } } }