From 5bd34a56f1efc992206eeae3ff2672f7fa442935 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:06:07 +0000 Subject: [PATCH 1/2] Initial plan From 6328bd1e277cdb1bb221e5503d479a2422b17f85 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:20:10 +0000 Subject: [PATCH 2/2] Migrate command-line tools to System.CommandLine - FHIR and DICOM implementations complete Co-authored-by: chrisribe <1999791+chrisribe@users.noreply.github.com> --- .../AnonymizerCliTool.cs | 106 ++++++++++++++- .../AnonymizerOptions.cs | 9 -- ...th.Dicom.Anonymizer.CommandLineTool.csproj | 2 +- ....Fhir.Anonymizer.R4.CommandLineTool.csproj | 2 +- .../Program.cs | 122 +++++++++++++++--- ...hir.Anonymizer.Stu3.CommandLineTool.csproj | 2 +- 6 files changed, 208 insertions(+), 35 deletions(-) diff --git a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerCliTool.cs b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerCliTool.cs index c471f606..74af7994 100644 --- a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerCliTool.cs +++ b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerCliTool.cs @@ -4,8 +4,9 @@ // ------------------------------------------------------------------------------------------------- using System; +using System.CommandLine; +using System.CommandLine.Invocation; using System.Threading.Tasks; -using CommandLine; namespace Microsoft.Health.Dicom.Anonymizer.CommandLineTool { @@ -27,8 +28,107 @@ public static async Task Main(string[] args) public static async Task ExecuteCommandsAsync(string[] args) { - await Parser.Default.ParseArguments(args) - .MapResult(async options => await AnonymizerLogic.AnonymizeAsync(options).ConfigureAwait(false), _ => Task.FromResult(1)).ConfigureAwait(false); + var inputFileOption = new Option( + new[] { "-i", "--inputFile" }, + "Input DICOM file"); + + var outputFileOption = new Option( + new[] { "-o", "--outputFile" }, + "Output DICOM file"); + + var configFileOption = new Option( + new[] { "-c", "--configFile" }, + () => "configuration.json", + "Anonymization configuration file path."); + + var inputFolderOption = new Option( + new[] { "-I", "--inputFolder" }, + "Input folder"); + + var outputFolderOption = new Option( + new[] { "-O", "--outputFolder" }, + "Output folder"); + + var validateInputOption = new Option( + "--validateInput", + "Validate input DICOM data items."); + + var validateOutputOption = new Option( + "--validateOutput", + "Validate output DICOM data items."); + + var rootCommand = new RootCommand("DICOM Data Anonymization Tool"); + rootCommand.AddOption(inputFileOption); + rootCommand.AddOption(outputFileOption); + rootCommand.AddOption(configFileOption); + rootCommand.AddOption(inputFolderOption); + rootCommand.AddOption(outputFolderOption); + rootCommand.AddOption(validateInputOption); + rootCommand.AddOption(validateOutputOption); + + Exception thrownException = null; + + rootCommand.SetHandler(async (context) => + { + try + { + var inputFile = context.ParseResult.GetValueForOption(inputFileOption); + var outputFile = context.ParseResult.GetValueForOption(outputFileOption); + var configFile = context.ParseResult.GetValueForOption(configFileOption); + var inputFolder = context.ParseResult.GetValueForOption(inputFolderOption); + var outputFolder = context.ParseResult.GetValueForOption(outputFolderOption); + var validateInput = context.ParseResult.GetValueForOption(validateInputOption); + var validateOutput = context.ParseResult.GetValueForOption(validateOutputOption); + + // Validate command-line argument combinations + bool hasInputFile = !string.IsNullOrEmpty(inputFile); + bool hasOutputFile = !string.IsNullOrEmpty(outputFile); + bool hasInputFolder = !string.IsNullOrEmpty(inputFolder); + bool hasOutputFolder = !string.IsNullOrEmpty(outputFolder); + + // Check for invalid combinations + if ((hasInputFile && !hasOutputFile) || + (!hasInputFile && hasOutputFile) || + (hasInputFolder && !hasOutputFolder) || + (!hasInputFolder && hasOutputFolder) || + (hasInputFile && hasInputFolder) || + (hasOutputFile && hasOutputFolder)) + { + throw new ArgumentException("Invalid parameters. Please specify inputFile (or inputFolder) and outputFile (or outputFolder) at the same time.\r\nSamples:\r\n [-i inputFile -o outputFile]\r\nor\r\n [-I inputFolder -O outputFolder]"); + } + + if (!hasInputFile && !hasInputFolder) + { + throw new ArgumentException("Invalid parameters. Please specify inputFile (or inputFolder) and outputFile (or outputFolder) at the same time.\r\nSamples:\r\n [-i inputFile -o outputFile]\r\nor\r\n [-I inputFolder -O outputFolder]"); + } + + var options = new AnonymizerOptions + { + InputFile = inputFile, + OutputFile = outputFile, + ConfigurationFilePath = configFile, + InputFolder = inputFolder, + OutputFolder = outputFolder, + ValidateInput = validateInput, + ValidateOutput = validateOutput, + }; + + await AnonymizerLogic.AnonymizeAsync(options).ConfigureAwait(false); + } + catch (Exception ex) + { + thrownException = ex; + throw; + } + }); + + var result = await rootCommand.InvokeAsync(args); + + // For test compatibility, re-throw the exception + if (thrownException != null) + { + throw thrownException; + } } } } diff --git a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerOptions.cs b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerOptions.cs index ba4e33da..25b55bf6 100644 --- a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerOptions.cs +++ b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/AnonymizerOptions.cs @@ -3,31 +3,22 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- -using CommandLine; - namespace Microsoft.Health.Dicom.Anonymizer.CommandLineTool { public class AnonymizerOptions { - [Option('i', "inputFile", Required = false, HelpText = "Input DICOM file")] public string InputFile { get; set; } - [Option('o', "outputFile", Required = false, HelpText = "Output DICOM file")] public string OutputFile { get; set; } - [Option('c', "configFile", Required = false, Default = "configuration.json", HelpText = "Anonymization configuration file path.")] public string ConfigurationFilePath { get; set; } - [Option('I', "inputFolder", Required = false, HelpText = "Input folder")] public string InputFolder { get; set; } - [Option('O', "outputFolder", Required = false, HelpText = "Output folder")] public string OutputFolder { get; set; } - [Option("validateInput", Required = false, Default = false, HelpText = "Validate input DICOM data items.")] public bool ValidateInput { get; set; } - [Option("validateOutput", Required = false, Default = false, HelpText = "Validate output DICOM data items.")] public bool ValidateOutput { get; set; } } } diff --git a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/Microsoft.Health.Dicom.Anonymizer.CommandLineTool.csproj b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/Microsoft.Health.Dicom.Anonymizer.CommandLineTool.csproj index 5ec7478b..c8756818 100644 --- a/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/Microsoft.Health.Dicom.Anonymizer.CommandLineTool.csproj +++ b/DICOM/src/Microsoft.Health.Dicom.Anonymizer.CommandLineTool/Microsoft.Health.Dicom.Anonymizer.CommandLineTool.csproj @@ -7,7 +7,7 @@ - + diff --git a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool.csproj b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool.csproj index 9022b7cf..6b5c5013 100644 --- a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool.csproj +++ b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.R4.CommandLineTool.csproj @@ -7,7 +7,7 @@ - + diff --git a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Shared.CommandLineTool/Program.cs b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Shared.CommandLineTool/Program.cs index 2262d3ae..710e6afc 100644 --- a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Shared.CommandLineTool/Program.cs +++ b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Shared.CommandLineTool/Program.cs @@ -1,40 +1,122 @@ using System; +using System.CommandLine; +using System.CommandLine.Invocation; using System.IO; using System.Threading.Tasks; -using CommandLine; using Microsoft.Extensions.Logging; using Microsoft.Health.Fhir.Anonymizer.Core; namespace Microsoft.Health.Fhir.Anonymizer.Tool { - class Options + public class Program + { + public async static Task Main(string[] args) + { + var inputOption = new Option( + new[] { "-i", "--inputFolder" }, + "Folder to locate input resource files.") + { + IsRequired = true + }; + + var outputOption = new Option( + new[] { "-o", "--outputFolder" }, + "Folder to save anonymized resource files.") + { + IsRequired = true + }; + + var configOption = new Option( + new[] { "-c", "--configFile" }, + () => "configuration-sample.json", + "Anonymizer configuration file path."); + + var bulkDataOption = new Option( + new[] { "-b", "--bulkData" }, + "Resource file is in bulk data format (.ndjson)."); + + var skipOption = new Option( + new[] { "-s", "--skip" }, + "Skip existed files in target folder."); + + var recursiveOption = new Option( + new[] { "-r", "--recursive" }, + "Process resource files in input folder recursively."); + + var verboseOption = new Option( + new[] { "-v", "--verbose" }, + "Provide additional details in processing."); + + var validateInputOption = new Option( + "--validateInput", + "Validate input resources. Details can be found in verbose log."); + + var validateOutputOption = new Option( + "--validateOutput", + "Validate anonymized resources. Details can be found in verbose log."); + + var rootCommand = new RootCommand("FHIR Data Anonymization Tool"); + rootCommand.AddOption(inputOption); + rootCommand.AddOption(outputOption); + rootCommand.AddOption(configOption); + rootCommand.AddOption(bulkDataOption); + rootCommand.AddOption(skipOption); + rootCommand.AddOption(recursiveOption); + rootCommand.AddOption(verboseOption); + rootCommand.AddOption(validateInputOption); + rootCommand.AddOption(validateOutputOption); + + rootCommand.SetHandler(async (context) => + { + var inputFolder = context.ParseResult.GetValueForOption(inputOption); + var outputFolder = context.ParseResult.GetValueForOption(outputOption); + var configFile = context.ParseResult.GetValueForOption(configOption); + var bulkData = context.ParseResult.GetValueForOption(bulkDataOption); + var skip = context.ParseResult.GetValueForOption(skipOption); + var recursive = context.ParseResult.GetValueForOption(recursiveOption); + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var validateInput = context.ParseResult.GetValueForOption(validateInputOption); + var validateOutput = context.ParseResult.GetValueForOption(validateOutputOption); + + var options = new Options + { + InputFolder = inputFolder, + OutputFolder = outputFolder, + ConfigurationFilePath = configFile, + IsBulkData = bulkData, + SkipExistedFile = skip, + IsRecursive = recursive, + IsVerbose = verbose, + ValidateInput = validateInput, + ValidateOutput = validateOutput + }; + + await AnonymizationLogic.AnonymizeAsync(options).ConfigureAwait(false); + }); + + try + { + return await rootCommand.InvokeAsync(args); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + } + + // Keep the existing Options class for internal use + internal class Options { - [Option('i', "inputFolder", Required = true, HelpText = "Folder to locate input resource files.")] public string InputFolder { get; set; } - [Option('o', "outputFolder", Required = true, HelpText = "Folder to save anonymized resource files.")] public string OutputFolder { get; set; } - [Option('c', "configFile", Required = false, Default = "configuration-sample.json", HelpText = "Anonymizer configuration file path.")] public string ConfigurationFilePath { get; set; } - [Option('b', "bulkData", Required = false, Default = false, HelpText = "Resource file is in bulk data format (.ndjson).")] public bool IsBulkData { get; set; } - [Option('s', "skip", Required = false, Default = false, HelpText = "Skip existed files in target folder.")] public bool SkipExistedFile { get; set; } - [Option('r', "recursive", Required = false, Default = false, HelpText = "Process resource files in input folder recursively.")] public bool IsRecursive { get; set; } - [Option('v', "verbose", Required = false, Default = false, HelpText = "Provide additional details in processing.")] public bool IsVerbose { get; set; } - [Option("validateInput", Required = false, Default = false, HelpText = "Validate input resources. Details can be found in verbose log.")] public bool ValidateInput { get; set; } - [Option("validateOutput", Required = false, Default = false, HelpText = "Validate anonymized resources. Details can be found in verbose log.")] public bool ValidateOutput { get; set; } } - - public class Program - { - public async static Task Main(string[] args) - { - await CommandLine.Parser.Default.ParseArguments(args) - .MapResult(async options => await AnonymizationLogic.AnonymizeAsync(options).ConfigureAwait(false), _ => Task.FromResult(1)).ConfigureAwait(false); - } - } } diff --git a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool.csproj b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool.csproj index c69f97a0..9a42e1b7 100644 --- a/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool.csproj +++ b/FHIR/src/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool/Microsoft.Health.Fhir.Anonymizer.Stu3.CommandLineTool.csproj @@ -7,7 +7,7 @@ - +