Commit: c754fe5
Parent: a98b1e0

Config loading in UI

Mårten Åsberg committed on 2026-10-29 at 22:47
Assisted-by: GitHub Copilot:gpt-6.1-sol
README.md +9 -0
diff --git a/README.md b/README.md
index af02fa2..890af99 100644
@@ -4,4 +4,13 @@ Special built for the event driven system I have at work.
Analyses code paths from a give file:line and can render a call graph.
Configuration loads in order: optional `~/.cfa.json`, optional local `.cfa.json`,
then an explicitly selected file (required when specified). The CLI uses the
working directory for local config and accepts `--config`; the UI uses the
solution directory and offers **File -> Load config...** without reloading the
solution. Later JSON keys override earlier keys using Microsoft.Extensions.Configuration;
arrays merge by index, rather than replacing the entire list. Invalid config is
reported and the UI retains its previous settings and graph. No environment
variable provider is added.
![](./ui/tests/Snapshots/analyzer-fit.png)
cli/src/ScanCommand.cs +2 -5
diff --git a/cli/src/ScanCommand.cs b/cli/src/ScanCommand.cs
index 1723a14..380e806 100644
@@ -133,8 +133,7 @@ internal sealed class ScanCommand : Command
private static Option<FileInfo> ConfigOption { get; } =
new("--config")
{
Description =
"JSON file containing message publisher and handler registration routes. (Defaults to ./.cfa.json)",
Description = "JSON routes overriding ~/.cfa.json and ./.cfa.json (both loaded optionally).",
};
public ScanCommand()
@@ -155,9 +154,7 @@ internal sealed class ScanCommand : Command
var solutionFile = parseResult.GetRequiredValue(PathArgument);
var options = new ScannerOptions()
{
Messages = parseResult.GetValue(ConfigOption) is { FullName: var configPath }
? MessageOptions.LoadJson(configPath)
: MessageOptions.LoadJson(".cfa.json", optional: true),
Messages = MessageOptions.LoadJson(parseResult.GetValue(ConfigOption)?.FullName),
IncludePatterns = parseResult.GetValue(IncludeOption) ?? [],
ExcludePatterns = parseResult.GetValue(ExcludeOption) ?? [],
};
core/src/MessageOptions.cs +46 -11
diff --git a/core/src/MessageOptions.cs b/core/src/MessageOptions.cs
index 96b39f4..53d12fa 100644
@@ -16,19 +16,54 @@ public sealed class MessageOptions
[ValidateEnumeratedItems]
public List<MessageRoute> MessageRoutes { get; set; } = [];
public static MessageOptions LoadJson(string path, bool optional = false)
public static MessageOptions LoadJson(string? path = null, bool optional = false, string? localPath = null)
{
var fullPath = Path.GetFullPath(path);
using var files = new PhysicalFileProvider(
Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory,
ExclusionFilters.None
var userPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cfa.json");
return LoadLayeredJson(
path,
optional,
userPath,
localPath ?? Path.Combine(Environment.CurrentDirectory, ".cfa.json")
);
using var configuration = new ConfigurationManager();
configuration.AddJsonFile(files, Path.GetFileName(fullPath), optional, reloadOnChange: false);
var services = new ServiceCollection();
services.AddOptions<MessageOptions>().Bind(configuration).Validate<MessageOptionsValidator>();
using var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<MessageOptions>>().Value;
}
internal static MessageOptions LoadLayeredJson(
string? path,
bool optional,
string userPath,
string? localPath = null
)
{
var files = new List<PhysicalFileProvider>();
try
{
using var configuration = new ConfigurationManager();
AddFile(userPath, true);
if (localPath is not null)
AddFile(localPath, true);
if (path is not null)
AddFile(path, optional);
var services = new ServiceCollection();
services.AddOptions<MessageOptions>().Bind(configuration).Validate<MessageOptionsValidator>();
using var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<MessageOptions>>().Value;
void AddFile(string file, bool isOptional)
{
var fullPath = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(fullPath)!;
if (isOptional && !Directory.Exists(directory))
return;
var source = new PhysicalFileProvider(directory, ExclusionFilters.None);
files.Add(source);
configuration.AddJsonFile(source, Path.GetFileName(fullPath), isOptional, reloadOnChange: false);
}
}
finally
{
foreach (var source in files)
source.Dispose();
}
}
}
core/src/Scanner.cs +7 -1
diff --git a/core/src/Scanner.cs b/core/src/Scanner.cs
index e546f4d..14b906d 100644
@@ -30,7 +30,7 @@ public sealed class Scanner : IDisposable
return new Scanner(options, workspace, solution, compilations);
}
private readonly ScannerOptions options;
private ScannerOptions options;
private readonly Workspace workspace;
private readonly Solution solution;
private readonly IReadOnlyCollection<Compilation> compilations;
@@ -63,6 +63,12 @@ public sealed class Scanner : IDisposable
return await CallGraphBuilder.BuildCallGraph(options, solution, compilations, symbol, cancellationToken);
}
public void ConfigureMessages(MessageOptions messages)
{
ArgumentNullException.ThrowIfNull(messages);
options = options with { Messages = messages };
}
private async Task<SyntaxNode> FindMethodAt(FileInfo startFile, int startLine, CancellationToken cancellationToken)
{
var syntaxTree =
core/tests/MessageOptionsTests/LoadJsonShould.cs +86 -2
diff --git a/core/tests/MessageOptionsTests/LoadJsonShould.cs b/core/tests/MessageOptionsTests/LoadJsonShould.cs
index 92d7228..c40a0cd 100644
@@ -8,6 +8,90 @@ namespace Cfa.Tests.MessageOptionsTests;
public sealed class LoadJsonShould
{
[Fact]
public void ReportMalformedOptionalUserConfigurationInsteadOfIgnoringIt()
{
// Arrange
var user = Path.GetTempFileName();
try
{
File.WriteAllText(user, "{ invalid");
// Act
var load = () => MessageOptions.LoadLayeredJson(null, false, user);
// Assert
load.ShouldThrow<InvalidDataException>();
}
finally
{
File.Delete(user);
}
}
[Fact]
public void OverlayTheSelectedFileOnUserSettingsUsingConfigurationKeyPrecedence()
{
// Arrange
var user = Path.GetTempFileName();
var selected = Path.GetTempFileName();
var local = Path.GetTempFileName();
try
{
File.WriteAllText(
user,
"""
{"messageRoutes":[{"publisher":{"method":{"type":"Bus","name":"Send"},"messageArgument":2},
"registrations":[{"method":{"type":"Receiver","name":"On"}}]}]}
"""
);
File.WriteAllText(selected, """{"messageRoutes":[{"publisher":{"method":{"name":"Publish"}}}]}""");
File.WriteAllText(
local,
"""{"messageRoutes":[{"publisher":{"method":{"type":"LocalBus","name":"LocalSend"}}}]}"""
);
// Act
var options = MessageOptions.LoadLayeredJson(selected, false, user, local);
var userOnly = MessageOptions.LoadLayeredJson(null, false, user);
var localOnly = MessageOptions.LoadLayeredJson(null, false, user, local);
// Assert
options.MessageRoutes.ShouldHaveSingleItem().Publisher.Method.Name.ShouldBe("Publish");
options.MessageRoutes[0].Publisher.Method.Type.ShouldBe("LocalBus");
localOnly.MessageRoutes[0].Publisher.Method.Name.ShouldBe("LocalSend");
options.MessageRoutes[0].Publisher.MessageArgument.ShouldBe(2);
options.MessageRoutes[0].Registrations.ShouldHaveSingleItem().Method.Name.ShouldBe("On");
userOnly.MessageRoutes[0].Publisher.Method.Name.ShouldBe("Send");
}
finally
{
File.Delete(user);
File.Delete(selected);
File.Delete(local);
}
}
[Fact]
public void AllowMissingUserAndOptionalLocalFilesButRequireExplicitFiles()
{
// Arrange
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), ".cfa.json");
// Act
var options = MessageOptions.LoadLayeredJson(missing, true, missing);
var required = () =>
MessageOptions.LoadLayeredJson(
missing,
false,
Path.Combine(Path.GetTempPath(), "absent-user-config-" + Guid.NewGuid())
);
// Assert
options.MessageRoutes.ShouldBeEmpty();
required.ShouldThrow<FileNotFoundException>();
}
[Theory]
[InlineData("""{"messageRoutes":[{}]}""")]
[InlineData("""{"messageRoutes":[{"publisher":{"method":{"type":"Bus","name":"Send"},"messageArgument":-1}}]}""")]
@@ -20,7 +104,7 @@ public sealed class LoadJsonShould
File.WriteAllText(path, json);
// Act
var load = () => MessageOptions.LoadJson(path);
var load = () => MessageOptions.LoadLayeredJson(path, false, path + ".missing");
// Assert
load.ShouldThrow<OptionsValidationException>();
@@ -58,7 +142,7 @@ public sealed class LoadJsonShould
);
// Act
var load = () => MessageOptions.LoadJson(path);
var load = () => MessageOptions.LoadLayeredJson(path, false, path + ".missing");
// Assert
var error = load.ShouldThrow<OptionsValidationException>();
ui/src/Services/DesktopServices.cs +14 -0
diff --git a/ui/src/Services/DesktopServices.cs b/ui/src/Services/DesktopServices.cs
index f9ccdbf..5c1ebeb 100644
@@ -10,6 +10,7 @@ namespace Cfa.Ui.Services;
public interface IDesktopServices
{
Task<FileInfo?> PickConfigAsync();
Task<FileInfo?> PickSolutionAsync();
Task ShowErrorAsync(string message);
Task OpenSourceAsync(string link);
@@ -17,6 +18,19 @@ public interface IDesktopServices
public sealed class DesktopServices(Window owner) : IDesktopServices
{
public async Task<FileInfo?> PickConfigAsync()
{
var files = await owner.StorageProvider.OpenFilePickerAsync(
new FilePickerOpenOptions
{
Title = "Load config",
AllowMultiple = false,
FileTypeFilter = [new FilePickerFileType("JSON configuration") { Patterns = ["*.json"] }],
}
);
return files.Count > 0 && files[0].TryGetLocalPath() is { } path ? new FileInfo(path) : null;
}
public async Task<FileInfo?> PickSolutionAsync()
{
var files = await owner.StorageProvider.OpenFilePickerAsync(
ui/src/Services/ScanSession.cs +8 -6
diff --git a/ui/src/Services/ScanSession.cs b/ui/src/Services/ScanSession.cs
index 41ffcaa..bd41386 100644
@@ -7,6 +7,7 @@ namespace Cfa.Ui.Services;
public interface IScanSession : IDisposable
{
void LoadConfig(FileInfo config);
Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token);
}
@@ -19,16 +20,17 @@ public sealed class ScanSessionFactory : IScanSessionFactory
{
public async Task<IScanSession> OpenAsync(FileInfo solution, CancellationToken token)
{
var options = new ScannerOptions
{
Messages = MessageOptions.LoadJson(Path.Combine(solution.DirectoryName!, ".cfa.json"), optional: true),
};
var localPath = Path.Combine(solution.DirectoryName!, ".cfa.json");
var options = new ScannerOptions { Messages = MessageOptions.LoadJson(localPath: localPath) };
var scanner = await Scanner.Create(solution, options, token);
return new ScanSession(scanner);
return new ScanSession(scanner, localPath);
}
private sealed class ScanSession(Scanner scanner) : IScanSession
private sealed class ScanSession(Scanner scanner, string localPath) : IScanSession
{
public void LoadConfig(FileInfo config) =>
scanner.ConfigureMessages(MessageOptions.LoadJson(config.FullName, localPath: localPath));
public async Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token) =>
Analyzer.Analyze(await scanner.BuildCallGraph(file, line, token));
ui/src/ViewModels/MainViewModel.cs +30 -0
diff --git a/ui/src/ViewModels/MainViewModel.cs b/ui/src/ViewModels/MainViewModel.cs
index 72a576e..4d2d64b 100644
@@ -28,11 +28,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
this.sessions = sessions;
OpenSolutionCommand = new AsyncRelayCommand(OpenSolutionAsync, () => !IsBusy);
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => CanAnalyze);
LoadConfigCommand = new AsyncRelayCommand(LoadConfigAsync, () => CanAnalyze);
OpenSourceCommand = new AsyncRelayCommand<GraphNode>(OpenSourceAsync);
}
public IAsyncRelayCommand OpenSolutionCommand { get; }
public IAsyncRelayCommand AnalyzeCommand { get; }
public IAsyncRelayCommand LoadConfigCommand { get; }
public IAsyncRelayCommand<GraphNode> OpenSourceCommand { get; }
public bool CanAnalyze => scanner is not null && !IsBusy;
public bool HasScene => Scene is not null;
@@ -56,6 +58,7 @@ public sealed class MainViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(CanAnalyze));
OpenSolutionCommand.NotifyCanExecuteChanged();
AnalyzeCommand.NotifyCanExecuteChanged();
LoadConfigCommand.NotifyCanExecuteChanged();
}
}
}
@@ -106,6 +109,33 @@ public sealed class MainViewModel : ObservableObject, IDisposable
}
}
private async Task LoadConfigAsync()
{
IsBusy = true;
try
{
var file = await desktop.PickConfigAsync();
if (file is null)
return;
await Task.Run(() => scanner!.LoadConfig(file), lifetime.Token);
if (!disposed)
{
Scene = null;
Status = "Config loaded: " + file.FullName;
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { }
catch (Exception exception)
{
Status = "Config loading failed.";
await desktop.ShowErrorAsync(exception.Message);
}
finally
{
FinishOperation();
}
}
private async Task AnalyzeAsync()
{
IsBusy = true;
ui/src/Views/MainWindow.axaml +5 -0
diff --git a/ui/src/Views/MainWindow.axaml b/ui/src/Views/MainWindow.axaml
index 98d8df6..e090b7f 100644
@@ -22,6 +22,11 @@
Command="{Binding OpenSolutionCommand}"
InputGesture="Ctrl+O"
/>
<MenuItem
Header="_Load config..."
Name="LoadConfigMenu"
Command="{Binding LoadConfigCommand}"
/>
</MenuItem>
</Menu>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto,Auto" Margin="16,8,16,12">
ui/tests/MainWindowTests/InteractShould.cs +58 -0
diff --git a/ui/tests/MainWindowTests/InteractShould.cs b/ui/tests/MainWindowTests/InteractShould.cs
index b7078e6..6c0e4f7 100644
@@ -23,6 +23,50 @@ namespace Cfa.Ui.Tests.MainWindowTests;
public sealed class InteractShould
{
[AvaloniaFact]
public async Task LoadConfigWithoutReopeningTheSolutionAndPreserveTheGraphOnFailure()
{
// Arrange
var desktop = new FakeDesktop { Config = new FileInfo("/repo/custom.json") };
var factory = new FakeFactory();
using var model = new MainViewModel(desktop, factory);
var window = new MainWindow { DataContext = model };
window.Show();
try
{
model.LoadConfigCommand.CanExecute(null).ShouldBeFalse();
await model.OpenSolutionCommand.ExecuteAsync(null);
model.Address = "Flow.cs:1";
await model.AnalyzeCommand.ExecuteAsync(null);
var menu = window.FindControl<MenuItem>("LoadConfigMenu")!;
// Act
menu.Command!.Execute(null);
await model.LoadConfigCommand.ExecutionTask!;
// Assert
factory.Opens.ShouldBe(1);
factory.Session.Config.ShouldBe(desktop.Config);
model.Scene.ShouldBeNull();
model.Status.ShouldContain("custom.json");
await model.AnalyzeCommand.ExecuteAsync(null);
var scene = model.Scene;
factory.Session.ConfigError = new InvalidOperationException("Invalid configuration");
await model.LoadConfigCommand.ExecuteAsync(null);
model.Scene.ShouldBeSameAs(scene);
desktop.Error.ShouldBe("Invalid configuration");
desktop.Config = null;
factory.Session.ConfigError = null;
await model.LoadConfigCommand.ExecuteAsync(null);
model.Scene.ShouldBeSameAs(scene);
}
finally
{
window.Close();
}
}
[AvaloniaFact]
public async Task KeepTheAddressDisabledWhileTheSolutionLoads()
{
// Arrange
@@ -317,6 +361,10 @@ public sealed class InteractShould
private sealed class FakeDesktop : IDesktopServices
{
public FileInfo? Config { get; set; }
public Task<FileInfo?> PickConfigAsync() => Task.FromResult(Config);
public string? Error { get; private set; }
public string? OpenedLink { get; private set; }
@@ -350,6 +398,16 @@ public sealed class InteractShould
private sealed class FakeSession : IScanSession
{
public FileInfo? Config { get; private set; }
public Exception? ConfigError { get; set; }
public void LoadConfig(FileInfo config)
{
if (ConfigError is not null)
throw ConfigError;
Config = config;
}
public int Analyses { get; private set; }
public FileInfo? LastFile { get; private set; }
public int LastLine { get; private set; }
ui/tests/MainWindowTests/RenderShould.cs +2 -0
diff --git a/ui/tests/MainWindowTests/RenderShould.cs b/ui/tests/MainWindowTests/RenderShould.cs
index 2969643..f8384e8 100644
@@ -128,6 +128,8 @@ public sealed class RenderShould
private sealed class Desktop(string root) : IDesktopServices
{
public Task<FileInfo?> PickConfigAsync() => Task.FromResult<FileInfo?>(null);
public Task<FileInfo?> PickSolutionAsync() =>
Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(root, "code-flow-analysis.slnx")));
ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs +10 -0
diff --git a/ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs b/ui/tests/ScanSessionFactoryTests/OpenAsyncShould.cs
index ec9de04..45948a2 100644
@@ -32,6 +32,16 @@ public sealed class OpenAsyncShould
// Act
using var session = await factory.OpenAsync(solution, timeout.Token);
var first = await session.AnalyzeAsync(source, line, timeout.Token);
var config = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(config, "{}");
session.LoadConfig(new FileInfo(config));
}
finally
{
File.Delete(config);
}
var second = await session.AnalyzeAsync(source, line, timeout.Token);
var scene = GraphLayout.Build(second, timeout.Token);