Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ implementation demonstrating best practices for DEMA Consulting .NET CLI tools.

## Tech Stack

- C# 12, .NET 8.0/9.0/10.0, dotnet CLI, NuGet
- C# (latest), .NET 8.0/9.0/10.0, dotnet CLI, NuGet

## Key Files

Expand Down
5 changes: 4 additions & 1 deletion docs/guide/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ Save validation results to a file:
templatetool --validate --results results.trx
```

The results file format is determined by the file extension: `.trx` for TRX (MSTest) format,
or `.xml` for JUnit format.

## Silent Mode

Suppress console output:
Expand All @@ -80,7 +83,7 @@ The following command-line options are supported:
| `-?`, `-h`, `--help` | Display help message |
| `--silent` | Suppress console output |
| `--validate` | Run self-validation |
| `--results <file>` | Write validation results to file (TRX or JUnit format) |
| `--results <file>` | Write validation results to file (.trx or .xml) |
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.trx makes sense, but .xml could be anything, I think people will understand TRX or JUnit better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in fa79789 — changed to "TRX or JUnit format".

| `--log <file>` | Write output to log file |

# Examples
Expand Down
31 changes: 31 additions & 0 deletions requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ sections:
- Context_Create_VersionFlag_SetsVersionTrue
- Context_Create_ShortVersionFlag_SetsVersionTrue
- Program_Run_WithVersionFlag_DisplaysVersionOnly
- Program_Version_ReturnsNonEmptyString
- IntegrationTest_VersionFlag_OutputsVersion

- id: TMPL-REQ-003
Expand Down Expand Up @@ -96,6 +97,7 @@ sections:
tests:
- Context_Create_ResultsFlag_SetsResultsFile
- IntegrationTest_ValidateWithResults_GeneratesTrxFile
- IntegrationTest_ValidateWithResults_GeneratesJUnitFile

- id: TMPL-REQ-007
title: The tool shall support --log flag to write output to a log file.
Expand All @@ -105,6 +107,35 @@ sections:
- Context_Create_LogFlag_OpensLogFile
- IntegrationTest_LogFlag_WritesOutputToFile

- id: TMPL-REQ-013
title: The tool shall write error messages to stderr.
justification: |
Error messages must be written to stderr so they remain visible to the user
without polluting stdout, which consumers may pipe or redirect for data capture.
tests:
- Context_WriteError_NotSilent_WritesToConsole
- IntegrationTest_UnknownArgument_ReturnsError

- id: TMPL-REQ-015
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps fix the order of the 15/14 requirements seeing as they're both new.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fa79789 — order is now TMPL-CMD-013, TMPL-CMD-014, TMPL-CMD-015.

title: The tool shall return a non-zero exit code on failure.
justification: |
Callers (scripts, CI/CD pipelines) must be able to detect failure conditions
programmatically via the process exit code.
tests:
- Context_WriteError_SetsErrorExitCode
- IntegrationTest_UnknownArgument_ReturnsError

- id: TMPL-REQ-014
title: The tool shall reject unknown or malformed command-line arguments with a descriptive error.
justification: |
Providing clear feedback for invalid arguments helps users quickly correct
mistakes and prevents silent misconfiguration.
tests:
- Context_Create_UnknownArgument_ThrowsArgumentException
- Context_Create_LogFlag_WithoutValue_ThrowsArgumentException
- Context_Create_ResultsFlag_WithoutValue_ThrowsArgumentException
- IntegrationTest_UnknownArgument_ReturnsError

- title: Platform Support
requirements:
- id: TMPL-REQ-008
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have all Platform Support requirements be TMPL-PLT-xxx

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in fa79789 — all Platform Support requirements renamed to TMPL-PLT-008 through TMPL-PLT-012.

Expand Down
6 changes: 4 additions & 2 deletions src/DemaConsulting.TemplateDotNetTool/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ private void OpenLogFile(string logFile)
{
try
{
_logWriter = new StreamWriter(logFile, append: false);
// Open with AutoFlush enabled so log entries are immediately written to disk
// even if the application terminates unexpectedly before Dispose is called
_logWriter = new StreamWriter(logFile, append: false) { AutoFlush = true };
}
// Generic catch is justified here to wrap any file system exception with context.
// Expected exceptions include IOException, UnauthorizedAccessException, ArgumentException,
Expand Down Expand Up @@ -267,7 +269,7 @@ public void WriteError(string message)
{
var previousColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(message);
Console.Error.WriteLine(message);
Console.ForegroundColor = previousColor;
}

Expand Down
6 changes: 3 additions & 3 deletions src/DemaConsulting.TemplateDotNetTool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,19 @@ private static int Main(string[] args)
catch (ArgumentException ex)
{
// Print expected argument exceptions and return error code
Console.WriteLine($"Error: {ex.Message}");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
catch (InvalidOperationException ex)
{
// Print expected operation exceptions and return error code
Console.WriteLine($"Error: {ex.Message}");
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
catch (Exception ex)
{
// Print unexpected exceptions and re-throw to generate event logs
Console.WriteLine($"Unexpected error: {ex.Message}");
Console.Error.WriteLine($"Unexpected error: {ex.Message}");
throw;
}
}
Expand Down
134 changes: 134 additions & 0 deletions test/DemaConsulting.TemplateDotNetTool.Tests/ContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,28 @@ public void Context_Create_UnknownArgument_ThrowsArgumentException()
Assert.Contains("Unsupported argument", exception.Message);
}

/// <summary>
/// Test creating a context with --log flag but no value throws exception.
/// </summary>
[TestMethod]
public void Context_Create_LogFlag_WithoutValue_ThrowsArgumentException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => Context.Create(["--log"]));
Assert.Contains("--log", exception.Message);
}

/// <summary>
/// Test creating a context with --results flag but no value throws exception.
/// </summary>
[TestMethod]
public void Context_Create_ResultsFlag_WithoutValue_ThrowsArgumentException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() => Context.Create(["--results"]));
Assert.Contains("--results", exception.Message);
}

/// <summary>
/// Test WriteLine writes to console output when not silent.
/// </summary>
Expand Down Expand Up @@ -256,4 +278,116 @@ public void Context_WriteLine_Silent_DoesNotWriteToConsole()
Console.SetOut(originalOut);
}
}

/// <summary>
/// Test WriteError does not write to console when silent.
/// </summary>
[TestMethod]
public void Context_WriteError_Silent_DoesNotWriteToConsole()
{
// Arrange
var originalError = Console.Error;
try
{
using var errWriter = new StringWriter();
Console.SetError(errWriter);
using var context = Context.Create(["--silent"]);

// Act
context.WriteError("Test error message");

// Assert - error output should be suppressed in silent mode
var output = errWriter.ToString();
Assert.DoesNotContain("Test error message", output);
}
finally
{
Console.SetError(originalError);
}
}

/// <summary>
/// Test WriteError sets exit code to 1.
/// </summary>
[TestMethod]
public void Context_WriteError_SetsErrorExitCode()
{
// Arrange
var originalError = Console.Error;
try
{
using var errWriter = new StringWriter();
Console.SetError(errWriter);
using var context = Context.Create([]);

// Act
context.WriteError("Test error message");

// Assert
Assert.AreEqual(1, context.ExitCode);
}
finally
{
Console.SetError(originalError);
}
}

/// <summary>
/// Test WriteError writes message to console when not silent.
/// </summary>
[TestMethod]
public void Context_WriteError_NotSilent_WritesToConsole()
{
// Arrange
var originalError = Console.Error;
try
{
using var errWriter = new StringWriter();
Console.SetError(errWriter);
using var context = Context.Create([]);

// Act
context.WriteError("Test error message");

// Assert
var output = errWriter.ToString();
Assert.Contains("Test error message", output);
}
finally
{
Console.SetError(originalError);
}
}

/// <summary>
/// Test WriteError writes message to log file when logging is enabled.
/// </summary>
[TestMethod]
public void Context_WriteError_WritesToLogFile()
{
// Arrange
var logFile = Path.GetTempFileName();
try
{
// Act - use silent to avoid console output; verify the error still goes to the log
using (var context = Context.Create(["--silent", "--log", logFile]))
{
context.WriteError("Test error in log");
Assert.AreEqual(1, context.ExitCode);
}

// Assert - log file should contain the error message
Assert.IsTrue(File.Exists(logFile));
var logContent = File.ReadAllText(logFile);
Assert.Contains("Test error in log", logContent);
}
finally
{
if (File.Exists(logFile))
{
File.Delete(logFile);
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PropertyGroup>
<!-- Build Configuration -->
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>12</LangVersion>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

Expand Down
37 changes: 37 additions & 0 deletions test/DemaConsulting.TemplateDotNetTool.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ public void IntegrationTest_LogFlag_WritesOutputToFile()
}
}

/// <summary>
/// Test that validate with results flag generates JUnit XML file.
/// </summary>
[TestMethod]
public void IntegrationTest_ValidateWithResults_GeneratesJUnitFile()
{
// Arrange
var resultsFile = Path.GetTempFileName();
resultsFile = Path.ChangeExtension(resultsFile, ".xml");

try
{
// Act
var exitCode = Runner.Run(
out var _,
"dotnet",
_dllPath,
"--validate",
"--results",
resultsFile);

// Assert
Assert.AreEqual(0, exitCode);
Assert.IsTrue(File.Exists(resultsFile), "Results file was not created");

var xmlContent = File.ReadAllText(resultsFile);
Assert.Contains("<testsuites", xmlContent);
}
finally
{
if (File.Exists(resultsFile))
{
File.Delete(resultsFile);
}
}
}

/// <summary>
/// Test that unknown argument returns error.
/// </summary>
Expand Down