diff --git a/TcBuilder.Tests/Commands/BuildCommandTests.cs b/TcBuilder.Tests/Commands/BuildCommandTests.cs
new file mode 100644
index 0000000..ed0dbb8
--- /dev/null
+++ b/TcBuilder.Tests/Commands/BuildCommandTests.cs
@@ -0,0 +1,82 @@
+using System.CommandLine;
+using Microsoft.Extensions.Logging.Abstractions;
+using TcBuilder.Commands;
+using TcBuilder.Services;
+using TcBuilder.Tests.Infrastructure;
+
+namespace TcBuilder.Tests.Commands;
+
+///
+/// Fast unit tests for the build verb — no TwinCAT, no filesystem
+/// beyond an empty placeholder .sln. Verifies that CLI parsing dispatches
+/// the right to the service.
+///
+public sealed class BuildCommandTests
+{
+ [Fact]
+ public async Task Fails_When_Solution_Does_Not_Exist()
+ {
+ FakeTwinCatBuildService fake = new();
+ RootCommand root = BuildRoot(fake);
+
+ string missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.sln");
+
+ int exitCode = await root.Parse(["build", missing]).InvokeAsync();
+
+ exitCode.ShouldBe(1);
+ fake.BuildInvocations.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Passes_Configuration_Flag_To_Service()
+ {
+ FakeTwinCatBuildService fake = new();
+ RootCommand root = BuildRoot(fake);
+
+ await using TempSolutionFile sln = TempSolutionFile.Create();
+
+ int exitCode = await root.Parse(["build", sln.Path, "-c", "Debug"]).InvokeAsync();
+
+ exitCode.ShouldBe(0);
+ fake.BuildInvocations.Count.ShouldBe(1);
+ fake.BuildInvocations[0].Configuration.ShouldBe("Debug");
+ fake.BuildInvocations[0].Solution.FullName.ShouldBe(sln.Path);
+ }
+
+ [Fact]
+ public async Task Defaults_Configuration_To_Release()
+ {
+ FakeTwinCatBuildService fake = new();
+ RootCommand root = BuildRoot(fake);
+
+ await using TempSolutionFile sln = TempSolutionFile.Create();
+
+ await root.Parse(["build", sln.Path]).InvokeAsync();
+
+ fake.BuildInvocations[0].Configuration.ShouldBe("Release");
+ }
+
+ [Fact]
+ public async Task Returns_Non_Zero_When_Build_Fails()
+ {
+ FakeTwinCatBuildService fake = new()
+ {
+ NextBuildResult = BuildResult.Fail(TimeSpan.Zero, "boom"),
+ };
+ RootCommand root = BuildRoot(fake);
+
+ await using TempSolutionFile sln = TempSolutionFile.Create();
+
+ int exitCode = await root.Parse(["build", sln.Path]).InvokeAsync();
+
+ exitCode.ShouldBe(1);
+ }
+
+ private static RootCommand BuildRoot(ITwinCatBuildService service)
+ {
+ BuildCommand command = new(service, NullLogger.Instance);
+ RootCommand root = new();
+ root.Subcommands.Add(command);
+ return root;
+ }
+}
diff --git a/TcBuilder.Tests/GlobalUsings.cs b/TcBuilder.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..b37457e
--- /dev/null
+++ b/TcBuilder.Tests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using Xunit;
+global using Shouldly;
diff --git a/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs b/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs
new file mode 100644
index 0000000..e825dc9
--- /dev/null
+++ b/TcBuilder.Tests/Infrastructure/FakeTwinCatBuildService.cs
@@ -0,0 +1,36 @@
+using TcBuilder.Services;
+
+namespace TcBuilder.Tests.Infrastructure;
+
+///
+/// Hand-rolled test double for .
+/// Records the arguments it receives and returns whatever the test configured.
+///
+internal sealed class FakeTwinCatBuildService : ITwinCatBuildService
+{
+ public List BuildInvocations { get; } = new();
+ public List CleanInvocations { get; } = new();
+ public List InfoInvocations { get; } = new();
+
+ public BuildResult NextBuildResult { get; set; } = BuildResult.Ok(TimeSpan.Zero);
+ public BuildResult NextCleanResult { get; set; } = BuildResult.Ok(TimeSpan.Zero);
+ public SolutionInfo NextInfo { get; set; } = new("stub", "stub", 0);
+
+ public Task BuildAsync(BuildRequest request, CancellationToken cancellationToken)
+ {
+ BuildInvocations.Add(request);
+ return Task.FromResult(NextBuildResult);
+ }
+
+ public Task CleanAsync(FileInfo solution, CancellationToken cancellationToken)
+ {
+ CleanInvocations.Add(solution);
+ return Task.FromResult(NextCleanResult);
+ }
+
+ public Task GetInfoAsync(FileInfo solution, CancellationToken cancellationToken)
+ {
+ InfoInvocations.Add(solution);
+ return Task.FromResult(NextInfo);
+ }
+}
diff --git a/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs b/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs
new file mode 100644
index 0000000..895a720
--- /dev/null
+++ b/TcBuilder.Tests/Infrastructure/TempSolutionFile.cs
@@ -0,0 +1,39 @@
+namespace TcBuilder.Tests.Infrastructure;
+
+///
+/// Creates an empty throwaway .sln file so command-wiring tests can
+/// satisfy the "solution must exist" check without a real TwinCAT project.
+///
+internal sealed class TempSolutionFile : IAsyncDisposable
+{
+ public string Path { get; }
+
+ private TempSolutionFile(string path) => Path = path;
+
+ public static TempSolutionFile Create()
+ {
+ string path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ $"tcbuilder-tests-{Guid.NewGuid():N}.sln");
+
+ File.WriteAllText(path, "Microsoft Visual Studio Solution File, Format Version 12.00" + Environment.NewLine);
+ return new TempSolutionFile(path);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (File.Exists(Path))
+ {
+ File.Delete(Path);
+ }
+ }
+ catch
+ {
+ // best effort — tests shouldn't fail because of cleanup
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs b/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs
new file mode 100644
index 0000000..d357d65
--- /dev/null
+++ b/TcBuilder.Tests/Infrastructure/TwinCatProjectFixture.cs
@@ -0,0 +1,93 @@
+namespace TcBuilder.Tests.Infrastructure;
+
+///
+/// Copies a named TwinCAT test asset into a fresh temp directory and cleans
+/// it up on disposal. Assets live under TestAssets/ in the test
+/// project and are copied next to the test assembly at build time.
+///
+///
+///
+/// await using var fixture = TwinCatProjectFixture.Load("MinimalTwinCatSolution");
+/// var result = await service.BuildAsync(
+/// new BuildRequest(fixture.SolutionFile, "Release", null),
+/// CancellationToken.None);
+///
+///
+internal sealed class TwinCatProjectFixture : IAsyncDisposable
+{
+ /// The fresh temp directory containing the copied asset.
+ public DirectoryInfo WorkingDirectory { get; }
+
+ /// The first .sln discovered under .
+ public FileInfo SolutionFile { get; }
+
+ private TwinCatProjectFixture(DirectoryInfo workingDirectory, FileInfo solutionFile)
+ {
+ WorkingDirectory = workingDirectory;
+ SolutionFile = solutionFile;
+ }
+
+ ///
+ /// Load the asset at TestAssets/{assetName}/ into a new temp dir.
+ ///
+ public static TwinCatProjectFixture Load(string assetName)
+ {
+ string source = Path.Combine(AppContext.BaseDirectory, "TestAssets", assetName);
+ if (!Directory.Exists(source))
+ {
+ throw new DirectoryNotFoundException(
+ $"Test asset '{assetName}' not found at '{source}'. " +
+ $"Drop a TwinCAT solution into TcBuilder.Tests/TestAssets/{assetName}/ and rebuild.");
+ }
+
+ string destination = Path.Combine(
+ Path.GetTempPath(),
+ "tcbuilder-tests",
+ Guid.NewGuid().ToString("N"));
+
+ CopyDirectory(source, destination);
+
+ string[] solutions = Directory.GetFiles(destination, "*.sln", SearchOption.AllDirectories);
+ if (solutions.Length == 0)
+ {
+ throw new FileNotFoundException(
+ $"No .sln file found under the copied asset at '{destination}'.");
+ }
+
+ return new TwinCatProjectFixture(
+ new DirectoryInfo(destination),
+ new FileInfo(solutions[0]));
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (WorkingDirectory.Exists)
+ {
+ WorkingDirectory.Delete(recursive: true);
+ }
+ }
+ catch
+ {
+ // best effort
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static void CopyDirectory(string source, string destination)
+ {
+ Directory.CreateDirectory(destination);
+
+ foreach (string dir in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
+ {
+ Directory.CreateDirectory(dir.Replace(source, destination));
+ }
+
+ foreach (string file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
+ {
+ File.Copy(file, file.Replace(source, destination), overwrite: true);
+ }
+ }
+}
diff --git a/TcBuilder.Tests/Integration/BuildIntegrationTests.cs b/TcBuilder.Tests/Integration/BuildIntegrationTests.cs
new file mode 100644
index 0000000..4325140
--- /dev/null
+++ b/TcBuilder.Tests/Integration/BuildIntegrationTests.cs
@@ -0,0 +1,52 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using TcBuilder.Services;
+using TcBuilder.Tests.Infrastructure;
+
+namespace TcBuilder.Tests.Integration;
+
+///
+/// End-to-end tests that exercise the real
+/// against a fixture TwinCAT solution copied to a fresh temp directory.
+/// These require TwinCAT / TcXaeShell on the machine running the tests and
+/// stay skipped until TestAssets/MinimalTwinCatSolution/ is populated.
+///
+[Trait("Category", "Integration")]
+public sealed class BuildIntegrationTests
+{
+ private const string MinimalAsset = "MinimalTwinCatSolution";
+
+ [Fact(Skip = "Enable once TcBuilder.Tests/TestAssets/MinimalTwinCatSolution is populated.")]
+ public async Task Builds_The_Minimal_Fixture_Solution()
+ {
+ await using TwinCatProjectFixture fixture = TwinCatProjectFixture.Load(MinimalAsset);
+
+ TwinCatBuildService service = new(NullLogger.Instance);
+
+ BuildRequest request = new(
+ Solution: fixture.SolutionFile,
+ Configuration: "Release",
+ OutputDirectory: null);
+
+ BuildResult result = await service.BuildAsync(request, TestContext.Current.CancellationToken);
+
+ result.Success.ShouldBeTrue(result.Message);
+ }
+
+ [Fact(Skip = "Enable once TcBuilder.Tests/TestAssets/MinimalTwinCatSolution is populated.")]
+ public async Task Clean_Then_Build_Yields_A_Fresh_Artifact()
+ {
+ await using TwinCatProjectFixture fixture = TwinCatProjectFixture.Load(MinimalAsset);
+
+ TwinCatBuildService service = new(NullLogger.Instance);
+ CancellationToken ct = TestContext.Current.CancellationToken;
+
+ BuildResult clean = await service.CleanAsync(fixture.SolutionFile, ct);
+ clean.Success.ShouldBeTrue(clean.Message);
+
+ BuildResult build = await service.BuildAsync(
+ new BuildRequest(fixture.SolutionFile, "Release", null),
+ ct);
+
+ build.Success.ShouldBeTrue(build.Message);
+ }
+}
diff --git a/TcBuilder.Tests/TcBuilder.Tests.csproj b/TcBuilder.Tests/TcBuilder.Tests.csproj
new file mode 100644
index 0000000..b8fbb5c
--- /dev/null
+++ b/TcBuilder.Tests/TcBuilder.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ enable
+ latest
+
+
+ Exe
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TcBuilder.Tests/TestAssets/README.md b/TcBuilder.Tests/TestAssets/README.md
new file mode 100644
index 0000000..6dfa5e6
--- /dev/null
+++ b/TcBuilder.Tests/TestAssets/README.md
@@ -0,0 +1,33 @@
+# Test assets
+
+Drop fixture TwinCAT solutions here. Each subfolder is treated as a named
+asset that tests load by name:
+
+```csharp
+await using var fixture = TwinCatProjectFixture.Load("MinimalTwinCatSolution");
+// fixture.SolutionFile is the copied .sln in a fresh temp directory
+// the directory is deleted on DisposeAsync
+```
+
+Everything under `TestAssets/` is copied next to the test assembly at build
+time (see the `` item in
+`TcBuilder.Tests.csproj`). At runtime the fixture resolves assets relative to
+`AppContext.BaseDirectory`, so the copy-to-output mechanism is what makes the
+layout below discoverable.
+
+## Expected contents
+
+### `MinimalTwinCatSolution/` (TODO)
+
+A stripped-down TwinCAT solution with a single PLC project — enough for
+`TwinCatBuildService.BuildAsync` to exercise the full open-solution /
+build-solution automation path.
+
+Once populated, remove the `Skip = "..."` from the tests in
+`Integration/BuildIntegrationTests.cs`.
+
+## Adding more fixtures
+
+Any additional folder under `TestAssets/` becomes a new asset name. For
+example, a `LargeMotionSolution/` folder would be loaded with
+`TwinCatProjectFixture.Load("LargeMotionSolution")`.
diff --git a/TcBuilder.sln b/TcBuilder.sln
index 2b1190f..c5dcbed 100644
--- a/TcBuilder.sln
+++ b/TcBuilder.sln
@@ -1,9 +1,10 @@
-
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
-VisualStudioVersion = 17.14.37027.9 d17.14
+VisualStudioVersion = 17.14.37027.9
MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder", "TcBuilder.csproj", "{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder", "TcBuilder\TcBuilder.csproj", "{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TcBuilder.Tests", "TcBuilder.Tests\TcBuilder.Tests.csproj", "{64BB703E-B925-61B5-C243-4EEBAAB8C36D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -15,10 +16,10 @@ Global
{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0EB6DEDD-EC7F-4EA3-BFEE-52AD71529AEC}.Release|Any CPU.Build.0 = Release|Any CPU
- {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {8E7F3B2A-9C4D-4E1F-A0B2-1C3D4E5F6789}.Release|Any CPU.Build.0 = Release|Any CPU
+ {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {64BB703E-B925-61B5-C243-4EEBAAB8C36D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Commands/BuildCommand.cs b/TcBuilder/Commands/BuildCommand.cs
similarity index 97%
rename from Commands/BuildCommand.cs
rename to TcBuilder/Commands/BuildCommand.cs
index 25e3785..c8ceb39 100644
--- a/Commands/BuildCommand.cs
+++ b/TcBuilder/Commands/BuildCommand.cs
@@ -7,7 +7,7 @@ namespace TcBuilder.Commands;
///
/// tcbuilder build <solution> [--configuration] [--output]
///
-internal sealed class BuildCommand : Command
+public class BuildCommand : Command
{
public BuildCommand(ITwinCatBuildService buildService, ILogger logger)
: base("build", "Build a TwinCAT solution.")
diff --git a/Commands/CleanCommand.cs b/TcBuilder/Commands/CleanCommand.cs
similarity index 100%
rename from Commands/CleanCommand.cs
rename to TcBuilder/Commands/CleanCommand.cs
diff --git a/Commands/InfoCommand.cs b/TcBuilder/Commands/InfoCommand.cs
similarity index 100%
rename from Commands/InfoCommand.cs
rename to TcBuilder/Commands/InfoCommand.cs
diff --git a/Program.cs b/TcBuilder/Program.cs
similarity index 100%
rename from Program.cs
rename to TcBuilder/Program.cs
diff --git a/Services/ITwinCatBuildService.cs b/TcBuilder/Services/ITwinCatBuildService.cs
similarity index 80%
rename from Services/ITwinCatBuildService.cs
rename to TcBuilder/Services/ITwinCatBuildService.cs
index d013a42..8ddf523 100644
--- a/Services/ITwinCatBuildService.cs
+++ b/TcBuilder/Services/ITwinCatBuildService.cs
@@ -4,7 +4,7 @@ namespace TcBuilder.Services;
/// Abstraction over the TwinCAT solution build pipeline.
/// Keeps command handlers free of automation details (DTE, MSBuild, etc.).
///
-internal interface ITwinCatBuildService
+public interface ITwinCatBuildService
{
Task BuildAsync(BuildRequest request, CancellationToken cancellationToken);
@@ -14,13 +14,13 @@ internal interface ITwinCatBuildService
}
/// Inputs for a single build invocation.
-internal sealed record BuildRequest(
+public record BuildRequest(
FileInfo Solution,
string Configuration,
DirectoryInfo? OutputDirectory);
/// Outcome of a build or clean.
-internal sealed record BuildResult(bool Success, string? Message, TimeSpan Elapsed)
+public record BuildResult(bool Success, string? Message, TimeSpan Elapsed)
{
public static BuildResult Ok(TimeSpan elapsed, string? message = null) =>
new(true, message, elapsed);
@@ -30,4 +30,4 @@ public static BuildResult Fail(TimeSpan elapsed, string message) =>
}
/// Surface-level metadata about a TwinCAT solution.
-internal sealed record SolutionInfo(string Name, string Path, int ProjectCount);
+public record SolutionInfo(string Name, string Path, int ProjectCount);
diff --git a/Services/TwinCatBuildService.cs b/TcBuilder/Services/TwinCatBuildService.cs
similarity index 97%
rename from Services/TwinCatBuildService.cs
rename to TcBuilder/Services/TwinCatBuildService.cs
index 2f4f20e..979737f 100644
--- a/Services/TwinCatBuildService.cs
+++ b/TcBuilder/Services/TwinCatBuildService.cs
@@ -8,7 +8,7 @@ namespace TcBuilder.Services;
/// This is a scaffold — the real automation (TcXaeShell / Visual Studio DTE)
/// hangs off the TODOs below.
///
-internal sealed class TwinCatBuildService : ITwinCatBuildService
+public class TwinCatBuildService : ITwinCatBuildService
{
private readonly ILogger _logger;
diff --git a/TcBuilder.csproj b/TcBuilder/TcBuilder.csproj
similarity index 100%
rename from TcBuilder.csproj
rename to TcBuilder/TcBuilder.csproj