Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pages/directory-and-name-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public Task<SqlDatabase> Build(
string? databaseSuffix = null,
[CallerMemberName] string memberName = "")
```
<sup><a href='/src/LocalDb/SqlInstance.cs#L138-L160' title='Snippet source file'>snippet source</a> | <a href='#snippet-ConventionBuildSignature' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/LocalDb/SqlInstance.cs#L136-L158' title='Snippet source file'>snippet source</a> | <a href='#snippet-ConventionBuildSignature' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

With these parameters the database name is the derived as follows:
Expand Down Expand Up @@ -150,7 +150,7 @@ If full control over the database name is required, there is an overload that ta
/// </summary>
public async Task<SqlDatabase> Build(string dbName)
```
<sup><a href='/src/LocalDb/SqlInstance.cs#L175-L182' title='Snippet source file'>snippet source</a> | <a href='#snippet-ExplicitBuildSignature' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/LocalDb/SqlInstance.cs#L173-L180' title='Snippet source file'>snippet source</a> | <a href='#snippet-ExplicitBuildSignature' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Which can be used as follows:
Expand Down
4 changes: 2 additions & 2 deletions pages/raw-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ public async Task SharedDatabase()
AreEqual(0, data.Count);
}
```
<sup><a href='/src/LocalDb.Tests/Tests.cs#L182-L196' title='Snippet source file'>snippet source</a> | <a href='#snippet-SharedDatabase' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/LocalDb.Tests/Tests.cs#L227-L241' title='Snippet source file'>snippet source</a> | <a href='#snippet-SharedDatabase' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Pass `useTransaction: true` to get an auto-rolling-back transaction, allowing writes without affecting other tests.
Expand Down Expand Up @@ -307,5 +307,5 @@ public async Task SharedDatabase_WithTransaction()
AreEqual(0, data.Count);
}
```
<sup><a href='/src/LocalDb.Tests/Tests.cs#L215-L243' title='Snippet source file'>snippet source</a> | <a href='#snippet-SharedDatabase_WithTransaction' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/LocalDb.Tests/Tests.cs#L260-L288' title='Snippet source file'>snippet source</a> | <a href='#snippet-SharedDatabase_WithTransaction' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;CA1416;CS8632;NU1608;NU1109</NoWarn>
<Version>24.1.1</Version>
<Version>24.1.2</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<ContinuousIntegrationBuild>false</ContinuousIntegrationBuild>
Expand Down
1 change: 1 addition & 0 deletions src/EfClassicLocalDb/EfClassicLocalDb.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<Compile Include="..\LocalDb\Wrapper.cs" />
<Compile Include="..\LocalDb\State.cs" />
<Compile Include="..\LocalDb\CiDetection.cs" />
<Compile Include="..\LocalDb\AiCliDetector.cs" />

<!-- explicit ref to avoid CVE -->
<PackageReference Include="System.Drawing.Common" />
Expand Down
141 changes: 141 additions & 0 deletions src/EfLocalDb.Tests/AiCliDetectorPrefixTests.cs
Original file line number Diff line number Diff line change
@@ -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<FakeContext>("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<FakeContext>("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;
}
}
}
1 change: 1 addition & 0 deletions src/EfLocalDb/EfLocalDb.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Compile Include="..\LocalDb\Wrapper.cs" />
<Compile Include="..\LocalDb\State.cs" />
<Compile Include="..\LocalDb\CiDetection.cs" />
<Compile Include="..\LocalDb\AiCliDetector.cs" />

<PackageReference Include="MethodTimer.Fody" PrivateAssets="All" />
<PackageReference Include="ConfigureAwait.Fody" PrivateAssets="All" />
Expand Down
4 changes: 2 additions & 2 deletions src/EfLocalDb/Storage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
55 changes: 50 additions & 5 deletions src/LocalDb.Tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions src/LocalDb/AiCliDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,45 @@ static AiCliDetector()
}

public static bool Detected { get; set; }

public const string Prefix = "chatbot_";

/// <summary>
/// Prepends <see cref="Prefix"/> to <paramref name="name"/> when an AI CLI session is
/// detected, isolating instance / storage names between AI and human-driven runs.
/// Idempotent — a name that already starts with <see cref="Prefix"/> is returned unchanged.
/// </summary>
public static string PrefixIfDetected(string name)
{
if (!Detected || name.StartsWith(Prefix, StringComparison.Ordinal))
{
return name;
}

return Prefix + name;
}

/// <summary>
/// Prepends <see cref="Prefix"/> to the leaf segment of <paramref name="directory"/> 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 <see cref="Prefix"/> is returned unchanged.
/// </summary>
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);
}
}
6 changes: 2 additions & 4 deletions src/LocalDb/SqlInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,7 @@ public SqlInstance(
}

Ensure.NotNullOrWhiteSpace(name);
if (AiCliDetector.Detected)
{
name = "chatbot_" + name;
}
name = AiCliDetector.PrefixIfDetected(name);

if (directory == null)
{
Expand All @@ -91,6 +88,7 @@ public SqlInstance(
else
{
Ensure.NotWhiteSpace(directory);
directory = AiCliDetector.PrefixDirectoryIfDetected(directory);
}

this.dbAutoOffline = CiDetection.ResolveDbAutoOffline(dbAutoOffline);
Expand Down
Loading