diff --git a/code/Executable/Controllers/ConfigFilesController.cs b/code/Executable/Controllers/ConfigFilesController.cs new file mode 100644 index 00000000..c78ee0c7 --- /dev/null +++ b/code/Executable/Controllers/ConfigFilesController.cs @@ -0,0 +1,309 @@ +using Infrastructure.Configuration; +using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Executable.Controllers; + +[ApiController] +[Route("api/config-files")] +public class ConfigFilesController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfigurationManager _configManager; + + public ConfigFilesController( + ILogger logger, + IConfigurationManager configManager) + { + _logger = logger; + _configManager = configManager; + } + + /// + /// Lists all available configuration files + /// + [HttpGet] + public IActionResult GetAllConfigFiles() + { + try + { + var files = _configManager.ListConfigurationFiles().ToList(); + return Ok(new { Files = files, Count = files.Count }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing configuration files"); + return StatusCode(500, "An error occurred while listing configuration files"); + } + } + + /// + /// Gets the content of a specific configuration file + /// + [HttpGet("{fileName}")] + public async Task GetConfigFile(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json")) + { + fileName = $"{fileName}.json"; + } + + try + { + // Read as dynamic to support any JSON structure + var config = await _configManager.GetConfigurationAsync(fileName); + + if (config == null) + { + return NotFound($"Configuration file '{fileName}' not found"); + } + + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading configuration file {fileName}", fileName); + return StatusCode(500, $"An error occurred while reading configuration file '{fileName}'"); + } + } + + /// + /// Creates or updates a configuration file + /// + [HttpPut("{fileName}")] + public async Task SaveConfigFile(string fileName, [FromBody] JsonElement content) + { + if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json")) + { + fileName = $"{fileName}.json"; + } + + try + { + // Convert the JsonElement to an object + var configObject = JsonSerializer.Deserialize(content.GetRawText()); + + if (configObject == null) + { + return BadRequest("Invalid JSON content"); + } + + var result = await _configManager.SaveConfigurationAsync(fileName, configObject); + + if (!result) + { + return StatusCode(500, $"Failed to save configuration file '{fileName}'"); + } + + return Ok(new { Message = $"Configuration file '{fileName}' saved successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration file {fileName}", fileName); + return StatusCode(500, $"An error occurred while saving configuration file '{fileName}'"); + } + } + + /// + /// Deletes a configuration file + /// + [HttpDelete("{fileName}")] + public async Task DeleteConfigFile(string fileName) + { + if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json")) + { + fileName = $"{fileName}.json"; + } + + try + { + var result = await _configManager.DeleteConfigurationAsync(fileName); + + if (!result) + { + return StatusCode(500, $"Failed to delete configuration file '{fileName}'"); + } + + return Ok(new { Message = $"Configuration file '{fileName}' deleted successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting configuration file {fileName}", fileName); + return StatusCode(500, $"An error occurred while deleting configuration file '{fileName}'"); + } + } + + /// + /// Updates a specific property in a configuration file + /// + [HttpPatch("{fileName}")] + public async Task UpdateConfigProperty( + string fileName, + [FromQuery] string propertyPath, + [FromBody] JsonElement value) + { + if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".json")) + { + fileName = $"{fileName}.json"; + } + + if (string.IsNullOrEmpty(propertyPath)) + { + return BadRequest("Property path is required"); + } + + try + { + // Convert the JsonElement to an object + var valueObject = JsonSerializer.Deserialize(value.GetRawText()); + + if (valueObject == null) + { + return BadRequest("Invalid value"); + } + + var result = await _configManager.UpdateConfigurationPropertyAsync(fileName, propertyPath, valueObject); + + if (!result) + { + return StatusCode(500, $"Failed to update property '{propertyPath}' in configuration file '{fileName}'"); + } + + return Ok(new { Message = $"Property '{propertyPath}' in configuration file '{fileName}' updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating property {propertyPath} in configuration file {fileName}", propertyPath, fileName); + return StatusCode(500, $"An error occurred while updating property '{propertyPath}' in configuration file '{fileName}'"); + } + } + + /// + /// Gets the Sonarr configuration + /// + [HttpGet("sonarr")] + public async Task GetSonarrConfig() + { + try + { + var config = await _configManager.GetSonarrConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Sonarr configuration"); + return StatusCode(500, "An error occurred while getting Sonarr configuration"); + } + } + + /// + /// Gets the Radarr configuration + /// + [HttpGet("radarr")] + public async Task GetRadarrConfig() + { + try + { + var config = await _configManager.GetRadarrConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Radarr configuration"); + return StatusCode(500, "An error occurred while getting Radarr configuration"); + } + } + + /// + /// Gets the Lidarr configuration + /// + [HttpGet("lidarr")] + public async Task GetLidarrConfig() + { + try + { + var config = await _configManager.GetLidarrConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Lidarr configuration"); + return StatusCode(500, "An error occurred while getting Lidarr configuration"); + } + } + + /// + /// Gets the ContentBlocker configuration + /// + [HttpGet("contentblocker")] + public async Task GetContentBlockerConfig() + { + try + { + var config = await _configManager.GetContentBlockerConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting ContentBlocker configuration"); + return StatusCode(500, "An error occurred while getting ContentBlocker configuration"); + } + } + + /// + /// Gets the QueueCleaner configuration + /// + [HttpGet("queuecleaner")] + public async Task GetQueueCleanerConfig() + { + try + { + var config = await _configManager.GetQueueCleanerConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting QueueCleaner configuration"); + return StatusCode(500, "An error occurred while getting QueueCleaner configuration"); + } + } + + /// + /// Gets the DownloadCleaner configuration + /// + [HttpGet("downloadcleaner")] + public async Task GetDownloadCleanerConfig() + { + try + { + var config = await _configManager.GetDownloadCleanerConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting DownloadCleaner configuration"); + return StatusCode(500, "An error occurred while getting DownloadCleaner configuration"); + } + } + + /// + /// Gets the DownloadClient configuration + /// + [HttpGet("downloadclient")] + public async Task GetDownloadClientConfig() + { + try + { + var config = await _configManager.GetDownloadClientConfigAsync(); + return Ok(config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting DownloadClient configuration"); + return StatusCode(500, "An error occurred while getting DownloadClient configuration"); + } + } +} diff --git a/code/Executable/DependencyInjection/ConfigurationDI.cs b/code/Executable/DependencyInjection/ConfigurationDI.cs index ee5cacb4..5615ddfd 100644 --- a/code/Executable/DependencyInjection/ConfigurationDI.cs +++ b/code/Executable/DependencyInjection/ConfigurationDI.cs @@ -1,16 +1,21 @@ -using Common.Configuration.Arr; +using Common.Configuration.Arr; using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadCleaner; using Common.Configuration.DownloadClient; using Common.Configuration.General; using Common.Configuration.Logging; using Common.Configuration.QueueCleaner; +using Infrastructure.Configuration; +using Infrastructure.Services; +using System.IO; namespace Executable.DependencyInjection; public static class ConfigurationDI { - public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) => + public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration configuration) + { + // Configure options from appsettings.json services .Configure(configuration) .Configure(configuration) @@ -25,4 +30,14 @@ public static class ConfigurationDI .Configure(configuration.GetSection(RadarrConfig.SectionName)) .Configure(configuration.GetSection(LidarrConfig.SectionName)) .Configure(configuration.GetSection(LoggingConfig.SectionName)); + + // Add JSON-based configuration services + string configDirectory = Path.Combine( + Path.GetDirectoryName(AppDomain.CurrentDomain.BaseDirectory) ?? AppDomain.CurrentDomain.BaseDirectory, + "config"); + + services.AddConfigurationServices(configDirectory); + + return services; + } } \ No newline at end of file diff --git a/code/Infrastructure/Configuration/ConfigurationExtensions.cs b/code/Infrastructure/Configuration/ConfigurationExtensions.cs new file mode 100644 index 00000000..fea075e9 --- /dev/null +++ b/code/Infrastructure/Configuration/ConfigurationExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Configuration; + +public static class ConfigurationExtensions +{ + public static IServiceCollection AddConfigurationServices(this IServiceCollection services, string configDirectory) + { + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + return new JsonConfigurationProvider(logger, configDirectory); + }); + + services.AddSingleton(); + + return services; + } +} diff --git a/code/Infrastructure/Configuration/ConfigurationManager.cs b/code/Infrastructure/Configuration/ConfigurationManager.cs new file mode 100644 index 00000000..c9f263d2 --- /dev/null +++ b/code/Infrastructure/Configuration/ConfigurationManager.cs @@ -0,0 +1,271 @@ +using Common.Configuration; +using Common.Configuration.Arr; +using Common.Configuration.ContentBlocker; +using Common.Configuration.DownloadCleaner; +using Common.Configuration.DownloadClient; +using Common.Configuration.QueueCleaner; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Infrastructure.Configuration; + +/// +/// Provides configuration management for various components with thread-safe file access. +/// +public interface IConfigurationManager +{ + // Configuration files + Task GetConfigurationAsync(string configFileName) where T : class, new(); + Task SaveConfigurationAsync(string configFileName, T config) where T : class; + Task UpdateConfigurationPropertyAsync(string configFileName, string propertyPath, T value); + Task MergeConfigurationAsync(string configFileName, T newValues) where T : class; + Task DeleteConfigurationAsync(string configFileName); + IEnumerable ListConfigurationFiles(); + + // Specific configuration types (for common configs) + Task GetSonarrConfigAsync(); + Task GetRadarrConfigAsync(); + Task GetLidarrConfigAsync(); + Task GetContentBlockerConfigAsync(); + Task GetQueueCleanerConfigAsync(); + Task GetDownloadCleanerConfigAsync(); + Task GetDownloadClientConfigAsync(); + + Task SaveSonarrConfigAsync(SonarrConfig config); + Task SaveRadarrConfigAsync(RadarrConfig config); + Task SaveLidarrConfigAsync(LidarrConfig config); + Task SaveContentBlockerConfigAsync(ContentBlockerConfig config); + Task SaveQueueCleanerConfigAsync(QueueCleanerConfig config); + Task SaveDownloadCleanerConfigAsync(DownloadCleanerConfig config); + Task SaveDownloadClientConfigAsync(DownloadClientConfig config); +} + +public class ConfigurationManager : IConfigurationManager +{ + private readonly ILogger _logger; + private readonly JsonConfigurationProvider _configProvider; + private readonly IOptionsMonitor _sonarrConfig; + private readonly IOptionsMonitor _radarrConfig; + private readonly IOptionsMonitor _lidarrConfig; + private readonly IOptionsMonitor _contentBlockerConfig; + private readonly IOptionsMonitor _queueCleanerConfig; + private readonly IOptionsMonitor _downloadCleanerConfig; + private readonly IOptionsMonitor _downloadClientConfig; + + // Define standard config file names + private const string SonarrConfigFile = "sonarr.json"; + private const string RadarrConfigFile = "radarr.json"; + private const string LidarrConfigFile = "lidarr.json"; + private const string ContentBlockerConfigFile = "contentblocker.json"; + private const string QueueCleanerConfigFile = "queuecleaner.json"; + private const string DownloadCleanerConfigFile = "downloadcleaner.json"; + private const string DownloadClientConfigFile = "downloadclient.json"; + + public ConfigurationManager( + ILogger logger, + JsonConfigurationProvider configProvider, + IOptionsMonitor sonarrConfig, + IOptionsMonitor radarrConfig, + IOptionsMonitor lidarrConfig, + IOptionsMonitor contentBlockerConfig, + IOptionsMonitor queueCleanerConfig, + IOptionsMonitor downloadCleanerConfig, + IOptionsMonitor downloadClientConfig) + { + _logger = logger; + _configProvider = configProvider; + _sonarrConfig = sonarrConfig; + _radarrConfig = radarrConfig; + _lidarrConfig = lidarrConfig; + _contentBlockerConfig = contentBlockerConfig; + _queueCleanerConfig = queueCleanerConfig; + _downloadCleanerConfig = downloadCleanerConfig; + _downloadClientConfig = downloadClientConfig; + } + + // Generic configuration methods + public Task GetConfigurationAsync(string configFileName) where T : class, new() + { + return _configProvider.ReadConfigurationAsync(configFileName); + } + + public Task SaveConfigurationAsync(string configFileName, T config) where T : class + { + // Validate if it's an IConfig + if (config is IConfig configurable) + { + try + { + configurable.Validate(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Configuration validation failed for {fileName}", configFileName); + return Task.FromResult(false); + } + } + + return _configProvider.WriteConfigurationAsync(configFileName, config); + } + + public Task UpdateConfigurationPropertyAsync(string configFileName, string propertyPath, T value) + { + return _configProvider.UpdateConfigurationPropertyAsync(configFileName, propertyPath, value); + } + + public Task MergeConfigurationAsync(string configFileName, T newValues) where T : class + { + return _configProvider.MergeConfigurationAsync(configFileName, newValues); + } + + public Task DeleteConfigurationAsync(string configFileName) + { + return _configProvider.DeleteConfigurationAsync(configFileName); + } + + public IEnumerable ListConfigurationFiles() + { + return _configProvider.ListConfigurationFiles(); + } + + // Specific configuration type methods + public async Task GetSonarrConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(SonarrConfigFile); + return config ?? _sonarrConfig.CurrentValue; + } + + public async Task GetRadarrConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(RadarrConfigFile); + return config ?? _radarrConfig.CurrentValue; + } + + public async Task GetLidarrConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(LidarrConfigFile); + return config ?? _lidarrConfig.CurrentValue; + } + + public async Task GetContentBlockerConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(ContentBlockerConfigFile); + return config ?? _contentBlockerConfig.CurrentValue; + } + + public async Task GetQueueCleanerConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(QueueCleanerConfigFile); + return config ?? _queueCleanerConfig.CurrentValue; + } + + public async Task GetDownloadCleanerConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(DownloadCleanerConfigFile); + return config ?? _downloadCleanerConfig.CurrentValue; + } + + public async Task GetDownloadClientConfigAsync() + { + var config = await _configProvider.ReadConfigurationAsync(DownloadClientConfigFile); + return config ?? _downloadClientConfig.CurrentValue; + } + + public Task SaveSonarrConfigAsync(SonarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(SonarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Sonarr configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveRadarrConfigAsync(RadarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(RadarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Radarr configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveLidarrConfigAsync(LidarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(LidarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Lidarr configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveContentBlockerConfigAsync(ContentBlockerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(ContentBlockerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "ContentBlocker configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveQueueCleanerConfigAsync(QueueCleanerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(QueueCleanerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "QueueCleaner configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveDownloadCleanerConfigAsync(DownloadCleanerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(DownloadCleanerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "DownloadCleaner configuration validation failed"); + return Task.FromResult(false); + } + } + + public Task SaveDownloadClientConfigAsync(DownloadClientConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfigurationAsync(DownloadClientConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "DownloadClient configuration validation failed"); + return Task.FromResult(false); + } + } +} diff --git a/code/Infrastructure/Configuration/JsonConfigurationProvider.cs b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs new file mode 100644 index 00000000..d7bcdac5 --- /dev/null +++ b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs @@ -0,0 +1,390 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Configuration; + +/// +/// Provides thread-safe access to JSON configuration files. +/// +public class JsonConfigurationProvider +{ + private readonly ILogger _logger; + private readonly string _configDirectory; + private readonly Dictionary _fileLocks = new(); + private readonly JsonSerializerOptions _serializerOptions; + + public JsonConfigurationProvider(ILogger logger, string configDirectory) + { + _logger = logger; + _configDirectory = configDirectory; + + // Create directory if it doesn't exist + if (!Directory.Exists(_configDirectory)) + { + try + { + Directory.CreateDirectory(_configDirectory); + _logger.LogInformation("Created configuration directory: {directory}", _configDirectory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create configuration directory: {directory}", _configDirectory); + throw; + } + } + + _serializerOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + } + + /// + /// Gets the lock object for a specific file, creating it if necessary. + /// + private SemaphoreSlim GetFileLock(string fileName) + { + if (_fileLocks.TryGetValue(fileName, out var semaphore)) + { + return semaphore; + } + + semaphore = new SemaphoreSlim(1, 1); + _fileLocks[fileName] = semaphore; + return semaphore; + } + + /// + /// Gets the full path to a configuration file. + /// + private string GetFullPath(string fileName) + { + return Path.Combine(_configDirectory, fileName); + } + + /// + /// Reads a configuration from a JSON file. + /// + public async Task ReadConfigurationAsync(string fileName) where T : class, new() + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + await fileLock.WaitAsync(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return new T(); + } + + var json = await File.ReadAllTextAsync(fullPath); + + if (string.IsNullOrWhiteSpace(json)) + { + _logger.LogWarning("Configuration file is empty: {file}", fullPath); + return new T(); + } + + var config = JsonSerializer.Deserialize(json, _serializerOptions); + _logger.LogDebug("Read configuration from {file}", fullPath); + return config ?? new T(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading configuration from {file}", fullPath); + return new T(); + } + finally + { + fileLock.Release(); + } + } + + /// + /// Writes a configuration to a JSON file in a thread-safe manner. + /// + public async Task WriteConfigurationAsync(string fileName, T configuration) where T : class + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + await fileLock.WaitAsync(); + + // Create backup if file exists + if (File.Exists(fullPath)) + { + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + _logger.LogDebug("Created backup of configuration file: {backup}", backupPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + // Continue anyway - prefer having new config to having no config + } + } + + var json = JsonSerializer.Serialize(configuration, _serializerOptions); + await File.WriteAllTextAsync(fullPath, json); + + _logger.LogInformation("Wrote configuration to {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error writing configuration to {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + + /// + /// Updates a specific property within a JSON configuration file. + /// + public async Task UpdateConfigurationPropertyAsync(string fileName, string propertyPath, T value) + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + await fileLock.WaitAsync(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return false; + } + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + + var json = await File.ReadAllTextAsync(fullPath); + var jsonNode = JsonNode.Parse(json)?.AsObject(); + + if (jsonNode == null) + { + _logger.LogError("Failed to parse configuration file: {file}", fullPath); + return false; + } + + // Handle simple property paths like "propertyName" + if (!propertyPath.Contains('.')) + { + jsonNode[propertyPath] = JsonValue.Create(value); + } + else + { + // Handle nested property paths like "parent.child.property" + var parts = propertyPath.Split('.'); + var current = jsonNode; + + for (int i = 0; i < parts.Length - 1; i++) + { + if (current[parts[i]] is JsonObject nestedObject) + { + current = nestedObject; + } + else + { + var newObject = new JsonObject(); + current[parts[i]] = newObject; + current = newObject; + } + } + + current[parts[^1]] = JsonValue.Create(value); + } + + var updatedJson = jsonNode.ToJsonString(_serializerOptions); + await File.WriteAllTextAsync(fullPath, updatedJson); + + _logger.LogInformation("Updated property {property} in {file}", propertyPath, fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating property {property} in {file}", propertyPath, fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + + /// + /// Merges an existing configuration with new values. + /// + public async Task MergeConfigurationAsync(string fileName, T newValues) where T : class + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + await fileLock.WaitAsync(); + + T currentConfig; + + if (File.Exists(fullPath)) + { + var json = await File.ReadAllTextAsync(fullPath); + currentConfig = JsonSerializer.Deserialize(json, _serializerOptions) ?? Activator.CreateInstance(); + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + } + else + { + currentConfig = Activator.CreateInstance() ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}"); + } + + // Merge properties using JsonNode + var currentJson = JsonSerializer.Serialize(currentConfig, _serializerOptions); + var currentNode = JsonNode.Parse(currentJson)?.AsObject(); + + var newJson = JsonSerializer.Serialize(newValues, _serializerOptions); + var newNode = JsonNode.Parse(newJson)?.AsObject(); + + if (currentNode == null || newNode == null) + { + _logger.LogError("Failed to parse configuration for merging: {file}", fullPath); + return false; + } + + MergeJsonNodes(currentNode, newNode); + + var mergedJson = currentNode.ToJsonString(_serializerOptions); + await File.WriteAllTextAsync(fullPath, mergedJson); + + _logger.LogInformation("Merged configuration in {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error merging configuration in {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + + private void MergeJsonNodes(JsonObject target, JsonObject source) + { + foreach (var property in source) + { + if (property.Value is JsonObject sourceObject) + { + if (target[property.Key] is JsonObject targetObject) + { + // Recursively merge nested objects + MergeJsonNodes(targetObject, sourceObject); + } + else + { + // Replace with new object + target[property.Key] = sourceObject.DeepClone(); + } + } + else + { + // Replace value + target[property.Key] = property.Value?.DeepClone(); + } + } + } + + /// + /// Deletes a configuration file. + /// + public async Task DeleteConfigurationAsync(string fileName) + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + await fileLock.WaitAsync(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return true; // Already gone + } + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + + File.Delete(fullPath); + _logger.LogInformation("Deleted configuration file: {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting configuration file: {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + + /// + /// Lists all configuration files in the configuration directory. + /// + public IEnumerable ListConfigurationFiles() + { + try + { + return Directory.GetFiles(_configDirectory, "*.json") + .Select(Path.GetFileName) + .Where(f => !f.EndsWith(".bak")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing configuration files in {directory}", _configDirectory); + return Enumerable.Empty(); + } + } +}