From c0b5665e07d6f810119073feb8a333cba985328e Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Wed, 27 May 2026 03:12:20 +0000 Subject: [PATCH 1/3] Initial release: NextIteration.SpectreConsole.Settings 0.1.0 Strongly-typed, JSON-persisted settings for Spectre.Console CLI tools, a sibling package to NextIteration.SpectreConsole.Auth mirroring its project structure, packaging, and quality standards (net10.0, TreatWarningsAsErrors, docs on, SourceLink, snupkg). Core: - SettingsBase: field-backed properties + OnPropertyChanged(); inert until bound at load so deserializer setters never write. - PersistenceMode Automatic (debounced async write, 250ms default) / Explicit (Save()/SaveAsync()). Fire-and-forget failures surface via a configurable error handler, never swallowed. - AddSettings() DI extension with required-SettingsDirectory validation; one {ClassName}.json per class; singletons resolve through ISettingsStore so in-place resets are observed by injected references. - Tolerant JSON: atomic writes, missing->default, unknown ignored, case-insensitive, string enums. - settings list / reset ( and --all) via AddSettingsBranch(); all commands honour -v/--verbose. Tests: 22 xUnit tests (load-on-missing-file, automatic persistence + round-trip, explicit persistence, debounce coalescing, reset/reset-all, tolerant deserialisation, atomic writes, error surfacing). Note: src/.../icon.png is a placeholder (copy of the Auth package icon) pending a dedicated package icon. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 103 +++++ .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 69 +++ .gitignore | 431 ++++++++++++++++++ CHANGELOG.md | 40 ++ Directory.Build.props | 9 + LICENSE | 21 + NextIteration.SpectreConsole.Settings.slnx | 4 + README.md | 240 ++++++++++ TODO.md | 29 ++ .../CommandConfiguratorExtensions.cs | 38 ++ .../Commands/CommandErrorReporter.cs | 34 ++ .../Commands/ListSettingsCommand.cs | 106 +++++ .../Commands/ResetSettingsCommand.cs | 99 ++++ .../Commands/SettingsCommandSettings.cs | 22 + .../ISettingsStore.cs | 41 ++ ...xtIteration.SpectreConsole.Settings.csproj | 50 ++ .../Persistence/AtomicFile.cs | 57 +++ .../Persistence/ISettingsPersister.cs | 18 + .../Persistence/JsonSettingsPersister.cs | 39 ++ .../Persistence/SettingsSerialization.cs | 38 ++ .../Persistence/SettingsStore.cs | 159 +++++++ .../Persistence/SettingsTypeDescriptor.cs | 30 ++ .../PersistenceMode.cs | 25 + .../ServiceCollectionExtensions.cs | 75 +++ .../SettingsBase.cs | 250 ++++++++++ .../SettingsOptions.cs | 51 +++ .../SettingsRegistration.cs | 26 ++ .../icon.png | Bin 0 -> 23685 bytes .../Infrastructure/CountingPersister.cs | 30 ++ .../Infrastructure/SampleSettings.cs | 66 +++ .../Infrastructure/TempDir.cs | 34 ++ ...ation.SpectreConsole.Settings.Tests.csproj | 42 ++ .../Persistence/AtomicFileTests.cs | 62 +++ .../SettingsBaseTests.cs | 123 +++++ .../SettingsStoreTests.cs | 190 ++++++++ 36 files changed, 2652 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Directory.Build.props create mode 100644 LICENSE create mode 100644 NextIteration.SpectreConsole.Settings.slnx create mode 100644 README.md create mode 100644 TODO.md create mode 100644 src/NextIteration.SpectreConsole.Settings/CommandConfiguratorExtensions.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Commands/CommandErrorReporter.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Commands/ListSettingsCommand.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Commands/ResetSettingsCommand.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Commands/SettingsCommandSettings.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/ISettingsStore.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/NextIteration.SpectreConsole.Settings.csproj create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/AtomicFile.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/ISettingsPersister.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/JsonSettingsPersister.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/SettingsSerialization.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/SettingsStore.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/Persistence/SettingsTypeDescriptor.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/PersistenceMode.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/ServiceCollectionExtensions.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/SettingsBase.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/SettingsOptions.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/SettingsRegistration.cs create mode 100644 src/NextIteration.SpectreConsole.Settings/icon.png create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/CountingPersister.cs create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/SampleSettings.cs create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/Infrastructure/TempDir.cs create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/NextIteration.SpectreConsole.Settings.Tests.csproj create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/Persistence/AtomicFileTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/SettingsBaseTests.cs create mode 100644 tests/NextIteration.SpectreConsole.Settings.Tests/SettingsStoreTests.cs 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..8ee082d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# 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] — 2026-05-27 + +### 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. + +[0.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/releases/tag/v0.1.0 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/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 0000000000000000000000000000000000000000..dd660dbcd654387d3afca2e98c68e1c3d8796701 GIT binary patch literal 23685 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 literal 0 HcmV?d00001 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))); + } +} From f7785cafd70669444de48dfaf4a9026a80215282 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Wed, 27 May 2026 03:51:59 +0000 Subject: [PATCH 2/3] Add package icon Replace the placeholder (copied from the Auth package) with the dedicated NextIteration.SpectreConsole.Settings icon: a 512x512 RGBA PNG (green gear over a stacked-database mark), consistent with the icon family across the sibling packages. The editable source vector is kept under design/icons/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../NextIteration.SpectreConsole.Settings.svg | 20 ++++++++++++++++++ .../icon.png | Bin 23685 -> 26811 bytes 2 files changed, 20 insertions(+) create mode 100644 design/icons/NextIteration.SpectreConsole.Settings.svg 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/icon.png b/src/NextIteration.SpectreConsole.Settings/icon.png index dd660dbcd654387d3afca2e98c68e1c3d8796701..88db75284457a8a462b0c567469b91fc9538ed4a 100644 GIT binary patch literal 26811 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⩔G_*BV?@0Nlfpk(&#i-LYh8Pg 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 literal 23685 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 From bda03cd8c71fa44f214ee5d8d2306558159d1bd3 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Wed, 27 May 2026 04:00:04 +0000 Subject: [PATCH 3/3] Mark 0.1.0 changelog as unreleased; note package icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.1.0 section was dated and linked to a release tag that doesn't exist yet — the package isn't being released at this point. Mark it Unreleased, point the link at the repo tree, and record the package icon in the initial-release notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee082d..319bca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [0.1.0] — 2026-05-27 +## [0.1.0] — Unreleased ### Added — initial release @@ -36,5 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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/releases/tag/v0.1.0 +[0.1.0]: https://github.com/StuartMeeks/NextIteration.SpectreConsole.Settings/tree/main