CSharp Project: .NET 9 / C# 12 CLI Tools
Project Goals:
Read a
data.jsonfile.Parse its content.
Convert the parsed JSON to a Markdown string.
Find a target Markdown file in a specified directory using a regex pattern.
Archive the original target Markdown file (if found) by renaming it with a timestamp.
Append the generated Markdown to the (new or existing) target Markdown file (using its original name).
Archive the original
data.jsonfile by renaming it with a timestamp.Use
System.CommandLinefor a good CLI experience.Structure the code into logical classes/modules for future extensibility.
Step 1: Create the .NET CLI Project
Open your terminal or command prompt.
Create a new console application:
dotnet new console -n JsonToMarkdownAppender -f net9.0 cd JsonToMarkdownAppender(Optional but recommended for .NET 9 preview features if any are used explicitly, though not strictly necessary for this example): Create or edit
Directory.Build.propsin the project root:<Project> <PropertyGroup> <LangVersion>12.0</LangVersion> <EnablePreviewFeatures>True</EnablePreviewFeatures> <!-- If using preview APIs --> </PropertyGroup> </Project>Note: For C# 12 features,
LangVersion12.0 is usually sufficient.EnablePreviewFeaturesis more for SDK/runtime previews.
Step 2: Add Necessary NuGet Packages
We'll use System.Text.Json for JSON processing and System.CommandLine for robust CLI argument parsing.
dotnet add package System.Text.Json
dotnet add package System.CommandLineStep 3: Define Core Interfaces (for Modularity)
Let's define interfaces for the key operations. This will allow us to swap out implementations later.
Create a new folder Core and add these files:
Core/IJsonProcessor.cs:
// Core/IJsonProcessor.cs
namespace JsonToMarkdownAppender.Core;
/// <summary>
/// Defines a contract for processing JSON content into a structured object.
/// </summary>
/// <typeparam name="T">The type of the structured object to parse into.</typeparam>
public interface IJsonProcessor<T>
{
Task<T?> ProcessAsync(string filePath);
}Core/IMarkdownConverter.cs:
// Core/IMarkdownConverter.cs
namespace JsonToMarkdownAppender.Core;
/// <summary>
/// Defines a contract for converting a structured object to a Markdown string.
/// </summary>
/// <typeparam name="T">The type of the structured object to convert from.</typeparam>
public interface IMarkdownConverter<T>
{
string Convert(T data);
}Core/IFileFinder.cs:
// Core/IFileFinder.cs
namespace JsonToMarkdownAppender.Core;
/// <summary>
/// Defines a contract for finding a file based on a pattern.
/// </summary>
public interface IFileFinder
{
string? FindFile(string directoryPath, string pattern);
}Core/IFileArchiver.cs:
// Core/IFileArchiver.cs
namespace JsonToMarkdownAppender.Core;
/// <summary>
/// Defines a contract for archiving a file.
/// </summary>
public interface IFileArchiver
{
Task<string?> ArchiveAsync(string filePath, string? archiveSubdirectory = null);
}Core/IFileWriter.cs:
// Core/IFileWriter.cs
namespace JsonToMarkdownAppender.Core;
/// <summary>
/// Defines a contract for writing content to a file.
/// </summary>
public interface IFileWriter
{
Task AppendAsync(string filePath, string content);
}Step 4: Implement Concrete Services
Create a new folder Services and add implementations for the interfaces.
Define a simple data model for our data.json:
Let's assume data.json looks like this:
{
"title": "My New Section",
"author": "AI Assistant",
"paragraphs": [
"This is the first paragraph from the JSON data.",
"Another interesting point to make."
],
"tags": ["update", "csharp", "dotnet"]
}Create Models/JsonContent.cs:
// Models/JsonContent.cs
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace JsonToMarkdownAppender.Models;
public class JsonContent
{
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("author")]
public string? Author { get; set; }
[JsonPropertyName("paragraphs")]
public List<string>? Paragraphs { get; set; }
[JsonPropertyName("tags")]
public List<string>? Tags { get; set; }
}Services/DefaultJsonProcessor.cs:
// Services/DefaultJsonProcessor.cs
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using JsonToMarkdownAppender.Core;
using JsonToMarkdownAppender.Models; // Assuming your JsonContent model is here
namespace JsonToMarkdownAppender.Services;
public class DefaultJsonProcessor : IJsonProcessor<JsonContent>
{
public async Task<JsonContent?> ProcessAsync(string filePath)
{
if (!File.Exists(filePath))
{
Console.Error.WriteLine($"Error: JSON file not found at '{filePath}'.");
return null;
}
try
{
var jsonString = await File.ReadAllTextAsync(filePath);
return JsonSerializer.Deserialize<JsonContent>(jsonString, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true // Good practice
});
}
catch (JsonException ex)
{
Console.Error.WriteLine($"Error parsing JSON file '{filePath}': {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"An unexpected error occurred while reading JSON file '{filePath}': {ex.Message}");
return null;
}
}
}Services/SimpleMarkdownConverter.cs:
// Services/SimpleMarkdownConverter.cs
using System.Linq;
using System.Text;
using JsonToMarkdownAppender.Core;
using JsonToMarkdownAppender.Models;
namespace JsonToMarkdownAppender.Services;
public class SimpleMarkdownConverter : IMarkdownConverter<JsonContent>
{
public string Convert(JsonContent data)
{
var sb = new StringBuilder();
sb.AppendLine($"## {data.Title ?? "Untitled Section"}"); // Use H2 for appended sections
if (!string.IsNullOrWhiteSpace(data.Author))
{
sb.AppendLine($"_By: {data.Author}_");
sb.AppendLine();
}
if (data.Paragraphs != null && data.Paragraphs.Any())
{
foreach (var p in data.Paragraphs)
{
sb.AppendLine(p);
sb.AppendLine(); // Add an empty line for paragraph spacing in Markdown
}
}
if (data.Tags != null && data.Tags.Any())
{
sb.AppendLine($"Tags: {string.Join(", ", data.Tags.Select(t => $"`{t}`"))}");
sb.AppendLine();
}
sb.AppendLine("---"); // Add a horizontal rule for separation
sb.AppendLine();
return sb.ToString();
}
}Services/RegexFileFinder.cs:
// Services/RegexFileFinder.cs
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using JsonToMarkdownAppender.Core;
namespace JsonToMarkdownAppender.Services;
public class RegexFileFinder : IFileFinder
{
public string? FindFile(string directoryPath, string pattern)
{
if (!Directory.Exists(directoryPath))
{
Console.Error.WriteLine($"Error: Target directory not found at '{directoryPath}'.");
return null;
}
try
{
var regex = new Regex(pattern, RegexOptions.IgnoreCase); // Regex is case-insensitive by default here
// We'll only search the top directory for simplicity, but you could change SearchOption.
var files = Directory.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
.Where(file => regex.IsMatch(Path.GetFileName(file)));
// Return the first match. You might want different logic for multiple matches.
return files.FirstOrDefault();
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"Error: Invalid regex pattern '{pattern}': {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error searching for files in '{directoryPath}': {ex.Message}");
return null;
}
}
}Services/TimestampFileArchiver.cs:
// Services/TimestampFileArchiver.cs
using System;
using System.IO;
using System.Threading.Tasks;
using JsonToMarkdownAppender.Core;
namespace JsonToMarkdownAppender.Services;
public class TimestampFileArchiver : IFileArchiver
{
public Task<string?> ArchiveAsync(string filePath, string? archiveSubdirectory = null)
{
if (!File.Exists(filePath))
{
// It's not an error if the file to archive doesn't exist (e.g., first run for target MD)
// Console.WriteLine($"Info: File '{filePath}' not found for archiving, skipping.");
return Task.FromResult<string?>(null);
}
try
{
string directory = Path.GetDirectoryName(filePath) ?? ".";
string fileName = Path.GetFileNameWithoutExtension(filePath);
string extension = Path.GetExtension(filePath);
string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
string archiveFileName = $"{fileName}_{timestamp}{extension}";
string archivePath;
if (!string.IsNullOrEmpty(archiveSubdirectory))
{
string fullArchiveSubDir = Path.Combine(directory, archiveSubdirectory);
Directory.CreateDirectory(fullArchiveSubDir); // Ensure archive subdir exists
archivePath = Path.Combine(fullArchiveSubDir, archiveFileName);
}
else
{
archivePath = Path.Combine(directory, archiveFileName);
}
File.Move(filePath, archivePath);
Console.WriteLine($"Archived '{filePath}' to '{archivePath}'.");
return Task.FromResult<string?>(archivePath);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error archiving file '{filePath}': {ex.Message}");
return Task.FromResult<string?>(null);
}
}
}Services/DefaultFileWriter.cs:
// Services/DefaultFileWriter.cs
using System.IO;
using System.Threading.Tasks;
using JsonToMarkdownAppender.Core;
namespace JsonToMarkdownAppender.Services;
public class DefaultFileWriter : IFileWriter
{
public async Task AppendAsync(string filePath, string content)
{
try
{
// Ensure directory exists
string? directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.AppendAllTextAsync(filePath, content);
Console.WriteLine($"Appended content to '{filePath}'.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error appending to file '{filePath}': {ex.Message}");
}
}
}Step 5: Create the Main Application Logic Class
This class will orchestrate the operations using the services.
Create AppLogic.cs:
// AppLogic.cs
using System;
using System.IO;
using System.Threading.Tasks;
using JsonToMarkdownAppender.Core;
using JsonToMarkdownAppender.Models;
namespace JsonToMarkdownAppender;
public class AppLogic(
IJsonProcessor<JsonContent> jsonProcessor,
IMarkdownConverter<JsonContent> markdownConverter,
IFileFinder fileFinder,
IFileArchiver fileArchiver,
IFileWriter fileWriter) // Using primary constructor (C# 12)
{
public async Task<int> RunAsync(string jsonFilePath, string targetDirectory, string targetFilePattern, string? archiveSubDir)
{
Console.WriteLine($"Processing JSON file: {jsonFilePath}");
Console.WriteLine($"Target directory: {targetDirectory}");
Console.WriteLine($"Target file pattern: {targetFilePattern}");
if (!string.IsNullOrEmpty(archiveSubDir))
{
Console.WriteLine($"Archive subdirectory: {archiveSubDir}");
}
Console.WriteLine("---");
// 1. Process JSON
var jsonData = await jsonProcessor.ProcessAsync(jsonFilePath);
if (jsonData == null)
{
Console.Error.WriteLine("Failed to process JSON data. Aborting.");
return 1; // Error code
}
// 2. Convert to Markdown
string markdownContent = markdownConverter.Convert(jsonData);
if (string.IsNullOrEmpty(markdownContent))
{
Console.Error.WriteLine("Generated Markdown content is empty. Aborting.");
return 1;
}
// Console.WriteLine("Generated Markdown:\n" + markdownContent); // For debugging
// 3. Find target Markdown file
string? targetFilePath = fileFinder.FindFile(targetDirectory, targetFilePattern);
if (targetFilePath != null)
{
Console.WriteLine($"Found target file: {targetFilePath}");
// 4. Archive existing target Markdown file
await fileArchiver.ArchiveAsync(targetFilePath, archiveSubDir);
}
else
{
// If no file is found, we'll create one with a name based on the pattern.
// This is a simple approach; a more robust one might require a specific output filename argument.
// For now, let's construct a name. E.g. if pattern is "^README.*\.md$", use "README.md".
// This is a simplification. A more robust approach might be to error out or require an explicit output filename.
string baseName = targetFilePattern.TrimStart('^').Split(new[] { ".*", ".md" }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "output";
targetFilePath = Path.Combine(targetDirectory, $"{baseName}.md");
Console.WriteLine($"No target file found matching pattern. Will create/append to: {targetFilePath}");
}
// 5. Append to (new or existing) target file (using original name)
await fileWriter.AppendAsync(targetFilePath, markdownContent);
// 6. Archive source JSON file
await fileArchiver.ArchiveAsync(jsonFilePath, archiveSubDir);
Console.WriteLine("---");
Console.WriteLine("Operation completed successfully.");
return 0; // Success
}
}Step 6: Setup System.CommandLine in Program.cs
Modify Program.cs to handle command-line arguments.
// Program.cs
using System;
using System.CommandLine;
using System.IO;
using System.Threading.Tasks;
using JsonToMarkdownAppender;
using JsonToMarkdownAppender.Core;
using JsonToMarkdownAppender.Models;
using JsonToMarkdownAppender.Services;
class Program
{
static async Task<int> Main(string[] args)
{
var jsonFileOption = new Option<FileInfo>(
name: "--json-file",
description: "The input JSON file path.",
getDefaultValue: () => new FileInfo("data.json")); // Default value
var targetDirOption = new Option<DirectoryInfo>(
name: "--target-directory",
description: "The directory to search for the target Markdown file.",
getDefaultValue: () => new DirectoryInfo(".")); // Default to current directory
var targetPatternOption = new Option<string>(
name: "--target-pattern",
description: "Regex pattern to find the target Markdown file (e.g., \"^README.*\\.md$\").",
getDefaultValue: () => "^NOTES.*\\.md$"); // Default pattern
var archiveSubDirOption = new Option<string?>( // Nullable string for optional argument
name: "--archive-subdir",
description: "Optional subdirectory within the source/target file's directory to move archived files to.");
var rootCommand = new RootCommand("CLI tool to parse JSON, convert to Markdown, and append to a file.");
rootCommand.AddOption(jsonFileOption);
rootCommand.AddOption(targetDirOption);
rootCommand.AddOption(targetPatternOption);
rootCommand.AddOption(archiveSubDirOption);
rootCommand.SetHandler(async (jsonFile, targetDir, targetPattern, archiveSubDir) =>
{
// Simple Dependency Injection setup (manual for now)
IJsonProcessor<JsonContent> jsonProcessor = new DefaultJsonProcessor();
IMarkdownConverter<JsonContent> markdownConverter = new SimpleMarkdownConverter();
IFileFinder fileFinder = new RegexFileFinder();
IFileArchiver fileArchiver = new TimestampFileArchiver();
IFileWriter fileWriter = new DefaultFileWriter();
var appLogic = new AppLogic(
jsonProcessor,
markdownConverter,
fileFinder,
fileArchiver,
fileWriter);
await appLogic.RunAsync(jsonFile.FullName, targetDir.FullName, targetPattern, archiveSubDir);
}, jsonFileOption, targetDirOption, targetPatternOption, archiveSubDirOption);
return await rootCommand.InvokeAsync(args);
}
}Step 7: Prepare Sample Files and Test
Create
data.jsonin your project root:{ "title": "My CLI Tool Update", "author": "Dev Assistant", "paragraphs": [ "Successfully implemented the CLI tool.", "It can parse JSON, convert to Markdown, and append.", "Archiving of source and target files is also working." ], "tags": ["cli", "dotnet9", "csharp12", "automation"] }Create a sample target Markdown file, e.g.,
NOTES.mdin your project root:# Project Notes This file contains various notes about the project. ## Old Section Some old content here. ---Build the project:
dotnet buildRun the tool:
Using defaults: (Assumes
data.jsonandNOTES.mdare in the current directory)dotnet runThis will use
data.json, look for^NOTES.*\.md$in the current directory.Specifying options:
dotnet run --json-file mydata.json --target-directory ./docs --target-pattern "^CHAPTER.*\\.md$" --archive-subdir "_archive"(You'd need to create
mydata.jsonand adocsdirectory with a matching file for this example).
Expected Outcome (after running with defaults):
A
data_YYYYMMDDHHMMSSFFF.jsonfile will be created (archive ofdata.json).A
NOTES_YYYYMMDDHHMMSSFFF.mdfile will be created (archive of originalNOTES.md).The
NOTES.mdfile will now contain:# Project Notes This file contains various notes about the project. ## Old Section Some old content here. --- ## My CLI Tool Update _By: Dev Assistant_ Successfully implemented the CLI tool. It can parse JSON, convert to Markdown, and append. Archiving of source and target files is also working. Tags: `cli`, `dotnet9`, `csharp12`, `automation` ---The original
data.jsonwill be gone (it's been archived).
Modularity and Future Enhancements:
Changing Parsers/Converters:
You can create a new class
AlternativeJsonProcessor : IJsonProcessor<AnotherModel>andAlternativeMarkdownConverter : IMarkdownConverter<AnotherModel>.Then, in
Program.cs, you'd instantiate these new classes. If the model changes, you'd adjustAppLogicor make it generic.
Changing File Operations:
If you want to store archives in Azure Blob Storage, you could create
AzureBlobStorageArchiver : IFileArchiver.
Dependency Injection Framework: For larger applications, you'd use a DI framework like
Microsoft.Extensions.DependencyInjection. You'd register your services (services.AddSingleton<IJsonProcessor<JsonContent>, DefaultJsonProcessor>();) and resolveAppLogicfrom the service provider.Packaging:
Class Libraries: The
Core,Models, andServicescould be moved into separate Class Library projects (.csproj). The main CLI project would then reference these libraries. This is the first step towards NuGet packages.NuGet Packages: Once you have separate class libraries, you can pack them into NuGet packages using
dotnet pack. This is useful if these components are to be shared across multiple applications.
Error Handling: The current error handling is basic (console output). For a production tool, more robust logging (e.g., Serilog, NLog) and potentially custom exceptions would be better.
Configuration: More complex settings could be moved to a configuration file (e.g.,
appsettings.json).
This structure provides a solid foundation for building a robust and extensible CLI tool. You can now easily swap out or add new implementations for parsing, conversion, or file handling by creating new classes that implement the defined interfaces.
Last updated