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: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,7 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp

# Generated schema files
# Generated files
*.dbml
.al2dbml/
.al2dbml/config.local.json
31 changes: 20 additions & 11 deletions src/AL2DBML.CLI/Commands/GenerateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,58 @@ public class GenerateSettings : CommandSettings
{
[CommandOption("-i|--input <INPUT_PATH>")]
[Description("The path to the AL project folder, AL file, or vscode workspace file of AL projects to parse.")]
public string InputPath { get; init; } = ".";
public string? InputPath { get; init; }

[CommandOption("-o|--output <OUTPUT_PATH>")]
[Description("The path to the output directory.")]
public string OutputPath { get; init; } = ".";
public string? OutputPath { get; init; }

[CommandOption("-n|--name <OUTPUT_NAME>")]
[Description("The name of the output file (without extension). 'schema' by default.")]
public string OutputName { get; init; } = "schema";
[Description("The name of the output file (without extension).")]
public string? OutputName { get; init; }
}

public class GenerateCommand : AsyncCommand<GenerateSettings>
{
private readonly IAlParser _alParser;
private readonly IDBMLWriter _dbmlWriter;
private readonly IParsingTracker _tracker;
private readonly IConfigService _configService;

public GenerateCommand(IAlParser alParser, IDBMLWriter dbmlWriter, IParsingTracker tracker)
public GenerateCommand(IAlParser alParser, IDBMLWriter dbmlWriter, IParsingTracker tracker, IConfigService configService)
{
_alParser = alParser;
_dbmlWriter = dbmlWriter;
_tracker = tracker;
_configService = configService;
}

protected override async Task<int> ExecuteAsync(CommandContext context, GenerateSettings settings, CancellationToken cancellationToken)
{
var inputType = FileSystemService.GetInputType(settings.InputPath);
var sharedConfig = _configService.LoadSharedConfig();
var localConfig = _configService.LoadLocalConfig();

var inputPath = settings.InputPath ?? localConfig?.Input.Path ?? ".";
var outputPath = settings.OutputPath ?? sharedConfig?.Output.Path ?? ".";
var outputName = settings.OutputName ?? sharedConfig?.Output.Name ?? "schema";

var inputType = FileSystemService.GetInputType(inputPath);
if (inputType == Enums.InputType.NotSupported)
{
AnsiConsole.MarkupLine("[red]Error:[/] Unsupported input type. Please provide a valid directory, AL file, or vscode workspace file.");
return -1;
}

var outputPath = Path.Combine(settings.OutputPath, $"{settings.OutputName}.dbml");
Directory.CreateDirectory(settings.OutputPath);
var fullOutputPath = Path.Combine(outputPath, $"{outputName}.dbml");
Directory.CreateDirectory(outputPath);

var factory = new InputStrategyFactory(inputType, _alParser, _tracker);
var outputSchema = factory.Strategy.Execute(settings.InputPath);
var outputSchema = factory.Strategy.Execute(inputPath);

var dbmlContent = await _dbmlWriter.WriteDBMLAsync(outputSchema);
await File.WriteAllTextAsync(outputPath, dbmlContent, cancellationToken);
await File.WriteAllTextAsync(fullOutputPath, dbmlContent, cancellationToken);

AnsiConsole.MarkupLine($"[green]Done:[/] {_tracker.FileCount} file(s) parsed in {_tracker.Elapsed.TotalSeconds:F2}s → {Markup.Escape(outputPath)}");
AnsiConsole.MarkupLine($"[green]Done:[/] {_tracker.FileCount} file(s) parsed in {_tracker.Elapsed.TotalSeconds:F2}s → {Markup.Escape(fullOutputPath)}");

return 0;
}
Expand Down
118 changes: 118 additions & 0 deletions src/AL2DBML.CLI/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using AL2DBML.CLI.Models;
using AL2DBML.CLI.Services;
using Spectre.Console;
using Spectre.Console.Cli;

namespace AL2DBML.CLI.Commands;

public class InitSettings : CommandSettings
{
}

public class InitCommand : AsyncCommand<InitSettings>
{
private const string HookStartMarker = "# [al2dbml-start]";
private const string HookEndMarker = "# [al2dbml-end]";
private const string HookCommand = "al2dbml generate";
private const string GitignoreEntry = ".al2dbml/config.local.json";

private readonly IConfigService _configService;

public InitCommand(IConfigService configService)
{
_configService = configService;
}

protected override Task<int> ExecuteAsync(CommandContext context, InitSettings settings, CancellationToken cancellationToken)
{
var existingShared = _configService.LoadSharedConfig();
var existingLocal = _configService.LoadLocalConfig();

if (_configService.ConfigExists())
AnsiConsole.MarkupLine("[yellow]Existing config found — pre-filling with current values.[/]");

var inputPath = AnsiConsole.Prompt(
new TextPrompt<string>("Input path (you can point to a code-workspace file):")
.DefaultValue(existingLocal?.Input.Path ?? "."));

var outputPath = AnsiConsole.Prompt(
new TextPrompt<string>("Output path:")
.DefaultValue(existingShared?.Output.Path ?? "./docs/"));

var outputName = AnsiConsole.Prompt(
new TextPrompt<string>("Output file name (without extension):")
.DefaultValue(existingShared?.Output.Name ?? "schema"));

var hookExists = File.Exists(".git/hooks/pre-commit") &&
File.ReadAllText(".git/hooks/pre-commit").Contains(HookStartMarker, StringComparison.Ordinal);
var createHook = AnsiConsole.Confirm("Create a pre-commit hook?", hookExists);

_configService.SaveSharedConfig(new SharedConfig
{
Output = new OutputConfig { Path = outputPath, Name = outputName }
});
_configService.SaveLocalConfig(new LocalConfig
{
Input = new InputConfig { Path = inputPath }
});

EnsureGitignoreEntry(GitignoreEntry);

if (createHook)
WritePreCommitHook();

AnsiConsole.MarkupLine("[green]Done:[/] AL2DBML initialized.");
return Task.FromResult(0);
}

private static void EnsureGitignoreEntry(string entry)
{
const string gitignorePath = ".gitignore";
var lines = File.Exists(gitignorePath)
? File.ReadAllLines(gitignorePath).ToList()
: [];

if (lines.Contains(entry))
return;

lines.Add(entry);
File.WriteAllLines(gitignorePath, lines);
}

private static void WritePreCommitHook()
{
const string hookPath = ".git/hooks/pre-commit";

if (!Directory.Exists(".git/hooks"))
{
AnsiConsole.MarkupLine("[yellow]Warning:[/] .git/hooks directory not found — skipping pre-commit hook creation.");
return;
}
var hookSection = $"{HookStartMarker}\nif command -v al2dbml > /dev/null 2>&1; then\n {HookCommand} || printf \"\\033[33mWarning: al2dbml generate failed, skipping DBML update.\\033[0m\\n\"\nelse\n printf \"\\033[33mWarning: al2dbml not found, skipping DBML update.\\033[0m\\n\"\nfi\n{HookEndMarker}";

string content;
if (File.Exists(hookPath))
{
content = File.ReadAllText(hookPath);
var startIdx = content.IndexOf(HookStartMarker, StringComparison.Ordinal);
var endIdx = content.IndexOf(HookEndMarker, StringComparison.Ordinal);

if (startIdx >= 0 && endIdx >= 0)
content = content[..startIdx] + hookSection + content[(endIdx + HookEndMarker.Length)..];
else
content = content.TrimEnd() + $"\n\n{hookSection}\n";
}
else
{
content = $"#!/bin/sh\n\n{hookSection}\n";
}

File.WriteAllText(hookPath, content);

Comment on lines +82 to +111

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

WritePreCommitHook() writes to .git/hooks/pre-commit but doesn’t verify that the repository is a git repo or that .git/hooks exists. Running init outside a git checkout will throw DirectoryNotFoundException. Consider detecting .git/hooks (or .git) first and either skipping hook creation with a warning or creating the directory structure where appropriate.

Copilot uses AI. Check for mistakes.
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(hookPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
}
11 changes: 11 additions & 0 deletions src/AL2DBML.CLI/Models/LocalConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace AL2DBML.CLI.Models;

public class LocalConfig
{
public InputConfig Input { get; set; } = new();
}

public class InputConfig
{
public string Path { get; set; } = ".";
}
12 changes: 12 additions & 0 deletions src/AL2DBML.CLI/Models/SharedConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace AL2DBML.CLI.Models;

public class SharedConfig
{
public OutputConfig Output { get; set; } = new();
}

public class OutputConfig
{
public string Path { get; set; } = "./docs/";
public string Name { get; set; } = "schema";
}
5 changes: 4 additions & 1 deletion src/AL2DBML.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
services
.AddAL2Dbml()
.AddScoped<GenerateCommand>()
.AddScoped<IParsingTracker, ParsingTracker>();
.AddScoped<InitCommand>()
.AddScoped<IParsingTracker, ParsingTracker>()
.AddScoped<IConfigService, ConfigService>();

var registrar = new TypeRegistrar(services);

Expand All @@ -20,6 +22,7 @@
app.Configure(config =>
{
config.AddCommand<GenerateCommand>("generate");
config.AddCommand<InitCommand>("init");
});
Comment on lines 22 to 26

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

PR description still contains the placeholder closes #. The repo’s PR validation workflow requires a real closes/fixes/resolves #<issue> reference, so this will likely fail CI until it’s updated.

Copilot uses AI. Check for mistakes.

return await app.RunAsync(args);
Expand Down
44 changes: 44 additions & 0 deletions src/AL2DBML.CLI/Services/ConfigService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Text.Json;
using AL2DBML.CLI.Models;

namespace AL2DBML.CLI.Services;

public class ConfigService : IConfigService
{
private const string ConfigDir = ".al2dbml";
private const string SharedConfigFile = "config.json";
private const string LocalConfigFile = "config.local.json";

private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

public bool ConfigExists() => Directory.Exists(ConfigDir);

public SharedConfig? LoadSharedConfig() => Load<SharedConfig>(Path.Combine(ConfigDir, SharedConfigFile));

public LocalConfig? LoadLocalConfig() => Load<LocalConfig>(Path.Combine(ConfigDir, LocalConfigFile));

private static T? Load<T>(string path)
{
if (!File.Exists(path)) return default;
try
{
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), JsonOptions);
}
catch (Exception e) when (e is IOException or JsonException)
{
return default;
}
}

public void SaveSharedConfig(SharedConfig config)
{
Directory.CreateDirectory(ConfigDir);
File.WriteAllText(Path.Combine(ConfigDir, SharedConfigFile), JsonSerializer.Serialize(config, JsonOptions));
}

public void SaveLocalConfig(LocalConfig config)
{
Directory.CreateDirectory(ConfigDir);
File.WriteAllText(Path.Combine(ConfigDir, LocalConfigFile), JsonSerializer.Serialize(config, JsonOptions));
}
}
12 changes: 12 additions & 0 deletions src/AL2DBML.CLI/Services/IConfigService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using AL2DBML.CLI.Models;

namespace AL2DBML.CLI.Services;

public interface IConfigService
{
bool ConfigExists();
SharedConfig? LoadSharedConfig();
LocalConfig? LoadLocalConfig();
void SaveSharedConfig(SharedConfig config);
void SaveLocalConfig(LocalConfig config);
}
Loading