diff --git a/src/SeqCli/Cli/Commands/ConfigCommand.cs b/src/SeqCli/Cli/Commands/ConfigCommand.cs index 5ec9beec..c4b1142b 100644 --- a/src/SeqCli/Cli/Commands/ConfigCommand.cs +++ b/src/SeqCli/Cli/Commands/ConfigCommand.cs @@ -44,24 +44,25 @@ protected override Task Run() try { - var config = SeqCliConfig.Read(); - + var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); + if (_key != null) { if (_clear) { verb = "clear"; - Clear(config, _key); - SeqCliConfig.Write(config); + KeyValueSettings.Clear(config, _key); + SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); } else if (_value != null) { verb = "update"; - Set(config, _key, _value); - SeqCliConfig.Write(config); + KeyValueSettings.Set(config, _key, _value); + SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); } else { + verb = "print"; Print(config, _key); } } @@ -78,114 +79,23 @@ protected override Task Run() return Task.FromResult(1); } } - + static void Print(SeqCliConfig config, string key) { if (config == null) throw new ArgumentNullException(nameof(config)); if (key == null) throw new ArgumentNullException(nameof(key)); - var pr = ReadPairs(config).SingleOrDefault(p => p.Key == key); - if (pr.Key == null) - throw new ArgumentException($"Option {key} not found."); - - Console.WriteLine(Format(pr.Value)); - } - - static void Set(SeqCliConfig config, string key, string? value) - { - if (config == null) throw new ArgumentNullException(nameof(config)); - if (key == null) throw new ArgumentNullException(nameof(key)); - - var steps = key.Split('.'); - if (steps.Length != 2) - throw new ArgumentException("The format of the key is incorrect; run the command without any arguments to view all keys."); - - var first = config.GetType().GetTypeInfo().DeclaredProperties - .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic) - .SingleOrDefault(p => Camelize(p.Name) == steps[0]); - - if (first == null) - throw new ArgumentException("The key could not be found; run the command without any arguments to view all keys."); - - if (first.PropertyType == typeof(Dictionary)) - throw new NotSupportedException("Use `seqcli profile create` to configure connection profiles."); - - var second = first.PropertyType.GetTypeInfo().DeclaredProperties - .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic) - .SingleOrDefault(p => Camelize(p.Name) == steps[1]); - - if (second == null) - throw new ArgumentException("The key could not be found; run the command without any arguments to view all keys."); + if (!KeyValueSettings.TryGetValue(config, key, out var value, out _)) + throw new ArgumentException($"Option {key} not found"); - if (!second.CanWrite || !second.SetMethod!.IsPublic) - throw new ArgumentException("The value is not writeable."); - - var targetValue = Convert.ChangeType(value, second.PropertyType, CultureInfo.InvariantCulture); - var configItem = first.GetValue(config); - second.SetValue(configItem, targetValue); - } - - static void Clear(SeqCliConfig config, string key) - { - Set(config, key, null); + Console.WriteLine(value); } static void List(SeqCliConfig config) { - foreach (var (key, value) in ReadPairs(config)) - { - Console.WriteLine($"{key}:"); - Console.WriteLine($" {Format(value)}"); - } - } - - static IEnumerable> ReadPairs(object config) - { - foreach (var property in config.GetType().GetTypeInfo().DeclaredProperties - .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic && !p.Name.StartsWith("Encoded")) - .OrderBy(p => p.Name)) + foreach (var (key, value, _) in KeyValueSettings.Inspect(config)) { - var propertyName = Camelize(property.Name); - var propertyValue = property.GetValue(config); - - if (propertyValue is IDictionary dict) - { - foreach (var elementKey in dict.Keys) - { - foreach (var elementPair in ReadPairs(dict[elementKey]!)) - { - yield return new KeyValuePair( - $"{propertyName}[{elementKey}].{elementPair.Key}", - elementPair.Value); - } - } - } - else if (propertyValue?.GetType().Namespace?.StartsWith("SeqCli.Config") ?? false) - { - foreach (var childPair in ReadPairs(propertyValue)) - { - var name = propertyName + "." + childPair.Key; - yield return new KeyValuePair(name, childPair.Value); - } - } - else - { - yield return new KeyValuePair(propertyName, propertyValue); - } + Console.WriteLine($"{key}={value}"); } } - - static string Camelize(string s) - { - if (s.Length < 2) - throw new NotSupportedException("No camel-case support for short names"); - return char.ToLowerInvariant(s[0]) + s.Substring(1); - } - - static string Format(object? value) - { - return value is IFormattable formattable - ? formattable.ToString(null, CultureInfo.InvariantCulture) - : value?.ToString() ?? ""; - } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs b/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs index 83d32e21..f4289763 100644 --- a/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/CreateCommand.cs @@ -48,9 +48,9 @@ int RunSync() try { - var config = SeqCliConfig.Read(); + var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); config.Profiles[_name] = new SeqCliConnectionConfig { ServerUrl = _url, ApiKey = _apiKey }; - SeqCliConfig.Write(config); + SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); return 0; } catch (Exception ex) diff --git a/src/SeqCli/Cli/Commands/Profile/ListCommand.cs b/src/SeqCli/Cli/Commands/Profile/ListCommand.cs index 86d0e7ee..8d3b8048 100644 --- a/src/SeqCli/Cli/Commands/Profile/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/ListCommand.cs @@ -11,7 +11,7 @@ class ListCommand : Command { protected override Task Run() { - var config = SeqCliConfig.Read(); + var config = RuntimeConfigurationLoader.Load(); foreach (var profile in config.Profiles.OrderBy(p => p.Key)) { diff --git a/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs index 12184ca6..a112bdb1 100644 --- a/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Profile/RemoveCommand.cs @@ -34,14 +34,14 @@ int RunSync() try { - var config = SeqCliConfig.Read(); + var config = SeqCliConfig.ReadFromFile(RuntimeConfigurationLoader.DefaultConfigFilename); if (!config.Profiles.Remove(_name)) { Log.Error("No profile with name {ProfileName} was found", _name); return 1; } - SeqCliConfig.Write(config); + SeqCliConfig.WriteToFile(config, RuntimeConfigurationLoader.DefaultConfigFilename); return 0; } diff --git a/src/SeqCli/Cli/Options.cs b/src/SeqCli/Cli/Options.cs index 5678ac29..c933f540 100644 --- a/src/SeqCli/Cli/Options.cs +++ b/src/SeqCli/Cli/Options.cs @@ -239,7 +239,7 @@ private static int GetLineEnd (int start, int length, string description) class OptionValueCollection : IList, IList { - List values = new List (); + List values = new(); OptionContext c; internal OptionValueCollection (OptionContext c) @@ -694,7 +694,7 @@ public Converter MessageLocalizer { get {return localizer;} } - List sources = new List (); + List sources = new(); ReadOnlyCollection roSources; public ReadOnlyCollection ArgumentSources { @@ -960,7 +960,7 @@ public List Parse (IEnumerable arguments) } class ArgumentEnumerator : IEnumerable { - List> sources = new List> (); + List> sources = new(); public ArgumentEnumerator (IEnumerable arguments) { @@ -1015,7 +1015,7 @@ private static bool Unprocessed (ICollection extra, Option def, OptionCo return false; } - private readonly Regex ValueOption = new Regex ( + private readonly Regex ValueOption = new( @"^(?--|-|/)(?[^:=]+)((?[:=])(?.*))?$"); protected bool GetOptionParts (string argument, out string flag, out string name, out string sep, out string value) diff --git a/src/SeqCli/Config/EnvironmentOverrides.cs b/src/SeqCli/Config/EnvironmentOverrides.cs new file mode 100644 index 00000000..42544c50 --- /dev/null +++ b/src/SeqCli/Config/EnvironmentOverrides.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace SeqCli.Config; + +static class EnvironmentOverrides +{ + public static void Apply(string prefix, SeqCliConfig config) + { + var environment = Environment.GetEnvironmentVariables(); + Apply(prefix, config, environment.Keys.Cast().ToDictionary(k => k, k => (string?)environment[k])); + } + + internal static void Apply(string prefix, SeqCliConfig config, Dictionary environment) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + if (environment == null) throw new ArgumentNullException(nameof(environment)); + + config.DisallowExport(); + + foreach (var (key, _, _) in KeyValueSettings.Inspect(config)) + { + var envVar = ToEnvironmentVariableName(prefix, key); + if (environment.TryGetValue(envVar, out var value)) + { + KeyValueSettings.Set(config, key, value ?? ""); + } + } + } + + internal static string ToEnvironmentVariableName(string prefix, string key) + { + return prefix + key.Replace(".", "_").ToUpperInvariant(); + } +} diff --git a/src/SeqCli/Config/KeyValueSettings.cs b/src/SeqCli/Config/KeyValueSettings.cs new file mode 100644 index 00000000..5b66ffee --- /dev/null +++ b/src/SeqCli/Config/KeyValueSettings.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; + +namespace SeqCli.Config; + +static class KeyValueSettings +{ + public static void Set(SeqCliConfig config, string key, string? value) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + if (key == null) throw new ArgumentNullException(nameof(key)); + + var steps = key.Split('.'); + if (steps.Length < 2) + throw new ArgumentException("The format of the key is incorrect; run `seqcli config list` to view all keys."); + + object? receiver = config; + for (var i = 0; i < steps.Length - 1; ++i) + { + var nextStep = receiver.GetType().GetTypeInfo().DeclaredProperties + .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic && p.GetCustomAttribute() == null) + .SingleOrDefault(p => Camelize(GetUserFacingName(p)) == steps[i]); + + if (nextStep == null) + throw new ArgumentException("The key could not be found; run `seqcli config list` to view all keys."); + + if (nextStep.PropertyType == typeof(Dictionary)) + throw new NotSupportedException("Use `seqcli profile create` to configure connection profiles."); + + receiver = nextStep.GetValue(receiver); + if (receiver == null) + throw new InvalidOperationException("Intermediate configuration object is null."); + } + + var targetProperty = receiver.GetType().GetTypeInfo().DeclaredProperties + .Where(p => p is { CanRead: true, CanWrite: true } && p.GetMethod!.IsPublic && p.SetMethod!.IsPublic && + !p.GetMethod.IsStatic && p.GetCustomAttribute() == null) + .SingleOrDefault(p => Camelize(GetUserFacingName(p)) == steps[^1]); + + if (targetProperty == null) + throw new ArgumentException("The key could not be found; run `seqcli config list` to view all keys."); + + var targetValue = ChangeType(value, targetProperty.PropertyType); + targetProperty.SetValue(receiver, targetValue); + } + + static object? ChangeType(string? value, Type propertyType) + { + if (propertyType == typeof(string[])) + return value?.Split(',').Select(e => e.Trim()).ToArray() ?? []; + + if (propertyType == typeof(int[])) + return value?.Split(',').Select(e => int.Parse(e.Trim(), CultureInfo.InvariantCulture)).ToArray() ?? Array.Empty(); + + if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + return string.IsNullOrWhiteSpace(value) ? null : ChangeType(value, propertyType.GetGenericArguments().Single()); + } + + if (propertyType.IsEnum) + return Enum.Parse(propertyType, value ?? throw new ArgumentException("The setting format is incorrect.")); + + return Convert.ChangeType(value, propertyType, CultureInfo.InvariantCulture); + } + + public static void Clear(SeqCliConfig config, string key) + { + Set(config, key, null); + } + + public static bool TryGetValue(object config, string key, out string? value, [NotNullWhen(true)] out PropertyInfo? metadata) + { + var (readKey, readValue, m) = Inspect(config).SingleOrDefault(p => p.Item1 == key); + if (readKey == null) + { + value = null; + metadata = null; + return false; + } + + value = readValue; + metadata = m; + return true; + } + + public static IEnumerable<(string, string, PropertyInfo)> Inspect(object config) + { + return Inspect(config, null); + } + + static IEnumerable<(string, string, PropertyInfo)> Inspect(object receiver, string? receiverName) + { + foreach (var nextStep in receiver.GetType().GetTypeInfo().DeclaredProperties + .Where(p => p.CanRead && p.GetMethod!.IsPublic && + !p.GetMethod.IsStatic && p.GetCustomAttribute() == null) + .OrderBy(GetUserFacingName)) + { + var camel = Camelize(GetUserFacingName(nextStep)); + var valuePath = receiverName == null ? camel : $"{receiverName}.{camel}"; + + if (nextStep.PropertyType.IsAssignableTo(typeof(IDictionary))) + { + var dict = (IDictionary)nextStep.GetValue(receiver)!; + foreach (var elementKey in dict.Keys) + { + foreach (var elementPair in Inspect(dict[elementKey]!)) + { + yield return ( + $"{valuePath}[{elementKey}].{elementPair.Item1}", + elementPair.Item2, + elementPair.Item3); + } + } + } + // I.e. all of our nested config types + else if (nextStep.PropertyType.Name.StartsWith("SeqCli", StringComparison.Ordinal)) + { + var subConfig = nextStep.GetValue(receiver); + if (subConfig != null) + { + foreach (var keyValuePair in Inspect(subConfig, valuePath)) + yield return keyValuePair; + } + } + else if (nextStep.CanRead && nextStep.GetMethod!.IsPublic && + nextStep.CanWrite && nextStep.SetMethod!.IsPublic && + !nextStep.SetMethod.IsStatic && + nextStep.GetCustomAttribute() == null) + { + var value = nextStep.GetValue(receiver); + yield return (valuePath, FormatConfigValue(value), nextStep); + } + } + } + + static string FormatConfigValue(object? value) + { + if (value is string[] strings) + return string.Join(",", strings); + + if (value is int[] ints) + return string.Join(",", ints.Select(i => i.ToString(CultureInfo.InvariantCulture))); + + if (value is decimal dec) + { + var floor = decimal.Floor(dec); + if (dec == floor) + value = floor; // JSON.NET adds a trailing zero, which System.Decimal preserves + } + + return value is IFormattable formattable ? + formattable.ToString(null, CultureInfo.InvariantCulture) : + value?.ToString() ?? ""; + } + + static string Camelize(string s) + { + if (s.Length < 2) + throw new NotImplementedException("No camel-case support for short names"); + + if (s.StartsWith("MS", StringComparison.Ordinal)) + return "ms" + s[2..]; + + return char.ToLowerInvariant(s[0]) + s[1..]; + } + + static string GetUserFacingName(PropertyInfo pi) + { + return pi.GetCustomAttribute()?.PropertyName ?? pi.Name; + } +} \ No newline at end of file diff --git a/src/SeqCli/Config/RuntimeConfigurationLoader.cs b/src/SeqCli/Config/RuntimeConfigurationLoader.cs new file mode 100644 index 00000000..8733aa1c --- /dev/null +++ b/src/SeqCli/Config/RuntimeConfigurationLoader.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; + +namespace SeqCli.Config; + +static class RuntimeConfigurationLoader +{ + public static readonly string DefaultConfigFilename = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SeqCli.json"); + + const string DefaultEnvironmentVariablePrefix = "SEQCLI_"; + + /// + /// This is the method to use when loading configuration for runtime use. It will read the default configuration + /// file, if any, and apply overrides from the environment. + /// + public static SeqCliConfig Load() + { + var config = SeqCliConfig.ReadFromFile(DefaultConfigFilename); + + EnvironmentOverrides.Apply(DefaultEnvironmentVariablePrefix, config); + + return config; + } +} \ No newline at end of file diff --git a/src/SeqCli/Config/SeqCliConfig.cs b/src/SeqCli/Config/SeqCliConfig.cs index dd3b51ca..c0d69b53 100644 --- a/src/SeqCli/Config/SeqCliConfig.cs +++ b/src/SeqCli/Config/SeqCliConfig.cs @@ -18,15 +18,15 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; +// ReSharper disable AutoPropertyCanBeMadeGetOnly.Global namespace SeqCli.Config; class SeqCliConfig { - static readonly string DefaultConfigFilename = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SeqCli.json"); - - static JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings + bool _exportable = true; + + static JsonSerializerSettings SerializerSettings { get; } = new() { ContractResolver = new CamelCasePropertyNamesContractResolver(), Converters = @@ -35,24 +35,40 @@ class SeqCliConfig } }; - public static SeqCliConfig Read() + /// + /// Loads without considering any environment overrides, nor performing any validation. + /// This method is typically used when editing/manipulating the configuration file itself. To read and use the + /// configuration at runtime, see . + /// + /// If does not exist, a new default configuration will be returned. + public static SeqCliConfig ReadFromFile(string filename) { - if (!File.Exists(DefaultConfigFilename)) + if (!File.Exists(filename)) return new SeqCliConfig(); - - var content = File.ReadAllText(DefaultConfigFilename); + + var content = File.ReadAllText(filename); return JsonConvert.DeserializeObject(content, SerializerSettings)!; } - public static void Write(SeqCliConfig data) + public static void WriteToFile(SeqCliConfig data, string filename) { - if (data == null) throw new ArgumentNullException(nameof(data)); + if (!data._exportable) + throw new InvalidOperationException("The provided configuration is not exportable."); + var content = JsonConvert.SerializeObject(data, Formatting.Indented, SerializerSettings); - File.WriteAllText(DefaultConfigFilename, content); + File.WriteAllText(filename, content); } - public SeqCliConnectionConfig Connection { get; set; } = new SeqCliConnectionConfig(); - public SeqCliOutputConfig Output { get; set; } = new SeqCliOutputConfig(); - public Dictionary Profiles { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); + public SeqCliConnectionConfig Connection { get; set; } = new(); + public SeqCliOutputConfig Output { get; set; } = new(); + public Dictionary Profiles { get; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Some configuration objects, for example those with environment overrides, should not be exported + /// back to JSON files. Call this method to mark the current configuration as non-exportable. + /// + public void DisallowExport() + { + _exportable = false; + } } \ No newline at end of file diff --git a/src/SeqCli/SeqCliModule.cs b/src/SeqCli/SeqCliModule.cs index 009d5172..723cb9e7 100644 --- a/src/SeqCli/SeqCliModule.cs +++ b/src/SeqCli/SeqCliModule.cs @@ -29,7 +29,7 @@ protected override void Load(ContainerBuilder builder) .As() .WithMetadataFrom(); builder.RegisterType(); - builder.Register(c => SeqCliConfig.Read()).SingleInstance(); + builder.Register(c => RuntimeConfigurationLoader.Load()).SingleInstance(); builder.Register(c => c.Resolve().Connection).SingleInstance(); builder.Register(c => c.Resolve().Output).SingleInstance(); } diff --git a/src/SeqCli/Syntax/QueryBuilder.cs b/src/SeqCli/Syntax/QueryBuilder.cs index 30b6aeaf..b2338ec4 100644 --- a/src/SeqCli/Syntax/QueryBuilder.cs +++ b/src/SeqCli/Syntax/QueryBuilder.cs @@ -21,10 +21,10 @@ namespace SeqCli.Syntax; class QueryBuilder { - readonly List<(string, string)> _columns = new List<(string, string)>(); - readonly List _where = new List(); - readonly List _groupBy = new List(); - readonly List _having = new List(); + readonly List<(string, string)> _columns = new(); + readonly List _where = new(); + readonly List _groupBy = new(); + readonly List _having = new(); public void Select(string value, string label) { diff --git a/test/SeqCli.Tests/Config/EnvironmentOverridesTests.cs b/test/SeqCli.Tests/Config/EnvironmentOverridesTests.cs new file mode 100644 index 00000000..1ffbbd84 --- /dev/null +++ b/test/SeqCli.Tests/Config/EnvironmentOverridesTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using SeqCli.Config; +using Xunit; + +namespace SeqCli.Tests.Config; + +public class EnvironmentOverridesTests +{ + [Fact] + public void EnvironmentVariableOverridesAreApplied() + { + const string initialUrl = "https://old.example.com"; + + var config = new SeqCliConfig + { + Connection = + { + ServerUrl = initialUrl + } + }; + + var environment = new Dictionary(); + EnvironmentOverrides.Apply("SEQCLI_", config, environment); + + Assert.Equal(initialUrl, config.Connection.ServerUrl); + + const string updatedUrl = "https://new.example.com"; + environment["SEQCLI_CONNECTION_SERVERURL"] = updatedUrl; + EnvironmentOverrides.Apply("SEQCLI_", config, environment); + + Assert.Equal(updatedUrl, config.Connection.ServerUrl); + } +} diff --git a/test/SeqCli.Tests/PlainText/NameValueExtractorTests.cs b/test/SeqCli.Tests/PlainText/NameValueExtractorTests.cs index 8deac0f2..cfcb203e 100644 --- a/test/SeqCli.Tests/PlainText/NameValueExtractorTests.cs +++ b/test/SeqCli.Tests/PlainText/NameValueExtractorTests.cs @@ -35,7 +35,7 @@ public void TheTrailingIndentPatternDoesNotMatchLinesStartingWithWhitespace() Assert.Equal(frame, remainder); } - static NameValueExtractor ClassMethodPattern { get; } = new NameValueExtractor(new[] + static NameValueExtractor ClassMethodPattern { get; } = new(new[] { new SimplePatternElement(Matchers.Identifier, "class"), new SimplePatternElement(Matchers.LiteralText(".")),