From 9bf0c29add9d0ba8b37a4fcbc614b2dab0c221dd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Jan 2026 09:40:38 -0500 Subject: [PATCH] refactor(configmanager): simplify SecureStore UI with unified info view Consolidate SecureStoreLockedFormView and SecureStoreUnlockedFormView into a single SecureStoreInfoFormView that displays store status and metadata. Simplifies MainWindowViewModel by removing redundant state management. Also adds design docs for RegexTransformer feature. --- NEW/src/JdeScoping.Host/appsettings.json | 11 +- .../Application/StoreUseCases.cs | 54 +- .../Models/ConfigModel.cs | 52 + .../Services/AvaloniaDialogService.cs | 23 + .../Services/IDialogService.cs | 7 + .../SecureStore/ISecureStoreManager.cs | 21 +- .../SecureStore/SecureStoreManager.cs | 72 +- .../Forms/ExcelExportFormViewModel.cs | 72 +- .../Forms/SecureStoreInfoFormViewModel.cs | 40 + .../Forms/SecureStoreLockedFormViewModel.cs | 52 - .../Forms/SecureStoreUnlockedFormViewModel.cs | 92 - .../ViewModels/MainWindowViewModel.cs | 458 ++--- .../ViewModels/TreeNodeViewModel.cs | 37 +- .../Views/Forms/ExcelExportFormView.axaml | 73 +- .../Views/Forms/SecureStoreInfoFormView.axaml | 75 + ...ml.cs => SecureStoreInfoFormView.axaml.cs} | 4 +- .../Forms/SecureStoreLockedFormView.axaml | 82 - .../Forms/SecureStoreUnlockedFormView.axaml | 125 -- .../SecureStoreUnlockedFormView.axaml.cs | 11 - .../SecureStore/SecureStoreManagerTests.cs | 41 - .../Forms/ExcelExportFormViewModelTests.cs | 53 +- .../SecureStoreLockedFormViewModelTests.cs | 115 -- .../SecureStoreUnlockedFormViewModelTests.cs | 327 ---- .../ViewModels/MainWindowViewModelTests.cs | 65 +- .../ViewModels/TreeNodeViewModelTests.cs | 114 -- PLANS/2025-01-22-regex-transformer-design.md | 216 +++ ...-01-22-regex-transformer-implementation.md | 1521 +++++++++++++++++ PLANS/securestore-auto-init-plan.md | 525 ++++++ 28 files changed, 2811 insertions(+), 1527 deletions(-) create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs delete mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs delete mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs create mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml rename NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/{SecureStoreLockedFormView.axaml.cs => SecureStoreInfoFormView.axaml.cs} (52%) delete mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml delete mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml delete mode 100644 NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs delete mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs delete mode 100644 NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs create mode 100644 PLANS/2025-01-22-regex-transformer-design.md create mode 100644 PLANS/2025-01-22-regex-transformer-implementation.md create mode 100644 PLANS/securestore-auto-init-plan.md diff --git a/NEW/src/JdeScoping.Host/appsettings.json b/NEW/src/JdeScoping.Host/appsettings.json index 8e442bf..48be1f6 100644 --- a/NEW/src/JdeScoping.Host/appsettings.json +++ b/NEW/src/JdeScoping.Host/appsettings.json @@ -145,9 +145,18 @@ "RequiredKeys": [ "RsaPrivateKey", "ExcelExport:CriteriaSheetPassword", - "ExcelExport:DataSheetPassword" + "ExcelExport:DataSheetPassword", + "JdeUser", + "JdePassword", + "GiwUser", + "GiwPassword", + "CmsUser", + "CmsPassword" ] }, + "Pipelines": { + "ConfigPath": "Pipelines/pipelines.json" + }, "WorkProcessor": { "Enabled": true, "WorkInterval": "00:00:05", diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs index 8dd9840..e864a73 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Application/StoreUseCases.cs @@ -23,55 +23,33 @@ public class StoreUseCases } /// - /// Creates a new store with either key file or password authentication. + /// Creates a new store with key file authentication. /// /// The path where the store will be created. - /// The path to the key file, or null for password-based authentication. - /// The password for authentication, or null for key file-based authentication. - public void CreateStore(string storePath, string? keyFilePath, string? password) + /// The path to the key file. + public void CreateStore(string storePath, string keyFilePath) { - _logger.LogInformation("Creating store at {StorePath}", storePath); + if (string.IsNullOrEmpty(keyFilePath)) + throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath)); - if (!string.IsNullOrEmpty(keyFilePath)) - { - _storeManager.CreateStore(storePath, keyFilePath); - _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath); - } - else if (!string.IsNullOrEmpty(password)) - { - _storeManager.CreateStoreWithPassword(storePath, password); - _logger.LogInformation("Password-protected store created"); - } - else - { - throw new ArgumentException("Either key file path or password must be provided."); - } + _logger.LogInformation("Creating store at {StorePath}", storePath); + _storeManager.CreateStore(storePath, keyFilePath); + _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath); } /// - /// Opens an existing store with either key file or password authentication. + /// Opens an existing store with key file authentication. /// /// The path to the existing store. - /// The path to the key file, or null for password-based authentication. - /// The password for authentication, or null for key file-based authentication. - public void OpenStore(string storePath, string? keyFilePath, string? password) + /// The path to the key file. + public void OpenStore(string storePath, string keyFilePath) { - _logger.LogInformation("Opening store at {StorePath}", storePath); + if (string.IsNullOrEmpty(keyFilePath)) + throw new ArgumentException("Key file path must be provided.", nameof(keyFilePath)); - if (!string.IsNullOrEmpty(keyFilePath)) - { - _storeManager.OpenStore(storePath, keyFilePath); - _logger.LogDebug("Store opened with key file"); - } - else if (!string.IsNullOrEmpty(password)) - { - _storeManager.OpenStoreWithPassword(storePath, password); - _logger.LogDebug("Store opened with password"); - } - else - { - throw new ArgumentException("Either key file path or password must be provided."); - } + _logger.LogInformation("Opening store at {StorePath}", storePath); + _storeManager.OpenStore(storePath, keyFilePath); + _logger.LogDebug("Store opened with key file"); } /// diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs index 076458e..7c12543 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Models/ConfigModel.cs @@ -41,6 +41,16 @@ public class ConfigModel /// Gets or sets the connection strings for external data sources. /// public Dictionary ConnectionStrings { get; set; } = new(); + + /// + /// Gets or sets the secure store configuration. + /// + public SecureStoreSection SecureStore { get; set; } = new(); + + /// + /// Gets or sets the pipelines configuration. + /// + public PipelinesSection Pipelines { get; set; } = new(); } public class DataSyncSection @@ -230,3 +240,45 @@ public class ExcelExportSection /// public string TimezoneAbbreviation { get; set; } = "CT"; } + +/// +/// Configuration section for the secure store. +/// +public class SecureStoreSection +{ + /// + /// Gets or sets the path to the secure store file. + /// + public string StorePath { get; set; } = "data/secrets.json"; + + /// + /// Gets or sets the path to the key file for decryption. + /// + public string KeyFilePath { get; set; } = "data/secrets.key"; + + /// + /// Gets or sets the environment variable name for the master key. + /// + public string MasterKeyEnvVar { get; set; } = "SCOPINGTOOL_MASTER_KEY"; + + /// + /// Gets or sets a value indicating whether to auto-create the store if it doesn't exist. + /// + public bool AutoCreateStore { get; set; } = true; + + /// + /// Gets or sets the list of required secret keys that must exist in the store. + /// + public List RequiredKeys { get; set; } = []; +} + +/// +/// Configuration section for pipelines. +/// +public class PipelinesSection +{ + /// + /// Gets or sets the path to the pipelines configuration file. + /// + public string ConfigPath { get; set; } = "Pipelines/pipelines.json"; +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs index 3df6570..93fd47a 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/AvaloniaDialogService.cs @@ -39,6 +39,29 @@ public class AvaloniaDialogService : IDialogService return folders.Count > 0 ? folders[0].Path.LocalPath : null; } + /// + public async Task ShowFilePickerAsync(string? title = null) + { + var window = _getMainWindow(); + if (window == null) + return null; + + var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title ?? "Select File", + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("JSON Files") + { + Patterns = new[] { "*.json" } + } + } + }); + + return files.Count > 0 ? files[0].Path.LocalPath : null; + } + /// public async Task ShowMessageAsync(string title, string message) { diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs index bce7db0..5d0a5b1 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IDialogService.cs @@ -13,6 +13,13 @@ public interface IDialogService /// The selected folder path, or null if cancelled. Task ShowFolderPickerAsync(string? title = null); + /// + /// Shows a file picker dialog filtered for JSON files. + /// + /// Optional title for the dialog. + /// The selected file path, or null if cancelled. + Task ShowFilePickerAsync(string? title = null); + /// /// Shows a message dialog. /// diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs index 5bc6747..b8e2987 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/ISecureStoreManager.cs @@ -27,13 +27,6 @@ public interface ISecureStoreManager /// Path for the key file (.key). void CreateStore(string storePath, string keyFilePath); - /// - /// Creates a new store secured with a password. - /// - /// Path for the new store file (.json). - /// Password to encrypt the store. - void CreateStoreWithPassword(string storePath, string password); - /// /// Opens an existing store using a key file. /// @@ -41,13 +34,6 @@ public interface ISecureStoreManager /// Path to the key file (.key). void OpenStore(string storePath, string keyFilePath); - /// - /// Opens an existing store using a password. - /// - /// Path to the store file (.json). - /// Password to decrypt the store. - void OpenStoreWithPassword(string storePath, string password); - /// /// Closes the currently open store without saving. /// @@ -84,6 +70,13 @@ public interface ISecureStoreManager /// The secret key to remove. void RemoveSecret(string key); + /// + /// Ensures all required keys exist in the store, creating blank values for any missing keys. + /// + /// List of keys that must exist. + /// List of keys that were added. + IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys); + /// /// Generates a new key file for use with store encryption. /// diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs index 2f27838..10ca764 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStore/SecureStoreManager.cs @@ -72,29 +72,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable _logger.LogInformation("Store created with key file: {KeyFilePath}", keyFilePath); } - /// - public void CreateStoreWithPassword(string storePath, string password) - { - ThrowIfDisposed(); - _logger.LogInformation("Creating password-protected store at {StorePath}", storePath); - CloseStoreInternal(); - - if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be empty.", nameof(password)); - - EnsureDirectory(storePath); - - _secretsManager = SecretsManager.CreateStore(); - _secretsManager.LoadKeyFromPassword(password); - - _currentStorePath = storePath; - _keys.Clear(); - _hasUnsavedChanges = true; - - Save(); - _logger.LogInformation("Password-protected store created"); - } - /// public void OpenStore(string storePath, string keyFilePath) { @@ -117,28 +94,6 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable _logger.LogDebug("Store opened with key file, contains {KeyCount} keys", _keys.Count); } - /// - public void OpenStoreWithPassword(string storePath, string password) - { - ThrowIfDisposed(); - _logger.LogInformation("Opening store at {StorePath} with password", storePath); - CloseStoreInternal(); - - if (!File.Exists(storePath)) - throw new FileNotFoundException("Store file not found.", storePath); - - if (string.IsNullOrEmpty(password)) - throw new ArgumentException("Password cannot be empty.", nameof(password)); - - _secretsManager = SecretsManager.LoadStore(storePath); - _secretsManager.LoadKeyFromPassword(password); - - _currentStorePath = storePath; - LoadKeysMetadata(); - _hasUnsavedChanges = false; - _logger.LogDebug("Store opened with password, contains {KeyCount} keys", _keys.Count); - } - /// public void CloseStore() { @@ -237,6 +192,33 @@ public class SecureStoreManager : ISecureStoreManager, IDisposable _hasUnsavedChanges = true; } + /// + public IReadOnlyList EnsureRequiredKeys(IEnumerable requiredKeys) + { + ThrowIfDisposed(); + + if (_secretsManager == null) + throw new InvalidOperationException("No store is currently open."); + + var addedKeys = new List(); + foreach (var key in requiredKeys) + { + if (!_keys.Contains(key)) + { + _logger.LogInformation("Adding missing required key: {Key}", key); + SetSecret(key, string.Empty); + addedKeys.Add(key); + } + } + + if (addedKeys.Count > 0) + { + Save(); + } + + return addedKeys.AsReadOnly(); + } + /// public void GenerateKeyFile(string path) { diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs index 5b32dfd..9b99de5 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/ExcelExportFormViewModel.cs @@ -9,45 +9,25 @@ public class ExcelExportFormViewModel : ViewModelBase { private readonly ExcelExportSection _model; private readonly Action _onChanged; + private string _selectedTimezone; + + /// + /// Gets the list of available system timezones (IANA format for cross-platform compatibility). + /// + public static IReadOnlyList AvailableTimezones { get; } = TimeZoneInfo + .GetSystemTimeZones() + .Select(tz => tz.Id) + .OrderBy(id => id) + .ToList() + .AsReadOnly(); public ExcelExportFormViewModel(ExcelExportSection model, Action onChanged) { _model = model ?? throw new ArgumentNullException(nameof(model)); _onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged)); - } - /// - /// Gets or sets the password for protecting the criteria worksheet. - /// - public string CriteriaSheetPassword - { - get => _model.CriteriaSheetPassword; - set - { - if (_model.CriteriaSheetPassword != value) - { - _model.CriteriaSheetPassword = value; - OnPropertyChanged(); - _onChanged(); - } - } - } - - /// - /// Gets or sets the password for protecting the data worksheet. - /// - public string DataSheetPassword - { - get => _model.DataSheetPassword; - set - { - if (_model.DataSheetPassword != value) - { - _model.DataSheetPassword = value; - OnPropertyChanged(); - _onChanged(); - } - } + // Initialize selected timezone from model (model defaults to "America/Chicago") + _selectedTimezone = _model.TimezoneId; } /// @@ -119,36 +99,20 @@ public class ExcelExportFormViewModel : ViewModelBase } /// - /// Gets or sets the time zone identifier for date/time conversions. + /// Gets or sets the selected timezone from the dropdown. /// - public string TimezoneId + public string SelectedTimezone { - get => _model.TimezoneId; + get => _selectedTimezone; set { - if (_model.TimezoneId != value) + if (_selectedTimezone != value) { + _selectedTimezone = value; _model.TimezoneId = value; OnPropertyChanged(); _onChanged(); } } } - - /// - /// Gets or sets the time zone abbreviation for display purposes. - /// - public string TimezoneAbbreviation - { - get => _model.TimezoneAbbreviation; - set - { - if (_model.TimezoneAbbreviation != value) - { - _model.TimezoneAbbreviation = value; - OnPropertyChanged(); - _onChanged(); - } - } - } } diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs new file mode 100644 index 0000000..f8812f1 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreInfoFormViewModel.cs @@ -0,0 +1,40 @@ +namespace JdeScoping.ConfigManager.ViewModels.Forms; + +/// +/// View model for the SecureStore info panel shown when the store node is selected. +/// +public class SecureStoreInfoFormViewModel : ViewModelBase +{ + /// + /// Gets the instruction text to display. + /// + public string InstructionText => "Select a secret from the tree to view or edit its value."; + + /// + /// Gets the store path for display. + /// + public string StorePath { get; } + + /// + /// Gets the key file path for display. + /// + public string KeyFilePath { get; } + + /// + /// Gets the number of secrets in the store. + /// + public int SecretCount { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The path to the secure store file. + /// The path to the key file. + /// The number of secrets in the store. + public SecureStoreInfoFormViewModel(string storePath, string keyFilePath, int secretCount) + { + StorePath = storePath; + KeyFilePath = keyFilePath; + SecretCount = secretCount; + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs deleted file mode 100644 index 47f57bb..0000000 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreLockedFormViewModel.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Windows.Input; - -namespace JdeScoping.ConfigManager.ViewModels.Forms; - -/// -/// ViewModel for displaying a locked secure store with unlock capability. -/// -public class SecureStoreLockedFormViewModel : ViewModelBase -{ - private readonly Action _onUnlockRequested; - - /// - /// Initializes a new instance of the class. - /// - /// The name of the secure store. - /// The full path to the secure store file. - /// The last modified date of the store file. - /// The action to invoke when unlock is requested. - public SecureStoreLockedFormViewModel( - string storeName, - string storePath, - DateTime? lastModified, - Action onUnlockRequested) - { - StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName)); - StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath)); - LastModified = lastModified; - _onUnlockRequested = onUnlockRequested ?? throw new ArgumentNullException(nameof(onUnlockRequested)); - - UnlockCommand = new RelayCommand(_onUnlockRequested); - } - - /// - /// Gets the name of the secure store. - /// - public string StoreName { get; } - - /// - /// Gets the full path to the secure store file. - /// - public string StorePath { get; } - - /// - /// Gets the last modified date of the store file. - /// - public DateTime? LastModified { get; } - - /// - /// Gets the command to unlock the store. - /// - public ICommand UnlockCommand { get; } -} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs deleted file mode 100644 index 9b41796..0000000 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/Forms/SecureStoreUnlockedFormViewModel.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Windows.Input; - -namespace JdeScoping.ConfigManager.ViewModels.Forms; - -/// -/// ViewModel for displaying an unlocked secure store. -/// -public class SecureStoreUnlockedFormViewModel : ViewModelBase -{ - private readonly Action _onLockRequested; - private readonly Action _onAddSecretRequested; - private readonly Action _onSaveRequested; - private bool _hasUnsavedChanges; - - /// - /// Initializes a new instance of the class. - /// - /// The name of the secure store. - /// The full path to the secure store file. - /// The number of secrets in the store. - /// Whether the store has unsaved changes. - /// The action to invoke when lock is requested. - /// The action to invoke when adding a new secret is requested. - /// The action to invoke when save is requested. - public SecureStoreUnlockedFormViewModel( - string storeName, - string storePath, - int secretCount, - bool hasUnsavedChanges, - Action onLockRequested, - Action onAddSecretRequested, - Action onSaveRequested) - { - StoreName = storeName ?? throw new ArgumentNullException(nameof(storeName)); - StorePath = storePath ?? throw new ArgumentNullException(nameof(storePath)); - SecretCount = secretCount; - _hasUnsavedChanges = hasUnsavedChanges; - _onLockRequested = onLockRequested ?? throw new ArgumentNullException(nameof(onLockRequested)); - _onAddSecretRequested = onAddSecretRequested ?? throw new ArgumentNullException(nameof(onAddSecretRequested)); - _onSaveRequested = onSaveRequested ?? throw new ArgumentNullException(nameof(onSaveRequested)); - - LockCommand = new RelayCommand(_onLockRequested); - AddSecretCommand = new RelayCommand(_onAddSecretRequested); - SaveCommand = new RelayCommand(_onSaveRequested, () => HasUnsavedChanges); - } - - /// - /// Gets the name of the secure store. - /// - public string StoreName { get; } - - /// - /// Gets the full path to the secure store file. - /// - public string StorePath { get; } - - /// - /// Gets the number of secrets in the store. - /// - public int SecretCount { get; } - - /// - /// Gets or sets whether the store has unsaved changes. - /// - public bool HasUnsavedChanges - { - get => _hasUnsavedChanges; - set - { - if (SetProperty(ref _hasUnsavedChanges, value)) - { - // Notify that SaveCommand's CanExecute may have changed - (SaveCommand as RelayCommand)?.RaiseCanExecuteChanged(); - } - } - } - - /// - /// Gets the command to lock the store. - /// - public ICommand LockCommand { get; } - - /// - /// Gets the command to add a new secret. - /// - public ICommand AddSecretCommand { get; } - - /// - /// Gets the command to save the store. - /// - public ICommand SaveCommand { get; } -} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs index d923825..4d1fef2 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs @@ -37,10 +37,6 @@ public class MainWindowViewModel : ViewModelBase private ConfigModel? _appSettings; private PipelinesConfigModel? _pipelines; - // SecureStore state tracking - private readonly Dictionary _openStores = new(); - private TreeNodeViewModel? _selectedStoreNode; - /// /// Gets or sets the currently loaded configuration folder path. /// @@ -149,26 +145,11 @@ public class MainWindowViewModel : ViewModelBase /// public ICommand AddExistingStoreCommand { get; } - /// - /// Gets the command for unlocking a secure store. - /// - public ICommand UnlockStoreCommand { get; } - - /// - /// Gets the command for locking a secure store. - /// - public ICommand LockStoreCommand { get; } - /// /// Gets the command for saving a secure store. /// public ICommand SaveStoreCommand { get; } - /// - /// Gets the command for locking all open secure stores. - /// - public ICommand LockAllStoresCommand { get; } - /// /// Gets the command for generating a new key file. /// @@ -246,10 +227,7 @@ public class MainWindowViewModel : ViewModelBase // SecureStore commands NewStoreCommand = new AsyncRelayCommand(NewStoreAsync); AddExistingStoreCommand = new AsyncRelayCommand(AddExistingStoreAsync); - UnlockStoreCommand = new AsyncRelayCommand(UnlockStoreAsync, CanUnlockStore); - LockStoreCommand = new RelayCommand(LockStore, CanLockStore); SaveStoreCommand = new AsyncRelayCommand(SaveStoreAsync, CanSaveStore); - LockAllStoresCommand = new RelayCommand(LockAllStores, () => _openStores.Count > 0); GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync); AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret); DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret); @@ -300,69 +278,114 @@ public class MainWindowViewModel : ViewModelBase if (folder != null) { await LoadConfigAsync(folder); - await EnsureDefaultSecureStoreAsync(folder); } } /// - /// Ensures a default secure store exists and is loaded. - /// Creates one if it doesn't exist. + /// Initializes the SecureStore automatically on config load. + /// Creates the store if it doesn't exist and AutoCreateStore is true. + /// Opens the store and ensures all required keys exist. /// - private async Task EnsureDefaultSecureStoreAsync(string configFolder) + private async Task InitializeSecureStoreAsync() { - var defaultStorePath = Path.Combine(configFolder, "default.secrets.json"); - var defaultKeyPath = Path.Combine(configFolder, "default.secrets.key"); + if (_appSettings?.SecureStore == null) + return; + + if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected") + return; + + var secureStoreConfig = _appSettings.SecureStore; + + // Resolve paths relative to config folder + var storePath = Path.IsPathRooted(secureStoreConfig.StorePath) + ? secureStoreConfig.StorePath + : Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath); + + var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath) + ? secureStoreConfig.KeyFilePath + : Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath); try { - // Create default store if it doesn't exist - if (!File.Exists(defaultStorePath)) + if (!File.Exists(storePath)) { - _logger?.LogInformation("Creating default secure store at {Path}", defaultStorePath); + if (!secureStoreConfig.AutoCreateStore) + { + _logger?.LogWarning("SecureStore not found and AutoCreateStore is false"); + return; + } - _secureStoreManager.CreateStore(defaultStorePath, defaultKeyPath); + // Create new store with keyfile + _logger?.LogInformation("Creating SecureStore at {StorePath}", storePath); + _secureStoreManager.CreateStore(storePath, keyFilePath); - // Add some example secrets - _secureStoreManager.SetSecret("jde-password", ""); - _secureStoreManager.SetSecret("cms-password", ""); - _secureStoreManager.SetSecret("lotfinder-password", ""); - _secureStoreManager.Save(); - _secureStoreManager.CloseStore(); + // Update appsettings.json with actual paths + secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath); + secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath); + await SaveAppSettingsAsync(); + } + else + { + // Open existing store + if (!File.Exists(keyFilePath)) + { + if (_dialogService != null) + { + await _dialogService.ShowMessageAsync( + "SecureStore Error", + $"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file."); + } + return; + } - // Rebuild tree to show the new store - BuildTreeNodes(); + _secureStoreManager.OpenStore(storePath, keyFilePath); } - // Auto-unlock the default store if key file exists - if (File.Exists(defaultStorePath) && File.Exists(defaultKeyPath)) + // Ensure all required keys exist + if (secureStoreConfig.RequiredKeys?.Count > 0) { - // Find the default store node in the tree - var secureStoresFolder = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStoresFolder); - var defaultStoreNode = secureStoresFolder?.Children.FirstOrDefault(n => - n.StorePath != null && n.StorePath.EndsWith("default.secrets.json")); - - if (defaultStoreNode != null) + var addedKeys = _secureStoreManager.EnsureRequiredKeys(secureStoreConfig.RequiredKeys); + if (addedKeys.Count > 0) { - _secureStoreManager.OpenStore(defaultStorePath, defaultKeyPath); - defaultStoreNode.IsUnlocked = true; - _openStores[defaultStorePath] = defaultStoreNode; - RefreshStoreChildren(defaultStoreNode); - defaultStoreNode.IsExpanded = true; - - _logger?.LogInformation("Auto-unlocked default secure store"); + _logger?.LogInformation("Added {Count} missing required keys", addedKeys.Count); } } } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to initialize default secure store"); + _logger?.LogError(ex, "Failed to initialize SecureStore"); + if (_dialogService != null) + { + await _dialogService.ShowMessageAsync( + "SecureStore Error", + $"Failed to initialize SecureStore:\n\n{ex.Message}"); + } } - - await Task.CompletedTask; } /// - /// Opens a folder picker dialog to select a configuration folder. + /// Gets the relative path from a base path to a full path. + /// + private static string GetRelativePath(string basePath, string fullPath) + { + var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); + var fullUri = new Uri(fullPath); + return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar)); + } + + /// + /// Saves the appsettings.json file. + /// + private async Task SaveAppSettingsAsync() + { + if (_appSettings == null) return; + + var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json"); + await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings); + } + + /// + /// Opens a file picker dialog to select a configuration file. /// private async Task OpenFolderAsync() { @@ -372,10 +395,14 @@ public class MainWindowViewModel : ViewModelBase return; } - var folder = await _dialogService.ShowFolderPickerAsync("Select Configuration Folder"); - if (folder != null) + var filePath = await _dialogService.ShowFilePickerAsync("Select Configuration File"); + if (filePath != null) { - await LoadConfigAsync(folder); + var folder = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(folder)) + { + await LoadConfigAsync(folder); + } } } @@ -390,15 +417,20 @@ public class MainWindowViewModel : ViewModelBase ConfigFolderPath = folderPath; var appSettingsPath = Path.Combine(folderPath, "appsettings.json"); - var pipelinesPath = Path.Combine(folderPath, "Pipelines", "pipelines.json"); - _appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath); + // Use config-driven pipeline path + var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json"; + var pipelinesPath = Path.Combine(folderPath, pipelinesConfigPath); + if (File.Exists(pipelinesPath)) { _pipelines = await _configFileService.LoadPipelinesAsync(pipelinesPath); } + // Initialize SecureStore (auto-create if needed, open, sync required keys) + await InitializeSecureStoreAsync(); + BuildTreeNodes(); Validate(); @@ -438,48 +470,55 @@ public class MainWindowViewModel : ViewModelBase } TreeNodes.Add(pipelinesFolder); - // Secure Stores folder - var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true }; - DiscoverSecureStores(secureStoresFolder); - TreeNodes.Add(secureStoresFolder); + // Secure Store node (single store, secrets as direct children) + var secureStoreNode = CreateSecureStoreNode(); + if (secureStoreNode != null) + { + TreeNodes.Add(secureStoreNode); + } } /// - /// Discovers existing secure store files in the configuration folder. + /// Creates the SecureStore tree node with secrets as direct children. /// - /// The parent tree node to add discovered stores to. - private void DiscoverSecureStores(TreeNodeViewModel parentNode) + private TreeNodeViewModel? CreateSecureStoreNode() { + if (_appSettings?.SecureStore == null) + return null; + if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected") - return; + return null; - try + var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath) + ? _appSettings.SecureStore.StorePath + : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath); + + var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath) + ? _appSettings.SecureStore.KeyFilePath + : Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath); + + var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore) { - // Look for *.secrets.json files in the config folder - var secretsFiles = Directory.GetFiles(ConfigFolderPath, "*.secrets.json", SearchOption.TopDirectoryOnly); + StorePath = storePath, + KeyFilePath = keyFilePath, + SectionKey = storePath, + IsExpanded = true + }; - foreach (var filePath in secretsFiles) + // Add secrets as direct children if store is open + if (_secureStoreManager.IsStoreOpen) + { + foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k)) { - var fileName = Path.GetFileName(filePath); - var storeName = Path.GetFileNameWithoutExtension(fileName); - if (storeName.EndsWith(".secrets")) - storeName = storeName[..^8]; // Remove ".secrets" suffix for display - - var storeNode = new TreeNodeViewModel(storeName, "🔒", TreeNodeType.SecureStore) + var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret) { - StorePath = filePath, - SectionKey = filePath, - IsUnlocked = false + SecretKey = key }; - parentNode.Children.Add(storeNode); + storeNode.Children.Add(secretNode); } + } - _logger?.LogDebug("Discovered {Count} secure store files", secretsFiles.Length); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to discover secure store files"); - } + return storeNode; } /// @@ -492,34 +531,18 @@ public class MainWindowViewModel : ViewModelBase if (_selectedNode == null) { SelectedFormViewModel = null; - _selectedStoreNode = null; return; } // Handle SecureStore-related node types first switch (_selectedNode.NodeType) { - case TreeNodeType.SecureStoresFolder: - SelectedFormViewModel = null; // Show empty state or instructions - _selectedStoreNode = null; - return; - case TreeNodeType.SecureStore: - _selectedStoreNode = _selectedNode; - if (_selectedNode.IsUnlocked) - { - SelectedFormViewModel = CreateUnlockedStoreFormViewModel(_selectedNode); - } - else - { - SelectedFormViewModel = CreateLockedStoreFormViewModel(_selectedNode); - } + SelectedFormViewModel = CreateSecureStoreInfoFormViewModel(); RaiseSecureStoreCommandsCanExecuteChanged(); return; case TreeNodeType.Secret: - // Find the parent store node - _selectedStoreNode = FindParentStoreNode(_selectedNode); SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode); RaiseSecureStoreCommandsCanExecuteChanged(); return; @@ -529,11 +552,9 @@ public class MainWindowViewModel : ViewModelBase if (_appSettings == null) { SelectedFormViewModel = null; - _selectedStoreNode = null; return; } - _selectedStoreNode = null; SelectedFormViewModel = _selectedNode.SectionKey switch { "DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged), @@ -552,39 +573,15 @@ public class MainWindowViewModel : ViewModelBase } /// - /// Creates a form view model for a locked secure store. + /// Creates a form view model for the SecureStore info panel. /// - private SecureStoreLockedFormViewModel CreateLockedStoreFormViewModel(TreeNodeViewModel storeNode) + private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel() { - DateTime? lastModified = null; - if (!string.IsNullOrEmpty(storeNode.StorePath) && File.Exists(storeNode.StorePath)) - { - lastModified = File.GetLastWriteTime(storeNode.StorePath); - } + var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown"; + var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown"; + var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0; - return new SecureStoreLockedFormViewModel( - storeNode.Name, - storeNode.StorePath ?? string.Empty, - lastModified, - () => _ = UnlockStoreAsync()); - } - - /// - /// Creates a form view model for an unlocked secure store. - /// - private SecureStoreUnlockedFormViewModel CreateUnlockedStoreFormViewModel(TreeNodeViewModel storeNode) - { - var secretCount = storeNode.Children.Count; - var hasUnsavedChanges = _secureStoreManager.HasUnsavedChanges; - - return new SecureStoreUnlockedFormViewModel( - storeNode.Name, - storeNode.StorePath ?? string.Empty, - secretCount, - hasUnsavedChanges, - () => LockStore(), - () => _ = AddSecretAsync(), - () => _ = SaveStoreAsync()); + return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount); } /// @@ -628,34 +625,12 @@ public class MainWindowViewModel : ViewModelBase } } - /// - /// Finds the parent store node for a secret node. - /// - private TreeNodeViewModel? FindParentStoreNode(TreeNodeViewModel secretNode) - { - foreach (var rootNode in TreeNodes) - { - if (rootNode.NodeType == TreeNodeType.SecureStoresFolder) - { - foreach (var storeNode in rootNode.Children) - { - if (storeNode.Children.Contains(secretNode)) - return storeNode; - } - } - } - return null; - } - /// /// Raises CanExecuteChanged for all SecureStore commands. /// private void RaiseSecureStoreCommandsCanExecuteChanged() { - (UnlockStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); - (LockStoreCommand as RelayCommand)?.RaiseCanExecuteChanged(); (SaveStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); - (LockAllStoresCommand as RelayCommand)?.RaiseCanExecuteChanged(); (AddSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); (DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); } @@ -705,7 +680,8 @@ public class MainWindowViewModel : ViewModelBase // Save pipelines if loaded if (_pipelines != null) { - var pipelinesPath = Path.Combine(ConfigFolderPath, "Pipelines", "pipelines.json"); + var pipelinesConfigPath = _appSettings?.Pipelines?.ConfigPath ?? "Pipelines/pipelines.json"; + var pipelinesPath = Path.Combine(ConfigFolderPath, pipelinesConfigPath); if (File.Exists(pipelinesPath)) { await _backupService.CreateBackupAsync(pipelinesPath); @@ -932,33 +908,12 @@ public class MainWindowViewModel : ViewModelBase #region SecureStore Commands - /// - /// Determines whether a store can be unlocked. - /// - private bool CanUnlockStore() - { - return _selectedStoreNode != null - && _selectedStoreNode.NodeType == TreeNodeType.SecureStore - && !_selectedStoreNode.IsUnlocked; - } - - /// - /// Determines whether a store can be locked. - /// - private bool CanLockStore() - { - return _selectedStoreNode != null - && _selectedStoreNode.NodeType == TreeNodeType.SecureStore - && _selectedStoreNode.IsUnlocked; - } - /// /// Determines whether the current store can be saved. /// private bool CanSaveStore() { - return _selectedStoreNode != null - && _selectedStoreNode.IsUnlocked + return _secureStoreManager.IsStoreOpen && _secureStoreManager.HasUnsavedChanges; } @@ -967,8 +922,7 @@ public class MainWindowViewModel : ViewModelBase /// private bool CanAddSecret() { - return _selectedStoreNode != null - && _selectedStoreNode.IsUnlocked; + return _secureStoreManager.IsStoreOpen; } /// @@ -978,8 +932,7 @@ public class MainWindowViewModel : ViewModelBase { return _selectedNode != null && _selectedNode.NodeType == TreeNodeType.Secret - && _selectedStoreNode != null - && _selectedStoreNode.IsUnlocked; + && _secureStoreManager.IsStoreOpen; } /// @@ -1018,105 +971,6 @@ public class MainWindowViewModel : ViewModelBase "File picker for existing stores not yet implemented."); } - /// - /// Unlocks the currently selected secure store. - /// - private async Task UnlockStoreAsync() - { - if (_selectedStoreNode == null || string.IsNullOrEmpty(_selectedStoreNode.StorePath)) - return; - - if (_dialogService == null) - { - _logger?.LogWarning("Dialog service is not available"); - return; - } - - // Note: In a full implementation, this would show the UnlockStoreDialog - // For now, we simulate with a simple confirmation - var confirmed = await _dialogService.ShowConfirmationAsync( - "Unlock Store", - $"Enter credentials to unlock '{_selectedStoreNode.Name}'.\n\n(Dialog not yet implemented - this is a placeholder)"); - - if (!confirmed) - return; - - // In a real implementation, we would get the key file path or password from the dialog - // For now, we look for a .key file with the same name - var keyFilePath = Path.ChangeExtension(_selectedStoreNode.StorePath, ".key"); - if (!File.Exists(keyFilePath)) - { - // Try alternate pattern: storename.secrets.key - keyFilePath = _selectedStoreNode.StorePath.Replace(".secrets.json", ".secrets.key"); - } - - if (!File.Exists(keyFilePath)) - { - await _dialogService.ShowMessageAsync( - SecureStoreStrings.ErrorTitle, - $"Key file not found. Expected at:\n{keyFilePath}"); - return; - } - - try - { - _secureStoreManager.OpenStore(_selectedStoreNode.StorePath, keyFilePath); - _selectedStoreNode.IsUnlocked = true; - _openStores[_selectedStoreNode.StorePath] = _selectedStoreNode; - - // Populate secret children - RefreshStoreChildren(_selectedStoreNode); - _selectedStoreNode.IsExpanded = true; - - // Refresh the form view - OnSelectedNodeChanged(); - - _logger?.LogInformation("Store unlocked: {StorePath}", _selectedStoreNode.StorePath); - } - catch (Exception ex) - { - _logger?.LogError(ex, "Failed to unlock store: {StorePath}", _selectedStoreNode.StorePath); - await _dialogService.ShowMessageAsync( - SecureStoreStrings.ErrorTitle, - string.Format(SecureStoreStrings.FailedToOpenStoreFormat, ex.Message)); - } - } - - /// - /// Locks the currently selected secure store. - /// - private void LockStore() - { - if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked) - return; - - LockStoreInternal(_selectedStoreNode); - OnSelectedNodeChanged(); - } - - /// - /// Internal method to lock a store and clean up. - /// - private void LockStoreInternal(TreeNodeViewModel storeNode) - { - if (storeNode.StorePath != null) - { - _openStores.Remove(storeNode.StorePath); - } - - // Check if this is the currently open store in the manager - if (_secureStoreManager.IsStoreOpen && - _secureStoreManager.CurrentStorePath == storeNode.StorePath) - { - _secureStoreManager.CloseStore(); - } - - storeNode.IsUnlocked = false; - storeNode.Children.Clear(); - - _logger?.LogInformation("Store locked: {StorePath}", storeNode.StorePath); - } - /// /// Saves the currently open secure store. /// @@ -1147,21 +1001,6 @@ public class MainWindowViewModel : ViewModelBase } } - /// - /// Locks all open secure stores. - /// - private void LockAllStores() - { - var openStoresCopy = _openStores.Values.ToList(); - foreach (var storeNode in openStoresCopy) - { - LockStoreInternal(storeNode); - } - - OnSelectedNodeChanged(); - _logger?.LogInformation("All stores locked"); - } - /// /// Generates a new key file. /// @@ -1184,7 +1023,7 @@ public class MainWindowViewModel : ViewModelBase /// private async Task AddSecretAsync() { - if (_selectedStoreNode == null || !_selectedStoreNode.IsUnlocked) + if (!_secureStoreManager.IsStoreOpen) return; if (_dialogService == null) @@ -1227,13 +1066,14 @@ public class MainWindowViewModel : ViewModelBase { _secureStoreManager.RemoveSecret(_selectedNode.SecretKey); - // Remove from tree - if (_selectedStoreNode != null) + // Find the Secure Store node and remove the secret from its children + var secureStoreNode = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore); + if (secureStoreNode != null) { - _selectedStoreNode.Children.Remove(_selectedNode); + secureStoreNode.Children.Remove(_selectedNode); // Select the parent store node - SelectedNode = _selectedStoreNode; + SelectedNode = secureStoreNode; } _logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey); diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs index 0a4415a..0ebff19 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/TreeNodeViewModel.cs @@ -7,8 +7,7 @@ public enum TreeNodeType Folder, SettingsSection, Pipeline, - SecureStoresFolder, // The "Secure Stores" folder - SecureStore, // Individual store files + SecureStore, // The single secure store node Secret // Individual secrets within a store } @@ -29,7 +28,6 @@ public class TreeNodeViewModel : ViewModelBase private bool _isExpanded; private bool _isSelected; private ValidationState _validationState = ValidationState.Unknown; - private bool _isUnlocked; /// /// Gets the name of the tree node. @@ -51,39 +49,18 @@ public class TreeNodeViewModel : ViewModelBase /// public string? SectionKey { get; init; } - /// - /// Gets or sets whether this secure store is currently unlocked. - /// Only applicable for SecureStore node types. - /// - public bool IsUnlocked - { - get => _isUnlocked; - set - { - if (SetProperty(ref _isUnlocked, value)) - { - OnPropertyChanged(nameof(LockIcon)); - OnPropertyChanged(nameof(IsLocked)); - } - } - } - - /// - /// Gets whether this secure store is locked. - /// - public bool IsLocked => !IsUnlocked; - - /// - /// Gets the lock icon for secure store nodes. - /// - public string LockIcon => IsUnlocked ? "🔓" : "🔒"; - /// /// Gets or sets the full path to the secure store file. /// Only applicable for SecureStore node types. /// public string? StorePath { get; init; } + /// + /// Gets or sets the full path to the key file for the secure store. + /// Only applicable for SecureStore node types. + /// + public string? KeyFilePath { get; init; } + /// /// Gets or sets the secret key name. /// Only applicable for Secret node types. diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml index d307a6e..53dc858 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/ExcelExportFormView.axaml @@ -13,43 +13,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -92,31 +55,17 @@ - - - - - - - - - - - - - - + + + + + diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml new file mode 100644 index 0000000..94f0731 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs similarity index 52% rename from NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs rename to NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs index 140275e..d318338 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreInfoFormView.axaml.cs @@ -2,9 +2,9 @@ using Avalonia.Controls; namespace JdeScoping.ConfigManager.Views.Forms; -public partial class SecureStoreLockedFormView : UserControl +public partial class SecureStoreInfoFormView : UserControl { - public SecureStoreLockedFormView() + public SecureStoreInfoFormView() { InitializeComponent(); } diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml deleted file mode 100644 index 8d0e5d6..0000000 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreLockedFormView.axaml +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml deleted file mode 100644 index 4d5aec3..0000000 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs deleted file mode 100644 index 8b9e3d1..0000000 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/Forms/SecureStoreUnlockedFormView.axaml.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Avalonia.Controls; - -namespace JdeScoping.ConfigManager.Views.Forms; - -public partial class SecureStoreUnlockedFormView : UserControl -{ - public SecureStoreUnlockedFormView() - { - InitializeComponent(); - } -} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs index 21dc9b7..f2f5d18 100644 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/Services/SecureStore/SecureStoreManagerTests.cs @@ -59,31 +59,6 @@ public class SecureStoreManagerTests : IDisposable File.Exists(keyPath).ShouldBeTrue(); } - [Fact] - public void CreateStoreWithPassword_CreatesStore() - { - // Arrange - var storePath = Path.Combine(_testDirectory, "test.json"); - - // Act - _sut.CreateStoreWithPassword(storePath, "testpassword123"); - - // Assert - _sut.IsStoreOpen.ShouldBeTrue(); - _sut.CurrentStorePath.ShouldBe(storePath); - File.Exists(storePath).ShouldBeTrue(); - } - - [Fact] - public void CreateStoreWithPassword_WithEmptyPassword_ThrowsArgumentException() - { - // Arrange - var storePath = Path.Combine(_testDirectory, "test.json"); - - // Act & Assert - Should.Throw(() => _sut.CreateStoreWithPassword(storePath, "")); - } - [Fact] public void OpenStore_WithValidKeyFile_OpensStore() { @@ -112,22 +87,6 @@ public class SecureStoreManagerTests : IDisposable Should.Throw(() => _sut.OpenStore(storePath, keyPath)); } - [Fact] - public void OpenStoreWithPassword_OpensStore() - { - // Arrange - var storePath = Path.Combine(_testDirectory, "test.json"); - var password = "testpassword123"; - _sut.CreateStoreWithPassword(storePath, password); - _sut.CloseStore(); - - // Act - _sut.OpenStoreWithPassword(storePath, password); - - // Assert - _sut.IsStoreOpen.ShouldBeTrue(); - } - [Fact] public void CloseStore_ClosesOpenStore() { diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs index b8a2321..508b37a 100644 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/ExcelExportFormViewModelTests.cs @@ -11,28 +11,45 @@ public class ExcelExportFormViewModelTests // Arrange var model = new ExcelExportSection { - CriteriaSheetPassword = "criteriaPass123", - DataSheetPassword = "dataPass456", MaxRowsPerSheet = 500000, DefaultDateFormat = "MM/dd/yyyy", DebugWriteToFile = true, DebugOutputDirectory = "/tmp/debug", - TimezoneId = "America/New_York", - TimezoneAbbreviation = "ET" + TimezoneId = "America/Los_Angeles" }; // Act var sut = new ExcelExportFormViewModel(model, () => { }); // Assert - sut.CriteriaSheetPassword.ShouldBe("criteriaPass123"); - sut.DataSheetPassword.ShouldBe("dataPass456"); sut.MaxRowsPerSheet.ShouldBe(500000); sut.DefaultDateFormat.ShouldBe("MM/dd/yyyy"); sut.DebugWriteToFile.ShouldBeTrue(); sut.DebugOutputDirectory.ShouldBe("/tmp/debug"); - sut.TimezoneId.ShouldBe("America/New_York"); - sut.TimezoneAbbreviation.ShouldBe("ET"); + sut.SelectedTimezone.ShouldBe("America/Los_Angeles"); + } + + [Fact] + public void Constructor_UsesModelTimezone() + { + // Arrange - model defaults to "America/Chicago" + var model = new ExcelExportSection(); + + // Act + var sut = new ExcelExportFormViewModel(model, () => { }); + + // Assert + sut.SelectedTimezone.ShouldBe("America/Chicago"); + } + + [Fact] + public void AvailableTimezones_ContainsSystemTimezones() + { + // Act & Assert + ExcelExportFormViewModel.AvailableTimezones.ShouldNotBeEmpty(); + // Check for common IANA timezones + ExcelExportFormViewModel.AvailableTimezones.ShouldContain("America/Chicago"); + ExcelExportFormViewModel.AvailableTimezones.ShouldContain("UTC"); } [Fact] @@ -49,16 +66,30 @@ public class ExcelExportFormViewModelTests model.MaxRowsPerSheet.ShouldBe(750000); } + [Fact] + public void SelectedTimezone_UpdatesModelTimezoneId() + { + // Arrange - model defaults to "America/Chicago" + var model = new ExcelExportSection(); + var sut = new ExcelExportFormViewModel(model, () => { }); + + // Act - change to a different timezone + sut.SelectedTimezone = "America/New_York"; + + // Assert + model.TimezoneId.ShouldBe("America/New_York"); + } + [Fact] public void PropertyChange_InvokesOnChanged() { // Arrange - var model = new ExcelExportSection(); + var model = new ExcelExportSection(); // Default TimezoneId is "America/Chicago" var changedInvoked = false; var sut = new ExcelExportFormViewModel(model, () => changedInvoked = true); - // Act - sut.TimezoneId = "Europe/London"; + // Act - change to a different timezone than the default + sut.SelectedTimezone = "America/Denver"; // Assert changedInvoked.ShouldBeTrue(); diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs deleted file mode 100644 index 180dc59..0000000 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreLockedFormViewModelTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -using JdeScoping.ConfigManager.ViewModels.Forms; - -namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; - -public class SecureStoreLockedFormViewModelTests -{ - [Fact] - public void Constructor_SetsPropertiesCorrectly() - { - // Arrange - var lastModified = DateTime.Now; - - // Act - var sut = new SecureStoreLockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - lastModified, - () => { }); - - // Assert - sut.StoreName.ShouldBe("test.secrets"); - sut.StorePath.ShouldBe("/path/to/test.secrets"); - sut.LastModified.ShouldBe(lastModified); - } - - [Fact] - public void Constructor_WithNullLastModified_SetsNullLastModified() - { - // Arrange & Act - var sut = new SecureStoreLockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - null, - () => { }); - - // Assert - sut.LastModified.ShouldBeNull(); - } - - [Fact] - public void UnlockCommand_InvokesCallback() - { - // Arrange - var unlockCalled = false; - var sut = new SecureStoreLockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - null, - () => unlockCalled = true); - - // Act - sut.UnlockCommand.Execute(null); - - // Assert - unlockCalled.ShouldBeTrue(); - } - - [Fact] - public void UnlockCommand_CanExecute_ReturnsTrue() - { - // Arrange - var sut = new SecureStoreLockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - null, - () => { }); - - // Act & Assert - sut.UnlockCommand.CanExecute(null).ShouldBeTrue(); - } - - [Fact] - public void Constructor_ThrowsOnNullStoreName() - { - // Act & Assert - Should.Throw(() => - new SecureStoreLockedFormViewModel(null!, "/path", null, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullStorePath() - { - // Act & Assert - Should.Throw(() => - new SecureStoreLockedFormViewModel("test", null!, null, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullUnlockCallback() - { - // Act & Assert - Should.Throw(() => - new SecureStoreLockedFormViewModel("test", "/path", null, null!)); - } - - [Fact] - public void Properties_AreReadOnly() - { - // Arrange - var sut = new SecureStoreLockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - DateTime.Now, - () => { }); - - // Assert - Verify properties are get-only (no setters) - var storeNameProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StoreName)); - var storePathProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.StorePath)); - var lastModifiedProperty = typeof(SecureStoreLockedFormViewModel).GetProperty(nameof(SecureStoreLockedFormViewModel.LastModified)); - - storeNameProperty!.CanWrite.ShouldBeFalse(); - storePathProperty!.CanWrite.ShouldBeFalse(); - lastModifiedProperty!.CanWrite.ShouldBeFalse(); - } -} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs deleted file mode 100644 index 3c0d284..0000000 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/Forms/SecureStoreUnlockedFormViewModelTests.cs +++ /dev/null @@ -1,327 +0,0 @@ -using JdeScoping.ConfigManager.ViewModels.Forms; - -namespace JdeScoping.ConfigManager.Tests.ViewModels.Forms; - -public class SecureStoreUnlockedFormViewModelTests -{ - [Fact] - public void Constructor_SetsPropertiesCorrectly() - { - // Arrange & Act - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 5, - false, - () => { }, - () => { }, - () => { }); - - // Assert - sut.StoreName.ShouldBe("test.secrets"); - sut.StorePath.ShouldBe("/path/to/test.secrets"); - sut.SecretCount.ShouldBe(5); - sut.HasUnsavedChanges.ShouldBeFalse(); - } - - [Fact] - public void Constructor_WithUnsavedChanges_SetsHasUnsavedChanges() - { - // Arrange & Act - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 3, - true, - () => { }, - () => { }, - () => { }); - - // Assert - sut.HasUnsavedChanges.ShouldBeTrue(); - } - - [Fact] - public void HasUnsavedChanges_RaisesPropertyChanged() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - var propertyChangedRaised = false; - sut.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges)) - propertyChangedRaised = true; - }; - - // Act - sut.HasUnsavedChanges = true; - - // Assert - propertyChangedRaised.ShouldBeTrue(); - } - - [Fact] - public void HasUnsavedChanges_DoesNotRaisePropertyChanged_WhenValueUnchanged() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - var propertyChangedRaised = false; - sut.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(SecureStoreUnlockedFormViewModel.HasUnsavedChanges)) - propertyChangedRaised = true; - }; - - // Act - sut.HasUnsavedChanges = false; // Same as initial value - - // Assert - propertyChangedRaised.ShouldBeFalse(); - } - - [Fact] - public void LockCommand_InvokesCallback() - { - // Arrange - var lockCalled = false; - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => lockCalled = true, - () => { }, - () => { }); - - // Act - sut.LockCommand.Execute(null); - - // Assert - lockCalled.ShouldBeTrue(); - } - - [Fact] - public void AddSecretCommand_InvokesCallback() - { - // Arrange - var addSecretCalled = false; - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => addSecretCalled = true, - () => { }); - - // Act - sut.AddSecretCommand.Execute(null); - - // Assert - addSecretCalled.ShouldBeTrue(); - } - - [Fact] - public void SaveCommand_InvokesCallback() - { - // Arrange - var saveCalled = false; - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - true, // Must have unsaved changes for save to be enabled - () => { }, - () => { }, - () => saveCalled = true); - - // Act - sut.SaveCommand.Execute(null); - - // Assert - saveCalled.ShouldBeTrue(); - } - - [Fact] - public void SaveCommand_CanExecute_ReturnsFalse_WhenNoUnsavedChanges() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - // Act & Assert - sut.SaveCommand.CanExecute(null).ShouldBeFalse(); - } - - [Fact] - public void SaveCommand_CanExecute_ReturnsTrue_WhenHasUnsavedChanges() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - true, - () => { }, - () => { }, - () => { }); - - // Act & Assert - sut.SaveCommand.CanExecute(null).ShouldBeTrue(); - } - - [Fact] - public void SaveCommand_CanExecute_Updates_WhenHasUnsavedChangesChanges() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - // Initial state - can't save - sut.SaveCommand.CanExecute(null).ShouldBeFalse(); - - // Act - sut.HasUnsavedChanges = true; - - // Assert - sut.SaveCommand.CanExecute(null).ShouldBeTrue(); - } - - [Fact] - public void SaveCommand_RaisesCanExecuteChanged_WhenHasUnsavedChangesChanges() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - var canExecuteChangedRaised = false; - sut.SaveCommand.CanExecuteChanged += (s, e) => canExecuteChangedRaised = true; - - // Act - sut.HasUnsavedChanges = true; - - // Assert - canExecuteChangedRaised.ShouldBeTrue(); - } - - [Fact] - public void LockCommand_CanExecute_ReturnsTrue() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - // Act & Assert - sut.LockCommand.CanExecute(null).ShouldBeTrue(); - } - - [Fact] - public void AddSecretCommand_CanExecute_ReturnsTrue() - { - // Arrange - var sut = new SecureStoreUnlockedFormViewModel( - "test.secrets", - "/path/to/test.secrets", - 0, - false, - () => { }, - () => { }, - () => { }); - - // Act & Assert - sut.AddSecretCommand.CanExecute(null).ShouldBeTrue(); - } - - [Fact] - public void Constructor_ThrowsOnNullStoreName() - { - // Act & Assert - Should.Throw(() => - new SecureStoreUnlockedFormViewModel(null!, "/path", 0, false, () => { }, () => { }, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullStorePath() - { - // Act & Assert - Should.Throw(() => - new SecureStoreUnlockedFormViewModel("test", null!, 0, false, () => { }, () => { }, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullLockCallback() - { - // Act & Assert - Should.Throw(() => - new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, null!, () => { }, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullAddSecretCallback() - { - // Act & Assert - Should.Throw(() => - new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, null!, () => { })); - } - - [Fact] - public void Constructor_ThrowsOnNullSaveCallback() - { - // Act & Assert - Should.Throw(() => - new SecureStoreUnlockedFormViewModel("test", "/path", 0, false, () => { }, () => { }, null!)); - } - - [Fact] - public void ReadOnlyProperties_CannotBeModified() - { - // Assert - Verify StoreName, StorePath, and SecretCount are get-only - var storeNameProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StoreName)); - var storePathProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.StorePath)); - var secretCountProperty = typeof(SecureStoreUnlockedFormViewModel).GetProperty(nameof(SecureStoreUnlockedFormViewModel.SecretCount)); - - storeNameProperty!.CanWrite.ShouldBeFalse(); - storePathProperty!.CanWrite.ShouldBeFalse(); - secretCountProperty!.CanWrite.ShouldBeFalse(); - } -} diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs index a3c74b1..e0fdd5b 100644 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/MainWindowViewModelTests.cs @@ -157,7 +157,7 @@ public class MainWindowViewModelTests // Assert sut.SelectedFormViewModel.ShouldBeOfType(); - ((ExcelExportFormViewModel)sut.SelectedFormViewModel!).TimezoneId.ShouldBe("America/New_York"); + ((ExcelExportFormViewModel)sut.SelectedFormViewModel!).SelectedTimezone.ShouldBe("America/New_York"); } [Fact] @@ -281,9 +281,11 @@ public class MainWindowViewModelTests sut.LoadConfigForTesting(config, null); // Assert - sut.TreeNodes.Count.ShouldBe(3); // Settings, Pipelines, and Secure Stores folders + // Without a configured/open SecureStore, only Settings and Pipelines appear + sut.TreeNodes.Count.ShouldBe(2); // Settings, Pipelines (no Secure Store when not configured) sut.TreeNodes[0].Name.ShouldBe("Settings"); sut.TreeNodes[0].Children.Count.ShouldBe(6); // DataSync, DataAccess, Auth, Ldap, Search, ExcelExport + sut.TreeNodes[1].Name.ShouldBe("Pipelines"); } [Fact] @@ -309,6 +311,65 @@ public class MainWindowViewModelTests sut.TreeNodes[1].Children.Count.ShouldBe(2); } + [Fact] + public async Task OpenFolderCommand_UsesFilePicker_AndDerivesFolder() + { + // Arrange + var expectedFilePath = "/path/to/folder/appsettings.json"; + var expectedFolder = "/path/to/folder"; + var config = new ConfigModel(); + + // Ensure auto-discovery doesn't load config + _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); + _dialogService.ShowFilePickerAsync(Arg.Any()) + .Returns(expectedFilePath); + _configFileService.LoadAppSettingsAsync(Arg.Any(), Arg.Any()) + .Returns(config); + + var sut = CreateViewModel(); + // Wait for constructor async init to complete + await Task.Delay(50); + _configFileService.ClearReceivedCalls(); + + // Act + sut.OpenFolderCommand.Execute(null); + // Give async command time to complete + await Task.Delay(100); + + // Assert + await _dialogService.Received(1).ShowFilePickerAsync("Select Configuration File"); + await _configFileService.Received(1).LoadAppSettingsAsync( + Arg.Is(s => s.Contains(expectedFolder)), + Arg.Any()); + sut.ConfigFolderPath.ShouldBe(expectedFolder); + } + + [Fact] + public async Task OpenFolderCommand_WhenCancelled_DoesNotLoadConfig() + { + // Arrange + // Ensure auto-discovery doesn't load config + _autoDiscoveryService.FindConfigFolderAsync().Returns((string?)null); + _dialogService.ShowFilePickerAsync(Arg.Any()) + .Returns((string?)null); + + var sut = CreateViewModel(); + var originalPath = sut.ConfigFolderPath; + // Wait for constructor async init to complete + await Task.Delay(50); + _configFileService.ClearReceivedCalls(); + + // Act + sut.OpenFolderCommand.Execute(null); + // Give async command time to complete + await Task.Delay(100); + + // Assert + await _dialogService.Received(1).ShowFilePickerAsync(Arg.Any()); + await _configFileService.DidNotReceive().LoadAppSettingsAsync(Arg.Any(), Arg.Any()); + sut.ConfigFolderPath.ShouldBe(originalPath); + } + private MainWindowViewModel CreateViewModel() { return new MainWindowViewModel( diff --git a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs index 947e3ae..031011b 100644 --- a/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs +++ b/NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/TreeNodeViewModelTests.cs @@ -222,7 +222,6 @@ public class TreeNodeViewModelTests #region SecureStore Node Type Tests [Theory] - [InlineData(TreeNodeType.SecureStoresFolder)] [InlineData(TreeNodeType.SecureStore)] [InlineData(TreeNodeType.Secret)] public void Constructor_WithSecureStoreNodeTypes_SetsNodeTypeCorrectly(TreeNodeType nodeType) @@ -234,119 +233,6 @@ public class TreeNodeViewModelTests sut.NodeType.ShouldBe(nodeType); } - [Fact] - public void IsUnlocked_DefaultsToFalse() - { - // Arrange & Act - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - - // Assert - sut.IsUnlocked.ShouldBeFalse(); - } - - [Fact] - public void IsUnlocked_WhenSet_RaisesPropertyChanged() - { - // Arrange - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - var propertyChangedRaised = false; - sut.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(TreeNodeViewModel.IsUnlocked)) - propertyChangedRaised = true; - }; - - // Act - sut.IsUnlocked = true; - - // Assert - propertyChangedRaised.ShouldBeTrue(); - } - - [Fact] - public void IsUnlocked_WhenSet_RaisesPropertyChangedForLockIcon() - { - // Arrange - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - var lockIconChanged = false; - sut.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(TreeNodeViewModel.LockIcon)) - lockIconChanged = true; - }; - - // Act - sut.IsUnlocked = true; - - // Assert - lockIconChanged.ShouldBeTrue(); - } - - [Fact] - public void LockIcon_WhenLocked_ReturnsLockedIcon() - { - // Arrange & Act - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - - // Assert - sut.LockIcon.ShouldBe("🔒"); - } - - [Fact] - public void LockIcon_WhenUnlocked_ReturnsUnlockedIcon() - { - // Arrange - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - - // Act - sut.IsUnlocked = true; - - // Assert - sut.LockIcon.ShouldBe("🔓"); - } - - [Fact] - public void IsLocked_WhenUnlocked_ReturnsFalse() - { - // Arrange - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - - // Act - sut.IsUnlocked = true; - - // Assert - sut.IsLocked.ShouldBeFalse(); - } - - [Fact] - public void IsLocked_WhenLocked_ReturnsTrue() - { - // Arrange & Act - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - - // Assert - sut.IsLocked.ShouldBeTrue(); - } - - [Fact] - public void IsUnlocked_WhenSet_RaisesPropertyChangedForIsLocked() - { - // Arrange - var sut = new TreeNodeViewModel("Store", "🔒", TreeNodeType.SecureStore); - var isLockedChanged = false; - sut.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(TreeNodeViewModel.IsLocked)) - isLockedChanged = true; - }; - - // Act - sut.IsUnlocked = true; - - // Assert - isLockedChanged.ShouldBeTrue(); - } - [Fact] public void StorePath_CanBeSetViaInitializer() { diff --git a/PLANS/2025-01-22-regex-transformer-design.md b/PLANS/2025-01-22-regex-transformer-design.md new file mode 100644 index 0000000..46f0f4b --- /dev/null +++ b/PLANS/2025-01-22-regex-transformer-design.md @@ -0,0 +1,216 @@ +# Regex Transformer Design + +**Date:** 2025-01-22 +**Status:** Approved +**Author:** Claude + User collaboration + +## Overview + +Add a new `RegexTransformer` to the DataSync ETL pipeline that transforms string values in a column using regular expressions. Includes a custom editor for the ConfigManager with a live test/preview feature. + +## Requirements + +1. **Two transformation modes:** + - **Find & Replace** - Replace matched text with replacement string + - **Match & Extract** - Extract first capture group from pattern + +2. **Single column per transformer** - Each transformer operates on one column; add multiple transformers for multiple columns + +3. **Configurable non-match behavior:** + - Keep original value (default) + - Return null + - Return empty string + +4. **Case-insensitive option** - Optional flag for case-insensitive matching + +5. **Test/preview in editor** - Users can test their regex pattern against sample input before saving + +## Architecture + +### Core Transformer + +**File:** `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs` + +```csharp +public enum NonMatchBehavior +{ + KeepOriginal, + ReturnNull, + ReturnEmpty +} + +public class RegexTransformer : DataTransformerBase +{ + public RegexTransformer( + string columnName, + string pattern, + string? replacement = null, // null = Match & Extract mode + bool ignoreCase = false, + NonMatchBehavior nonMatchBehavior = NonMatchBehavior.KeepOriginal) +} +``` + +**Behavior:** +- Extends `DataTransformerBase` +- In `OnInitialize()`: finds column ordinal, compiles regex +- Overrides `GetValue()`: transforms target column, passes through others +- Mode determined by `replacement` parameter: non-null = Find & Replace, null = Match & Extract + +**Transformation logic:** +1. If not target column → pass through +2. If null/DBNull → pass through +3. Find & Replace mode: `regex.Replace(value, replacement)` +4. Match & Extract mode: return `match.Groups[1].Value` if match, else apply `NonMatchBehavior` + +### Configuration Model + +**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs` + +Add enum: +```csharp +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum NonMatchBehavior +{ + KeepOriginal, + ReturnNull, + ReturnEmpty +} +``` + +Add properties to `TransformerModel`: +```csharp +public string? ColumnName { get; set; } +public string? Pattern { get; set; } +public string? Replacement { get; set; } +public bool IgnoreCase { get; set; } +public NonMatchBehavior NonMatchBehavior { get; set; } = NonMatchBehavior.KeepOriginal; +``` + +**JSON example:** +```json +{ + "Type": "Regex", + "ColumnName": "BatchID", + "Pattern": "^IIS_", + "Replacement": "", + "IgnoreCase": true, + "NonMatchBehavior": "KeepOriginal" +} +``` + +### ViewModel + +**File:** `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs` + +Add `RegexTransformerViewModel` class: + +**Core properties (bound to config):** +- `ColumnName` (string) +- `Pattern` (string) +- `Replacement` (string) +- `IgnoreCase` (bool) +- `NonMatchBehavior` (enum) + +**Mode properties (computed):** +- `IsFindReplaceMode` / `IsMatchExtractMode` - for radio button binding +- `PatternHelpText` - changes based on mode + +**Test feature properties:** +- `TestInput` (string) +- `TestResultValue` (string) +- `TestResultLabel` (string) - "Output" or "No Match" +- `TestResultIcon` (string) - "✓" or "—" +- `TestResultBackground` (string) - green or orange +- `HasTestResult` / `HasTestError` (bool) +- `TestErrorMessage` (string) + +**Command:** +- `TestPatternCommand` - executes regex test + +### Editor View + +**File:** `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml` + +Layout sections: +1. **Header** - Title "Regex Transformer" + description +2. **Column Name** - Text input with required indicator +3. **Mode Selection** - Radio buttons: "Find & Replace" / "Match & Extract" +4. **Pattern** - Monospace text input with dynamic help text +5. **Replacement** - Monospace text input (visible only in Find & Replace mode) +6. **Options Row** - Case Insensitive checkbox + Non-Match Behavior dropdown +7. **Test Section** - Bordered area with: + - Sample input textbox + - Test button (blue) + - Result display with status icon (green checkmark / orange dash) + - Error display (red-tinted) for invalid patterns +8. **Help Box** - Pattern examples + +**Design tokens (matching existing editors):** +- Background: `#0D0F12`, `#151920`, `#232A35` +- Text: `#E6EDF5` (bright), `#9BA8B8` (labels), `#5C6A7A` (dim) +- Accent: `#3B82F6` (blue button), `#22C55E` (success), `#F59E0B` (warning), `#FF6B6B` (error) +- Font: JetBrains Mono for code fields +- Spacing: 16px between sections, 4px within field groups + +### Registration + +**TransformerFactory updates:** +```csharp +// Create() switch: +"regex" => new RegexTransformerViewModel(model, onChanged), + +// CreateNew() switch: +"regex" => new RegexTransformerViewModel(onChanged), + +// AvailableTypes: +["ColumnDrop", "ColumnRename", "JdeDate", "Regex"] +``` + +**MainWindow.axaml DataTemplate:** +```xml + + + +``` + +## Testing Strategy + +### RegexTransformer Unit Tests + +| Test Case | Description | +|-----------|-------------| +| `FindReplace_RemovesPrefix` | `^IIS_` + `""` → `IIS_12345` becomes `12345` | +| `FindReplace_ReplacesWithText` | `foo` + `bar` → `foo123` becomes `bar123` | +| `FindReplace_UseCaptureGroups` | `(\d+)-(\d+)` + `$2-$1` swaps groups | +| `MatchExtract_ExtractsFirstGroup` | `ID_(\d+)` extracts `12345` from `ID_12345` | +| `MatchExtract_NoMatch_KeepOriginal` | Returns original when no match | +| `MatchExtract_NoMatch_ReturnNull` | Returns DBNull when configured | +| `MatchExtract_NoMatch_ReturnEmpty` | Returns `""` when configured | +| `IgnoreCase_MatchesDifferentCase` | `^iis_` matches `IIS_12345` | +| `NullValue_PassesThrough` | DBNull input returns DBNull | +| `NonTargetColumn_Unchanged` | Other columns pass through | +| `InvalidRegex_ThrowsOnInitialize` | Bad pattern throws meaningful exception | + +### RegexTransformerViewModel Unit Tests + +| Test Case | Description | +|-----------|-------------| +| `TestPattern_ValidRegex_ShowsResult` | Test button displays transformed output | +| `TestPattern_InvalidRegex_ShowsError` | Bad pattern shows error message | +| `ModeSwitch_UpdatesHelpText` | Help text changes with mode | +| `ToModel_SerializesCorrectly` | ViewModel produces valid TransformerModel | +| `FromModel_LoadsAllProperties` | Constructor populates from existing config | + +## Files Summary + +**Create:** +- `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs` +- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml` +- `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs` +- `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs` +- `NEW/tests/JdeScoping.ConfigManager.Tests/ViewModels/RegexTransformerViewModelTests.cs` + +**Modify:** +- `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs` - Add enum + properties +- `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs` - Add ViewModel + factory +- `NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml` - Add DataTemplate diff --git a/PLANS/2025-01-22-regex-transformer-implementation.md b/PLANS/2025-01-22-regex-transformer-implementation.md new file mode 100644 index 0000000..4ccfded --- /dev/null +++ b/PLANS/2025-01-22-regex-transformer-implementation.md @@ -0,0 +1,1521 @@ +# Regex Transformer Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a RegexTransformer to the DataSync ETL pipeline that transforms string column values using regex, with a custom ConfigManager editor featuring live test/preview. + +**Architecture:** The transformer extends `DataTransformerBase` and overrides `GetValue()` to apply regex transformations. Supports two modes: Find & Replace (uses `Regex.Replace`) and Match & Extract (extracts first capture group). The ConfigManager gets a new `RegexTransformerViewModel` and `RegexEditorView` with integrated pattern testing. + +**Tech Stack:** .NET 10, System.Text.RegularExpressions, Avalonia UI, xUnit + NSubstitute + +**Design Doc:** `PLANS/2025-01-22-regex-transformer-design.md` + +--- + +## Task 1: Add NonMatchBehavior Enum to PipelineModel + +**Files:** +- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs:181-215` + +**Step 1: Add the enum and new properties** + +Add after line 215 (after `TransformerModel` class closing brace), then add properties to `TransformerModel`: + +```csharp +// Add this using at top of file: +using System.Text.Json.Serialization; + +// Add this enum after TransformerModel class (line ~216): +/// +/// Specifies behavior when a regex pattern does not match the input value. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum NonMatchBehavior +{ + /// Keep the original value unchanged. + KeepOriginal, + /// Return null/DBNull. + ReturnNull, + /// Return an empty string. + ReturnEmpty +} + +// Add these properties inside TransformerModel class (after OutputColumn property, ~line 214): + + /// + /// Gets or sets the column name for Regex transformer. + /// + public string? ColumnName { get; set; } + + /// + /// Gets or sets the regex pattern for Regex transformer. + /// + public string? Pattern { get; set; } + + /// + /// Gets or sets the replacement string for Regex transformer (null = Match & Extract mode). + /// + public string? Replacement { get; set; } + + /// + /// Gets or sets whether regex matching is case-insensitive. + /// + public bool IgnoreCase { get; set; } + + /// + /// Gets or sets the behavior when regex pattern does not match. + /// + public NonMatchBehavior NonMatchBehavior { get; set; } = NonMatchBehavior.KeepOriginal; +``` + +**Step 2: Verify it compiles** + +Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/Utils/JdeScoping.ConfigManager/Models/PipelineModel.cs +git commit -m "$(cat <<'EOF' +feat(configmanager): add NonMatchBehavior enum and regex properties to TransformerModel + +Add configuration model support for the new Regex transformer including: +- NonMatchBehavior enum with JSON string serialization +- ColumnName, Pattern, Replacement, IgnoreCase, NonMatchBehavior properties +EOF +)" +``` + +--- + +## Task 2: Create RegexTransformer with First Test (Find & Replace) + +**Files:** +- Create: `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs` +- Create: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs` + +**Step 1: Write the first failing test** + +Create test file: + +```csharp +using System.Data; +using JdeScoping.DataSync.Etl.Transformers; +using NSubstitute; + +namespace JdeScoping.DataSync.Tests.Etl.Transformers; + +public class RegexTransformerTests +{ + [Fact] + public void FindReplace_RemovesPrefix() + { + // Arrange + var source = CreateMockReader( + columns: new[] { "BatchID", "Name" }, + values: new object[] { "IIS_12345", "Test" }); + + var transformer = new RegexTransformer( + columnName: "BatchID", + pattern: "^IIS_", + replacement: ""); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("12345", reader.GetValue(0)); + Assert.Equal("Test", reader.GetValue(1)); // Other column unchanged + } + + private static IDataReader CreateMockReader(string[] columns, object[] values) + { + var reader = Substitute.For(); + reader.FieldCount.Returns(columns.Length); + for (int i = 0; i < columns.Length; i++) + { + var index = i; + reader.GetName(index).Returns(columns[index]); + reader.GetOrdinal(columns[index]).Returns(index); + reader.GetFieldType(index).Returns(values[index]?.GetType() ?? typeof(object)); + reader.GetValue(index).Returns(values[index]); + reader.IsDBNull(index).Returns(values[index] == null || values[index] == DBNull.Value); + } + return reader; + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests.FindReplace_RemovesPrefix" -v n` +Expected: FAIL - "The type or namespace name 'RegexTransformer' could not be found" + +**Step 3: Write minimal implementation** + +Create `NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs`: + +```csharp +using System.Data; +using System.Text.RegularExpressions; + +namespace JdeScoping.DataSync.Etl.Transformers; + +/// +/// Specifies behavior when a regex pattern does not match the input value. +/// +public enum NonMatchBehavior +{ + /// Keep the original value unchanged. + KeepOriginal, + /// Return null/DBNull. + ReturnNull, + /// Return an empty string. + ReturnEmpty +} + +/// +/// A data transformer that applies regex transformations to string values in a column. +/// Supports two modes: Find & Replace (when replacement is provided) and Match & Extract +/// (when replacement is null, extracts first capture group). +/// +public class RegexTransformer : DataTransformerBase +{ + private readonly string _columnName; + private readonly string _pattern; + private readonly string? _replacement; + private readonly bool _ignoreCase; + private readonly NonMatchBehavior _nonMatchBehavior; + + private Regex? _regex; + private int _columnOrdinal = -1; + + /// + public override string TransformerName => $"Regex:{_columnName}"; + + /// + /// Creates a new RegexTransformer. + /// + /// The column to transform. + /// The regex pattern. + /// Replacement string for Find & Replace mode, or null for Match & Extract mode. + /// Whether to use case-insensitive matching. + /// Behavior when pattern does not match. + public RegexTransformer( + string columnName, + string pattern, + string? replacement = null, + bool ignoreCase = false, + NonMatchBehavior nonMatchBehavior = NonMatchBehavior.KeepOriginal) + { + ArgumentException.ThrowIfNullOrWhiteSpace(columnName); + ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + + _columnName = columnName; + _pattern = pattern; + _replacement = replacement; + _ignoreCase = ignoreCase; + _nonMatchBehavior = nonMatchBehavior; + } + + /// + protected override void OnInitialize(IDataReader source) + { + _columnOrdinal = source.GetOrdinal(_columnName); + + var options = RegexOptions.Compiled; + if (_ignoreCase) + options |= RegexOptions.IgnoreCase; + + _regex = new Regex(_pattern, options); + } + + /// + public override object GetValue(int ordinal, IDataReader source) + { + var value = source.GetValue(ordinal); + + // Only transform the target column + if (ordinal != _columnOrdinal) + return value; + + // Pass through null/DBNull + if (value == null || value == DBNull.Value) + return value; + + var stringValue = value.ToString() ?? string.Empty; + + // Find & Replace mode (replacement is not null) + if (_replacement != null) + { + return _regex!.Replace(stringValue, _replacement); + } + + // Match & Extract mode (replacement is null) + var match = _regex!.Match(stringValue); + if (match.Success && match.Groups.Count > 1) + { + return match.Groups[1].Value; + } + + // No match - apply NonMatchBehavior + return _nonMatchBehavior switch + { + NonMatchBehavior.ReturnNull => DBNull.Value, + NonMatchBehavior.ReturnEmpty => string.Empty, + _ => value // KeepOriginal + }; + } + + /// + public override Type GetFieldType(int ordinal, IDataReader source) + { + // Target column always returns string + if (ordinal == _columnOrdinal) + return typeof(string); + return source.GetFieldType(ordinal); + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests.FindReplace_RemovesPrefix" -v n` +Expected: PASS + +**Step 5: Commit** + +```bash +git add NEW/src/JdeScoping.DataSync/Etl/Transformers/RegexTransformer.cs NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs +git commit -m "$(cat <<'EOF' +feat(datasync): add RegexTransformer with Find & Replace mode + +Initial implementation supporting: +- Find & Replace mode with regex pattern and replacement string +- Case-insensitive option +- NonMatchBehavior enum for handling non-matches +EOF +)" +``` + +--- + +## Task 3: Add Match & Extract Mode Tests + +**Files:** +- Modify: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs` + +**Step 1: Write the failing test for Match & Extract** + +Add to test class: + +```csharp +[Fact] +public void MatchExtract_ExtractsFirstCaptureGroup() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Code" }, + values: new object[] { "ID_12345" }); + + var transformer = new RegexTransformer( + columnName: "Code", + pattern: @"ID_(\d+)", + replacement: null); // null = Match & Extract mode + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("12345", reader.GetValue(0)); +} + +[Fact] +public void MatchExtract_NoMatch_KeepOriginal() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Code" }, + values: new object[] { "UNKNOWN" }); + + var transformer = new RegexTransformer( + columnName: "Code", + pattern: @"ID_(\d+)", + replacement: null, + nonMatchBehavior: NonMatchBehavior.KeepOriginal); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("UNKNOWN", reader.GetValue(0)); +} + +[Fact] +public void MatchExtract_NoMatch_ReturnNull() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Code" }, + values: new object[] { "UNKNOWN" }); + + var transformer = new RegexTransformer( + columnName: "Code", + pattern: @"ID_(\d+)", + replacement: null, + nonMatchBehavior: NonMatchBehavior.ReturnNull); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal(DBNull.Value, reader.GetValue(0)); +} + +[Fact] +public void MatchExtract_NoMatch_ReturnEmpty() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Code" }, + values: new object[] { "UNKNOWN" }); + + var transformer = new RegexTransformer( + columnName: "Code", + pattern: @"ID_(\d+)", + replacement: null, + nonMatchBehavior: NonMatchBehavior.ReturnEmpty); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal(string.Empty, reader.GetValue(0)); +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests" -v n` +Expected: All 5 tests PASS + +**Step 3: Commit** + +```bash +git add NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs +git commit -m "$(cat <<'EOF' +test(datasync): add Match & Extract mode tests for RegexTransformer + +Tests cover: +- Extracting first capture group +- NonMatchBehavior: KeepOriginal, ReturnNull, ReturnEmpty +EOF +)" +``` + +--- + +## Task 4: Add Edge Case Tests + +**Files:** +- Modify: `NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs` + +**Step 1: Add edge case tests** + +Add to test class: + +```csharp +[Fact] +public void FindReplace_UseCaptureGroups() +{ + // Arrange - swap two numbers + var source = CreateMockReader( + columns: new[] { "Value" }, + values: new object[] { "123-456" }); + + var transformer = new RegexTransformer( + columnName: "Value", + pattern: @"(\d+)-(\d+)", + replacement: "$2-$1"); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("456-123", reader.GetValue(0)); +} + +[Fact] +public void IgnoreCase_MatchesDifferentCase() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "BatchID" }, + values: new object[] { "IIS_12345" }); + + var transformer = new RegexTransformer( + columnName: "BatchID", + pattern: "^iis_", // lowercase pattern + replacement: "", + ignoreCase: true); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("12345", reader.GetValue(0)); +} + +[Fact] +public void NullValue_PassesThrough() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "BatchID" }, + values: new object[] { DBNull.Value }); + + var transformer = new RegexTransformer( + columnName: "BatchID", + pattern: "^IIS_", + replacement: ""); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal(DBNull.Value, reader.GetValue(0)); +} + +[Fact] +public void NonTargetColumn_Unchanged() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "BatchID", "OtherColumn" }, + values: new object[] { "IIS_12345", "IIS_Should_Not_Change" }); + + var transformer = new RegexTransformer( + columnName: "BatchID", + pattern: "^IIS_", + replacement: ""); + + // Act + var reader = transformer.Transform(source); + source.Read().Returns(true); + reader.Read(); + + // Assert + Assert.Equal("12345", reader.GetValue(0)); + Assert.Equal("IIS_Should_Not_Change", reader.GetValue(1)); +} + +[Fact] +public void InvalidRegex_ThrowsOnTransform() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Value" }, + values: new object[] { "test" }); + + var transformer = new RegexTransformer( + columnName: "Value", + pattern: "[invalid(regex", + replacement: ""); + + // Act & Assert + var ex = Assert.Throws(() => transformer.Transform(source)); + Assert.Contains("Invalid", ex.Message, StringComparison.OrdinalIgnoreCase); +} + +[Fact] +public void ColumnNotFound_ThrowsOnTransform() +{ + // Arrange + var source = CreateMockReader( + columns: new[] { "Value" }, + values: new object[] { "test" }); + source.GetOrdinal("NonExistent").Returns(_ => throw new IndexOutOfRangeException("Column not found")); + + var transformer = new RegexTransformer( + columnName: "NonExistent", + pattern: "test", + replacement: ""); + + // Act & Assert + Assert.Throws(() => transformer.Transform(source)); +} +``` + +**Step 2: Run all transformer tests** + +Run: `dotnet test NEW/tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj --filter "FullyQualifiedName~RegexTransformerTests" -v n` +Expected: All 11 tests PASS + +**Step 3: Commit** + +```bash +git add NEW/tests/JdeScoping.DataSync.Tests/Etl/Transformers/RegexTransformerTests.cs +git commit -m "$(cat <<'EOF' +test(datasync): add edge case tests for RegexTransformer + +Tests cover: +- Capture group substitution in replacement +- Case-insensitive matching +- Null/DBNull passthrough +- Non-target columns unchanged +- Invalid regex pattern handling +- Column not found handling +EOF +)" +``` + +--- + +## Task 5: Create RegexTransformerViewModel + +**Files:** +- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs` + +**Step 1: Add the ViewModel class** + +Add after `JdeDateTransformerViewModel` class (before `TransformerFactory`): + +```csharp +/// +/// View model for Regex transformer. +/// +public class RegexTransformerViewModel : TransformerStepViewModelBase +{ + private string _columnName = string.Empty; + private string _pattern = string.Empty; + private string? _replacement = string.Empty; + private bool _isFindReplaceMode = true; + private bool _ignoreCase; + private NonMatchBehavior _nonMatchBehavior = NonMatchBehavior.KeepOriginal; + + // Test feature fields + private string _testInput = string.Empty; + private string _testResultValue = string.Empty; + private string _testResultLabel = string.Empty; + private string _testResultIcon = string.Empty; + private string _testResultBackground = string.Empty; + private bool _hasTestResult; + private bool _hasTestError; + private string _testErrorMessage = string.Empty; + + public RegexTransformerViewModel(TransformerModel model, Action onChanged) : base(onChanged) + { + _columnName = model.ColumnName ?? string.Empty; + _pattern = model.Pattern ?? string.Empty; + _replacement = model.Replacement; + _isFindReplaceMode = model.Replacement != null; + _ignoreCase = model.IgnoreCase; + _nonMatchBehavior = model.NonMatchBehavior; + TestPatternCommand = new RelayCommand(ExecuteTestPattern); + } + + public RegexTransformerViewModel(Action onChanged) : base(onChanged) + { + TestPatternCommand = new RelayCommand(ExecuteTestPattern); + } + + public override string TransformerType => "Regex"; + public override string DisplayName => "Regex Transform"; + public override string Icon => "󰑑"; // mdi-regex + public override string Summary => !string.IsNullOrEmpty(_columnName) + ? $"{_columnName}: {(_isFindReplaceMode ? "Replace" : "Extract")}" + : "Configure..."; + + /// Gets or sets the column name to transform. + public string ColumnName + { + get => _columnName; + set + { + if (SetProperty(ref _columnName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(Summary)); + NotifyChanged(); + } + } + } + + /// Gets or sets the regex pattern. + public string Pattern + { + get => _pattern; + set + { + if (SetProperty(ref _pattern, value ?? string.Empty)) + { + ClearTestResult(); + NotifyChanged(); + } + } + } + + /// Gets or sets the replacement string (Find & Replace mode). + public string? Replacement + { + get => _replacement; + set + { + if (SetProperty(ref _replacement, value)) + { + ClearTestResult(); + NotifyChanged(); + } + } + } + + /// Gets or sets whether Find & Replace mode is active. + public bool IsFindReplaceMode + { + get => _isFindReplaceMode; + set + { + if (SetProperty(ref _isFindReplaceMode, value)) + { + OnPropertyChanged(nameof(IsMatchExtractMode)); + OnPropertyChanged(nameof(PatternHelpText)); + OnPropertyChanged(nameof(Summary)); + ClearTestResult(); + NotifyChanged(); + } + } + } + + /// Gets or sets whether Match & Extract mode is active. + public bool IsMatchExtractMode + { + get => !_isFindReplaceMode; + set => IsFindReplaceMode = !value; + } + + /// Gets the help text for the pattern field based on current mode. + public string PatternHelpText => _isFindReplaceMode + ? "Pattern to search for in the column value" + : "Pattern with capture group - first group (parentheses) will be extracted"; + + /// Gets or sets whether matching is case-insensitive. + public bool IgnoreCase + { + get => _ignoreCase; + set + { + if (SetProperty(ref _ignoreCase, value)) + { + ClearTestResult(); + NotifyChanged(); + } + } + } + + /// Gets or sets the behavior when pattern doesn't match. + public NonMatchBehavior NonMatchBehavior + { + get => _nonMatchBehavior; + set + { + if (SetProperty(ref _nonMatchBehavior, value)) + { + ClearTestResult(); + NotifyChanged(); + } + } + } + + // Test feature properties + public string TestInput + { + get => _testInput; + set => SetProperty(ref _testInput, value ?? string.Empty); + } + + public string TestResultValue + { + get => _testResultValue; + set => SetProperty(ref _testResultValue, value); + } + + public string TestResultLabel + { + get => _testResultLabel; + set => SetProperty(ref _testResultLabel, value); + } + + public string TestResultIcon + { + get => _testResultIcon; + set => SetProperty(ref _testResultIcon, value); + } + + public string TestResultBackground + { + get => _testResultBackground; + set => SetProperty(ref _testResultBackground, value); + } + + public bool HasTestResult + { + get => _hasTestResult; + set => SetProperty(ref _hasTestResult, value); + } + + public bool HasTestError + { + get => _hasTestError; + set => SetProperty(ref _hasTestError, value); + } + + public string TestErrorMessage + { + get => _testErrorMessage; + set => SetProperty(ref _testErrorMessage, value); + } + + public ICommand TestPatternCommand { get; } + + private void ExecuteTestPattern() + { + ClearTestResult(); + + if (string.IsNullOrEmpty(_pattern)) + { + HasTestError = true; + TestErrorMessage = "Pattern is required"; + return; + } + + try + { + var options = _ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None; + var regex = new Regex(_pattern, options); + + string result; + bool matched; + + if (_isFindReplaceMode) + { + result = regex.Replace(_testInput, _replacement ?? string.Empty); + matched = regex.IsMatch(_testInput); + } + else + { + var match = regex.Match(_testInput); + if (match.Success && match.Groups.Count > 1) + { + result = match.Groups[1].Value; + matched = true; + } + else + { + matched = false; + result = _nonMatchBehavior switch + { + NonMatchBehavior.ReturnNull => "(null)", + NonMatchBehavior.ReturnEmpty => "(empty)", + _ => _testInput + }; + } + } + + HasTestResult = true; + TestResultValue = result; + TestResultLabel = matched ? "Output" : "No Match"; + TestResultIcon = matched ? "✓" : "—"; + TestResultBackground = matched ? "#22C55E" : "#F59E0B"; + } + catch (RegexParseException ex) + { + HasTestError = true; + TestErrorMessage = ex.Message; + } + } + + private void ClearTestResult() + { + HasTestResult = false; + HasTestError = false; + TestResultValue = string.Empty; + TestResultLabel = string.Empty; + TestErrorMessage = string.Empty; + } + + public override TransformerModel ToModel() => new() + { + Type = TransformerType, + ColumnName = _columnName, + Pattern = _pattern, + Replacement = _isFindReplaceMode ? _replacement : null, + IgnoreCase = _ignoreCase, + NonMatchBehavior = _nonMatchBehavior + }; +} +``` + +**Step 2: Add using statement** + +Add at top of file: +```csharp +using System.Text.RegularExpressions; +using JdeScoping.ConfigManager.Models; +``` + +**Step 3: Verify it compiles** + +Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj` +Expected: Build succeeded + +**Step 4: Commit** + +```bash +git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs +git commit -m "$(cat <<'EOF' +feat(configmanager): add RegexTransformerViewModel + +Implements ViewModel for Regex transformer editor with: +- Column, Pattern, Replacement, IgnoreCase, NonMatchBehavior properties +- Mode toggle between Find & Replace and Match & Extract +- Live test/preview functionality with error handling +EOF +)" +``` + +--- + +## Task 6: Update TransformerFactory + +**Files:** +- Modify: `NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs:284-318` + +**Step 1: Update factory switch statements and AvailableTypes** + +In `TransformerFactory.Create()` method, add case: +```csharp +"regex" => new RegexTransformerViewModel(model, onChanged), +``` + +In `TransformerFactory.CreateNew()` method, add case: +```csharp +"regex" => new RegexTransformerViewModel(onChanged), +``` + +Update `AvailableTypes`: +```csharp +public static IReadOnlyList AvailableTypes => ["ColumnDrop", "ColumnRename", "JdeDate", "Regex"]; +``` + +**Step 2: Verify it compiles** + +Run: `dotnet build NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj` +Expected: Build succeeded + +**Step 3: Commit** + +```bash +git add NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/PipelineSteps/TransformerStepViewModels.cs +git commit -m "$(cat <<'EOF' +feat(configmanager): register Regex transformer in TransformerFactory + +Add Regex to: +- Create() factory method +- CreateNew() factory method +- AvailableTypes list +EOF +)" +``` + +--- + +## Task 7: Create RegexEditorView XAML + +**Files:** +- Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml` +- Create: `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml.cs` + +**Step 1: Create the XAML file** + +Create `NEW/src/Utils/JdeScoping.ConfigManager/Views/Editors/RegexEditorView.axaml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeepOriginal + ReturnNull + ReturnEmpty + + + + + + + + + + + + + + + +