From 4248ee7cb97b0e82a86b066c16b968169ee8165f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Wed, 27 May 2026 14:30:09 +1000 Subject: [PATCH] Apply AI session prefix universally (custom names, directories, EF packages) --- pages/directory-and-name-resolution.md | 4 +- pages/raw-usage.md | 4 +- src/Directory.Build.props | 2 +- src/EfClassicLocalDb/EfClassicLocalDb.csproj | 1 + .../AiCliDetectorPrefixTests.cs | 141 ++++++++++++++++++ src/EfLocalDb/EfLocalDb.csproj | 1 + src/EfLocalDb/Storage.cs | 4 +- src/LocalDb.Tests/Tests.cs | 55 ++++++- src/LocalDb/AiCliDetector.cs | 41 +++++ src/LocalDb/SqlInstance.cs | 6 +- 10 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 src/EfLocalDb.Tests/AiCliDetectorPrefixTests.cs diff --git a/pages/directory-and-name-resolution.md b/pages/directory-and-name-resolution.md index fc4644e0..3cd8c8d7 100644 --- a/pages/directory-and-name-resolution.md +++ b/pages/directory-and-name-resolution.md @@ -113,7 +113,7 @@ public Task Build( string? databaseSuffix = null, [CallerMemberName] string memberName = "") ``` -snippet source | anchor +snippet source | anchor With these parameters the database name is the derived as follows: @@ -150,7 +150,7 @@ If full control over the database name is required, there is an overload that ta /// public async Task Build(string dbName) ``` -snippet source | anchor +snippet source | anchor Which can be used as follows: diff --git a/pages/raw-usage.md b/pages/raw-usage.md index b5bb6ec3..c6245b91 100644 --- a/pages/raw-usage.md +++ b/pages/raw-usage.md @@ -271,7 +271,7 @@ public async Task SharedDatabase() AreEqual(0, data.Count); } ``` -snippet source | anchor +snippet source | anchor Pass `useTransaction: true` to get an auto-rolling-back transaction, allowing writes without affecting other tests. @@ -307,5 +307,5 @@ public async Task SharedDatabase_WithTransaction() AreEqual(0, data.Count); } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8a97ed8d..061b3a55 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CA1416;CS8632;NU1608;NU1109 - 24.1.1 + 24.1.2 preview 1.0.0 false diff --git a/src/EfClassicLocalDb/EfClassicLocalDb.csproj b/src/EfClassicLocalDb/EfClassicLocalDb.csproj index 07d3f882..a47de136 100644 --- a/src/EfClassicLocalDb/EfClassicLocalDb.csproj +++ b/src/EfClassicLocalDb/EfClassicLocalDb.csproj @@ -34,6 +34,7 @@ + diff --git a/src/EfLocalDb.Tests/AiCliDetectorPrefixTests.cs b/src/EfLocalDb.Tests/AiCliDetectorPrefixTests.cs new file mode 100644 index 00000000..581b1688 --- /dev/null +++ b/src/EfLocalDb.Tests/AiCliDetectorPrefixTests.cs @@ -0,0 +1,141 @@ +// Verifies that, when AiCliDetector reports an AI session, every name/directory that flows +// into a SqlInstance carries the chatbot_ prefix — regardless of whether it was derived +// from the DbContext, produced by Storage.FromSuffix, or supplied directly by the caller +// via `new Storage(name, directory)`. The prefixing is centralised in the Storage +// constructor + AiCliDetector helpers, so all entry points pick it up. See commit 2cafc032. + +[TestFixture] +public class AiCliDetectorPrefixTests +{ + class FakeContext; + + [Test] + public void FromSuffix_PrefixesNameAndDirectory_WhenAiDetected() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = true; + + var storage = Storage.FromSuffix("Worker1"); + + That(storage.Name, Is.EqualTo("chatbot_FakeContext_Worker1")); + That(Path.GetFileName(storage.Directory), Is.EqualTo("chatbot_FakeContext_Worker1")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void FromSuffix_LeavesNameUnprefixed_WhenNotDetected() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = false; + + var storage = Storage.FromSuffix("Worker1"); + + That(storage.Name, Is.EqualTo("FakeContext_Worker1")); + That(Path.GetFileName(storage.Directory), Is.EqualTo("FakeContext_Worker1")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void CustomStorage_PrefixesNameAndDirectoryLeaf_WhenAiDetected() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = true; + + var storage = new Storage("MyCustomInstance", @"C:\TestDatabases\MyApp"); + + That(storage.Name, Is.EqualTo("chatbot_MyCustomInstance")); + That(storage.Directory, Is.EqualTo(@"C:\TestDatabases\chatbot_MyApp")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void CustomStorage_LeavesUserInputAlone_WhenNotDetected() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = false; + + var storage = new Storage("MyCustomInstance", @"C:\TestDatabases\MyApp"); + + That(storage.Name, Is.EqualTo("MyCustomInstance")); + That(storage.Directory, Is.EqualTo(@"C:\TestDatabases\MyApp")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void PrefixIfDetected_IsIdempotent() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = true; + + That(AiCliDetector.PrefixIfDetected("chatbot_Foo"), Is.EqualTo("chatbot_Foo")); + That(AiCliDetector.PrefixIfDetected("Foo"), Is.EqualTo("chatbot_Foo")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void PrefixDirectoryIfDetected_IsIdempotent() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = true; + + That( + AiCliDetector.PrefixDirectoryIfDetected(@"C:\Data\chatbot_Foo"), + Is.EqualTo(@"C:\Data\chatbot_Foo")); + That( + AiCliDetector.PrefixDirectoryIfDetected(@"C:\Data\Foo"), + Is.EqualTo(@"C:\Data\chatbot_Foo")); + } + finally + { + AiCliDetector.Detected = original; + } + } + + [Test] + public void PrefixIfDetected_ReturnsInputUnchanged_WhenNotDetected() + { + var original = AiCliDetector.Detected; + try + { + AiCliDetector.Detected = false; + That(AiCliDetector.PrefixIfDetected("Foo"), Is.EqualTo("Foo")); + That(AiCliDetector.PrefixDirectoryIfDetected(@"C:\Data\Foo"), Is.EqualTo(@"C:\Data\Foo")); + } + finally + { + AiCliDetector.Detected = original; + } + } +} diff --git a/src/EfLocalDb/EfLocalDb.csproj b/src/EfLocalDb/EfLocalDb.csproj index 70348193..fb2bf41f 100644 --- a/src/EfLocalDb/EfLocalDb.csproj +++ b/src/EfLocalDb/EfLocalDb.csproj @@ -32,6 +32,7 @@ + diff --git a/src/EfLocalDb/Storage.cs b/src/EfLocalDb/Storage.cs index 30d83271..a5f53c63 100644 --- a/src/EfLocalDb/Storage.cs +++ b/src/EfLocalDb/Storage.cs @@ -63,8 +63,8 @@ public Storage(string name, string directory) { Ensure.NotNullOrWhiteSpace(directory); Ensure.NotNullOrWhiteSpace(name); - Name = name; - Directory = directory; + Name = AiCliDetector.PrefixIfDetected(name); + Directory = AiCliDetector.PrefixDirectoryIfDetected(directory); } /// diff --git a/src/LocalDb.Tests/Tests.cs b/src/LocalDb.Tests/Tests.cs index 774b7229..d3a99f94 100644 --- a/src/LocalDb.Tests/Tests.cs +++ b/src/LocalDb.Tests/Tests.cs @@ -172,11 +172,56 @@ public async Task Multiple() [Test] public void DirectoryParameter_ShouldBeUsed() { - var customDirectory = Path.Combine(Path.GetTempPath(), "CustomLocalDbDirectory"); - var instance = new SqlInstance("DirectoryTest", TestDbBuilder.CreateTable, directory: customDirectory); - var actualDirectory = instance.Wrapper.Directory; - AreEqual(customDirectory, actualDirectory, "The directory parameter should be used, not overwritten"); - instance.Cleanup(); + var originalDetected = AiCliDetector.Detected; + try + { + // Without AI detection, an explicit directory parameter is used verbatim. + AiCliDetector.Detected = false; + + var customDirectory = Path.Combine(Path.GetTempPath(), "CustomLocalDbDirectory"); + var instance = new SqlInstance("DirectoryTest", TestDbBuilder.CreateTable, directory: customDirectory); + try + { + var actualDirectory = instance.Wrapper.Directory; + AreEqual(customDirectory, actualDirectory, "The directory parameter should be used, not overwritten"); + } + finally + { + instance.Cleanup(); + } + } + finally + { + AiCliDetector.Detected = originalDetected; + } + } + + [Test] + public void DirectoryParameter_LeafIsPrefixed_WhenAiDetected() + { + var originalDetected = AiCliDetector.Detected; + try + { + // With AI detection, the leaf segment of an explicit directory is prefixed so AI + // and human runs don't share a template folder even with caller-supplied paths. + AiCliDetector.Detected = true; + + var customDirectory = Path.Combine(Path.GetTempPath(), "CustomLocalDbDirectory"); + var expectedDirectory = Path.Combine(Path.GetTempPath(), "chatbot_CustomLocalDbDirectory"); + var instance = new SqlInstance("DirectoryTestAi", TestDbBuilder.CreateTable, directory: customDirectory); + try + { + AreEqual(expectedDirectory, instance.Wrapper.Directory); + } + finally + { + instance.Cleanup(); + } + } + finally + { + AiCliDetector.Detected = originalDetected; + } } #region SharedDatabase diff --git a/src/LocalDb/AiCliDetector.cs b/src/LocalDb/AiCliDetector.cs index ec3dff41..651c9618 100644 --- a/src/LocalDb/AiCliDetector.cs +++ b/src/LocalDb/AiCliDetector.cs @@ -64,4 +64,45 @@ static AiCliDetector() } public static bool Detected { get; set; } + + public const string Prefix = "chatbot_"; + + /// + /// Prepends to when an AI CLI session is + /// detected, isolating instance / storage names between AI and human-driven runs. + /// Idempotent — a name that already starts with is returned unchanged. + /// + public static string PrefixIfDetected(string name) + { + if (!Detected || name.StartsWith(Prefix, StringComparison.Ordinal)) + { + return name; + } + + return Prefix + name; + } + + /// + /// Prepends to the leaf segment of when + /// an AI CLI session is detected, so AI and human runs don't share an on-disk template + /// folder even when callers supply an explicit directory. + /// Idempotent — a leaf that already starts with is returned unchanged. + /// + public static string PrefixDirectoryIfDetected(string directory) + { + if (!Detected) + { + return directory; + } + + var leaf = Path.GetFileName(directory); + if (leaf.StartsWith(Prefix, StringComparison.Ordinal)) + { + return directory; + } + + var parent = Path.GetDirectoryName(directory); + var prefixedLeaf = Prefix + leaf; + return parent is null ? prefixedLeaf : Path.Combine(parent, prefixedLeaf); + } } diff --git a/src/LocalDb/SqlInstance.cs b/src/LocalDb/SqlInstance.cs index a71199d7..7b799e8a 100644 --- a/src/LocalDb/SqlInstance.cs +++ b/src/LocalDb/SqlInstance.cs @@ -79,10 +79,7 @@ public SqlInstance( } Ensure.NotNullOrWhiteSpace(name); - if (AiCliDetector.Detected) - { - name = "chatbot_" + name; - } + name = AiCliDetector.PrefixIfDetected(name); if (directory == null) { @@ -91,6 +88,7 @@ public SqlInstance( else { Ensure.NotWhiteSpace(directory); + directory = AiCliDetector.PrefixDirectoryIfDetected(directory); } this.dbAutoOffline = CiDetection.ResolveDbAutoOffline(dbAutoOffline);