diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ea8de59 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.verified.txt text eol=lf working-tree-encoding=UTF-8 +*.verified.xml text eol=lf working-tree-encoding=UTF-8 +*.verified.json text eol=lf working-tree-encoding=UTF-8 +*.verified.bin binary \ No newline at end of file diff --git a/.gitignore b/.gitignore index f34ab01..2fe225e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ riderModule.iml /_ReSharper.Caches/ # Don't care about IDE specific things /.idea -*.DotSettings.user \ No newline at end of file +*.DotSettings.user +# Don't care about files created from the build script +output/ \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..6ef9afe --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,18 @@ + + + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PowershellModule.slnx b/PowershellModule.slnx new file mode 100644 index 0000000..2f6dd79 --- /dev/null +++ b/PowershellModule.slnx @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..a707719 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,2 @@ +dotnet run --project build/Build.csproj -- $args +exit $LASTEXITCODE; \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..dfd6b85 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +dotnet run --project ./build/Build.csproj -- "$@" diff --git a/build/Build.csproj b/build/Build.csproj new file mode 100644 index 0000000..0d0955f --- /dev/null +++ b/build/Build.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + $(MSBuildProjectDirectory) + + + + + + + + \ No newline at end of file diff --git a/build/BuildContext.cs b/build/BuildContext.cs new file mode 100644 index 0000000..a4c2588 --- /dev/null +++ b/build/BuildContext.cs @@ -0,0 +1,61 @@ +using Cake.Common.IO; +using Cake.Common.IO.Paths; +using Cake.Core; +using Cake.Core.IO; +using Cake.Frosting; + +namespace Build; + +public class BuildContext : FrostingContext +{ + /// + /// Base source directory + /// + public DirectoryPath BaseSourceLocation { get; set; } + + /// + /// Powershell module project folder + /// + public ConvertableDirectoryPath PowershellModuleProjectDirectory { get; set; } + + /// + /// Powershell module csproj location + /// + public ConvertableFilePath PowershellModuleCsproj { get; set; } + + /// + /// Output directory for built DLLs and powershell module manifest files + /// + public ConvertableDirectoryPath PowershellModuleOutputDir { get; set; } + + /// + /// Path to powershell script that creates the module manifest files + /// + public ConvertableFilePath CreateModuleManifestScript { get; set; } + + /// + /// Suffix to tag the build with, defaults to pre-release. + /// + /// Immediately follows the version number and before the commit hash. + /// + /// + public string BuildSuffix { get; set; } + + /// + /// Disable the commit hash from being added + /// + public bool DisableCommitHash { get; set; } + + public BuildContext(ICakeContext context) + : base(context) + { + BaseSourceLocation = context.Directory("../src").Path.MakeAbsolute(context.Environment); + PowershellModuleProjectDirectory = BaseSourceLocation + context.Directory("PowershellModule"); + PowershellModuleCsproj = PowershellModuleProjectDirectory + context.File("PowershellModule.csproj"); + PowershellModuleOutputDir = context.Directory("../") + context.Directory("output") + context.Directory("PowershellModule"); + var buildScriptDirectory = context.Directory("./Scripts"); + CreateModuleManifestScript = buildScriptDirectory + context.File("CreateModuleManifest.ps1"); + BuildSuffix = context.Configuration.GetValue(nameof(BuildSuffix)) ?? "pre-release"; + DisableCommitHash = context.Configuration.GetBoolValue(nameof(DisableCommitHash)); + } +} \ No newline at end of file diff --git a/build/Helpers.cs b/build/Helpers.cs new file mode 100644 index 0000000..a8f2bcb --- /dev/null +++ b/build/Helpers.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +namespace Build; + +public class Helpers +{ + /// + /// Returns a formatted version of the assembly that contains PowerShell cmdlets + /// + /// + /// + public static string GetPowershellModuleVersion(string powershelModuleOutputLocation) + { + // When the project name changes, this dll will also need to be updated + var moduleVersionInfo = FileVersionInfo.GetVersionInfo(Path.Combine(powershelModuleOutputLocation, "PowershellModule.dll")); + + // It's (almost) impossible to not have a product version tag here. There is a reason why the implementation + // returns a string? but I can't find it and I don't really care too much. If we have null return an empty string + // and append no version to the archive bundle. + if (moduleVersionInfo.ProductVersion == null) + { + return string.Empty; + } + + var versionRegex = new Regex(@"(\d+\.\d+\.\d+)(?:\-?([\w\-]+)\+?(\w+)?)?"); + var match = versionRegex.Match(moduleVersionInfo.ProductVersion); + if (match.Success) + { + // If we have 4 groups, we've got a version number, suffix, and commit hash, so we return the first 2 as is + // and the commit has capped to 8 characters + if (match.Groups.Count == 4) + { + return $"{match.Groups[1]}-{match.Groups[2]}+{match.Groups[3].Value.Substring(0, 8)}"; + } + + if (match.Groups.Count == 3) + { + return $"{match.Groups[1]}-{match.Groups[2]}"; + } + + return $"{match.Groups[1]}"; + } + + return string.Empty; + } +} \ No newline at end of file diff --git a/build/Program.cs b/build/Program.cs new file mode 100644 index 0000000..d94f389 --- /dev/null +++ b/build/Program.cs @@ -0,0 +1,13 @@ +using Cake.Frosting; + +namespace Build; + +public static class Program +{ + public static int Main(string[] args) + { + return new CakeHost() + .UseContext() + .Run(args); + } +} \ No newline at end of file diff --git a/build/Scripts/CreateModuleManifest.ps1 b/build/Scripts/CreateModuleManifest.ps1 new file mode 100644 index 0000000..d7dbd59 --- /dev/null +++ b/build/Scripts/CreateModuleManifest.ps1 @@ -0,0 +1,27 @@ +param ( + [string]$powershellModuleFileLocation, + [string]$guid, + [string]$author, + [string[]]$nestedModules, + [string]$rootModule, + [string[]]$cmdletsToExport, + [string]$manifestFileLocation, + # not used for anything yet, but I should probably simplify the module locations as they get the output dir + # created in CopyOutputTask.cs + [string]$outputDir +) + +$manifestSplat = @{ + Path = "$powershellModuleFileLocation" + GUID = "$guid" + Author = "$author" + NestedModules = @($nestedModules) + RootModule = "$rootModule" + CmdletsToExport = @($cmdletsToExport) + FunctionsToExport = @() + VariablesToExport = @() + AliasesToExport = @() +} + +New-ModuleManifest @manifestSplat +New-Item "$manifestFileLocation" -ItemType File -Value "# This file is run when Import-Module `"PowershellModule`" is called" \ No newline at end of file diff --git a/build/Tasks/BuildTask.cs b/build/Tasks/BuildTask.cs new file mode 100644 index 0000000..089ac75 --- /dev/null +++ b/build/Tasks/BuildTask.cs @@ -0,0 +1,49 @@ +using Cake.Common.Tools.DotNet; +using Cake.Common.Tools.DotNet.Build; +using Cake.Common.Tools.DotNet.MSBuild; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("Build")] +[IsDependentOn(typeof(CleanTask))] +[IsDependeeOf(typeof(CopyOutputTask))] +public class BuildTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.Log.Information($"Building: {context.PowershellModuleCsproj}"); + + var buildSettings = new DotNetBuildSettings() + { + MSBuildSettings = new DotNetMSBuildSettings(), + OutputDirectory = context.PowershellModuleOutputDir, + DiagnosticOutput = true, + }; + + // If a build suffix is provided, use it, otherwise whatever comes from Directory.Build.props will be used. + // It's not possible to set the actual version number for a build in this project as that is purely controlled + // from individual Directory.Build.props files + if (!string.IsNullOrEmpty(context.BuildSuffix)) + { + buildSettings.MSBuildSettings.VersionSuffix = context.BuildSuffix; + } + + // Disable SourceLink automatic commit hash addition. Documentation about how that package work is garbage and + // this property is only mentioned in breaking changes https://learn.microsoft.com/en-us/dotnet/core/compatibility/sdk/8.0/source-link + // which is pretty on par for shit like this. + if (context.DisableCommitHash) + { + buildSettings.MSBuildSettings.Properties.Add("IncludeSourceRevisionInInformationalVersion", ["false"]); + } + + // /p:DebugSymbols=false + buildSettings.MSBuildSettings.Properties.Add("DebugSymbols", ["false"]); + // /p:DebugType=None + buildSettings.MSBuildSettings.Properties.Add("DebugType", ["None"]); + + context.DotNetBuild(context.PowershellModuleCsproj, buildSettings); + base.Run(context); + } +} \ No newline at end of file diff --git a/build/Tasks/CleanTask.cs b/build/Tasks/CleanTask.cs new file mode 100644 index 0000000..7335f97 --- /dev/null +++ b/build/Tasks/CleanTask.cs @@ -0,0 +1,23 @@ +using Cake.Common; +using Cake.Common.Tools.DotNet; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("Clean")] +public class CleanTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.DotNetClean(context.PowershellModuleProjectDirectory); + + var outDir = context.FileSystem.GetDirectory(context.PowershellModuleOutputDir); + if (outDir.Exists) + { + outDir.Delete(true); + } + + base.Run(context); + } +} \ No newline at end of file diff --git a/build/Tasks/CopyOutputTask.cs b/build/Tasks/CopyOutputTask.cs new file mode 100644 index 0000000..1bcd241 --- /dev/null +++ b/build/Tasks/CopyOutputTask.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cake.Core.Diagnostics; +using Cake.Core.IO; +using Cake.Frosting; +using Cake.Powershell; + +namespace Build.Tasks; + +[TaskName("CopyOutput")] +[IsDependeeOf(typeof(CreateBundleArchiveTask))] +public class CopyOutputTask : FrostingTask +{ + public override void Run(BuildContext context) + { + var powershellModuleName = "PowershellModule"; + // TODO: [#10] Refactor file paths used in build project to be more explict and easier to understand + // Probably don't create full file locations when I can pass the output dir in and have the script make the path + var scriptParams = new + { + PowershellModuleFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psd1", + Guid = Guid.Parse("5cdf4635-edb0-428c-8d9b-92d0bcd47443"), + Author = "Me", + NestedModules = new[] { $"{powershellModuleName}.dll" }, + RootModule = $"{powershellModuleName}.psm1", + CmdletsToExport = GetExportedCmdlets(), + ManifestFileLocation = $"{context.PowershellModuleOutputDir}/{powershellModuleName}.psm1", + OutputLocation = context.PowershellModuleOutputDir, + }; + + var psSettings = new PowershellSettings() + { + Arguments = new ProcessArgumentBuilder(), + }; + + // This feels a bit ugly/redundant seeing as I've defined the scriptParams above, but I'm leaving it as is + // until I want to come back and clean this up properly + psSettings.Arguments.Append("powershellModuleFileLocation", ToPowershellSafeString(scriptParams.PowershellModuleFileLocation)); + psSettings.Arguments.Append("guid", ToPowershellSafeString(scriptParams.Guid.ToString())); + psSettings.Arguments.Append("author", ToPowershellSafeString(scriptParams.Author)); + psSettings.Arguments.Append("nestedModules", $"@({string.Join(",", scriptParams.NestedModules.Select(ToPowershellSafeString))})"); + psSettings.Arguments.Append("rootModule", ToPowershellSafeString(scriptParams.RootModule)); + psSettings.Arguments.Append("cmdletsToExport", $"@({string.Join(",", scriptParams.CmdletsToExport.Select(ToPowershellSafeString))})"); + psSettings.Arguments.Append("manifestFileLocation", ToPowershellSafeString(scriptParams.ManifestFileLocation)); + psSettings.Arguments.Append("outputDir", ToPowershellSafeString(scriptParams.OutputLocation)); + + context.StartPowershellFile(context.CreateModuleManifestScript, psSettings); + context.Log.Information($"Module files output to: {context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment)}"); + + base.Run(context); + } + + private static string ToPowershellSafeString(string unescapedString) => $"'{unescapedString}'"; + + private static List GetExportedCmdlets() + { + return ["Get-Calendar", .. GetGitRepoRegistrationVerbs()]; + } + + private static List GetGitRepoRegistrationVerbs() + { + // TODO: I should make some reference file for these verbs and name but that'd pull in powershell dependencies + // to the build and I'd like to avoid that. + // This isn't great but commands are unlikely to change that frequently + string[] verbs = ["Get", "New", "Remove", "Show"]; + var commandName = "GitRepoRegistration"; + return [.. verbs.Select(x => $"{x}-{commandName}")]; + } +} \ No newline at end of file diff --git a/build/Tasks/CreateBundleArchiveTask.cs b/build/Tasks/CreateBundleArchiveTask.cs new file mode 100644 index 0000000..52a2313 --- /dev/null +++ b/build/Tasks/CreateBundleArchiveTask.cs @@ -0,0 +1,136 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Text.RegularExpressions; +using Cake.Common.Build; +using Cake.Common.Tools.MSBuild; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("CreateBundleArchive")] +[IsDependentOn(typeof(CopyOutputTask))] +public class CreateBundleArchiveTask : FrostingTask +{ + public override void Run(BuildContext context) + { + context.Log.Information($"Creating bundle archive"); + BundleMinimalFiles(context); + base.Run(context); + } + + private static void BundleMinimalFiles(BuildContext context) + { + // We're effectively replicating PostBuild.ps1 with all of this as Remove-Item at the end of it does not remove files the same way. + // TODO: See if that's something I can fix + var powershellModuleBuildLocation = context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath; + // PowershellModule is here so that the script that adds to the users $Env:PSModulePath correctly resolves when using + // Import-Module "PowershellModule" + // TODO: Generate the script to add to the PSModulePath in the bundleOutputLocation directory + + // Top level bundle output location + var bundleRootLocation = Directory.CreateDirectory(Path.Combine(powershellModuleBuildLocation, "bundle")).FullName; + // Module folder location that will be zipped up and will contain any setup scripts/readme etc + var moduleFolderLocation = Directory.CreateDirectory(Path.Combine(bundleRootLocation, "NulahModule")).FullName; + // Location to put all the module dlls and other files + var powershelModuleOutputLocation = Directory.CreateDirectory(Path.Combine(moduleFolderLocation, "PowershellModule")).FullName; + + context.Log.Information($"Bundle location: {bundleRootLocation}"); + context.Log.Information($"Built file location: {powershellModuleBuildLocation}"); + + // PostBuild.ps1 for PowershellModule should have already been run at this point - it won't remove files, but it will copy the + // correct e_sqlite3.dll that we need. + // This code is all commented out because I'm not entirely satisfied with PostBuild.ps1 doing some things but + // this build doing slightly different. + // // Get the location for the specific version of e_sqlite3.dll we need - PowerShell binary modules resolve dependent dlls + // // from the executing assembly directory first, and the built output of the module includes a lot of other dlls that + // // are already available with the dotnet runtime so there's no need for us to include them. + // // I mean, we could, but we'd also be terrible software engineers if we couldn't do something as basic as reducing files we need. + // // TODO: Account for other runtimes such as linux based ones where the dotnet runtime might _not_ provide these files for free. + // // It'd require looking into how the module loads for PowerShell on those operating systems, and I might never + // // get around to doing that because I use windows (currently). + // // TODO: Update this later to handle other runtimes, for now we hardcode to win-x64 because it's what I use + // var nativeSqliteDllLocation = System.IO.Path.Combine(powershellModuleBuildLocation, "runtimes", "win-x64", "native", "e_sqlite3.dll"); + // + // if (!File.Exists(nativeSqliteDllLocation)) + // { + // context.Log.Error($"BUNDLE FAILED: Unable to locate ${nativeSqliteDllLocation}"); + // return; + // } + // + // context.Log.Information($"Copying '${nativeSqliteDllLocation}' to '${bundleOutputLocation}'"); + // + // File.Copy(nativeSqliteDllLocation, System.IO.Path.Combine(bundleOutputLocation, System.IO.Path.GetFileName(nativeSqliteDllLocation))); + + var moduleCoreFiles = Directory.GetFiles(powershellModuleBuildLocation, "ModuleCore*", searchOption: SearchOption.TopDirectoryOnly); + var powershellModuleFiles = Directory.GetFiles(powershellModuleBuildLocation, "PowershellModule*", searchOption: SearchOption.TopDirectoryOnly); + var sqliteFiles = Directory.GetFiles(powershellModuleBuildLocation, "*SQLite*", searchOption: SearchOption.TopDirectoryOnly); + + string[] allFiles = [.. moduleCoreFiles, .. powershellModuleFiles, .. sqliteFiles]; + + context.Log.Information($"Copying {allFiles.Length} files to the module folder {powershelModuleOutputLocation}"); + foreach (var file in allFiles) + { + context.Log.Information($"Copying {Path.GetFileName(file)}"); + File.Copy(file, Path.Combine(powershelModuleOutputLocation, Path.GetFileName(file))); + } + + context.Log.Information($"Bundle files copied"); + + GenerateModuleImportScript(moduleFolderLocation, context); + var moduleVersionForFilename = Helpers.GetPowershellModuleVersion(powershelModuleOutputLocation); + CreateBundleZip(bundleRootLocation, moduleFolderLocation, moduleVersionForFilename, context); + } + + private static void GenerateModuleImportScript(string bundleLocation, BuildContext context) + { + context.Log.Information($"Generating module import script"); + var scriptFileName = Path.Combine(bundleLocation, "NulahPowershell.ps1"); + + using var scriptFile = File.Create(scriptFileName); + using var fileWriter = new StreamWriter(scriptFile); + // TODO: Document the set up function + // TODO: Create a ticket for fleshing out the set up function (or just do that work later) + fileWriter.Write( + """ + # To use, first copy all files to the same location as your $profile under ./NulahModule, then open your powershell profile located at $profile and add the following: + + # Script setup style: Using the setup script to import as needed (this method will also set custom prompts and other alias functions) + # . "$PSScriptRoot/NulahModule/NulahPowershell.ps1" + # SetupNulahPowershell + # Just the module: Use the following to just import just the powershell module + # Import-Module -Name "$PSScriptRoot/NulahModule/PowershellModule" + + function SetupNulahPowershell + { + # only add to our module path once + # The intent for this is that a user will have copied this bundle folder alongside their $profile + # location, eg, they'll have a folder called NulahModule that will contain everything within the bundle zip + $testBundleDirectory = $PSScriptRoot + $bundleInPath = ($Env:PSModulePath -split ';').TrimEnd('\') -contains $testBundleDirectory; + + if ($false -eq $bundleInPath) + { + $env:PSModulePath = @( + $env:PSModulePath + $testBundleDirectory + ) -Join [System.IO.Path]::PathSeparator + } + + Import-Module "PowershellModule" + } + """ + ); + context.Log.Information($"Module import script created at '{scriptFileName}'"); + } + + private static void CreateBundleZip(string bundleLocation, string moduleFolderLocation, string version, BuildContext context) + { + var bundleZipFileLocaiton = Path.Combine(bundleLocation, $"NulahModule-{version}.zip"); + context.Log.Information($"Bundling module into archive '{bundleZipFileLocaiton}'"); + ZipFile.CreateFromDirectory(moduleFolderLocation, bundleZipFileLocaiton, CompressionLevel.Fastest, true); + context.Log.Information($"Archive created at '{bundleZipFileLocaiton}'"); + } +} \ No newline at end of file diff --git a/build/Tasks/DefaultTask.cs b/build/Tasks/DefaultTask.cs new file mode 100644 index 0000000..217c75c --- /dev/null +++ b/build/Tasks/DefaultTask.cs @@ -0,0 +1,19 @@ +using Cake.Core; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +// Consider this the "entry" point for builds, task order is defined by a chain of IsDependentOn +[TaskName("Default")] +[IsDependentOn(typeof(BuildTask))] +[IsDependentOn(typeof(TagCommitTask))] +[IsDependentOn(typeof(CopyOutputTask))] +[IsDependentOn(typeof(CreateBundleArchiveTask))] +public class DefaultTask : FrostingTask +{ + public override void Run(ICakeContext context) + { + base.Run(context); + } +} \ No newline at end of file diff --git a/build/Tasks/TagCommitTask.cs b/build/Tasks/TagCommitTask.cs new file mode 100644 index 0000000..35b53f8 --- /dev/null +++ b/build/Tasks/TagCommitTask.cs @@ -0,0 +1,54 @@ +using System; +using System.Diagnostics; +using Cake.Core.Diagnostics; +using Cake.Frosting; + +namespace Build.Tasks; + +[TaskName("TagCurrentCommitWithVersion")] +[IsDependentOn(typeof(BuildTask))] +public class TagCommitTask : FrostingTask +{ + public override void Run(BuildContext context) + { + // This task only really exists to create pre-release builds for pull requests. + // It'll tag the current commit with the same version that'll be used for the bundle archive filename. + // TODO: update build.ps1 in the solution root so it doesn't do this tagging task + TagCommit(context); + base.Run(context); + } + + private static void TagCommit(BuildContext context) + { + var moduleVersionForFilename = Helpers.GetPowershellModuleVersion(context.PowershellModuleOutputDir.Path.MakeAbsolute(context.Environment).FullPath); + + var ps = new ProcessStartInfo("git", + ["-C", context.BaseSourceLocation.FullPath, "tag", moduleVersionForFilename]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? + if (gitProcess is null) + { + throw new Exception("git failed to start") + { + Source = "git-process", + }; + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardError.EndOfStream) + { + throw new Exception($"Unable to tag: {gitProcess.StandardError.ReadToEnd().Trim()}. Either remove the existing tag, or commit your changes before running a build"); + } + + context.Log.Information($"Tagged commit with {moduleVersionForFilename}"); + } +} \ No newline at end of file diff --git a/docs/GitRepositoryRegistration.md b/docs/GitRepositoryRegistration.md new file mode 100644 index 0000000..935dd8e --- /dev/null +++ b/docs/GitRepositoryRegistration.md @@ -0,0 +1,133 @@ +# Git Repo Registration + +- all commands support `-debug` +- at its simplest level these commands allow you to register a git repo against a simple name, and provide the ability to quickly pushd to a location + +Most `*-GitRepoRegistration` commands are expected to be run within a folder that is contained within a git repo. The only exceptions to this are any verbs that list information or change locations such as `Get-`, `Show-`, `Push-`, `Pop-`. + +Any exceptions to behaviours are outlined within the relevant command section. + +> ### Case-Sensitivity +> For all commands, the `Name` parameter is treated as _case-sensitive_, so multiple registrations can exist with the same name but different casing and point to different git repo locations or the same location - acting as an alias in a sense. +> +> While this isn't explicitly supported functionality, we don't do anything to prevent you having multiple registrations for the same git repo. Why should we after all? This set of commandlets are designed to make git repos more organised so how you use it is up to you. + +## New-GitRepoRegistration + +`New-GitRepoRegistration [-Name string]` + +Creates a new registration for the current directory, optionally registering against the given name. If `-Name` is not given the folder for the root level of the git repo will be used. + +Name registrations are _not_ case-sensitive, so registrations can be made using different cases for the same location, and multiple registrations can exist for the git repository. + +If `New-GitRepoRegistration` is used in 2 repositories with the same name but different locations, the 2nd call will fail as a registration will already exist by name. + +```pwsh +# Default registration without a name within a folder in a git repo +PS D:/Repos/Project-a> New-GitRepoRegistration -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: No name given for registration, defaulting to git folder root. +DEBUG: Registered 'D:/Repos/Project-a' to name 'Project-a' + +# Named registration +PS D:/Repos/Project-a> New-GitRepoRegistration "test repo" -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Registered 'D:/Repos/Project-a' to name 'test repo' + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +If the current directory is not in a git repo, a registration already exists by name or default folder, or if any other issue occurs such as git not being available an error will be thrown. + +## Get-GitRepoRegistration + +`Get-GitRepoRegistration` + +Returns all currently registered git repositories, as well as their current branch. + +```pwsh +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +test-repo D:/Repos/Project-a main +Project-a D:/Repos/Project-a main +``` + +The current branch is cached for 15 minutes for performance reasons so it may not be up to date if a git repo has recently had its branch changed. + +## Show-GitRepoRegistration + +`Show-GitRepoRegistration -Name string [-NoStack|-NoPushLocation]` + +Changes your location to the location of the git repo registered by the given `-Name` and your original location will be preserved and can be returned to at any time via `Pop-Location`/`Popd`. By default, this command is identical to calling +`pushd [repository directory]`. + +Using `-NoStack`/`-NoPushLocation` will not push your current location onto the stack, and will behave the same as `cd [repository directory]`/`Set-Location [repository directory]`. + +```pwsh +PS C:/> Show-GitRepoRegistration Project-a +PS D:/Repos/Project-a> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a> cd ./build +PS D:/Repos/Project-a/Build> Get-Location -stack + +Path +---- +C:\ + +PS D:/Repos/Project-a/Build> popd +PS C:/> +``` + +## Remove-GitRepoRegistration + +`Remove-GitRepoRegistration [-Name string]` + +Removes a repo registration by the given name, or if no name is specified, attempts to remove the git repo registered by resolving the git repo. + +If `Name` is not provided then the command must be run in a location that is a git repo and the registration to remove will use the parent git repo folder name. If `Name` _is_ provided this command can be executed from any location. + +```pwsh +# Default registration without a name within a folder in a git repo +PS D:/Repos/Project-a> New-GitRepoRegistration + +# List registrations +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Project-a D:/Repos/Project-a main +Test-Repo D:/Repos/Project-a main + +# Remove a registration from within a git repo +PS D:/Repos/Project-a> Remove-GitRepoRegistration +# No output indicates successful removal + +PS D:/Repos/Project-a> Get-GitRepoRegistration + +Name Location CurrentBranch +---- -------- ------------- +Test-Repo D:/Repos/Project-a main + +# Duplicate calls error if there is no registration +PS D:/Repos/Project-a> Remove-GitRepoRegistration +Remove-GitRepoRegistration: No registration exists for 'Project-a'. + +# Debug output via named argument +PS D:/Repos/Project-a> Remove-GitRepoRegistration -name Test-Repo -debug +DEBUG: Checking if current directory is a git repository... +DEBUG: ...location is a git repo! +DEBUG: Removed Test-Repo. +``` \ No newline at end of file diff --git a/src/ModuleCore/Calendar/CalendarGenerator.cs b/src/ModuleCore/Calendar/CalendarGenerator.cs new file mode 100644 index 0000000..daa81ce --- /dev/null +++ b/src/ModuleCore/Calendar/CalendarGenerator.cs @@ -0,0 +1,261 @@ +using System.Globalization; +using System.Text; + +namespace ModuleCore.Calendar; + +public sealed class CalendarGenerator +{ + public const string DefaultMarkedOffSymbol = "x"; + public const DayOfWeek DefaultStartOfWeek = DayOfWeek.Monday; + + private readonly string _markedOffSymbol; + private readonly DayOfWeek _startOfWeek; + + /// + /// Holds configured day abbreviations, based on . + /// + /// Using english as the culture: Sunday = Sun, Monday = Mon and so on. This lookup also starts with + /// Sunday as the first day to match the enum starting with Sunday. + /// + /// + private readonly List _daysOfWeekLookup; + + /// + /// Used to work out how to pad columns for each day, based on the longest abbreviated day + /// + private readonly int _dayWidth; + + private readonly CultureInfo _cultureInfo; + + private readonly string _rolloverDaySymbol = ""; + + public CalendarGenerator(string? markedDaySymbol = null, DayOfWeek? startOfWeek = null) + { + _markedOffSymbol = markedDaySymbol ?? DefaultMarkedOffSymbol; + _startOfWeek = startOfWeek ?? DefaultStartOfWeek; + + // Generate localised day of week lookup table + _daysOfWeekLookup = new List(7); + _dayWidth = _markedOffSymbol.Length; + _cultureInfo = CultureInfo.CurrentCulture; + for (var i = 0; i < 7; i++) + { + var localisedDayAbbreviation = _cultureInfo.DateTimeFormat.GetAbbreviatedDayName((DayOfWeek)i); + + // For unicode languages (such as Japanese and Chinese), the length of a day will be reported as 1, + // but will display in a way that makes them look short or offset. + // eg: + // ┌───────────────────────────┐ + // │ 7月 │ + // ├───┬───┬───┬───┬───┬───┬───┤ + // │ 月 │ 火 │ 水 │ 木 │ 金 │ 土 │ 日 │ + // ├───┴───┴───┴───┴───┴───┴───┤ + // There's not really an easy fix I know of (yet) to resolve this (and I'm not really too bothered by it yet) + // so unfortunately it has to stay as a known bug as is currently + if (localisedDayAbbreviation.Length > _dayWidth) + { + _dayWidth = localisedDayAbbreviation.Length; + } + + _daysOfWeekLookup.Add(localisedDayAbbreviation); + } + + // Increase the width by 2 to include a space on either side + _dayWidth += 2; + } + + /// + /// Renders the configured calendar for the given datetime + /// + /// + /// Defaults to , used to display elapsed days + /// + public string Render(DateTime month, DateTime? todayOverride = null) + { + var sb = new StringBuilder(); + // Generate the header for the month/days of week + GenerateHeader(sb, month); + + var calendarForMonth = GenerateCalendarForMonth(month, _markedOffSymbol, _startOfWeek, _rolloverDaySymbol, todayOverride ?? DateTime.Now); + + for (var i = 0; i < calendarForMonth.WeeksInMonth; i++) + { + sb.Append("│"); + for (var j = 0; j < 7; j++) + { + var index = j + i * 7; + // We align days to the right of their box, 1 off from the border. + // Sure I could center them, but honestly this is better to read. + sb.Append(calendarForMonth.Calendar[index].PadLeft(_dayWidth - 1)); + sb.Append(" │"); + } + + sb.AppendLine(); + } + + // Cap the calendar off + GenerateSpacer(sb, "└", "─", "┴", "┘", true); + + return sb.ToString(); + } + + private void GenerateHeader(StringBuilder sb, DateTime month) + { + // Top border for the calendar, with padder and seperator the same as there's no columns immediately + // under it + GenerateSpacer(sb, "┌", "─", "─", "┐"); + + var monthString = month.ToString("MMMM yyyy", _cultureInfo); + // total width of the calendar is width of the localised day calculated in the constructor, plus 7 extra + // to account for separators for each day. + // It shouldn't need to be said that the 7 here stands for days in a week + var calendarWidth = _dayWidth * 7 + 7; + // Calculate the offsets for + var offsets = ( + left: Math.Floor(calendarWidth / 2.0 + monthString.Length / 2.0), + right: Math.Ceiling(calendarWidth / 2.0 - monthString.Length / 2.0) + ); + + sb.Append("│") + .Append(monthString.PadLeft((int)offsets.left)) + .AppendLine("│".PadLeft((int)offsets.right)); + + // Top border for the week + GenerateSpacer(sb, "├", "─", "┬", "┤"); + + // Work out the week display based on start of week + var d = 0; + do + { + // Index into the day of week, wrapping if needed from the configured start of the week. + // The DayOfWeek enum starts with Sunday, so by default the start of the week is 0, + // But most calendars visually start with Monday as the start of the week which is our default start + // of week. + // We also pad the string to the width of the day (minus 2 for spaces either side of the day), if + // the marked day symbol is a long string. + // Why did I decide to support arbitrary length marked days? Why not? + sb.Append($"│ {_daysOfWeekLookup[(d + (int)_startOfWeek) % 7].PadLeft(_dayWidth - 2)} "); + d++; + } while (d < 7); + + // End the week block + sb.AppendLine("│"); + // bottom border + GenerateSpacer(sb, "├", "─", "┼", "┤"); + } + + private void GenerateSpacer(StringBuilder sb, string leftCap, string padder, string seperator, string rightCap, bool noNewLine = false) + { + sb.Append(leftCap); + for (var i = 0; i < 7; i++) + { + for (var j = 0; j < _dayWidth; j++) + { + sb.Append(padder); + } + + if (i < 6) + { + sb.Append(seperator); + } + } + + if (noNewLine) + { + sb.Append(rightCap); + } + else + { + sb.AppendLine(rightCap); + } + } + + private MonthCalendar GenerateCalendarForMonth(DateTime month, string markedOffSymbol, DayOfWeek calendarStartOfWeek, string rolloverDaySymbol, DateTime today) + { + // TODO: use startOfWeek to offset the start of the calendar later + // TODO: I think I meant the calendar name header? + + // Reset the month to the start + var startOfMonth = new DateTime(month.Year, month.Month, 1); + // If the visual start of the month is before the actual start day of the month, we need to visually shift the + // calendar "back" a week, and pad appropriately. + // Eg, if the start of the month is Sunday, but our visual start of the week is a Monday, then we need to + // make a new week to display Monday as the start, and then pad blank days to Sunday. + // This could probably be calculated better to avoid annoying to read logic but I'm drunk at this moment so the + // brain ain't there. + // For what its worth, I wasn't drunk for the rest of the code in this file, as hard as that is to believe! + var daysToPadToStartMonth = startOfMonth.DayOfWeek >= calendarStartOfWeek + ? startOfMonth.DayOfWeek - calendarStartOfWeek + : 7 - (calendarStartOfWeek - startOfMonth.DayOfWeek); + + var daysInMonth = DateTime.DaysInMonth(month.Year, month.Month); + // TODO: a lot of this could probably be simplified by simply taking the start day of the week, and then + // based on where it would be in the first week, work out if the month is 4, 5 or 6 weeks from that. + // all this needs is a pre-calculated table and that's it. + // Weeks have 7 days so there's only a fixed permutation of weeks based on the day of the week a month + // starts on, and months have anywhere from 28 to 31 days + // Increase the days by the previous months + var paddedDaysInMonth = daysInMonth + daysToPadToStartMonth; + // How many days to add to the end to make the absolute number of days (including any + // days that would visually roll over on a calendar) + // eg, if there are 2 "roll over" days from the previous month and this month has 31 days, + // Work out how many days we have left from 7, then get the _final_ number of days to make it a multiple + // _of_ 7. + // So this results in (33%7) == 5 - 7 == 2 + 33 == 35 % 7 == 0 which means we have a "complete" month + // including next month roll over. + // Visually we won't be showing those roll over days but it makes things easier if I decide to later. + // If the days in the month plus padded days is already a multiple of 7, we don't need to add any more days. + var daysToPadToEndMonth = paddedDaysInMonth % 7 != 0 + ? 7 - paddedDaysInMonth % 7 + : 0; + paddedDaysInMonth += daysToPadToEndMonth; + // Round to the nearest week after padding for the previous months days. + // Naively allowing a loss of precision via int division because the paddedDaysInMonth should always + // be a multiple of 7. + // "should always" is going to be found not as true later + var weeksInMonth = paddedDaysInMonth / 7; + + var cal = new List(); + for (var i = 0; i < daysToPadToStartMonth; i++) + { + cal.Add(rolloverDaySymbol); + } + + // If we're in the same year and month, any days already elapsed should be marked off, + // otherwise we display all days. + // If the DateTime we're rendering is in the past we render all days as marked off + // TODO: maybe don't do that and display the calendar differently + var hideElapsedDays = today.Year == month.Year && today.Month == month.Month; + + for (var i = 1; i < daysInMonth + 1; i++) + { + // if we're marking off elapsed days and we're not today or ahead, 'mark' it off + if (hideElapsedDays && i < today.Day) + { + cal.Add(markedOffSymbol); + } + else + { + cal.Add($"{i}"); + } + } + + // pad the rollover days at the end of the calendar + for (var i = 0; i < daysToPadToEndMonth; i++) + { + cal.Add(rolloverDaySymbol); + } + + return new MonthCalendar + { + Calendar = cal, + WeeksInMonth = weeksInMonth + }; + } +} + +public class MonthCalendar +{ + public List Calendar { get; set; } = []; + public int WeeksInMonth { get; set; } +} \ No newline at end of file diff --git a/src/ModuleCore/Database/DatabaseManager.cs b/src/ModuleCore/Database/DatabaseManager.cs new file mode 100644 index 0000000..f4e8afe --- /dev/null +++ b/src/ModuleCore/Database/DatabaseManager.cs @@ -0,0 +1,86 @@ +using SQLite; + +namespace ModuleCore.Database; + +public class DatabaseManager +{ + private readonly FileInfo _databaseLocation; + + /// + /// Creates a new manager for the given database file by name + /// + /// + /// Filename for the database with no extension. Slashes are accepted and will create directories as needed. + /// + public DatabaseManager(string databaseName) + { + // Even though the xmldoc says "with no extension", we strip off any extension regardless + _databaseLocation = new FileInfo(Path.Combine(".", "data", $"{SanitiseFilename(databaseName)}.db")); + + Directory.CreateDirectory(_databaseLocation.DirectoryName!); + + if (!File.Exists(_databaseLocation.FullName)) + { + var file = File.Create(_databaseLocation.FullName); + file.Close(); + } + } + + /// + /// Runs the given action within a new database connection + /// + /// + public void InConnection(Action dbAction) + { + using var conn = new SQLiteConnection(_databaseLocation.FullName); + dbAction(conn); + } + + /// + /// Runs the given func in a new database connection, returning the result + /// + /// + /// + /// + public T InConnection(Func dbAction) + { + using var conn = new SQLiteConnection(_databaseLocation.FullName); + return dbAction(conn); + } + + /// + /// Returns a bool for the given query. Convenience method for . + /// + /// A query starting with SELECT 1, optionally paramaterised with ? + /// Parameter values + /// + public bool Exists(string query, params object[] args) + { + using var conn = new SQLiteConnection(_databaseLocation.FullName); + var exists = conn.ExecuteScalar(query, args); + return exists ?? false; + } + + /// + /// Deletes the current database file. This will cause any future instance methods to fail on database action if + /// a new instance is not created. + /// + /// This method should be avoided unless calling from a test. + /// + /// + internal void DeleteDatabase() + { + _databaseLocation.Delete(); + } + + /// + /// Removes double dots from the filename and removes the file extension + /// + /// + /// + private string SanitiseFilename(string filename) + { + // Honestly not really needed seeing as its just me and this isn't coming from user supplied code, but eh. + return Path.GetFileNameWithoutExtension(filename.Replace("..", string.Empty)); + } +} \ No newline at end of file diff --git a/src/ModuleCore/Directory.Build.props b/src/ModuleCore/Directory.Build.props new file mode 100644 index 0000000..dd32fa3 --- /dev/null +++ b/src/ModuleCore/Directory.Build.props @@ -0,0 +1,6 @@ + + + 0.0.1 + dev + + \ No newline at end of file diff --git a/src/ModuleCore/Git/GitRepoRegistrationManager.cs b/src/ModuleCore/Git/GitRepoRegistrationManager.cs new file mode 100644 index 0000000..98cab87 --- /dev/null +++ b/src/ModuleCore/Git/GitRepoRegistrationManager.cs @@ -0,0 +1,393 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using ModuleCore.Database; +using ModuleCore.Git.Models; +using SQLite; + +namespace ModuleCore.Git; + +/// +/// Manages git repo registration, including creating any registration persistence via a backing +/// +public class GitRepoRegistrationManager +{ + private static readonly Lazy GitManagerInstance = new(() => new GitRepoRegistrationManager()); + private static Action? _debugWriterDelegate; + private readonly DatabaseManager _db; + private readonly ConcurrentDictionary _registrations; + + private GitRepoRegistrationManager(string? databaseName = null) + { + _registrations = new ConcurrentDictionary(); + // Regular usage of this constructor will never pass a database name in. Currently only tests should be hitting + // a code path that has a different database name + _db = new DatabaseManager(databaseName ?? "git.db"); + + InitialiseRegistrations(); + } + + /// + /// Returns the current instance. If no instance has been created, returns a new instance + /// and then the same instance every call after. + /// + public static GitRepoRegistrationManager Instance => GitManagerInstance.Value; + + /// + /// Always returns a new clean instance of GitManager + /// + internal static GitRepoRegistrationManager InternalFreshInstance(string databaseName) => new(databaseName); + + /// + /// Deletes the underlying database file. + /// + /// Avoid calling this outside of tests. + /// + /// + internal void DeleteDatabase() => _db.DeleteDatabase(); + + /// + /// Creates up any database tables and loads all previously saved git registrations. + /// + private void InitialiseRegistrations() + { + _debugWriterDelegate?.Invoke("Initialising GitManager from first run - this should only happen once."); + + _db.InConnection(conn => + { + var createTableResult = conn.CreateTable(); + + if (createTableResult == CreateTableResult.Created) + { + _debugWriterDelegate?.Invoke($"Created table {InternalGitRegistration.TableName}."); + } + }); + + _debugWriterDelegate?.Invoke("Loading previous registrations from database."); + + var registrations = _db.InConnection>(conn => + conn.Table() + .ToList() + ); + + foreach (var internalGitRegistration in registrations) + { + _debugWriterDelegate?.Invoke($"Loading {internalGitRegistration.Name} ({internalGitRegistration.Id}) from database..."); + if (!_registrations.TryAdd(internalGitRegistration.Name, internalGitRegistration)) + { + _debugWriterDelegate?.Invoke("...failed to restore - potential duplicate name."); + } + } + } + + /// + /// Registers a git repository based on an absolute location. If is null or empty, + /// the registration will use the folder name for the git repo at the top level. + /// + /// + /// + /// The normalised string the repository was registered against + public string RegisterRepo(string absoluteRepositoryLocation, string registrationName) + { + registrationName = string.IsNullOrWhiteSpace(registrationName) + ? new DirectoryInfo(absoluteRepositoryLocation).Name + : registrationName; + + var gitRegistration = new InternalGitRegistration + { + Name = registrationName, + Location = absoluteRepositoryLocation, + Id = Guid.CreateVersion7(), + }; + + return _db.InConnection(conn => + { + // Query if we already have a registration either by name. Previously we also checked by location, but I + // decided to stick with constraining to the name only, same as the key used for the dictionary. + // tbh this is a bit of a janky way to do exists when I have to pass the query in anyway, but I just didn't + // want to do null checks and a truthy check so I wrap it in a barely-valuable method. + var registrationExists = _db.Exists( + $""" + SELECT 1 + FROM {InternalGitRegistration.TableName} + WHERE Name = ? + """, + gitRegistration.Name + ); + + if (registrationExists) + { + throw new Exception($"A Git repo is already registered with the name {registrationName}."); + } + + // Insert the new record + conn.Insert(gitRegistration); + + if (_registrations.TryAdd(registrationName, gitRegistration)) + { + _debugWriterDelegate?.Invoke($"Registered '{gitRegistration.Location}' to name '{registrationName}'"); + return registrationName; + } + + // This error case should be unlikely, but if a registration was removed but the registrations wasn't updated + // correctly then we'd unable to re-add a repo with the same name + throw new Exception("An error occured during registration."); + }); + } + + /// + /// Unregisters a git repo registration by name. If no registration exists an exception will be thrown. + /// + /// + /// + public void UnregisterRepo(string registrationName) + { + _db.InConnection(conn => + { + var existingRegistration = conn.Query( + $""" + SELECT {nameof(InternalGitRegistration.Id)} + ,{nameof(InternalGitRegistration.Name)} + ,{nameof(InternalGitRegistration.Location)} + FROM {InternalGitRegistration.TableName} + WHERE {nameof(InternalGitRegistration.Name)} = ? + """, + registrationName) + .FirstOrDefault(); + + if (existingRegistration is null) + { + throw new Exception($"No registration exists for '{registrationName}'.") + { + Source = "unregister-repository", + }; + } + + var deleted = conn.Delete(existingRegistration.Id); + + // If we somehow found a registration but delete returned nothing, just return and assume we've already + // removed it from registrations. + // Seems a bit risky when you read it logically, but by this point the registration shouldn't exist so it doesn't + // matter. + if (deleted == 0) + { + return; + } + + // Remove by the name we get from the database instead of what was passed in + if (_registrations.TryRemove(registrationName, out var removedItem)) + { + _debugWriterDelegate?.Invoke($"Removed {registrationName}."); + return; + } + + // Weird error to throw, but by this stage we shouldn't have a git repo registered under this name + throw new Exception("Failed to remove registration - no registration exists.") + { + Source = "unregister-repository", + }; + }); + } + + /// + /// Returns all currently registered git repositories, including additional information such as the git repositories + /// current branch. + /// + /// + public List ListRepos() + { + return _registrations.Select(x => + new GitRegistration + { + Name = x.Value.Name, + Location = x.Value.Location, + CurrentBranch = x.Value.CurrentBranch, + } + ) + .ToList(); + } + + /// + /// Returns the file location for a git repo registration by name. + /// + /// + /// + /// + public string GetDirectoryForRegisteredRepo(string? registeredName) + { + if (string.IsNullOrEmpty(registeredName)) + { + throw new Exception("Name cannot be null"); + } + + if (_registrations.TryGetValue(registeredName, out var registration)) + { + return registration.Location; + } + + throw new Exception($"No git repo has been registered with the name {registeredName}"); + } + + /// + /// Returns the git root directory for any nested directory if git rev-parse --show-toplevel returns a value. + /// + /// Will always return a non-null value if the directory is a git repo, otherwise an exception will be thrown + /// + /// + /// Path to check if it or any of its parents contain a git repository + /// + /// + /// Git fails to start, returns an error (ie: the directory is not in a git repo), or the git process does not return + /// any output or error. + /// + public static ParsedGitFolderDetails IsGitRepo(string path) + { + _debugWriterDelegate?.Invoke("Checking if current directory is a git repository..."); + + var ps = new ProcessStartInfo("git", + ["-C", path, "rev-parse", "--show-toplevel"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? Not really sure of the conditions where the process could be started + // but return null, but I'm going to consider that unrecoverable error territory + if (gitProcess is null) + { + throw new Exception("git failed to start") + { + Source = "git-process", + }; + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardOutput.EndOfStream) + { + var directory = gitProcess.StandardOutput.ReadToEnd(); + // Gotta trim what we get as it might already have a newline character at the end + var dirInfo = new DirectoryInfo(directory.Trim()); + + _debugWriterDelegate?.Invoke("...location is a git repo!"); + + var repoFolderInfo = new ParsedGitFolderDetails + { + Directory = dirInfo.FullName, + Folder = dirInfo.Name, + }; + + return repoFolderInfo; + } + + if (!gitProcess.StandardError.EndOfStream) + { + throw new Exception(gitProcess.StandardError.ReadToEnd()) + { + Source = "git-not-found", + }; + } + + throw new Exception("Unable to determine if directory is repository: git command returned no output or errors.") + { + Source = "git-parse-failed", + }; + } + + /// + /// Registers an output for debug output. should be called as soon as the need for output + /// is no longer needed. + /// + /// + public static void SetDebugWriter(Action commandRuntime) + { + _debugWriterDelegate = commandRuntime; + } + + /// + /// Clears any output previously registered with + /// + public static void ClearDebugWriter() + { + _debugWriterDelegate = null; + } + + /// + /// Used for internal git registration and handles getting the current branch + /// + [Table(TableName)] + private class InternalGitRegistration + { + internal const string TableName = "GitRegistration"; + private string _currentBranch = string.Empty; + private long _nextCheckTime; + + [PrimaryKey] + public Guid Id { get; set; } + + [Indexed(Unique = true)] + public string Name { get; set; } = null!; + + public string Location { get; set; } = null!; + + /// + /// The current branch of the repository. This value is cached for 15 minutes after which it becomes stale and + /// will be refreshed on the next call to this property. + /// + public string CurrentBranch => GetCurrentBranch(); + + // TODO: [#13] Create GitManager to centralise calls to git process + private string GetCurrentBranch() + { + var now = DateTime.Now; + // git branch should be quick enough that even with a large number of registrations this shouldn't be that slow + // when doing Get-GitRepo, but regardless we still only get the current branch via git if it's been some amount + // of time since the last time we did. + if (now.Ticks < _nextCheckTime) + { + return _currentBranch; + } + + // use -C for the git command so we don't need to set the working directory and the git command can be run + // from anywhere against the appropriate location + var ps = new ProcessStartInfo("git", + ["-C", Location, "branch", "--show-current"]) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + // If the user doesn't have git on their path, this will throw an exception that I don't have to do anything + // special with, it'll be unhandled and powershell will handle it + var gitProcess = Process.Start(ps); + + // This probably shouldn't be possible? Not really sure of the conditions where the process could be started + // but return null, but I'm going to consider that unrecoverable error territory + if (gitProcess is null) + { + throw new Exception("git failed to start"); + } + + gitProcess.WaitForExit(); + + if (!gitProcess.StandardOutput.EndOfStream) + { + _currentBranch = gitProcess.StandardOutput.ReadToEnd(); + } + + if (!gitProcess.StandardError.EndOfStream) + { + _currentBranch = gitProcess.StandardError.ReadToEnd(); + } + + // Set the next check to be in the future so we don't hold up any list commands every time. + _nextCheckTime = now.AddMinutes(15).Ticks; + + // The branch name could (will) have a newline character at the end, so we trim that off + return _currentBranch.Trim(); + } + } +} \ No newline at end of file diff --git a/src/ModuleCore/Git/Models/GitRegistration.cs b/src/ModuleCore/Git/Models/GitRegistration.cs new file mode 100644 index 0000000..8c38d03 --- /dev/null +++ b/src/ModuleCore/Git/Models/GitRegistration.cs @@ -0,0 +1,21 @@ +namespace ModuleCore.Git.Models; + +/// +/// Details for a registered git repo +/// +public class GitRegistration +{ + /// + /// Display name for the registration. Can either be the name of the repo retrieved from git, or a user supplied + /// alias + /// + public required string Name { get; set; } + /// + /// Location on disk for the top level of the registered git repo. Not guaranteed to exist on disk + /// + public required string Location { get; set; } + /// + /// Current branch for the git repo + /// + public required string CurrentBranch { get; set; } +} \ No newline at end of file diff --git a/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs b/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs new file mode 100644 index 0000000..a785be0 --- /dev/null +++ b/src/ModuleCore/Git/Models/ParsedGitFolderDetails.cs @@ -0,0 +1,17 @@ +namespace ModuleCore.Git.Models; + +/// +/// The directory details of the directory returned from git rev-parse --show-toplevel +/// +public class ParsedGitFolderDetails +{ + /// + /// The full path to the top level folder containing a git repository + /// + public string Directory { get; init; } = null!; + + /// + /// The last folder name of the directory + /// + public string Folder { get; init; } = null!; +} \ No newline at end of file diff --git a/src/ModuleCore/ModuleCore.csproj b/src/ModuleCore/ModuleCore.csproj new file mode 100644 index 0000000..dd723a8 --- /dev/null +++ b/src/ModuleCore/ModuleCore.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + latestmajor + + + + + <_Parameter1>ModuleTests + + + + + + + + diff --git a/src/ModuleHarness/ModuleHarness.csproj b/src/ModuleHarness/ModuleHarness.csproj new file mode 100644 index 0000000..9fe2c2b --- /dev/null +++ b/src/ModuleHarness/ModuleHarness.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + latestmajor + + + + + + + diff --git a/src/ModuleHarness/Program.cs b/src/ModuleHarness/Program.cs new file mode 100644 index 0000000..d314f9d --- /dev/null +++ b/src/ModuleHarness/Program.cs @@ -0,0 +1,20 @@ +using System.Text; +using ModuleCore.Calendar; + +namespace ModuleHarness; + +class Program +{ + static void Main(string[] args) + { + Console.OutputEncoding = Encoding.Unicode; + var a = new CalendarGenerator(); + var lastMonth = a.Render(DateTime.Now.AddMonths(-1)); + var now = a.Render(DateTime.Now); + var august = a.Render(DateTime.Now.AddMonths(1)); + + Console.Write(lastMonth); + Console.Write(now); + Console.Write(august); + } +} \ No newline at end of file diff --git a/src/PowershellHarness/CustomHost.cs b/src/PowershellHarness/CustomHost.cs new file mode 100644 index 0000000..dcc81ac --- /dev/null +++ b/src/PowershellHarness/CustomHost.cs @@ -0,0 +1,236 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; +using System.Text; + +namespace PowershellHarness; + +// A whole bunch of empty implementations just so cmdlets have access to anything within Host.Ui (and probably any cmdlets +// that prompt for information later) +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSHost.cs#L13 +public class CustomHost : PSHost +{ + public override string Name => "Custom Host"; + public override Version Version { get; } = new Version(0, 0, 0, 0); + public override Guid InstanceId { get; } = Guid.NewGuid(); + + private CustomUiHost _ui = new CustomUiHost(); + public override CustomUiHost UI => _ui; + + public override CultureInfo CurrentCulture { get; } = CultureInfo.CurrentCulture; + public override CultureInfo CurrentUICulture { get; } = CultureInfo.CurrentUICulture; + + public CustomHost(int width, int height = 100) + { + _ui.RawUI.WindowSize = new() { Width = width, Height = height }; + } + + public override void EnterNestedPrompt() + { + throw new NotImplementedException("EnterNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void ExitNestedPrompt() + { + throw new NotImplementedException("ExitNestedPrompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void NotifyBeginApplication() + { + } + + public override void NotifyEndApplication() + { + } + + public override void SetShouldExit(int exitCode) + { + } +} + +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/d1b498d874948f41e6dc053204c511fa6fc11c9c/OffensivePowerShellTasking/CustomPSHostUserInterface.cs#L8 +public class CustomUiHost : PSHostUserInterface +{ + // The only stuff that really matters to expose a host for any cmdlets + private readonly CustomRawUiHost _rawUI = new CustomRawUiHost(); + public override CustomRawUiHost RawUI => _rawUI; + + // Replace StringBuilder with whatever your preferred output method is (e.g. a socket or a named pipe) + public StringBuilder output { get; set; } + + public CustomUiHost() + { + output = new StringBuilder(); + } + + public CustomUiHost(ref StringBuilder sb) + { + output = sb; + } + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + //output.Append("!").Append(value); + } + + public override void WriteLine() + { + //output.Append("!").Append("\n"); + } + + public override void WriteLine(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + //output.Append("!").Append(value + "\n"); + } + + public override void Write(string value) + { + //output.Append("!").Append(value); + } + + public override void WriteDebugLine(string message) + { + //output.Append("!").AppendLine("DEBUG: " + message); + } + + public override void WriteErrorLine(string value) + { + //output.Append("!").AppendLine("ERROR: " + value); + } + + public override void WriteLine(string value) + { + //output.Append("!").AppendLine(value); + } + + public override void WriteVerboseLine(string message) + { + //output.Append("!").AppendLine("VERBOSE: " + message); + } + + public override void WriteWarningLine(string message) + { + //output.Append("!").AppendLine("WARNING: " + message); + } + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + } + + public string Output => output.ToString(); + + private int? _nextChoiceOption; + + /// + /// Sets the next choice option to be used on the next call to . + /// + /// You'll probably get the + /// + /// + public void SetNextPromptChoice(int choiceOption) + { + _nextChoiceOption = choiceOption; + } + + public override Dictionary Prompt(string caption, string message, System.Collections.ObjectModel.Collection descriptions) + { + throw new NotImplementedException("Prompt is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection choices, int defaultChoice) + { + if (_nextChoiceOption is null) + { + throw new NotImplementedException("PromptForChoice is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + var choiceReturn = _nextChoiceOption.Value; + _nextChoiceOption = null; + return choiceReturn; + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) + { + throw new NotImplementedException("PromptForCredential1 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) + { + throw new NotImplementedException("PromptForCredential2 is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override string ReadLine() + { + throw new NotImplementedException("ReadLine is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override SecureString ReadLineAsSecureString() + { + throw new NotImplementedException("ReadLineAsSecureString is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } +} + +// https://github.com/leechristensen/OffensivePowerShellTasking/blob/master/OffensivePowerShellTasking/CustomPSRHostRawUserInterface.cs#L8 +public class CustomRawUiHost : PSHostRawUserInterface +{ + public override ConsoleColor BackgroundColor { get; set; } = ConsoleColor.Black; + + public override Size BufferSize { get; set; } = new Size { Width = 100, Height = 1000 }; + + public override Coordinates CursorPosition { get; set; } = new Coordinates { X = 0, Y = 0 }; + + public override int CursorSize { get; set; } = 1; + + public override void FlushInputBuffer() + { + throw new NotImplementedException("FlushInputBuffer is not implemented."); + } + + public override ConsoleColor ForegroundColor { get; set; } = ConsoleColor.White; + + public override BufferCell[,] GetBufferContents(Rectangle rectangle) + { + throw new NotImplementedException("GetBufferContents is not implemented."); + } + + public override bool KeyAvailable + { + get { throw new NotImplementedException("KeyAvailable is not implemented."); } + } + + public override Size MaxPhysicalWindowSize { get; } = new Size + { + Width = int.MaxValue, + Height = int.MaxValue + }; + + public override Size MaxWindowSize { get; } = new Size { Width = 100, Height = 100 }; + + public override KeyInfo ReadKey(ReadKeyOptions options) + { + throw new NotImplementedException("ReadKey is not implemented. The script is asking for input, which is a problem since there's no console. Make sure the script can execute without prompting the user for input."); + } + + public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) + { + throw new NotImplementedException("ScrollBufferContents is not implemented"); + } + + public override void SetBufferContents(Rectangle rectangle, BufferCell fill) + { + throw new NotImplementedException("SetBufferContents is not implemented."); + } + + public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) + { + throw new NotImplementedException("SetBufferContents is not implemented"); + } + + public override Coordinates WindowPosition { get; set; } = new Coordinates { X = 0, Y = 0 }; + + public override Size WindowSize { get; set; } = new Size { Width = 120, Height = 100 }; + + public override string WindowTitle { get; set; } = ""; +} \ No newline at end of file diff --git a/src/PowershellHarness/PowershellHarness.csproj b/src/PowershellHarness/PowershellHarness.csproj new file mode 100644 index 0000000..80cdb76 --- /dev/null +++ b/src/PowershellHarness/PowershellHarness.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/PowershellHarness/Program.cs b/src/PowershellHarness/Program.cs new file mode 100644 index 0000000..4bbd0be --- /dev/null +++ b/src/PowershellHarness/Program.cs @@ -0,0 +1,188 @@ +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; +using PowershellModule.Calendar; +using PowershellModule.Git; + +namespace PowershellHarness; + +// https://github.com/FuseCP/FuseCP/blob/278a19dc06949600f25a1b4ed74d0419a8fa3fc2/FuseCP/Sources/FuseCP.Providers.HostedSolution.SfB2015/SfBBase.cs#L224 +// some useful code in here tbh +class Program +{ + static void Main(string[] args) + { + // I've gotta work out how to get the rider terminal to act closer to powershell because .PadLeft in powershell + // will correctly output lines when output with WriteOutput, but the Jetbrains debug worker (or whatever + // it is that rider spawns), has none of that and operates in its own world. + // Which is fine if you never want new lines. + // Ideally you'd just launch pwsh.exe and attach to that, but _that_ has its own issues as well. + // The new line issue might be related to how I've set up the host seeing as I just did a bunch of copy paste shit + // from something I found on github to get it across the line so I could debug it. + // I'll eventually revisit those hosts and rewrite them properly once I start adding more commands, and if I care. + // Threads & variables has all the visuals I need after all. +#if DEBUG + if (!System.Diagnostics.Debugger.IsAttached) + { + Console.BackgroundColor = ConsoleColor.Yellow; + Console.WriteLine(CenterText("Running without a debugger attached may result in lines not being output correctly")); + Console.ResetColor(); + } +#endif + Console.WriteLine(CenterText(" PowerShell Debug Harness ")); + Console.WriteLine($"Reported console window size: {Console.WindowWidth}x{Console.WindowHeight}"); + Console.WriteLine($"Reported console buffer size: {Console.BufferWidth}x{Console.BufferHeight}"); + Console.WriteLine(CenterText(" WIDTH ", '-')); + + var host = new CustomHost(Console.WindowWidth); + var runspace = InitialisePowershellHost(host); + + host.UI.SetNextPromptChoice(3); + + InvokeCommand(runspace, "New-PSDrive", [ + CreateCommand("name", "git-test"), + CreateCommand("PSProvider", "GitRepo"), + CreateCommand("Root", "\\"), + ]); + + InvokeCommand(runspace, "Set-Location", + [ + // Technically this can just be the command but this is a bit easier + CreateCommand("path", "git-test:/") + ]); + + InvokeCommand(runspace, "Get-Location"); + } + + private static void CalendarTestCommands(Runspace runspace) + { + InvokeCommand(runspace, GetCalendarCommand.FullName); + foreach (var day in Enum.GetValues()) + { + InvokeCommand(runspace, GetCalendarCommand.FullName, [ + CreateCommand(nameof(GetCalendarCommand.MarkedDaySymbol), "faker"), + CreateCommand(nameof(GetCalendarCommand.StartOfWeek), day.ToString()), + CreateCommand(nameof(GetCalendarCommand.Date), "22/6/26"), + // CreateCommand(nameof(GetCalendarCommand.AlignRight)) + ]); + } + + InvokeCommand(runspace, GetCalendarCommand.FullName, [ + new CommandParameter(nameof(GetCalendarCommand.MarkedDaySymbol), "🤫"), + new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), DayOfWeek.Saturday) + ]); + InvokeCommand(runspace, GetCalendarCommand.FullName, [new CommandParameter(nameof(GetCalendarCommand.StartOfWeek), "Sunday")]); + } + + private static void TestGitProvider(Runspace runspace) + { + } + + private static CommandParameter CreateCommand(string name, string? argument = null) + { + if (argument is not null) + { + return new CommandParameter(name, argument); + } + + return new CommandParameter(name); + } + + static string CenterText(string text, char paddingChar = ' ') + { + return text.PadLeft((Console.WindowWidth + text.Length) / 2, paddingChar).PadRight(Console.WindowWidth, paddingChar); + } + + /// + /// Initialises a PowerShell session and returns an open Runspace + /// + /// + /// + static Runspace InitialisePowershellHost(CustomHost host) + { + // Create the initial session state for the host. Yes, that 2 on the end does indicate that the underlying + // implementation for this is C++ + // Welcome to the lands of fuck all documentation and figuring shit out from an increasingly shit internet + // where finding non-slop answers gets harder by the day as people close sources of information to prevent + // scraping. Fuck every single person involved modern AI. + // It wasn't much better finding documentation about the System.Management.* namespace before that, but it's + // definitely a lot worse because of it + var initialSessionState = InitialSessionState.CreateDefault2(); + + + // No idea what the helpFileName should be. As is common with _a lot_ of the System.Management.* namespace, + // fuck all is actually documented with comments, or documented at all! + // From https://learn.microsoft.com/en-us/powershell/scripting/developer/hosting/creating-a-constrained-runspace?view=powershell-7.6 + // (which yes, this section _is_ under a legacy category!), it's perfectly fine to leave it as null. + // We also don't really care as we're just doing this so we can test commands without having to deal with Rider + // and it's quirks around runnning a powershell terminal and attaching to it. Yeah it technically works, but + // it's way too common to end up with Rider refusing to build correctly or ensure the right dll is used for the module + var getCalendarCommand = new SessionStateCmdletEntry(GetCalendarCommand.FullName, typeof(GetCalendarCommand), null); + initialSessionState.Commands.Add(getCalendarCommand); + + + var gitProvider = new SessionStateProviderEntry(GitProvider.Name, typeof(GitProvider), null); + initialSessionState.Providers.Add(gitProvider); + + // Create a runspace from the state, open and return it + var runspace = RunspaceFactory.CreateRunspace(host, initialSessionState); + + // A runspace is technically disposable, but we're not reimplementing a full host and we won't be doing anything + // that would require a fresh runspace multiple times over (yet), so we can just treat the lifetime of the + // disposable as application lifetime + runspace.Open(); + + return runspace; + } + + static void InvokeCommand(Runspace runspace, string command, IEnumerable? parameters = null) + { + // Not too sure on the difference of runspace vs PowerShell here. Doesn't seem to make a difference either way + // and this is just a debug harness so it doesn't really matter for now + using var pipeline = runspace.CreatePipeline(); + // using var powershell = PowerShell.Create(runspace); + + // StringBuilder to store the output of this command including any output results (but not errors yet) + // this is just a rudimentary test and the pwsh debug profile should be used instead as it loads the module + // in a full powershell window with debugger attached. Just no automatic command running sadly. + var sb = new StringBuilder(); + + sb.Append(command); + + var cmd = new Command(command); + if (parameters is not null) + { + var param = parameters.ToList(); + sb.Append(' ') + .AppendJoin(' ', param.Select(x => $"-{x.Name} {x.Value}")); + + foreach (var commandParameter in param) + { + cmd.Parameters.Add(commandParameter); + } + } + + sb.AppendLine(); + + pipeline.Commands.Add(cmd); + // powershell.Commands.AddCommand(cmd); + + try + { + var results = pipeline.Invoke(); + // var results = powershell.Invoke(); + foreach (var result in results) + { + sb.AppendLine(result.ToString()); + // Console.WriteLine(result); + } + + Console.WriteLine(sb); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to execute command {command}"); + Console.Error.WriteLine(ex.GetBaseException().Message); + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Calendar/GetCalendarCommand.cs b/src/PowershellModule/Calendar/GetCalendarCommand.cs new file mode 100644 index 0000000..ff16bc3 --- /dev/null +++ b/src/PowershellModule/Calendar/GetCalendarCommand.cs @@ -0,0 +1,174 @@ +using System; +using System.Globalization; +using System.Management.Automation; +using ModuleCore.Calendar; + +namespace PowershellModule.Calendar +{ + [Cmdlet(VerbsCommon.Get, Noun)] + public class GetCalendarCommand : PSCmdlet + { +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + [Parameter( + Mandatory = false, + Position = 0)] + public string Date { get; set; } + + [Parameter( + Mandatory = false, + Position = 1)] + public string MarkedDaySymbol { get; set; } + + [Parameter( + Mandatory = false, + Position = 2)] + [ValidateSet("Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday")] + // pass a type in to get dynamic types + // NOTE: the generator gets called for _each_ character input, so ensure the values are idempotent for every + // keydown (and fast), or cached + // [ValidateSet(typeof(StartDayOfWeekGenerator))] + public string StartOfWeek { get; set; } + + [Parameter( + Mandatory = false, + Position = 3)] + public SwitchParameter AlignRight { get; set; } + +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + // Guaranteed to be initialised in BeginProcessing. If it's not, something is cooked in some other way + private CalendarGenerator _calendar = null!; + private const string Noun = "Calendar"; + public const string FullName = $"{VerbsCommon.Get}-{Noun}"; + + private DateTime _dateToRender = DateTime.Now; + private int _leftPadding; + + protected override void BeginProcessing() + { + // This is the first point in script execution where we'll have parameters populated + _calendar = new( + MarkedDaySymbol, + DayOfWeekFromString(StartOfWeek) + ); + + if (!string.IsNullOrWhiteSpace(Date)) + { + _dateToRender = DateTime.ParseExact(Date, CultureInfo.CurrentCulture.DateTimeFormat.GetAllDateTimePatterns(), null); + } + + if (AlignRight) + { + _leftPadding = Host.UI.RawUI.WindowSize.Width; + } + } + + /// + /// Returns a from a string. + /// + /// Defaults to the calendars default start of week on null or invalid values. + /// + /// + /// + /// + private static DayOfWeek DayOfWeekFromString(string? dayOfWeek) + { + if (dayOfWeek is null) + { + return CalendarGenerator.DefaultStartOfWeek; + } + + return dayOfWeek.ToLowerInvariant() switch + { + "sunday" => DayOfWeek.Sunday, + "monday" => DayOfWeek.Monday, + "tuesday" => DayOfWeek.Tuesday, + "wednesday" => DayOfWeek.Wednesday, + "thursday" => DayOfWeek.Thursday, + "friday" => DayOfWeek.Friday, + "saturday" => DayOfWeek.Saturday, + _ => CalendarGenerator.DefaultStartOfWeek + }; + } + + protected override void ProcessRecord() + { + var calendar = _calendar.Render(_dateToRender); + + // From _very_ basic testing, string split allocates the same as making a span and iterating over it. + // I'm assuming this is because a lot of the span code is what string.Split() does internally and the rest + // is trivially optimised out. + // Not too fussed currently but it's most likely because Pad methods also allocate a string, so they end up + // equivalent at the end + // OutputCalendarStringSplit(calendar); + OutputCalendar(calendar); + + base.ProcessRecord(); + } + + private void OutputCalendarStringSplit(string calendar) + { + var splitCalendarLines = calendar.Split(Environment.NewLine); + foreach (var splitLines in splitCalendarLines) + { + // Have to output a newline character here + WriteObject(splitLines.PadLeft(_leftPadding)); + } + } + + private void OutputCalendar(string calendar) + { + // Work on a span - we avoid using string.Split() as this allocates on certain runtimes + var calendarSpan = calendar.AsSpan(); + // Get the end of line for the calendar. We use Environment.NewLine as the calendar is built up using + // StringBuilder which uses Environment.NewLine for new lines so this should be consistent across operating systems + var firstNewlineIndex = calendarSpan.IndexOf(Environment.NewLine); + // Calculate how many lines the calendar is from how many new lines we have. + // Sure we could count the number of \r\n are present, but the last line might not end with + // a new line. + // Conveniently enough, int division works out in our favor here + var lines = calendarSpan.Length / firstNewlineIndex; + + // We need to account for the size of the newline so we have an accurate length when outputting each line + // of the calendar below + var newLineLength = firstNewlineIndex + Environment.NewLine.Length; + + for (int i = 0; i < lines; i++) + { + // The start of each calendar line offset starts at a position including the length of the newline, + // so in effect the start of the line is the length of the previous line output, plus however many + // characters the newline was that weren't output as we're taking control there + var startOfLine = i * newLineLength; + // End of the line is exclusive of any newline characters + var endOfLine = startOfLine + firstNewlineIndex; + var calendarLine = calendarSpan[startOfLine .. endOfLine]; + + WriteObject(calendarLine.ToString().PadLeft(_leftPadding)); + } + } + } + + // Was here to test dynamic day names but most people using the console will be using english command names + // so there's at least some assumption that they understand days of the week in english. + // It sucks but it remains at least consistent with the argument being in english as well. + // public class StartDayOfWeekGenerator : IValidateSetValuesGenerator + // { + // private string[] _cached { get; set; } = new string[7]; + // + // public string[] GetValidValues() + // { + // if (_cached is null) + // { + // // _cached = Enumerable.Range(0, r.Next(3, 63)).Select(x => x.ToString()).ToArray(); + // } + // + // return _cached; + // } + // } +} \ No newline at end of file diff --git a/src/PowershellModule/Directory.Build.props b/src/PowershellModule/Directory.Build.props new file mode 100644 index 0000000..dd32fa3 --- /dev/null +++ b/src/PowershellModule/Directory.Build.props @@ -0,0 +1,6 @@ + + + 0.0.1 + dev + + \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..506aee2 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GetGitRepoRegistrationCommand.cs @@ -0,0 +1,22 @@ +using System.Management.Automation; +using ModuleCore.Git; +using ModuleCore.Git.Models; + +namespace PowershellModule.Git.Commands; + +/// +/// Lists all currently registered git repositories, with additional information such as their current branch. +/// +[Cmdlet(VerbsCommon.Get, GitCommands.GitRepoRegistrationNoun)] +[OutputType(typeof(GitRegistration))] +public class GetGitRepoRegistrationCommand : PSCmdlet +{ + protected override void BeginProcessing() + { + var repos = GitRepoRegistrationManager.Instance.ListRepos(); + + WriteObject(repos); + + base.BeginProcessing(); + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/GitCommands.cs b/src/PowershellModule/Git/Commands/GitCommands.cs new file mode 100644 index 0000000..7fe1856 --- /dev/null +++ b/src/PowershellModule/Git/Commands/GitCommands.cs @@ -0,0 +1,6 @@ +namespace PowershellModule.Git.Commands; + +public class GitCommands +{ + public const string GitRepoRegistrationNoun = "GitRepoRegistration"; +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..9e0729f --- /dev/null +++ b/src/PowershellModule/Git/Commands/NewGitRepoRegistrationCommand.cs @@ -0,0 +1,41 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.New, GitCommands.GitRepoRegistrationNoun)] +public sealed class NewGitRepoRegistrationCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + protected override void BeginProcessing() + { + try + { + GitRepoRegistrationManager.SetDebugWriter(WriteDebug); + + // Test that we're in a git repo first. If we aren't (or git isn't available), this method will throw + // so we don't need to handle for null (yet). + var repoFolder = GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path); + + if (string.IsNullOrWhiteSpace(Name)) + { + WriteDebug("No name given for registration, defaulting to git folder root."); + } + + GitRepoRegistrationManager.Instance.RegisterRepo(repoFolder.Directory, Name ?? repoFolder.Folder); + GitRepoRegistrationManager.ClearDebugWriter(); + + base.BeginProcessing(); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..a79060e --- /dev/null +++ b/src/PowershellModule/Git/Commands/RemoveGitRepoRegistrationCommand.cs @@ -0,0 +1,43 @@ +using System; +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Remove, GitCommands.GitRepoRegistrationNoun)] +public class RemoveGitRepoRegistrationCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + protected override void BeginProcessing() + { + try + { + GitRepoRegistrationManager.SetDebugWriter(WriteDebug); + + // If we aren't given a value for the Name argument, default behaviour is to attempt to remove a registration + // by the current git repo folder name for the current location. + // If we have a name, don't bother testing for a git repo, just attempt to remove the registration by name + // regardless of where we're being called from + var registrationNameToRemove = string.IsNullOrEmpty(Name) + ? GitRepoRegistrationManager.IsGitRepo(SessionState.Path.CurrentLocation.Path).Folder + : Name; + + GitRepoRegistrationManager.Instance.UnregisterRepo(registrationNameToRemove); + + // Removing a registration works similar to registering a new one - we either remove by exact name, or by + // the folder if no name is given (so a user can remove a registration from a git repo they're currently in) + GitRepoRegistrationManager.ClearDebugWriter(); + + base.BeginProcessing(); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, ex.Source, ErrorCategory.FromStdErr, null)); + } + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs new file mode 100644 index 0000000..e4eb647 --- /dev/null +++ b/src/PowershellModule/Git/Commands/ShowGitRepoRegistrationCommand.cs @@ -0,0 +1,42 @@ +using System.Management.Automation; +using ModuleCore.Git; + +namespace PowershellModule.Git.Commands; + +[Cmdlet(VerbsCommon.Show, GitCommands.GitRepoRegistrationNoun)] +public class ShowGitRepoRegistrationCommand : PSCmdlet +{ + [Parameter( + Position = 0, + ValueFromPipeline = true, + Mandatory = true, + HelpMessage = "Reference name for the repo")] + public string? Name { get; set; } + + [Parameter( + Mandatory = false, + HelpMessage = "Changes directory directly instead of using Set-Location")] + [Alias("NoPushLocation")] + public SwitchParameter NoStack { get; set; } + + protected override void BeginProcessing() + { + var location = GitRepoRegistrationManager.Instance.GetDirectoryForRegisteredRepo(Name); + + // By default instead of doing the same as cd, we instead do pushd so a user can popd straight back to where + // they came from. + // TODO: incorporate this into the custom prompt when I develop that + if (!NoStack) + { + // Push the current location to the stack + // TODO: support named stacks. PowerShell *-Location commands support named stacks, but I don't personally + // use them myself so I haven't implemented them initially. I would like to in the future though, but right + // now it's low value to me. + // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.6#example-4-set-the-current-location-to-a-named-stack + SessionState.Path.PushCurrentLocation(null); + } + + SessionState.Path.SetLocation(location); + base.BeginProcessing(); + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/GitProvider.cs b/src/PowershellModule/Git/GitProvider.cs new file mode 100644 index 0000000..b9ece49 --- /dev/null +++ b/src/PowershellModule/Git/GitProvider.cs @@ -0,0 +1,82 @@ +using System; +using System.Management.Automation; +using System.Management.Automation.Provider; + +namespace PowershellModule.Git; + +// https://learn.microsoft.com/en-us/powershell/scripting/developer/provider/accessdbprovidersample02?view=powershell-7.6 +[CmdletProvider(Name, ProviderCapabilities.None)] +public class GitProvider : NavigationCmdletProvider +{ + public const string Name = "GitRepo"; + + protected override PSDriveInfo NewDrive(PSDriveInfo drive) + { + // This is a bit of a hack to ensure that no drive is registered with a root location. + // It seems to be how PSDrives are registered for Alias providers, and we technically don't have any concept + // of a normal file system + if (drive.Root != string.Empty) + { + throw new Exception("Drive root must be an empty string"); + } + + return base.NewDrive(drive); + } + + protected override void NewItem(string path, string itemTypeName, object newItemValue) + { + //base.NewItem(path, itemTypeName, newItemValue); + } + + protected override PSDriveInfo RemoveDrive(PSDriveInfo drive) + { + return base.RemoveDrive(drive); + } + + protected override void GetChildNames(string path, ReturnContainers returnContainers) + { + // TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then + // output their repository names + } + + protected override void GetChildItems(string path, bool recurse) + { + // TODO: get all configured repos based on a path from the underlying GitPsDriveInfo then + // output their repository locations and any other meta data I store + } + + // I think this is needed to be able to cd into it + protected override bool IsItemContainer(string path) + { + return true; + } + + // I think this is needed to be able to cd into it + protected override bool ItemExists(string path) + { + return true; + } + + protected override string MakePath(string parent, string child) + { + // Test to see what happens if set the location if the path matches a pretend repo registered with the name test + // The result of the test is that yes, this does work, but there's also a shit load of calls to this method as + // it traverses the entire directory path. + // This seems to just be internal powershell behaviour so as long as this is quick the first time, everything + // afterwards just always happens. But probably happens more seeing as I'm changing the location of the path + // in this current SessionState and probably doesn't happen if this were a normal path traversal + // where I don't set the location. + if (child == "test") + { + SessionState.Path.SetLocation("F:/Repos/PowershellModule/src/PowershellModule/Git"); + return "F:/Repos/PowershellModule/src/PowershellModule/Git"; + } + + return base.MakePath(parent, child); + } + + protected override bool IsValidPath(string path) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/PowershellModule/Git/GitPsDriveInfo.cs b/src/PowershellModule/Git/GitPsDriveInfo.cs new file mode 100644 index 0000000..b1c761d --- /dev/null +++ b/src/PowershellModule/Git/GitPsDriveInfo.cs @@ -0,0 +1,11 @@ +using System.Management.Automation; + +namespace PowershellModule.Git; + +public class GitPsDriveInfo : PSDriveInfo +{ + protected GitPsDriveInfo(PSDriveInfo driveInfo) + : base(driveInfo) + { + } +} \ No newline at end of file diff --git a/src/PowershellModule/PostBuild.ps1 b/src/PowershellModule/PostBuild.ps1 new file mode 100644 index 0000000..518b78a --- /dev/null +++ b/src/PowershellModule/PostBuild.ps1 @@ -0,0 +1,28 @@ +<# + .SYNOPSIS + Removes all non-core files from build output +#> +param( + [Parameter(ValueFromPipeline = $true, Position = 0, Mandatory = $true)] + $targetDir +) + +$nativeSqliteDllLocation = "$( $targetDir )runtimes\win-x64\native\e_sqlite3.dll"; + +if ($false -eq (Test-Path $nativeSqliteDllLocation)) +{ + Write-Error "POST BUILD FAILED: Unable to locate $nativeSqliteDllLocation" +} + +Write-Host "Copying '$nativeSqliteDllLocation' to '$targetDir'" +Copy-Item -Path $nativeSqliteDllLocation -Destination $targetDir + +# Because we use CopyLocalLockFileAssemblies Files that are needed for the module +$allowList = @( + "ModuleCore*" + "PowershellModule*" + "*SQLite*" + "data" +) +Write-Host "Removing all non-module required files from '$targetDir'" +Get-ChildItem -Path $targetDir -exclude $allowList | Remove-Item -Recurse \ No newline at end of file diff --git a/src/PowershellModule/PowershellModule.csproj b/src/PowershellModule/PowershellModule.csproj new file mode 100644 index 0000000..7cb5192 --- /dev/null +++ b/src/PowershellModule/PowershellModule.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + PowershellModule + latestmajor + enable + true + + + + + All + + + + + + + + + + + + diff --git a/tests/ModuleTests/Calendar/BasicRenderTest.cs b/tests/ModuleTests/Calendar/BasicRenderTest.cs new file mode 100644 index 0000000..b08f0bd --- /dev/null +++ b/tests/ModuleTests/Calendar/BasicRenderTest.cs @@ -0,0 +1,48 @@ +using ModuleCore.Calendar; +using ModuleTests.Calendar.TestData; +using System.Globalization; + +namespace ModuleTests.Calendar; + +public class BasicRenderTest +{ + private static readonly VerifySettings Settings; + + static BasicRenderTest() + { + Settings = new VerifySettings(); + var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(BasicRenderTest)); + + Settings.UseDirectory(testBaseDirectory); + Settings.DisableDiff(); + } + + [Theory] + [ClassData(typeof(CalendarTestDates))] + public Task BasicCalendarRender(DateTime month) + { + Settings.UseFileName($"{month:yyyy-MM-dd}"); + + var calendar = new CalendarGenerator(); + // Render the calendar with the start of the month as the today date so no elapsed marks are shown + var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1)); + + return Verify(renderedMonth, Settings); + } + + [Theory] + [ClassData(typeof(CultureCodeTestData))] + public Task CultureInfoCalendarRender(string cultureCode) + { + Settings.UseFileName($"{cultureCode}"); + + Thread.CurrentThread.CurrentCulture = new CultureInfo(cultureCode); + + var month = new DateTime(2026, 8, 1); + var calendar = new CalendarGenerator(); + // Render the calendar with the start of the month as the today date so no elapsed marks are shown + var renderedMonth = calendar.Render(month, new DateTime(month.Year, month.Month, 1)); + + return Verify(renderedMonth, Settings); + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs new file mode 100644 index 0000000..a169518 --- /dev/null +++ b/tests/ModuleTests/Calendar/MarkedDayRenderTests.cs @@ -0,0 +1,86 @@ +using System.Text; +using ModuleCore.Calendar; + +namespace ModuleTests.Calendar; + +public class MarkedDayRenderTests +{ + private static readonly VerifySettings Settings; + + static MarkedDayRenderTests() + { + Settings = new VerifySettings(); + var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(MarkedDayRenderTests)); + + Settings.UseDirectory(testBaseDirectory); + Settings.DisableDiff(); + } + + [Fact] + public Task DefaultMarkedDayRender() + { + Settings.UseFileName(nameof(DefaultMarkedDayRender)); + + var date = new DateTime(2026, 8, 1); + var calendar = new CalendarGenerator(); + var renderedMonth = calendar.Render(date, date.AddDays(15)); + + return Verify(renderedMonth, Settings); + } + + [Fact] + public Task SimpleMarkedDayRender() + { + Settings.UseFileName(nameof(SimpleMarkedDayRender)); + + var date = new DateTime(2026, 8, 1); + var calendar = new CalendarGenerator("|"); + var renderedMonth = calendar.Render(date, date.AddDays(15)); + + return Verify(renderedMonth, Settings); + } + + [Fact] + public Task LongMarkedDayRender() + { + Settings.UseFileName(nameof(LongMarkedDayRender)); + + var date = new DateTime(2026, 8, 1); + var calendar = new CalendarGenerator("passed"); + var renderedMonth = calendar.Render(date, date.AddDays(15)); + + return Verify(renderedMonth, Settings); + } + + [Fact] + public Task EmojiMarkedDayRender() + { + Settings.UseFileName(nameof(EmojiMarkedDayRender)); + + var date = new DateTime(2026, 8, 1); + var calendar = new CalendarGenerator("🤫"); + var sb = new StringBuilder(); + sb.AppendLine("Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding.") + .AppendLine(calendar.Render(date, date.AddDays(15))); + + return Verify(sb, Settings); + } + + [Fact] + public Task DoubleWideMarkedDayRender() + { + Settings.UseFileName(nameof(DoubleWideMarkedDayRender)); + + var date = new DateTime(2026, 8, 1); + // Using an asian-character to have a test covering the unfortunate reality of character rendering of + // characters that are visually 1 character in width, but render as double width or some other cursed form. + // Written language was a mistake but trying to render it on computers alongside a different one + // is arguably worse. + var calendar = new CalendarGenerator("火"); + var sb = new StringBuilder(); + sb.AppendLine("Double-wide characters will display incorrectly but still tested until rendering can be improved") + .AppendLine(calendar.Render(date, date.AddDays(15))); + + return Verify(sb, Settings); + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt new file mode 100644 index 0000000..39f169d --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-06-01.verified.txt @@ -0,0 +1,11 @@ +┌─────────────────────────────────────────┐ +│ June 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ +│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │ +│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │ +│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │ +│ 29 │ 30 │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt new file mode 100644 index 0000000..0f44012 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-07-01.verified.txt @@ -0,0 +1,11 @@ +┌─────────────────────────────────────────┐ +│ July 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │ +│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │ +│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │ +│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ +│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt new file mode 100644 index 0000000..f3c1199 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/2026-08-01.verified.txt @@ -0,0 +1,12 @@ +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt new file mode 100644 index 0000000..a1a264b --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/cs-CZ.verified.txt @@ -0,0 +1,12 @@ +┌──────────────────────────────────┐ +│ srpen 2026 │ +├────┬────┬────┬────┬────┬────┬────┤ +│ po │ út │ st │ čt │ pá │ so │ ne │ +├────┼────┼────┼────┼────┼────┼────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└────┴────┴────┴────┴────┴────┴────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt new file mode 100644 index 0000000..d69d3f7 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/da-DK.verified.txt @@ -0,0 +1,12 @@ +┌─────────────────────────────────────────┐ +│ august 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ man │ tir │ ons │ tor │ fre │ lør │ søn │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt new file mode 100644 index 0000000..f3c1199 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/en-AU.verified.txt @@ -0,0 +1,12 @@ +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt new file mode 100644 index 0000000..4a4de20 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/es-PR.verified.txt @@ -0,0 +1,12 @@ +┌────────────────────────────────────────────────┐ +│ agosto 2026 │ +├──────┬──────┬──────┬──────┬──────┬──────┬──────┤ +│ lun. │ mar. │ mié. │ jue. │ vie. │ sáb. │ dom. │ +├──────┼──────┼──────┼──────┼──────┼──────┼──────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└──────┴──────┴──────┴──────┴──────┴──────┴──────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt new file mode 100644 index 0000000..9a7e138 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/fr-LU.verified.txt @@ -0,0 +1,12 @@ +┌────────────────────────────────────────────────┐ +│ août 2026 │ +├──────┬──────┬──────┬──────┬──────┬──────┬──────┤ +│ lun. │ mar. │ mer. │ jeu. │ ven. │ sam. │ dim. │ +├──────┼──────┼──────┼──────┼──────┼──────┼──────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└──────┴──────┴──────┴──────┴──────┴──────┴──────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt new file mode 100644 index 0000000..2a9615a --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/nl-NL.verified.txt @@ -0,0 +1,12 @@ +┌──────────────────────────────────┐ +│ augustus 2026 │ +├────┬────┬────┬────┬────┬────┬────┤ +│ ma │ di │ wo │ do │ vr │ za │ zo │ +├────┼────┼────┼────┼────┼────┼────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└────┴────┴────┴────┴────┴────┴────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt new file mode 100644 index 0000000..bee6c08 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/BasicRenderTest/te-IN.verified.txt @@ -0,0 +1,12 @@ +┌───────────────────────────────────────────────────────┐ +│ ఆగస్టు 2026 │ +├───────┬───────┬───────┬───────┬───────┬───────┬───────┤ +│ సోమ │ మంగళ │ బుధ │ గురు │ శుక్ర │ శని │ ఆది │ +├───────┼───────┼───────┼───────┼───────┼───────┼───────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└───────┴───────┴───────┴───────┴───────┴───────┴───────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt new file mode 100644 index 0000000..a134a32 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DefaultMarkedDayRender.verified.txt @@ -0,0 +1,12 @@ +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ x │ x │ +│ x │ x │ x │ x │ x │ x │ x │ +│ x │ x │ x │ x │ x │ x │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt new file mode 100644 index 0000000..3fb6ad7 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/DoubleWideMarkedDayRender.verified.txt @@ -0,0 +1,13 @@ +Double-wide characters will display incorrectly but still tested until rendering can be improved +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 火 │ 火 │ +│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ +│ 火 │ 火 │ 火 │ 火 │ 火 │ 火 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ diff --git a/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt new file mode 100644 index 0000000..fc7c092 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/EmojiMarkedDayRender.verified.txt @@ -0,0 +1,13 @@ +Emojis will display incorrectly in text but output correctly in terminals with UTF-8 encoding. +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 🤫 │ 🤫 │ +│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ +│ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 🤫 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ diff --git a/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt new file mode 100644 index 0000000..14e0523 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/LongMarkedDayRender.verified.txt @@ -0,0 +1,12 @@ +┌──────────────────────────────────────────────────────────────┐ +│ August 2026 │ +├────────┬────────┬────────┬────────┬────────┬────────┬────────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├────────┼────────┼────────┼────────┼────────┼────────┼────────┤ +│ │ │ │ │ │ passed │ passed │ +│ passed │ passed │ passed │ passed │ passed │ passed │ passed │ +│ passed │ passed │ passed │ passed │ passed │ passed │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└────────┴────────┴────────┴────────┴────────┴────────┴────────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt new file mode 100644 index 0000000..007bfcb --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/MarkedDayRenderTests/SimpleMarkedDayRender.verified.txt @@ -0,0 +1,12 @@ +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ | │ | │ +│ | │ | │ | │ | │ | │ | │ | │ +│ | │ | │ | │ | │ | │ | │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt b/tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt new file mode 100644 index 0000000..aaa4fa5 --- /dev/null +++ b/tests/ModuleTests/Calendar/Snapshots/StartDayOfWeekTests/StartOfDayRender.verified.txt @@ -0,0 +1,86 @@ +Start Day: Sunday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ │ 1 │ +│ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ +│ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ +│ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ +│ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ +│ 30 │ 31 │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Monday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ │ 1 │ 2 │ +│ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ +│ 10 │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ +│ 17 │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ +│ 24 │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ +│ 31 │ │ │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Tuesday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Tue │ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ │ 1 │ 2 │ 3 │ +│ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ +│ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ +│ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │ +│ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Wednesday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Wed │ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ │ 1 │ 2 │ 3 │ 4 │ +│ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ +│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ +│ 19 │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ +│ 26 │ 27 │ 28 │ 29 │ 30 │ 31 │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Thursday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Thu │ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ │ 1 │ 2 │ 3 │ 4 │ 5 │ +│ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │ +│ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │ +│ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ +│ 27 │ 28 │ 29 │ 30 │ 31 │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Friday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Fri │ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ +│ 7 │ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ +│ 14 │ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ +│ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ +│ 28 │ 29 │ 30 │ 31 │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ +Start Day: Saturday +┌─────────────────────────────────────────┐ +│ August 2026 │ +├─────┬─────┬─────┬─────┬─────┬─────┬─────┤ +│ Sat │ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ +├─────┼─────┼─────┼─────┼─────┼─────┼─────┤ +│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ +│ 8 │ 9 │ 10 │ 11 │ 12 │ 13 │ 14 │ +│ 15 │ 16 │ 17 │ 18 │ 19 │ 20 │ 21 │ +│ 22 │ 23 │ 24 │ 25 │ 26 │ 27 │ 28 │ +│ 29 │ 30 │ 31 │ │ │ │ │ +└─────┴─────┴─────┴─────┴─────┴─────┴─────┘ diff --git a/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs new file mode 100644 index 0000000..fb4c52c --- /dev/null +++ b/tests/ModuleTests/Calendar/StartDayOfWeekTests.cs @@ -0,0 +1,35 @@ +using System.Text; +using ModuleCore.Calendar; + +namespace ModuleTests.Calendar; + +public class StartDayOfWeekTests +{ + private static readonly VerifySettings Settings; + + static StartDayOfWeekTests() + { + Settings = new VerifySettings(); + var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(StartDayOfWeekTests)); + + Settings.UseDirectory(testBaseDirectory); + Settings.DisableDiff(); + } + + [Fact] + public Task StartOfDayRender() + { + Settings.UseFileName(nameof(StartOfDayRender)); + + var date = new DateTime(2026, 8, 1); + var sb = new StringBuilder(); + foreach (var dayOfWeek in Enum.GetValues()) + { + var calendar = new CalendarGenerator(startOfWeek: dayOfWeek); + sb.AppendLine($"Start Day: {dayOfWeek}"); + sb.AppendLine(calendar.Render(date, date)); + } + + return Verify(sb, Settings); + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs new file mode 100644 index 0000000..3c5b048 --- /dev/null +++ b/tests/ModuleTests/Calendar/TestData/CalendarTestDates.cs @@ -0,0 +1,14 @@ +namespace ModuleTests.Calendar.TestData; + +public class CalendarTestDates : TestDataEnumerator +{ + public CalendarTestDates() + { + Data = + [ + new DateTime(2026, 06, 01), + new DateTime(2026, 07, 01), + new DateTime(2026, 08, 01), + ]; + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs new file mode 100644 index 0000000..f72e6c3 --- /dev/null +++ b/tests/ModuleTests/Calendar/TestData/CultureCodeTestData.cs @@ -0,0 +1,19 @@ +namespace ModuleTests.Calendar.TestData; + +public class CultureCodeTestData : TestDataEnumerator +{ + public CultureCodeTestData() + { + Data = + [ + "da-DK", + "en-AU", + "cs-CZ", + "es-PR", + "fr-LU", + "nl-NL", + // Will render unaligned due to the character encoding, here to catch if I fix this later + "te-IN" + ]; + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Git/AddRegistrationTests.cs b/tests/ModuleTests/Git/AddRegistrationTests.cs new file mode 100644 index 0000000..c862773 --- /dev/null +++ b/tests/ModuleTests/Git/AddRegistrationTests.cs @@ -0,0 +1,150 @@ +using System.Text; +using ModuleCore.Git; +using ModuleTests.Git.TestData; + +namespace ModuleTests.Git; + +// TODO: [#12] GitRepoRegistration tests should reset database at start of each test +public class AddRegistrationTests +{ + private static readonly VerifySettings Settings; + + static AddRegistrationTests() + { + Settings = new VerifySettings(); + var testBaseDirectory = Path.Join(TestConstants.SnapshotFolderName, nameof(AddRegistrationTests)); + + Settings.UseDirectory(testBaseDirectory); + Settings.DisableDiff(); + } + + [Theory] + [ClassData(typeof(AddRegistrationTestData))] + public Task BasicRepoRegistration((int testId, string path) testData) + { + Settings.UseFileName($"{nameof(BasicRepoRegistration)}_{testData.testId}"); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(BasicRepoRegistration)); + + try + { + var repoRegistration = gitManager.RegisterRepo("Test:/some/test/repo", testData.path); + var sb = new StringBuilder(); + sb.AppendLine($"Attempted to register: {testData.path}") + .AppendLine($"Registration result: {repoRegistration}"); + + return Verify(sb, Settings); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithEmptyName() + { + Settings.UseFileName(nameof(RepoRegistrationWithEmptyName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithEmptyName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + + try + { + var emptyName = gitManager.RegisterRepo(testRepoAbsolutePath, ""); + + Assert.Equal("repo", emptyName); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithNullName() + { + Settings.UseFileName(nameof(RepoRegistrationWithNullName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithNullName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + try + { + // Name is technically not-nullable, but string is a reference type so null can be passed in so we should test + // it regardless + var nullName = gitManager.RegisterRepo(testRepoAbsolutePath, null!); + + Assert.Equal("repo", nullName); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void RepoRegistrationWithWhitespaceName() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + try + { + var whitespaceName = gitManager.RegisterRepo(testRepoAbsolutePath, " "); + + Assert.Equal("repo", whitespaceName); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void DuplicateRepoRegistrationShouldFail() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + string[] paths = ["test", "nested", "path"]; + var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths)); + + try + { + var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); + var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1])); + + Assert.Equal(Path.Combine(paths), firstRegistration); + Assert.Equal(Path.Combine(paths[..1]), secondRegistration); + + Assert.Throws(() => gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1]))); + } + finally + { + gitManager.DeleteDatabase(); + } + } + + [Fact] + public void DuplicateRepoRegistrationDifferentSlashShouldNotFail() + { + Settings.UseFileName(nameof(RepoRegistrationWithWhitespaceName)); + var gitManager = GitRepoRegistrationManager.InternalFreshInstance(nameof(RepoRegistrationWithWhitespaceName)); + var testRepoAbsolutePath = "Test:/some/test/repo"; + string[] paths = ["test", "nested", "path"]; + var names = (NormalSeparator: string.Join(Path.DirectorySeparatorChar, paths), AltSeparator: string.Join(Path.AltDirectorySeparatorChar, paths)); + + try + { + var firstRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.NormalSeparator); + var secondRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, Path.Combine(paths[..1])); + var differentPathSeparatorRegistration = gitManager.RegisterRepo(testRepoAbsolutePath, names.AltSeparator); + + Assert.Equal(Path.Combine(paths), firstRegistration); + Assert.Equal(Path.Combine(paths[..1]), secondRegistration); + Assert.Equal(names.AltSeparator, differentPathSeparatorRegistration); + } + finally + { + gitManager.DeleteDatabase(); + } + } +} \ No newline at end of file diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt new file mode 100644 index 0000000..3183455 --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_0.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: test +Registration result: test diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt new file mode 100644 index 0000000..e9b945d --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_1.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: test\path +Registration result: test\path diff --git a/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt new file mode 100644 index 0000000..7f2c4e6 --- /dev/null +++ b/tests/ModuleTests/Git/Snapshots/AddRegistrationTests/BasicRepoRegistration_2.verified.txt @@ -0,0 +1,2 @@ +Attempted to register: other/path +Registration result: other/path diff --git a/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs b/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs new file mode 100644 index 0000000..2d44e39 --- /dev/null +++ b/tests/ModuleTests/Git/TestData/AddRegistrationTestData.cs @@ -0,0 +1,18 @@ +using System.Linq; + +namespace ModuleTests.Git.TestData; + +public class AddRegistrationTestData : TestDataEnumerator<(int testId, string path)> +{ + public AddRegistrationTestData() + { + Data = new List() + { + "test", + $"test{Path.DirectorySeparatorChar}path", + $"other{Path.AltDirectorySeparatorChar}path" + } + .Select((x, i) => (i, x)) + .ToList(); + } +} \ No newline at end of file diff --git a/tests/ModuleTests/ModuleTests.csproj b/tests/ModuleTests/ModuleTests.csproj new file mode 100644 index 0000000..4b97606 --- /dev/null +++ b/tests/ModuleTests/ModuleTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + Exe + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/ModuleTests/TestConstants.cs b/tests/ModuleTests/TestConstants.cs new file mode 100644 index 0000000..812da26 --- /dev/null +++ b/tests/ModuleTests/TestConstants.cs @@ -0,0 +1,6 @@ +namespace ModuleTests; + +public class TestConstants +{ + public const string SnapshotFolderName = "Snapshots"; +} \ No newline at end of file diff --git a/tests/ModuleTests/TestDataEnumerator.cs b/tests/ModuleTests/TestDataEnumerator.cs new file mode 100644 index 0000000..f756af4 --- /dev/null +++ b/tests/ModuleTests/TestDataEnumerator.cs @@ -0,0 +1,15 @@ +using System.Collections; + +namespace ModuleTests; + +public abstract class TestDataEnumerator : IEnumerable> +{ + protected IEnumerable Data { get; init; } = []; + + public IEnumerator> GetEnumerator() + { + return Data.Select(data => new TheoryDataRow(data)).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} \ No newline at end of file