diff --git a/CHANGELOG.md b/CHANGELOG.md index 43de7e4..5f99530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Changed +- Replace `sqlct.config.json` with `sqlct.config.yaml` as the project configuration file; config files are now written in YAML with a header comment block containing a tool introduction, installation instructions, usage instructions, and a link to the GitHub repository. + +### Removed +- `sqlct.config.json` is no longer supported; existing users should rename the file to `sqlct.config.yaml` and run `sqlct config` to rewrite it in the new format. + ## [0.3.0] - 2026-04-12 ### Fixed diff --git a/specs/02-config.md b/specs/02-config.md index 0d86185..efe1e84 100644 --- a/specs/02-config.md +++ b/specs/02-config.md @@ -1,53 +1,47 @@ # Config Status: draft -Last updated: 2026-04-06 +Last updated: 2026-04-13 ## Authoritative Configuration -`sqlct.config.json` is the authoritative configuration file for `sqlct` projects. +`sqlct.config.yaml` is the authoritative configuration file for `sqlct` projects. - Required for baseline `sqlct` workflows. - Stores database connection settings and tool options. - All command behavior is resolved from this file (with CLI overrides where supported). ## Schema -The `sqlct.config.json` structure defines the current contract. +The `sqlct.config.yaml` structure defines the current contract. Current shape: -```json -{ - "database": { - "server": "localhost", - "name": "MyDb", - "auth": "integrated", - "user": "", - "password": "", - "trustServerCertificate": false - }, - "options": { - "parallelism": 0 - }, - "data": { - "trackedTables": [ - "dbo.Customer", - "Sales.SalesOrderHeader" - ] - } -} +```yaml +database: + server: localhost + name: MyDb + auth: integrated + user: '' + password: '' + trustServerCertificate: false +options: + parallelism: 0 +data: + trackedTables: + - dbo.Customer + - Sales.SalesOrderHeader ``` ## Init Behavior -`sqlct init` initializes `sqlct.config.json` in the target project directory. -- Writes default config when missing. +`sqlct init` initializes `sqlct.config.yaml` in the target project directory. +- Writes default config when missing. The written file begins with a header comment block containing a short intro, installation instructions, usage instructions, and a link to the GitHub repository. - Preserves existing compatibility files if present. ## Config Command Behavior `sqlct config` parses, validates, and writes configuration from the project directory. -- Requires `sqlct.config.json` to already exist in the project directory. If the file is missing, `config` exits with code `2` (`invalid config`) and prints: `Error: project directory is not initialized.` with hint `run \`sqlct init\` first.` No file is created. -- Validates `sqlct.config.json` as the primary source. +- Requires `sqlct.config.yaml` to already exist in the project directory. If the file is missing, `config` exits with code `2` (`invalid config`) and prints: `Error: project directory is not initialized.` with hint `run \`sqlct init\` first.` No file is created. +- Validates `sqlct.config.yaml` as the primary source. - Detects optional compatibility file presence for summary output only. -- Writes normalized configuration back to `sqlct.config.json`. +- Writes normalized configuration back to `sqlct.config.yaml`. The header comment block is regenerated on every write; any user-added comments inside the file are not preserved. - Deprecated fields are tolerated on read and omitted on rewrite. Deprecated runtime fields removed from v1 contract: @@ -56,7 +50,7 @@ Deprecated runtime fields removed from v1 contract: - `options.comparison.*` ## File Handling Policy -- `sqlct.config.json`: required; read/write by `init` and `config`. +- `sqlct.config.yaml`: required; read/write by `init` and `config`. - External compatibility files: optional; presence may be scanned/reported; files are preserved as-is. - Do not fail baseline workflows solely because optional external compatibility files are missing. @@ -67,17 +61,14 @@ Deprecated runtime fields removed from v1 contract: - `sql`: Uses SQL Server Authentication. `user` is required; `password` is optional (empty string is accepted). ### SQL Authentication example -```json -{ - "database": { - "server": "my-server.example.com", - "name": "MyDb", - "auth": "sql", - "user": "my_login", - "password": "my_password", - "trustServerCertificate": false - } -} +```yaml +database: + server: my-server.example.com + name: MyDb + auth: sql + user: my_login + password: my_password + trustServerCertificate: false ``` Validation rules: diff --git a/specs/12-project-plan.md b/specs/12-project-plan.md index ec7801e..1312210 100644 --- a/specs/12-project-plan.md +++ b/specs/12-project-plan.md @@ -26,7 +26,7 @@ Status values: `not_started`, `in_progress`, `blocked`, `done`. | Stream | Scope | Status | Notes | | --- | --- | --- | --- | | S1 | CLI foundation and command wiring | done | Command registration and global settings are in place. | -| S2 | Config and init flows | done | `sqlct.config.json` read/write and project seeding are functional. | +| S2 | Config and init flows | done | `sqlct.config.yaml` read/write and project seeding are functional. | | S3 | SQL adapter and schema mapping | done | Active object-type services are integrated into command runtime. | | S3b | Additional object-type activation | done | Active scope covers broker, full-text, XML schema collection, search-property-list, and assembly support needed for current compatibility work. | | S4 | Status/diff engine | done | End-to-end behavior implemented for active object types. | @@ -164,7 +164,7 @@ Status values: `not_started`, `in_progress`, `blocked`, `done`. ## Cross-Cutting Rules - Specs are authoritative over inferred behavior. -- `sqlct.config.json` is the primary runtime configuration source. +- `sqlct.config.yaml` is the primary runtime configuration source. - Local artifacts under `local/` remain untracked. - Keep naming compatibility-neutral in code and docs. - Keep v1 runtime scope constrained to the active object types and simplified config contract. diff --git a/src/SqlChangeTracker/Commands/InitCommand.cs b/src/SqlChangeTracker/Commands/InitCommand.cs index 5e0a718..734353f 100644 --- a/src/SqlChangeTracker/Commands/InitCommand.cs +++ b/src/SqlChangeTracker/Commands/InitCommand.cs @@ -162,7 +162,7 @@ private static ConnectionSetup PromptForConnectionSetup() string? password = null; if (string.Equals(auth, "sql", StringComparison.OrdinalIgnoreCase)) { - Console.WriteLine(" WARNING: password will be stored in plain text in sqlct.config.json."); + Console.WriteLine(" WARNING: password will be stored in plain text in sqlct.config.yaml."); Console.Write(" Username: "); user = Console.ReadLine()?.Trim(); Console.Write(" Password: "); @@ -225,7 +225,7 @@ private static IReadOnlyList GetNextSteps(InitConnectionTestResult? conn return [ - "Edit 'sqlct.config.json' to configure your database connection.", + "Edit 'sqlct.config.yaml' to configure your database connection.", "Run 'sqlct config' to validate your configuration.", "Run 'sqlct pull' to pull the current database schema into your folder.", ]; diff --git a/src/SqlChangeTracker/Config/Constants.cs b/src/SqlChangeTracker/Config/Constants.cs index 9a29c01..aed0185 100644 --- a/src/SqlChangeTracker/Config/Constants.cs +++ b/src/SqlChangeTracker/Config/Constants.cs @@ -11,7 +11,7 @@ internal static class ExitCodes internal static class ConfigFileNames { - public const string SqlctConfigFileName = "sqlct.config.json"; + public const string SqlctConfigFileName = "sqlct.config.yaml"; } internal static class ErrorCodes diff --git a/src/SqlChangeTracker/Config/SqlctConfigReader.cs b/src/SqlChangeTracker/Config/SqlctConfigReader.cs index 0d4a07a..7c46704 100644 --- a/src/SqlChangeTracker/Config/SqlctConfigReader.cs +++ b/src/SqlChangeTracker/Config/SqlctConfigReader.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace SqlChangeTracker.Config; @@ -20,13 +22,13 @@ public SqlctConfigReadResult Read(string configPath) try { - var json = File.ReadAllText(configPath); - var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - ReadCommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true - }); + var yaml = File.ReadAllText(configPath); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + + var config = deserializer.Deserialize(yaml); if (config == null) { @@ -38,10 +40,10 @@ public SqlctConfigReadResult Read(string configPath) SqlctConfigNormalizer.Normalize(config); return SqlctConfigReadResult.Ok(config); } - catch (JsonException ex) + catch (YamlException ex) { return SqlctConfigReadResult.Failure( - new ErrorInfo(ErrorCodes.InvalidConfig, "invalid config file.", Detail: $"invalid JSON: {ex.Message}"), + new ErrorInfo(ErrorCodes.InvalidConfig, "invalid config file.", Detail: $"invalid YAML: {ex.Message}"), ExitCodes.InvalidConfig); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) diff --git a/src/SqlChangeTracker/Config/SqlctConfigWriter.cs b/src/SqlChangeTracker/Config/SqlctConfigWriter.cs index 009de39..b140eec 100644 --- a/src/SqlChangeTracker/Config/SqlctConfigWriter.cs +++ b/src/SqlChangeTracker/Config/SqlctConfigWriter.cs @@ -1,9 +1,29 @@ -using System.Text.Json; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace SqlChangeTracker.Config; internal sealed class SqlctConfigWriter { + private const string HeaderComment = + "# SQL Change Tracker (sqlct)\n" + + "# https://github.com/ElegantCodeAtelier/sql-change-tracker\n" + + "#\n" + + "# Installation:\n" + + "# dotnet tool install --global sqlct\n" + + "#\n" + + "# Update:\n" + + "# dotnet tool update --global sqlct\n" + + "#\n" + + "# Usage:\n" + + "# sqlct --help - print help\n" + + "# sqlct init - initialize this project\n" + + "# sqlct config - validate and rewrite configuration\n" + + "# sqlct status - compare database against schema folder\n" + + "# sqlct diff - show textual schema differences\n" + + "# sqlct pull - pull database schema into folder\n" + + "#\n"; + public static string GetDefaultPath(string baseDirectory) => Path.Combine(baseDirectory, ConfigFileNames.SqlctConfigFileName); @@ -24,13 +44,12 @@ public ConfigWriteResult Write(string configPath, SqlctConfig config, bool overw return ConfigWriteResult.Ok(Array.Empty(), new[] { ConfigFileNames.SqlctConfigFileName }); } - var options = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); - var payload = JsonSerializer.Serialize(config, options); + var yaml = serializer.Serialize(config); + var payload = HeaderComment + yaml; File.WriteAllText(configPath, payload); return ConfigWriteResult.Ok(new[] { ConfigFileNames.SqlctConfigFileName }, Array.Empty()); diff --git a/src/SqlChangeTracker/SqlChangeTracker.csproj b/src/SqlChangeTracker/SqlChangeTracker.csproj index 63349cd..5911284 100644 --- a/src/SqlChangeTracker/SqlChangeTracker.csproj +++ b/src/SqlChangeTracker/SqlChangeTracker.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/SqlChangeTracker.Tests/Commands/DataCommandTests.cs b/tests/SqlChangeTracker.Tests/Commands/DataCommandTests.cs index bfcadf4..3568b20 100644 --- a/tests/SqlChangeTracker.Tests/Commands/DataCommandTests.cs +++ b/tests/SqlChangeTracker.Tests/Commands/DataCommandTests.cs @@ -19,7 +19,7 @@ public void DataTrackCommand_WhenNoMatches_ReturnsSuccessWithoutConfirmation() new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", [], ["dbo.Customer"], @@ -44,7 +44,7 @@ public void DataTrackCommand_WhenDeclined_ReturnsSuccessWithoutApplying() new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", ["dbo.Customer", "dbo.Order"], ["dbo.Customer"], @@ -79,7 +79,7 @@ public void DataTrackCommand_WhenConfirmationUnavailable_ReturnsExecutionFailure new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", ["dbo.Customer"], [], @@ -114,7 +114,7 @@ public void DataUntrackCommand_WhenNoMatches_ReturnsSuccessWithoutConfirmation() new DataUntrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "*.Missing", [], ["dbo.Customer"], @@ -139,7 +139,7 @@ public void DataUntrackCommand_WhenDeclined_WritesPreviewAndDoesNotApply() new DataUntrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", ["dbo.Customer"], ["dbo.Customer", "dbo.Order"], @@ -181,7 +181,7 @@ public void DataTrackCommand_WithJsonFlag_WritesPromptToStdErrAndJsonToStdOut() new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", ["dbo.Customer"], [], @@ -344,7 +344,7 @@ public void DataTrackCommand_WithObjectOption_PassesObjectPatternToService() new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", [], [], @@ -370,7 +370,7 @@ public void DataTrackCommand_WithFilterOption_PassesFilterPatternToService() new DataTrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "^dbo\\.", [], [], @@ -435,7 +435,7 @@ public void DataUntrackCommand_WithObjectOption_PassesObjectPatternToService() new DataUntrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "dbo.*", [], [], @@ -461,7 +461,7 @@ public void DataUntrackCommand_WithFilterOption_PassesFilterPatternToService() new DataUntrackPlan( ".\\schema", ".\\schema", - ".\\schema\\sqlct.config.json", + ".\\schema\\sqlct.config.yaml", "^dbo\\.", [], [], diff --git a/tests/SqlChangeTracker.Tests/Config/SqlctConfigWriterTests.cs b/tests/SqlChangeTracker.Tests/Config/SqlctConfigWriterTests.cs index ce3e986..eb428ad 100644 --- a/tests/SqlChangeTracker.Tests/Config/SqlctConfigWriterTests.cs +++ b/tests/SqlChangeTracker.Tests/Config/SqlctConfigWriterTests.cs @@ -1,5 +1,4 @@ using SqlChangeTracker.Config; -using System.Text.Json; using Xunit; namespace SqlChangeTracker.Tests.Config; @@ -52,12 +51,16 @@ public void Write_CreatesConfigWithDefaults() Assert.Contains(ConfigFileNames.SqlctConfigFileName, result.Created); Assert.True(File.Exists(configPath)); - using var document = JsonDocument.Parse(File.ReadAllText(configPath)); - var root = document.RootElement; - Assert.Equal(string.Empty, root.GetProperty("database").GetProperty("server").GetString()); - Assert.Equal(string.Empty, root.GetProperty("database").GetProperty("name").GetString()); - Assert.Equal("integrated", root.GetProperty("database").GetProperty("auth").GetString()); - Assert.Equal(0, root.GetProperty("data").GetProperty("trackedTables").GetArrayLength()); + var yamlText = File.ReadAllText(configPath); + Assert.StartsWith("#", yamlText); + + // Deserialize to verify values via the reader. + var readResult = new SqlctConfigReader().Read(configPath); + Assert.True(readResult.Success); + Assert.Equal(string.Empty, readResult.Config!.Database.Server); + Assert.Equal(string.Empty, readResult.Config.Database.Name); + Assert.Equal("integrated", readResult.Config.Database.Auth); + Assert.Empty(readResult.Config.Data.TrackedTables); } finally { @@ -77,25 +80,23 @@ public void ReadThenWrite_LegacyConfig_RemovesDeprecatedFields() try { var configPath = SqlctConfigWriter.GetDefaultPath(tempDir); + // Write a YAML config that contains deprecated fields (IgnoreUnmatchedProperties drops them). File.WriteAllText(configPath, """ - { - "database": { - "server": "localhost", - "name": "MyDb", - "auth": "integrated", - "user": "", - "password": "", - "trustServerCertificate": false - }, - "options": { - "includeSchemas": ["dbo"], - "excludeObjects": ["sys.*"], - "orderByDependencies": false, - "comparison": { - "ignoreWhitespace": true - } - } - } + database: + server: localhost + name: MyDb + auth: integrated + user: '' + password: '' + trustServerCertificate: false + options: + includeSchemas: + - dbo + excludeObjects: + - sys.* + orderByDependencies: false + comparison: + ignoreWhitespace: true """); var reader = new SqlctConfigReader(); @@ -106,12 +107,17 @@ public void ReadThenWrite_LegacyConfig_RemovesDeprecatedFields() var write = writer.Write(configPath, read.Config!, overwriteExisting: true); Assert.True(write.Success); - using var document = JsonDocument.Parse(File.ReadAllText(configPath)); - var options = document.RootElement.GetProperty("options"); - Assert.False(options.TryGetProperty("includeSchemas", out _)); - Assert.False(options.TryGetProperty("excludeObjects", out _)); - Assert.False(options.TryGetProperty("comparison", out _)); - Assert.Equal(0, document.RootElement.GetProperty("data").GetProperty("trackedTables").GetArrayLength()); + var yamlText = File.ReadAllText(configPath); + // Deprecated keys must not appear in the rewritten output. + Assert.DoesNotContain("includeSchemas", yamlText); + Assert.DoesNotContain("excludeObjects", yamlText); + Assert.DoesNotContain("orderByDependencies", yamlText); + Assert.DoesNotContain("comparison", yamlText); + + // Re-read and verify the data section is empty. + var readAgain = new SqlctConfigReader().Read(configPath); + Assert.True(readAgain.Success); + Assert.Empty(readAgain.Config!.Data.TrackedTables); } finally { @@ -139,15 +145,10 @@ public void Write_NormalizesTrackedTables() Assert.True(result.Success); - using var document = JsonDocument.Parse(File.ReadAllText(configPath)); - var tracked = document.RootElement - .GetProperty("data") - .GetProperty("trackedTables") - .EnumerateArray() - .Select(item => item.GetString()) - .ToArray(); - - Assert.Equal(new[] { "dbo.Customer", "sales.Order" }, tracked); + // Read back via the reader and verify normalization. + var readResult = new SqlctConfigReader().Read(configPath); + Assert.True(readResult.Success); + Assert.Equal(new[] { "dbo.Customer", "sales.Order" }, readResult.Config!.Data.TrackedTables.ToArray()); } finally { @@ -158,3 +159,4 @@ public void Write_NormalizesTrackedTables() } } } + diff --git a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs index be02817..8fa750f 100644 --- a/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs +++ b/tests/SqlChangeTracker.Tests/Sync/SyncCommandServiceTests.cs @@ -745,7 +745,7 @@ public void CollectUnsupportedFolderWarnings_IgnoresSupportedFilesAndNonSqlArtif { var supportedTable = CreateFile(tempDir, Path.Combine("Tables", "dbo.Customer.sql"), "CREATE TABLE dbo.Customer;"); var supportedView = CreateFile(tempDir, Path.Combine("Views", "dbo.CustomerView.sql"), "CREATE VIEW dbo.CustomerView AS SELECT 1;"); - File.WriteAllText(Path.Combine(tempDir, "sqlct.config.json"), "{}"); + File.WriteAllText(Path.Combine(tempDir, "sqlct.config.yaml"), "{}"); File.WriteAllText(Path.Combine(tempDir, "notes.txt"), "ignored"); var warnings = SyncCommandService.CollectUnsupportedFolderWarnings(tempDir, [supportedTable, supportedView]); @@ -2786,25 +2786,22 @@ public void RunStatus_WithIntegratedAuth_PassesAuthValidation() private static void WriteConfigWithAuth(string projectDir, string auth, string? user, string? password) { Directory.CreateDirectory(projectDir); - var configPath = Path.Combine(projectDir, "sqlct.config.json"); - var userLine = user != null ? $""" - "user": "{user}", - """ : string.Empty; - var passwordLine = password != null ? $""" - "password": "{password}", - """ : string.Empty; - File.WriteAllText(configPath, $$""" - { - "database": { - "server": "non-existent-server-for-auth-test", - "name": "TestDb", - "auth": "{{auth}}", - {{userLine}} - {{passwordLine}} - "trustServerCertificate": true - } - } - """); + var configPath = Path.Combine(projectDir, "sqlct.config.yaml"); + var userLine = user != null ? $" user: '{user}'" : string.Empty; + var passwordLine = password != null ? $" password: '{password}'" : string.Empty; + var lines = new List + { + "database:", + " server: non-existent-server-for-auth-test", + " name: TestDb", + $" auth: {auth}", + }; + if (!string.IsNullOrEmpty(userLine)) + lines.Add(userLine); + if (!string.IsNullOrEmpty(passwordLine)) + lines.Add(passwordLine); + lines.Add(" trustServerCertificate: true"); + File.WriteAllText(configPath, string.Join("\n", lines) + "\n"); } private static string CreateFile(string root, string relativePath, string content)