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:Note: For C# 12 features,<Project> <PropertyGroup> <LangVersion>12.0</LangVersion> <EnablePreviewFeatures>True</EnablePreviewFeatures> <!-- If using preview APIs --> </PropertyGroup> </Project>
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 build
-
Run the tool:
-
Using defaults: (Assumes
data.jsonandNOTES.mdare in the current directory)dotnet run
This 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.
- You can create a new class
- Changing File Operations:
- If you want to store archives in Azure Blob Storage, you could create
AzureBlobStorageArchiver : IFileArchiver.
- If you want to store archives in Azure Blob Storage, you could create
- 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.
- Class Libraries: The
- 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.