diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..41f2e91 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,103 @@ +root = true + +# ------------------------------- +# General +# ------------------------------- +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +# ------------------------------- +# C# files +# ------------------------------- +[*.cs] + +indent_size = 4 + +# New lines & braces +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:warning + +# Using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true + +# var usage (Spectre-style: pragmatic) +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members (used where clean) +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion + +# Pattern matching / modern C# +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Nullability helpers +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion + +# Readonly fields +dotnet_style_readonly_field = true:suggestion + +# ------------------------------- +# Naming +# ------------------------------- + +# Private fields: _camelCase +dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_with_underscore + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case +dotnet_naming_style.camel_case_with_underscore.required_prefix = _ + +# Interfaces: IMyInterface +dotnet_naming_rule.interfaces_should_start_with_i.severity = suggestion +dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_should_start_with_i.style = interface_prefix + +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +dotnet_naming_style.interface_prefix.required_prefix = I +dotnet_naming_style.interface_prefix.capitalization = pascal_case + +# ------------------------------- +# Analyzers +# ------------------------------- + +# Keep warnings visible but not painful +dotnet_analyzer_diagnostic.severity = warning + +# Unused usings +dotnet_diagnostic.IDE0005.severity = warning + +# Simplification +dotnet_diagnostic.IDE0007.severity = suggestion +dotnet_diagnostic.IDE0008.severity = suggestion + +# Documentation (Spectre.Console is pragmatic here) +dotnet_diagnostic.CS1591.severity = silent + +# ------------------------------- +# JSON / YAML +# ------------------------------- +[*.json] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[*.yaml] +indent_size = 2 \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b437036 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* stuart.meeks@stuartmeeks.net diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..314340b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + branches: [ main ] + tags: + - 'v*' + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Pack + run: dotnet pack --configuration Release --no-build --output ./artifacts + + - name: Upload package artifact + uses: actions/upload-artifact@v6 + with: + name: nuget-package + # Capture both .nupkg and .snupkg so the publish job's + # `dotnet nuget push *.nupkg` can also push the matching + # symbol package next to it. + path: ./artifacts/*nupkg + + publish: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Download artifact + uses: actions/download-artifact@v7 + with: + name: nuget-package + path: ./artifacts + + - name: Publish to NuGet + run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ec041a --- /dev/null +++ b/.gitignore @@ -0,0 +1,431 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# Claude Code local (personal) settings +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..319bca7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to `NextIteration.SpectreConsole.Settings` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.1.0] — Unreleased + +### Added — initial release + +- **Strongly-typed settings** — `SettingsBase`: derive, back each property with + a field, call `OnPropertyChanged()`. An instance is inert until bound at load, + so deserializer setter calls never trigger a write. +- **Automatic / explicit persistence** — `PersistenceMode`. `Automatic` debounces + a burst of changes into a single async write (default 250 ms window, configurable); + `Explicit` persists only via `Save()` / `SaveAsync()`. Fire-and-forget write + failures are surfaced through a configurable error handler, never swallowed. +- **`AddSettings()`** DI extension with required-`SettingsDirectory` validation. + Each class is persisted to its own `{ClassName}.json` file. Per-class singletons + resolve through `ISettingsStore`, so an in-place reset is observed by injected + references. +- **Tolerant JSON persistence** — atomic writes (temp-file + rename), missing + properties default, unknown properties ignored, case-insensitive matching, + string-valued enums. +- **`settings` command branch** — `list` and `reset` (`` and + `--all`), drop-in via `CommandConfiguratorExtensions.AddSettingsBranch()`. All + commands honour `-v` / `--verbose`. +- **`ISettingsStore`** — enumerate registrations, resolve instances, and reset one + or all classes at runtime. +- Full XML documentation on the public surface. +- Test suite (xUnit) with 22 tests covering load-on-missing-file, automatic + persistence + round-trip, explicit persistence, debounce coalescing, reset / + reset-all, tolerant deserialisation, atomic writes, and error surfacing. +- SourceLink, deterministic builds, published symbol packages (`snupkg`). +- `TreatWarningsAsErrors=true`, `AnalysisLevel=latest` — zero-warning public API. +- Package icon, with the editable source vector kept under `design/icons/`. + +[0.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/tree/main diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e844269 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + net10.0 + enable + true + Stuart Meeks + Next Iteration + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..47b8ed6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stuart Meeks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NextIteration.SpectreConsole.Settings.slnx b/NextIteration.SpectreConsole.Settings.slnx new file mode 100644 index 0000000..df2a85b --- /dev/null +++ b/NextIteration.SpectreConsole.Settings.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ae44eb --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# NextIteration.SpectreConsole.Settings + +[![NuGet](https://img.shields.io/nuget/v/NextIteration.SpectreConsole.Settings.svg)](https://www.nuget.org/packages/NextIteration.SpectreConsole.Settings/) +[![Downloads](https://img.shields.io/nuget/dt/NextIteration.SpectreConsole.Settings.svg)](https://www.nuget.org/packages/NextIteration.SpectreConsole.Settings/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![.NET](https://img.shields.io/badge/.NET-10.0-purple.svg)](https://dotnet.microsoft.com/) +[![CI](https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/actions/workflows/ci.yml/badge.svg)](https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/actions/workflows/ci.yml) + +Strongly-typed, JSON-persisted settings and ready-made `settings` commands for CLI tools built on [Spectre.Console](https://spectreconsole.net/). + +Stop hand-rolling the same `~/.app/config.json` + load/save boilerplate into every CLI you build. Declare a settings class, register it, and inject it into any command. Change a property and it's persisted — automatically and debounced, or explicitly when you say so — and `my-cli settings list` / `reset` just work. + +--- + +## Features + +- **Strongly-typed settings** — derive from `SettingsBase`, back each property with a field, call `OnPropertyChanged()`. That's the whole contract. +- **Automatic or explicit persistence** — `Automatic` mode debounces a burst of changes into a single async disk write; `Explicit` mode persists only when you call `Save()` / `SaveAsync()`. +- **`settings` command branch** — `list` and `reset` wired into your existing `CommandApp` with a single call. +- **Atomic writes** — a crash mid-write never leaves a half-written settings file on disk. +- **Tolerant deserialisation** — added a property? It defaults. Removed one? The old key is ignored. No migration ceremony for ordinary schema evolution. +- **Errors are never swallowed** — a failed fire-and-forget write is routed to a configurable error handler (default: one line to stderr). +- **Zero compiler warnings, fully documented public surface** — `` on, analyzers on, `TreatWarningsAsErrors` on. + +--- + +## Install + +```shell +dotnet add package NextIteration.SpectreConsole.Settings +``` + +Targets `net10.0`. + +--- + +## Quick start + +### 1. Declare a settings class + +Derive from `SettingsBase`, back each property with a field, and call `OnPropertyChanged()` from the setter: + +```csharp +using NextIteration.SpectreConsole.Settings; + +public sealed class AppSettings : SettingsBase +{ + private DataSourceMode _mode = DataSourceMode.File; + + public DataSourceMode Mode + { + get => _mode; + set + { + _mode = value; + OnPropertyChanged(); + } + } +} + +public enum DataSourceMode { File, Api } +``` + +### 2. Register it + +One call per settings class. `SettingsDirectory` is required — there's no smart default: + +```csharp +using NextIteration.SpectreConsole.Settings; + +services.AddSettings(opts => +{ + opts.SettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".my-cli", "settings"); +}); + +// A second class — this one persists only on explicit Save(): +services.AddSettings(opts => +{ + opts.SettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".my-cli", "settings"); + opts.PersistenceMode = PersistenceMode.Explicit; +}); +``` + +### 3. Hook the `settings` branch into your configurator + +```csharp +app.Configure(config => +{ + config.AddSettingsBranch(); + // ... your other commands +}); +``` + +### 4. Inject and use it from any command + +```csharp +public sealed class SwitchModeCommand(AppSettings settings) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context) + { + settings.Mode = DataSourceMode.Api; + // No explicit Save() needed — persisted automatically (debounced). + return 0; + } +} +``` + +That's it. Each settings class is serialised to its own JSON file in the directory, named after the class (e.g. `AppSettings.json`). On the next launch it's deserialised and handed to your commands. + +--- + +## The `settings` branch + +| Command | Description | +|---|---| +| `settings list` | Table of every registered settings class and its current property values. | +| `settings reset ` | Reset one settings class to default values and persist. | +| `settings reset --all` | Reset every registered settings class to defaults and persist. | + +Every command accepts `-v` / `--verbose` for full stack-trace output when something goes wrong. + +```console +$ my-cli settings list +AppSettings (Automatic) +/home/me/.my-cli/settings/AppSettings.json +┌──────────┬──────┐ +│ Property │ Value│ +├──────────┼──────┤ +│ Mode │ Api │ +└──────────┴──────┘ + +$ my-cli settings reset AppSettings +Reset 'AppSettings' to defaults. +``` + +--- + +## Persistence model + +### Automatic (default) + +Every `OnPropertyChanged()` schedules a **debounced** asynchronous write. A burst of changes in the same synchronous call stack — or within the debounce window — coalesces into a single disk write, so setting five properties in a row touches the file once. The default window is 250 ms; tune it per class via `SettingsOptions.DebounceInterval`. + +Writes are fire-and-forget, but failures are **never silently swallowed** — they're routed to `SettingsOptions.ErrorHandler` (default: a single line to stderr). + +### Explicit + +`OnPropertyChanged()` becomes a no-op; nothing is written until you call `Save()` or `SaveAsync()`. + +- `Save()` — fire-and-forget. Convenient, but in a command that mutates-then-returns the process may exit before the write lands. +- `SaveAsync()` — awaitable. Use it when you need the write to be on disk before the command returns. Exceptions propagate to the awaiter rather than the error handler. + +Both methods work in **either** mode — call `SaveAsync()` in `Automatic` mode too when you want to flush deterministically. + +### Loading + +On startup each registered class is deserialised from its JSON file. If the file doesn't exist, a default-constructed instance is used — no exception. Property assignments performed by the deserializer during load never trigger a write; persistence only kicks in for changes you make afterwards. + +--- + +## Schema evolution + +Settings classes change over time. The default serializer is tolerant: + +- **Added a property?** Files written by older versions simply lack the key, so it takes its constructed default. +- **Removed a property?** The now-unknown key in older files is ignored. +- Property matching is case-insensitive; enums round-trip as readable strings; comments and trailing commas are tolerated on read. + +Supply your own `SettingsOptions.SerializerOptions` if you need different behaviour. + +--- + +## Resetting at runtime + +`ISettingsStore` is registered as a singleton and aggregates every class you registered. It powers `settings reset`, and you can use it directly: + +```csharp +public sealed class FactoryResetCommand(ISettingsStore store) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context) + { + await store.ResetAllAsync(); + return 0; + } +} +``` + +A reset restores defaults **in place** on the live instance and persists immediately — any command that injected the settings object directly observes the change without a restart. + +--- + +## Configuration reference + +`AddSettings(opts => …)` accepts a `SettingsOptions`: + +| Option | Default | Notes | +|---|---|---| +| `SettingsDirectory` | — (required) | Absolute path; throws at registration if unset. | +| `PersistenceMode` | `Automatic` | `Automatic` (debounced) or `Explicit`. | +| `DebounceInterval` | `250 ms` | Coalescing window for automatic writes. | +| `ErrorHandler` | stderr line | Invoked when a fire-and-forget write fails. | +| `SerializerOptions` | tolerant default | Override the `JsonSerializerOptions`. | + +`T` must derive from `SettingsBase` and have a public parameterless constructor (used for defaults and by the deserializer). + +--- + +## Requirements + +- **.NET 10.0** or later +- **Spectre.Console** 0.54+ and **Spectre.Console.Cli** 0.53+ +- **Microsoft.Extensions.DependencyInjection.Abstractions** 10.0+ + +Everything else is transitive. + +--- + +## Contributing + +Issues and PRs welcome. The [TODO](TODO.md) tracks outstanding ideas. + +When contributing code, please keep the zero-warning, fully-documented public surface. `TreatWarningsAsErrors` is on for a reason. + +--- + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release notes. + +--- + +## License + +[MIT](LICENSE) © Stuart Meeks + +Built for — and unaffiliated with — the excellent [Spectre.Console](https://github.com/spectreconsole/spectre.console) project. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e2d0174 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# TODO + +The initial release (0.1.0) is feature-complete against the original brief. +Outstanding ideas for future versions: + +## Persistence + +- **`INotifyPropertyChanged` interop** — optionally raise the standard event from + `OnPropertyChanged()` so settings objects can bind to data-bound UIs as well as + drive persistence. +- **Source generator** — emit the field + `OnPropertyChanged()` setter boilerplate + from a `[Setting]` attribute, so consumers declare only the property. +- **External change detection** — optionally watch the settings file and reload + when another process rewrites it (last-writer-wins today). +- **Backup-on-corrupt-load** — when a malformed file falls back to defaults, + side-car the unreadable file (e.g. `AppSettings.json.bak`) before the next write + overwrites it, instead of only surfacing the parse error to the handler. + +## Commands + +- **`settings get` / `settings set`** — read or mutate a single property by name + from the CLI, for scripting. +- **Confirmation prompt on `reset`** — mirror the `--force` pattern from the Auth + package's `accounts delete`. + +## Tooling + +- Decide whether to expose hardened file permissions (Unix `0600`) as an opt-in + for settings that happen to hold semi-sensitive values. diff --git a/design/icons/NextIteration.SpectreConsole.Settings.svg b/design/icons/NextIteration.SpectreConsole.Settings.svg new file mode 100644 index 0000000..115e091 --- /dev/null +++ b/design/icons/NextIteration.SpectreConsole.Settings.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/NextIteration.SpectreConsole.Settings/CommandConfiguratorExtensions.cs b/src/NextIteration.SpectreConsole.Settings/CommandConfiguratorExtensions.cs new file mode 100644 index 0000000..83214ec --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/CommandConfiguratorExtensions.cs @@ -0,0 +1,38 @@ +using NextIteration.SpectreConsole.Settings.Commands; + +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Spectre.Console.Cli configurator extensions for registering the + /// settings-management command branch in a CLI. + /// + public static class CommandConfiguratorExtensions + { + /// + /// Registers the settings branch of settings-management commands + /// (list, reset). + /// + public static IConfigurator AddSettingsBranch(this IConfigurator configurator) + { + ArgumentNullException.ThrowIfNull(configurator); + + configurator.AddBranch("settings", settings => + { + settings.SetDescription("Settings management commands"); + + settings.AddCommand("list") + .WithDescription("List all registered settings and their current values") + .WithExample("settings", "list"); + + settings.AddCommand("reset") + .WithDescription("Reset a settings class (or all) to default values") + .WithExample("settings", "reset", "AppSettings") + .WithExample("settings", "reset", "--all"); + }); + + return configurator; + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Commands/CommandErrorReporter.cs b/src/NextIteration.SpectreConsole.Settings/Commands/CommandErrorReporter.cs new file mode 100644 index 0000000..dfaf6b8 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Commands/CommandErrorReporter.cs @@ -0,0 +1,34 @@ +using Spectre.Console; + +namespace NextIteration.SpectreConsole.Settings.Commands +{ + /// + /// Centralised error rendering for the settings commands so every + /// command's catch block behaves identically — a terse single-line message + /// by default, the full exception when --verbose is set. + /// + internal static class CommandErrorReporter + { + /// + /// Writes to the console. In verbose mode the full + /// exception view is rendered; otherwise a single coloured line prefixed + /// with . + /// + internal static void Report(Exception ex, string contextMessage, bool verbose) + { + // Escape defensively: contextMessage is library-internal today, but + // ex.Message is always external (JSON/IO errors commonly contain + // '[' / ']' which Spectre would otherwise parse as markup). + if (verbose) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(contextMessage)}[/]"); + AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything); + } + else + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(contextMessage)}: {Markup.Escape(ex.Message)}[/]"); + AnsiConsole.MarkupLine("[grey]Run with --verbose for more detail.[/]"); + } + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Commands/ListSettingsCommand.cs b/src/NextIteration.SpectreConsole.Settings/Commands/ListSettingsCommand.cs new file mode 100644 index 0000000..0f5ea15 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Commands/ListSettingsCommand.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using System.Reflection; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Settings.Commands +{ + /// + /// Spectre.Console command for settings list. Renders every + /// registered settings class and its current property values, one table + /// per class. + /// + /// DI constructor. + public sealed class ListSettingsCommand(ISettingsStore store) : AsyncCommand + { + private readonly ISettingsStore _store = store; + + /// CLI settings for settings list. + public sealed class Settings : SettingsCommandSettings + { + } + + /// + protected override Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + try + { + if (_store.Registrations.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No settings classes are registered.[/]"); + return Task.FromResult(0); + } + + foreach (var registration in _store.Registrations) + { + RenderRegistration(registration); + AnsiConsole.WriteLine(); + } + + return Task.FromResult(0); + } + catch (Exception ex) + { + CommandErrorReporter.Report(ex, "Error listing settings", settings.Verbose); + return Task.FromResult(1); + } + } + + private void RenderRegistration(SettingsRegistration registration) + { + var instance = _store.GetInstance(registration.SettingsType); + + AnsiConsole.MarkupLine( + $"[bold]{Markup.Escape(registration.Name)}[/] [grey]({registration.PersistenceMode})[/]"); + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(registration.FilePath)}[/]"); + + var table = new Table(); + _ = table.AddColumn("Property"); + _ = table.AddColumn("Value"); + + var properties = registration.SettingsType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .ToList(); + + if (properties.Count == 0) + { + AnsiConsole.MarkupLine("[grey](no public properties)[/]"); + return; + } + + foreach (var property in properties) + { + _ = table.AddRow( + Markup.Escape(property.Name), + Markup.Escape(FormatValue(property, instance))); + } + + _ = table.Expand(); + AnsiConsole.Write(table); + } + + private static string FormatValue(PropertyInfo property, object instance) + { + object? value; + try + { + value = property.GetValue(instance); + } + catch (TargetInvocationException ex) + { + // A consumer getter that throws shouldn't take down `settings list`. + return $""; + } + + return value switch + { + null => string.Empty, + string s => s, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty, + }; + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Commands/ResetSettingsCommand.cs b/src/NextIteration.SpectreConsole.Settings/Commands/ResetSettingsCommand.cs new file mode 100644 index 0000000..4062ff9 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Commands/ResetSettingsCommand.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; + +using Spectre.Console; +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Settings.Commands +{ + /// + /// Spectre.Console command for settings reset. Resets a single + /// settings class to defaults, or every registered class with + /// --all, then persists. + /// + /// DI constructor. + public sealed class ResetSettingsCommand(ISettingsStore store) : AsyncCommand + { + private readonly ISettingsStore _store = store; + + /// CLI settings for settings reset. + public sealed class Settings : SettingsCommandSettings + { + /// + /// Name of the settings class to reset (the simple type name, e.g. + /// AppSettings). Omit when using . + /// + [CommandArgument(0, "[SETTINGS_CLASS]")] + [Description("The settings class to reset to defaults")] + public string? SettingsClass { get; set; } + + /// Reset every registered settings class. + [CommandOption("--all")] + [Description("Reset all registered settings classes")] + public bool All { get; set; } + } + + /// + protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + try + { + if (settings.All) + { + if (!string.IsNullOrWhiteSpace(settings.SettingsClass)) + { + AnsiConsole.MarkupLine("[red]Specify either a settings class or --all, not both.[/]"); + return 1; + } + + if (_store.Registrations.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No settings classes are registered.[/]"); + return 0; + } + + await _store.ResetAllAsync(cancellationToken).ConfigureAwait(false); + AnsiConsole.MarkupLine($"[green]Reset all {_store.Registrations.Count} settings class(es) to defaults.[/]"); + return 0; + } + + if (string.IsNullOrWhiteSpace(settings.SettingsClass)) + { + AnsiConsole.MarkupLine("[red]Specify a settings class to reset, or pass --all.[/]"); + RenderAvailableClasses(); + return 1; + } + + var registration = _store.Registrations + .FirstOrDefault(r => string.Equals(r.Name, settings.SettingsClass, StringComparison.OrdinalIgnoreCase)); + + if (registration is null) + { + AnsiConsole.MarkupLine($"[red]Unknown settings class '{Markup.Escape(settings.SettingsClass)}'.[/]"); + RenderAvailableClasses(); + return 1; + } + + await _store.ResetAsync(registration.SettingsType, cancellationToken).ConfigureAwait(false); + AnsiConsole.MarkupLine($"[green]Reset '{Markup.Escape(registration.Name)}' to defaults.[/]"); + return 0; + } + catch (Exception ex) + { + CommandErrorReporter.Report(ex, "Error resetting settings", settings.Verbose); + return 1; + } + } + + private void RenderAvailableClasses() + { + if (_store.Registrations.Count == 0) + { + AnsiConsole.MarkupLine("[grey]No settings classes are registered.[/]"); + return; + } + + var names = string.Join(", ", _store.Registrations.Select(r => r.Name)); + AnsiConsole.MarkupLine($"[grey]Available: {Markup.Escape(names)}[/]"); + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Commands/SettingsCommandSettings.cs b/src/NextIteration.SpectreConsole.Settings/Commands/SettingsCommandSettings.cs new file mode 100644 index 0000000..5119de4 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Commands/SettingsCommandSettings.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +using Spectre.Console.Cli; + +namespace NextIteration.SpectreConsole.Settings.Commands +{ + /// + /// Base CLI settings shared by every command in the settings branch. + /// Carries the common --verbose flag that toggles full stack-trace + /// rendering when a command fails. + /// + public class SettingsCommandSettings : CommandSettings + { + /// + /// When , the command prints the full exception + /// (type, message, stack trace) on failure instead of only the message. + /// + [CommandOption("-v|--verbose")] + [Description("Show full stack traces on error")] + public bool Verbose { get; set; } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/ISettingsStore.cs b/src/NextIteration.SpectreConsole.Settings/ISettingsStore.cs new file mode 100644 index 0000000..7d94bfc --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/ISettingsStore.cs @@ -0,0 +1,41 @@ +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Aggregates every settings class registered via AddSettings<T>. + /// Resolves and caches the live singleton instance for each class, and + /// powers the settings list / settings reset commands. + /// Registered as a singleton; the per-class singletons resolve through it + /// so a reset is observed by any command that injected the instance + /// directly. + /// + public interface ISettingsStore + { + /// All registered settings classes, in registration order. + IReadOnlyList Registrations { get; } + + /// + /// Returns the live singleton instance for , + /// loading it from disk (or constructing a default) on first access. + /// + /// + /// was never registered. + /// + SettingsBase GetInstance(Type settingsType); + + /// + /// Resets a single settings class to default values in place and + /// persists the result immediately. The reset is applied to the live + /// instance, so callers holding a reference observe the change. + /// + /// + /// was never registered. + /// + Task ResetAsync(Type settingsType, CancellationToken cancellationToken = default); + + /// + /// Resets every registered settings class to default values and + /// persists each one immediately. + /// + Task ResetAllAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj b/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj new file mode 100644 index 0000000..daa769c --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + enable + enable + en + true + latest + + + + NextIteration.SpectreConsole.Settings + 0.1.0 + Stuart Meeks + Strongly-typed, JSON-persisted settings for CLI tools, with automatic or explicit persistence and ready-made Spectre.Console settings commands. + true + $(MSBuildThisFileDirectory)..\..\artifacts\packages + true + MIT + README.md + https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings + https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings.git + git + spectre;cli;settings;configuration;json + icon.png + © Stuart Meeks + true + true + true + true + snupkg + portable + true + + + + + + + + + + + + + + + + diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs new file mode 100644 index 0000000..c992349 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs @@ -0,0 +1,57 @@ +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// Crash-safe text file writer. Writes to a uniquely-named temp file in the + /// same directory as the final path, then performs an atomic rename. + /// is atomic on NTFS and + /// backed by rename(2) on POSIX, so a reader observes either the old + /// content or the new content — never a partial write, even if the process + /// is killed mid-call. + /// + /// + /// This does not serialise concurrent writers. Two writers each producing a + /// new version observe "last-rename-wins" semantics — adequate for the + /// interactive CLI usage this library targets. + /// + internal static class AtomicFile + { + internal static async Task WriteAllTextAsync( + string path, + string contents, + CancellationToken cancellationToken = default) + { + var tempPath = BuildTempPath(path); + try + { + await File.WriteAllTextAsync(tempPath, contents, cancellationToken).ConfigureAwait(false); + File.Move(tempPath, path, overwrite: true); + } + catch + { + TryDelete(tempPath); + throw; + } + } + + private static string BuildTempPath(string finalPath) => + // Unique per call so concurrent writers don't collide on a shared + // "{path}.tmp" name. + $"{finalPath}.{Guid.NewGuid():N}.tmp"; + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Best-effort cleanup; a stray ".tmp" file is harmless — it + // doesn't match the "{ClassName}.json" name the loader reads. + } + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/ISettingsPersister.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/ISettingsPersister.cs new file mode 100644 index 0000000..95d2f52 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/ISettingsPersister.cs @@ -0,0 +1,18 @@ +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// Writes a single instance back to its backing + /// store. One persister is bound to each settings instance at load time; + /// it captures the file path and serializer options for that instance so + /// itself stays storage-agnostic. + /// + internal interface ISettingsPersister + { + /// + /// Serialises and writes it to disk. + /// Implementations write atomically so a crash mid-write never leaves + /// a half-written file. + /// + Task PersistAsync(SettingsBase settings, CancellationToken cancellationToken = default); + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/JsonSettingsPersister.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/JsonSettingsPersister.cs new file mode 100644 index 0000000..3517c9d --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/JsonSettingsPersister.cs @@ -0,0 +1,39 @@ +using System.Text.Json; + +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// backed by a single JSON file. Created + /// once per settings instance at load time, capturing that instance's file + /// path, concrete type, and serializer options. + /// + internal sealed class JsonSettingsPersister : ISettingsPersister + { + private readonly string _filePath; + private readonly Type _settingsType; + private readonly JsonSerializerOptions _serializerOptions; + + internal JsonSettingsPersister(string filePath, Type settingsType, JsonSerializerOptions serializerOptions) + { + _filePath = filePath; + _settingsType = settingsType; + _serializerOptions = serializerOptions; + } + + public async Task PersistAsync(SettingsBase settings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + + // The directory may not exist on the very first write (settings is + // created from defaults when the file is absent). + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(settings, _settingsType, _serializerOptions); + await AtomicFile.WriteAllTextAsync(_filePath, json, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsSerialization.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsSerialization.cs new file mode 100644 index 0000000..c8787a4 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsSerialization.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// Shared serialization defaults and the fallback error handler used when a + /// consumer doesn't supply their own. + /// + internal static class SettingsSerialization + { + /// + /// Builds the tolerant default : + /// indented output for human-readable files, case-insensitive property + /// matching, enums written as strings, comments and trailing commas + /// allowed on read. Missing properties fall back to their constructed + /// defaults and unknown properties are ignored — both native + /// behaviours — which is what gives the + /// store its schema-evolution tolerance. + /// + internal static JsonSerializerOptions CreateDefaultOptions() => new(JsonSerializerDefaults.General) + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /// + /// Fallback error handler. Writes a single diagnostic line to + /// so a failed fire-and-forget write is + /// never silently swallowed. + /// + internal static void DefaultErrorHandler(Exception exception) => + Console.Error.WriteLine($"[NextIteration.SpectreConsole.Settings] Failed to persist settings: {exception.Message}"); + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs new file mode 100644 index 0000000..c0f70d5 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs @@ -0,0 +1,159 @@ +using System.Reflection; +using System.Text.Json; + +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// Default . Holds the descriptor for every + /// registered settings class, loads each instance lazily on first access + /// (from disk, or a default when the file is absent), binds it to its + /// persister, and caches it. The per-class DI singletons resolve through + /// this store, so an in-place reset is observed by any command that + /// injected the instance directly. + /// + internal sealed class SettingsStore : ISettingsStore + { + private readonly Lock _gate = new(); + private readonly Dictionary _descriptors; + private readonly Dictionary _entries = new(); + private readonly List _registrations; + + public SettingsStore(IEnumerable descriptors) + { + ArgumentNullException.ThrowIfNull(descriptors); + + var ordered = descriptors.ToList(); + _descriptors = ordered.ToDictionary(d => d.SettingsType); + _registrations = ordered + .Select(d => new SettingsRegistration + { + Name = d.Name, + SettingsType = d.SettingsType, + FilePath = d.FilePath, + PersistenceMode = d.PersistenceMode, + }) + .ToList(); + } + + public IReadOnlyList Registrations => _registrations; + + public SettingsBase GetInstance(Type settingsType) + { + ArgumentNullException.ThrowIfNull(settingsType); + + lock (_gate) + { + return GetOrLoadLocked(settingsType).Instance; + } + } + + public async Task ResetAsync(Type settingsType, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settingsType); + + Entry entry; + SettingsTypeDescriptor descriptor; + lock (_gate) + { + descriptor = GetDescriptorLocked(settingsType); + entry = GetOrLoadLocked(settingsType); + } + + ResetInstanceToDefaults(entry.Instance, descriptor); + await entry.Persister.PersistAsync(entry.Instance, cancellationToken).ConfigureAwait(false); + } + + public async Task ResetAllAsync(CancellationToken cancellationToken = default) + { + foreach (var registration in _registrations) + { + await ResetAsync(registration.SettingsType, cancellationToken).ConfigureAwait(false); + } + } + + private Entry GetOrLoadLocked(Type settingsType) + { + if (_entries.TryGetValue(settingsType, out var existing)) + { + return existing; + } + + var descriptor = GetDescriptorLocked(settingsType); + var instance = Load(descriptor); + var persister = new JsonSettingsPersister(descriptor.FilePath, descriptor.SettingsType, descriptor.SerializerOptions); + + // Bind only after deserialization, so the setter calls the + // deserializer makes never schedule a write. + instance.Bind(persister, descriptor.PersistenceMode, descriptor.ErrorHandler, descriptor.DebounceInterval); + + var entry = new Entry(instance, persister); + _entries[settingsType] = entry; + return entry; + } + + private SettingsTypeDescriptor GetDescriptorLocked(Type settingsType) + { + if (!_descriptors.TryGetValue(settingsType, out var descriptor)) + { + throw new ArgumentException( + $"Settings type '{settingsType.FullName}' was not registered. Call AddSettings<{settingsType.Name}>() during startup.", + nameof(settingsType)); + } + + return descriptor; + } + + private static SettingsBase Load(SettingsTypeDescriptor descriptor) + { + try + { + if (File.Exists(descriptor.FilePath)) + { + var json = File.ReadAllText(descriptor.FilePath); + if (!string.IsNullOrWhiteSpace(json)) + { + var loaded = (SettingsBase?)JsonSerializer.Deserialize(json, descriptor.SettingsType, descriptor.SerializerOptions); + if (loaded is not null) + { + return loaded; + } + } + } + } + catch (JsonException ex) + { + // A malformed file shouldn't crash CLI startup. Surface it via + // the configured handler, then fall back to defaults. (Missing + // files are the common case and don't reach here.) + descriptor.ErrorHandler(ex); + } + + return descriptor.Factory(); + } + + private static void ResetInstanceToDefaults(SettingsBase instance, SettingsTypeDescriptor descriptor) + { + var defaults = descriptor.Factory(); + + // Suspend notifications so the batch of setter calls produces no + // intermediate writes; ResetAsync issues a single write afterwards. + instance.SuspendNotifications(); + try + { + foreach (var property in descriptor.SettingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0) + { + property.SetValue(instance, property.GetValue(defaults)); + } + } + } + finally + { + instance.ResumeNotifications(); + } + } + + private sealed record Entry(SettingsBase Instance, ISettingsPersister Persister); + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsTypeDescriptor.cs b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsTypeDescriptor.cs new file mode 100644 index 0000000..23e4d77 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/Persistence/SettingsTypeDescriptor.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace NextIteration.SpectreConsole.Settings.Persistence +{ + /// + /// Immutable registration record for one settings class. One descriptor is + /// registered in DI per AddSettings<T> call; the + /// receives them all and uses them to load, + /// bind, and reset instances lazily. + /// + internal sealed class SettingsTypeDescriptor + { + public required Type SettingsType { get; init; } + + public required string Name { get; init; } + + public required string FilePath { get; init; } + + public required PersistenceMode PersistenceMode { get; init; } + + public required TimeSpan DebounceInterval { get; init; } + + public required Action ErrorHandler { get; init; } + + public required JsonSerializerOptions SerializerOptions { get; init; } + + /// Constructs a fresh default instance (new T()). + public required Func Factory { get; init; } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/PersistenceMode.cs b/src/NextIteration.SpectreConsole.Settings/PersistenceMode.cs new file mode 100644 index 0000000..e55ebae --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/PersistenceMode.cs @@ -0,0 +1,25 @@ +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Controls when a instance is written back to + /// disk after its properties change. + /// + public enum PersistenceMode + { + /// + /// The default. Each + /// call schedules a debounced asynchronous write — a burst of property + /// changes in the same synchronous call stack coalesces into a single + /// disk write. + /// + Automatic = 0, + + /// + /// is a no-op; + /// nothing is persisted until the consumer calls + /// or + /// . + /// + Explicit = 1, + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/ServiceCollectionExtensions.cs b/src/NextIteration.SpectreConsole.Settings/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..fff9a72 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/ServiceCollectionExtensions.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using NextIteration.SpectreConsole.Settings.Persistence; + +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// DI extensions for registering strongly-typed, JSON-persisted settings + /// classes from NextIteration.SpectreConsole.Settings. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers a single settings class. The instance is loaded from + /// {SettingsDirectory}/{typeof(T).Name}.json on first use (or + /// constructed from defaults when the file is absent) and registered as + /// a singleton — inject directly into any + /// command. Call once per settings class. + /// + /// + /// The settings class. Must derive from and + /// have a public parameterless constructor (used both for defaults and + /// by the JSON deserializer). + /// + /// The service collection. + /// + /// Configures . + /// is required. + /// + /// + /// was not supplied. + /// + public static IServiceCollection AddSettings( + this IServiceCollection services, + Action configure) + where T : SettingsBase, new() + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var options = new SettingsOptions(); + configure(options); + + if (string.IsNullOrWhiteSpace(options.SettingsDirectory)) + { + throw new InvalidOperationException( + $"{nameof(SettingsOptions)}.{nameof(SettingsOptions.SettingsDirectory)} must be set when registering settings class '{typeof(T).Name}'."); + } + + var name = typeof(T).Name; + var descriptor = new SettingsTypeDescriptor + { + SettingsType = typeof(T), + Name = name, + FilePath = Path.Combine(options.SettingsDirectory, name + ".json"), + PersistenceMode = options.PersistenceMode, + DebounceInterval = options.DebounceInterval, + ErrorHandler = options.ErrorHandler ?? SettingsSerialization.DefaultErrorHandler, + SerializerOptions = options.SerializerOptions ?? SettingsSerialization.CreateDefaultOptions(), + Factory = static () => new T(), + }; + + services.AddSingleton(descriptor); + services.TryAddSingleton(); + + // Resolve T through the store so the singleton instance is shared + // with the store — a reset mutates the same object the consumer + // injected. + services.AddSingleton(sp => (T)sp.GetRequiredService().GetInstance(typeof(T))); + + return services; + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/SettingsBase.cs b/src/NextIteration.SpectreConsole.Settings/SettingsBase.cs new file mode 100644 index 0000000..a671f2d --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/SettingsBase.cs @@ -0,0 +1,250 @@ +using System.Runtime.CompilerServices; + +using NextIteration.SpectreConsole.Settings.Persistence; + +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Base class for consumer settings. Derive from it, back each public + /// property with a field, and call + /// from the setter — that's the whole contract: + /// + /// public sealed class AppSettings : SettingsBase + /// { + /// private string _theme = "dark"; + /// public string Theme + /// { + /// get => _theme; + /// set { _theme = value; OnPropertyChanged(); } + /// } + /// } + /// + /// In mode each change schedules a + /// debounced asynchronous write; in + /// mode the consumer calls / . + /// + /// + /// An instance is inert until the framework s it during + /// load. That means property assignments performed by the JSON deserializer + /// (which run before binding) never trigger a write — only changes the + /// consumer makes after startup do. + /// + public abstract class SettingsBase + { + private readonly Lock _gate = new(); + + private ISettingsPersister? _persister; + private PersistenceMode _persistenceMode = PersistenceMode.Automatic; + private Action _errorHandler = static _ => { }; + private TimeSpan _debounceInterval = TimeSpan.FromMilliseconds(250); + + // The CTS for the in-flight debounce window. A newer change (or an + // explicit Save) cancels it so only the last write in a burst survives. + private CancellationTokenSource? _debounceCts; + + // Re-entrant suspend counter. While > 0, OnPropertyChanged is a no-op — + // used by an in-place reset that mutates many properties but wants a + // single explicit write afterwards. + private int _suspendDepth; + + /// + /// Wires this instance to its backing store. Called once by the + /// framework immediately after load, before the instance is handed to + /// consumers. Idempotent re-binding is allowed (last binding wins). + /// + internal void Bind( + ISettingsPersister persister, + PersistenceMode persistenceMode, + Action errorHandler, + TimeSpan debounceInterval) + { + ArgumentNullException.ThrowIfNull(persister); + ArgumentNullException.ThrowIfNull(errorHandler); + + lock (_gate) + { + _persister = persister; + _persistenceMode = persistenceMode; + _errorHandler = errorHandler; + _debounceInterval = debounceInterval; + } + } + + /// + /// Call from a property setter after mutating the backing field. In + /// mode this schedules a + /// debounced write; in mode it + /// does nothing. + /// + /// + /// Supplied automatically by the compiler via + /// . Not used to decide what to + /// persist (the whole object is always written) — accepted so derived + /// types can pass it through and so future change-tracking can use it. + /// + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + lock (_gate) + { + if (_suspendDepth > 0) + { + return; + } + + if (_persistenceMode != PersistenceMode.Automatic || _persister is null) + { + return; + } + + ScheduleDebouncedWriteLocked(_persister); + } + } + + /// + /// Persists the current state immediately (fire-and-forget). Valid in + /// both persistence modes. Any pending debounced write is superseded. + /// Errors are routed to the configured error handler rather than thrown + /// on the caller's stack. Use when you need to + /// await completion — e.g. before a CLI command returns and the process + /// exits. + /// + public void Save() + { + ISettingsPersister? persister; + lock (_gate) + { + CancelPendingWriteLocked(); + persister = _persister; + } + + if (persister is null) + { + return; + } + + _ = PersistGuardedAsync(persister); + } + + /// + /// Persists the current state immediately and returns a task that + /// completes when the write finishes. Valid in both persistence modes. + /// Any pending debounced write is superseded. Unlike , + /// exceptions propagate to the awaiter. + /// + public Task SaveAsync(CancellationToken cancellationToken = default) + { + ISettingsPersister? persister; + lock (_gate) + { + CancelPendingWriteLocked(); + persister = _persister; + } + + return persister is null + ? Task.CompletedTask + : persister.PersistAsync(this, cancellationToken); + } + + /// + /// Suspends writes until the + /// matching . Re-entrant. Used by an + /// in-place reset so a batch of property mutations does not each trigger + /// a separate write. + /// + internal void SuspendNotifications() + { + lock (_gate) + { + _suspendDepth++; + } + } + + /// Counterpart to . + internal void ResumeNotifications() + { + lock (_gate) + { + if (_suspendDepth > 0) + { + _suspendDepth--; + } + } + } + + private void ScheduleDebouncedWriteLocked(ISettingsPersister persister) + { + CancelPendingWriteLocked(); + + var cts = new CancellationTokenSource(); + _debounceCts = cts; + + _ = DebounceAndPersistAsync(persister, _debounceInterval, cts); + } + + private void CancelPendingWriteLocked() + { + if (_debounceCts is null) + { + return; + } + + _debounceCts.Cancel(); + _debounceCts.Dispose(); + _debounceCts = null; + } + + private async Task DebounceAndPersistAsync( + ISettingsPersister persister, + TimeSpan interval, + CancellationTokenSource cts) + { + try + { + if (interval > TimeSpan.Zero) + { + await Task.Delay(interval, cts.Token).ConfigureAwait(false); + } + else + { + // Let the current synchronous call stack unwind so a burst + // of setters all schedule-then-cancel and only the last + // survives to write — coalescing with a zero interval too. + await Task.Yield(); + cts.Token.ThrowIfCancellationRequested(); + } + } + catch (OperationCanceledException) + { + return; // superseded by a newer change or an explicit Save. + } + finally + { + lock (_gate) + { + if (ReferenceEquals(_debounceCts, cts)) + { + _debounceCts = null; + } + } + + cts.Dispose(); + } + + await PersistGuardedAsync(persister).ConfigureAwait(false); + } + + private async Task PersistGuardedAsync(ISettingsPersister persister) + { + try + { + await persister.PersistAsync(this).ConfigureAwait(false); + } + catch (Exception ex) + { + // Never swallow: route to the configured handler (default + // writes to stderr). This is the fire-and-forget safety net. + _errorHandler(ex); + } + } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/SettingsOptions.cs b/src/NextIteration.SpectreConsole.Settings/SettingsOptions.cs new file mode 100644 index 0000000..3fbb418 --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/SettingsOptions.cs @@ -0,0 +1,51 @@ +using System.Text.Json; + +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Options passed to AddSettings<T> to configure how a single + /// settings class is loaded from and persisted to disk. + /// + public sealed class SettingsOptions + { + /// + /// Absolute path to the directory where settings JSON files are stored. + /// Required — there is no smart default. Registration throws if + /// this is left unset. Each settings class is written to its own file + /// within this directory, named after the class (e.g. + /// AppSettings.json). + /// + public string SettingsDirectory { get; set; } = string.Empty; + + /// + /// Whether property changes are persisted automatically (debounced) or + /// only when the consumer explicitly calls . + /// Defaults to . + /// + public PersistenceMode PersistenceMode { get; set; } = PersistenceMode.Automatic; + + /// + /// Invoked when an asynchronous (fire-and-forget) disk write fails, so + /// that errors on the automatic-persistence path are surfaced rather + /// than silently swallowed. When , the library + /// writes a single diagnostic line to . + /// + public Action? ErrorHandler { get; set; } + + /// + /// How long to wait after the last property change before an automatic + /// write is performed. A burst of changes within this window collapses + /// into a single write. Defaults to 250 ms. Ignored in + /// mode. + /// + public TimeSpan DebounceInterval { get; set; } = TimeSpan.FromMilliseconds(250); + + /// + /// Optional override. When + /// , a tolerant default is used (indented output, + /// case-insensitive property matching, missing properties fall back to + /// defaults, unknown properties are ignored). + /// + public JsonSerializerOptions? SerializerOptions { get; set; } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/SettingsRegistration.cs b/src/NextIteration.SpectreConsole.Settings/SettingsRegistration.cs new file mode 100644 index 0000000..d53738a --- /dev/null +++ b/src/NextIteration.SpectreConsole.Settings/SettingsRegistration.cs @@ -0,0 +1,26 @@ +namespace NextIteration.SpectreConsole.Settings +{ + /// + /// Describes one settings class registered via AddSettings<T>. + /// Surfaced by so commands such + /// as settings list can enumerate every registered class and where + /// it lives on disk without reflecting over the DI container. + /// + public sealed class SettingsRegistration + { + /// + /// Display name of the settings class — the simple type name (e.g. + /// AppSettings), which is also the JSON file's base name. + /// + public required string Name { get; init; } + + /// The concrete settings type, a subclass of . + public required Type SettingsType { get; init; } + + /// Absolute path to this class's JSON file on disk. + public required string FilePath { get; init; } + + /// The persistence mode configured for this class at registration time. + public required PersistenceMode PersistenceMode { get; init; } + } +} diff --git a/src/NextIteration.SpectreConsole.Settings/icon.png b/src/NextIteration.SpectreConsole.Settings/icon.png new file mode 100644 index 0000000..88db752 Binary files /dev/null and b/src/NextIteration.SpectreConsole.Settings/icon.png differ diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs new file mode 100644 index 0000000..1bae07e --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs @@ -0,0 +1,30 @@ +using NextIteration.SpectreConsole.Settings.Persistence; + +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +/// +/// Test double for that counts writes (and can +/// optionally fail), letting debounce / explicit / +/// error-surfacing behaviour be asserted deterministically without touching +/// disk. +/// +internal sealed class CountingPersister : ISettingsPersister +{ + private int _writeCount; + private readonly bool _throwOnWrite; + + public CountingPersister(bool throwOnWrite = false) + { + _throwOnWrite = throwOnWrite; + } + + public int WriteCount => Volatile.Read(ref _writeCount); + + public Task PersistAsync(SettingsBase settings, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _writeCount); + return _throwOnWrite + ? Task.FromException(new InvalidOperationException("write failed")) + : Task.CompletedTask; + } +} diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs new file mode 100644 index 0000000..f373360 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs @@ -0,0 +1,66 @@ +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +/// Mode enum exercised by the sample settings (round-trips as a string). +public enum SampleMode +{ + First, + Second, +} + +/// +/// Representative consumer settings class used across the test suite: a +/// string, an int, and an enum, each backed by a field and calling +/// OnPropertyChanged() from its setter. +/// +public sealed class SampleSettings : SettingsBase +{ + private string _name = "default-name"; + private int _count = 1; + private SampleMode _mode = SampleMode.First; + + public string Name + { + get => _name; + set + { + _name = value; + OnPropertyChanged(); + } + } + + public int Count + { + get => _count; + set + { + _count = value; + OnPropertyChanged(); + } + } + + public SampleMode Mode + { + get => _mode; + set + { + _mode = value; + OnPropertyChanged(); + } + } +} + +/// A second settings class, so multi-class and reset-all behaviour can be tested. +public sealed class SecondarySettings : SettingsBase +{ + private bool _enabled = true; + + public bool Enabled + { + get => _enabled; + set + { + _enabled = value; + OnPropertyChanged(); + } + } +} diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs new file mode 100644 index 0000000..39b795c --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs @@ -0,0 +1,34 @@ +namespace NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +/// +/// Throwaway directory under the system temp path. Tests that need a settings +/// directory (or any disk scratch space) should wrap this in using so +/// the directory is recursively removed when the test ends regardless of +/// pass/fail. +/// +internal sealed class TempDir : IDisposable +{ + public string Path { get; } = + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ni.scs.tests." + Guid.NewGuid().ToString("N")); + + public TempDir() + { + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch + { + // Best-effort cleanup. Stray scratch dirs in %TEMP% aren't a + // problem — the OS reclaims temp eventually. + } + } +} diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj b/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj new file mode 100644 index 0000000..4354952 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj @@ -0,0 +1,42 @@ + + + + net10.0 + enable + enable + false + true + false + + $(NoWarn);CA1707;CA1515;CA2007 + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs new file mode 100644 index 0000000..ac355f8 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs @@ -0,0 +1,62 @@ +using NextIteration.SpectreConsole.Settings.Persistence; +using NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +using Xunit; + +namespace NextIteration.SpectreConsole.Settings.Tests.Persistence; + +public sealed class AtomicFileTests +{ + [Fact] + public async Task WriteAllTextAsync_WritesExpectedContent() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); + + await AtomicFile.WriteAllTextAsync(target, "hello"); + + Assert.Equal("hello", await File.ReadAllTextAsync(target)); + } + + [Fact] + public async Task WriteAllTextAsync_NoTempFileLeftBehindAfterSuccess() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); + + await AtomicFile.WriteAllTextAsync(target, "hello"); + + var files = Directory.GetFiles(temp.Path); + Assert.Single(files); + Assert.Equal(target, files[0]); + } + + [Fact] + public async Task WriteAllTextAsync_OverwritesExisting() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); + await File.WriteAllTextAsync(target, "original"); + + await AtomicFile.WriteAllTextAsync(target, "replaced"); + + Assert.Equal("replaced", await File.ReadAllTextAsync(target)); + } + + [Fact] + public async Task WriteAllTextAsync_ConcurrentWriters_OneWinsNoStragglers() + { + using var temp = new TempDir(); + var target = Path.Combine(temp.Path, "file.txt"); + + await Task.WhenAll( + AtomicFile.WriteAllTextAsync(target, "writer-a"), + AtomicFile.WriteAllTextAsync(target, "writer-b")); + + var final = await File.ReadAllTextAsync(target); + Assert.True(final is "writer-a" or "writer-b", $"expected one writer to win, got: {final}"); + + // Unique temp names mean no leftover ".tmp" files. + Assert.Single(Directory.GetFiles(temp.Path)); + } +} diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs new file mode 100644 index 0000000..9a8c2d4 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs @@ -0,0 +1,123 @@ +using NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +using Xunit; + +namespace NextIteration.SpectreConsole.Settings.Tests; + +public sealed class SettingsBaseTests +{ + private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); + + // Comfortably longer than the debounce window so a scheduled write has + // definitely fired (or definitely not, in the explicit/no-op cases). + private static readonly TimeSpan _settle = TimeSpan.FromMilliseconds(400); + + [Fact] + public void OnPropertyChanged_WhenUnbound_DoesNotThrow() + { + var settings = new SampleSettings(); + + // No Bind() has happened (mirrors the deserializer setting properties + // during load) — the change must be a silent no-op, not a crash. + var ex = Record.Exception(() => settings.Name = "changed"); + + Assert.Null(ex); + } + + [Fact] + public async Task Automatic_SingleChange_PersistsOnce() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); + + settings.Name = "changed"; + await Task.Delay(_settle); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Automatic_BurstOfChanges_CoalescesIntoSingleWrite() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Automatic, static _ => { }, _debounce); + + // Three synchronous mutations in one call stack must collapse to one + // disk write — each change supersedes the previous pending write. + settings.Name = "a"; + settings.Count = 2; + settings.Mode = SampleMode.Second; + + await Task.Delay(_settle); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Explicit_PropertyChange_DoesNotPersist() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Name = "changed"; + await Task.Delay(_settle); + + Assert.Equal(0, persister.WriteCount); + } + + [Fact] + public async Task Explicit_SaveAsync_Persists() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Name = "changed"; + await settings.SaveAsync(); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Save_FireAndForget_EventuallyPersists() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + settings.Save(); + await Task.Delay(_settle); + + Assert.Equal(1, persister.WriteCount); + } + + [Fact] + public async Task Automatic_FailedWrite_SurfacesToErrorHandler() + { + Exception? captured = null; + var settings = new SampleSettings(); + var persister = new CountingPersister(throwOnWrite: true); + settings.Bind(persister, PersistenceMode.Automatic, ex => captured = ex, _debounce); + + settings.Name = "boom"; + await Task.Delay(_settle); + + Assert.NotNull(captured); + Assert.IsType(captured); + } + + [Fact] + public async Task SaveAsync_FailedWrite_PropagatesToAwaiter() + { + var settings = new SampleSettings(); + var persister = new CountingPersister(throwOnWrite: true); + settings.Bind(persister, PersistenceMode.Explicit, static _ => { }, _debounce); + + // SaveAsync is awaitable, so the error reaches the caller directly + // rather than the fire-and-forget handler. + await Assert.ThrowsAsync(() => settings.SaveAsync()); + } +} diff --git a/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs new file mode 100644 index 0000000..c6086b5 --- /dev/null +++ b/tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.DependencyInjection; + +using NextIteration.SpectreConsole.Settings.Tests.Infrastructure; + +using Xunit; + +namespace NextIteration.SpectreConsole.Settings.Tests; + +public sealed class SettingsStoreTests +{ + private static readonly TimeSpan _debounce = TimeSpan.FromMilliseconds(40); + private static readonly TimeSpan _settle = TimeSpan.FromMilliseconds(400); + + private static ServiceProvider BuildProvider(string directory, PersistenceMode mode = PersistenceMode.Automatic) => + new ServiceCollection() + .AddSettings(options => + { + options.SettingsDirectory = directory; + options.PersistenceMode = mode; + options.DebounceInterval = _debounce; + }) + .BuildServiceProvider(); + + private static string FileFor(string directory) => + Path.Combine(directory, typeof(T).Name + ".json"); + + [Fact] + public void AddSettings_WithoutSettingsDirectory_Throws() + { + var services = new ServiceCollection(); + + var ex = Assert.Throws( + () => services.AddSettings(_ => { })); + + Assert.Contains(nameof(SettingsOptions.SettingsDirectory), ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void MissingFile_ResolvesDefaults_WithoutCreatingFile() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); + + var settings = provider.GetRequiredService(); + + Assert.Equal("default-name", settings.Name); + Assert.Equal(1, settings.Count); + Assert.Equal(SampleMode.First, settings.Mode); + // Loading a missing file must not write one — only a mutation does. + Assert.False(File.Exists(FileFor(temp.Path))); + } + + [Fact] + public void Resolved_Instance_IsSameAsStoreInstance() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); + + var injected = provider.GetRequiredService(); + var fromStore = provider.GetRequiredService().GetInstance(typeof(SampleSettings)); + + Assert.Same(injected, fromStore); + } + + [Fact] + public void Registration_FileIsNamedAfterClass() + { + using var temp = new TempDir(); + using var provider = BuildProvider(temp.Path); + + var registration = Assert.Single(provider.GetRequiredService().Registrations); + + Assert.Equal("SampleSettings", registration.Name); + Assert.Equal(FileFor(temp.Path), registration.FilePath); + } + + [Fact] + public async Task Automatic_Mutation_PersistsAndRoundTrips() + { + using var temp = new TempDir(); + + await using (var provider = BuildProvider(temp.Path)) + { + var settings = provider.GetRequiredService(); + settings.Name = "persisted"; + settings.Mode = SampleMode.Second; + await Task.Delay(_settle); + } + + // A fresh provider over the same directory must observe the writes. + await using var reloadedProvider = BuildProvider(temp.Path); + var reloaded = reloadedProvider.GetRequiredService(); + + Assert.Equal("persisted", reloaded.Name); + Assert.Equal(SampleMode.Second, reloaded.Mode); + } + + [Fact] + public async Task Explicit_DoesNotWriteUntilSave() + { + using var temp = new TempDir(); + var file = FileFor(temp.Path); + + await using var provider = BuildProvider(temp.Path, PersistenceMode.Explicit); + var settings = provider.GetRequiredService(); + + settings.Name = "changed"; + await Task.Delay(_settle); + Assert.False(File.Exists(file)); + + await settings.SaveAsync(); + Assert.True(File.Exists(file)); + } + + [Fact] + public async Task TolerantDeserialization_MissingDefaulted_UnknownIgnored() + { + using var temp = new TempDir(); + + // Case-insensitive key, one known property, no Count/Mode, plus an + // unknown property the schema has never heard of. + await File.WriteAllTextAsync( + FileFor(temp.Path), + """{ "name": "fromfile", "removedLegacyProperty": 42 }"""); + + await using var provider = BuildProvider(temp.Path); + var settings = provider.GetRequiredService(); + + Assert.Equal("fromfile", settings.Name); // present (case-insensitive) + Assert.Equal(1, settings.Count); // missing -> default + Assert.Equal(SampleMode.First, settings.Mode); // missing -> default + } + + [Fact] + public async Task ResetAsync_RestoresDefaults_InPlaceAndOnDisk() + { + using var temp = new TempDir(); + + await using var provider = BuildProvider(temp.Path); + var store = provider.GetRequiredService(); + var settings = provider.GetRequiredService(); + + settings.Name = "changed"; + settings.Count = 99; + await settings.SaveAsync(); + + await store.ResetAsync(typeof(SampleSettings)); + + // The live instance the consumer holds is reset in place. + Assert.Equal("default-name", settings.Name); + Assert.Equal(1, settings.Count); + + // And the defaults are persisted: a fresh load sees them too. + await using var reloadedProvider = BuildProvider(temp.Path); + var reloaded = reloadedProvider.GetRequiredService(); + Assert.Equal("default-name", reloaded.Name); + Assert.Equal(1, reloaded.Count); + } + + [Fact] + public async Task ResetAllAsync_RestoresEveryRegisteredClass() + { + using var temp = new TempDir(); + + await using var provider = new ServiceCollection() + .AddSettings(o => o.SettingsDirectory = temp.Path) + .AddSettings(o => o.SettingsDirectory = temp.Path) + .BuildServiceProvider(); + + var sample = provider.GetRequiredService(); + var secondary = provider.GetRequiredService(); + sample.Name = "changed"; + secondary.Enabled = false; + + await provider.GetRequiredService().ResetAllAsync(); + + Assert.Equal("default-name", sample.Name); + Assert.True(secondary.Enabled); + } + + [Fact] + public async Task ResetAsync_UnregisteredType_Throws() + { + using var temp = new TempDir(); + await using var provider = BuildProvider(temp.Path); + var store = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => store.ResetAsync(typeof(SecondarySettings))); + } +}