diff --git a/src/Stale.Cli/Context.cs b/src/Stale.Cli/Context.cs index 897def9..de4f04d 100644 --- a/src/Stale.Cli/Context.cs +++ b/src/Stale.Cli/Context.cs @@ -8,6 +8,8 @@ class Context : IDisposable { + readonly CancellationTokenSource _cancelSource = new(); + public Context() { _ansiConsole = new TerminalAnsiConsole(this); @@ -15,13 +17,15 @@ public Context() public void Dispose() { - Cancel.Dispose(); + _cancelSource.Dispose(); } - public StaleCliArguments Options = null!; - public bool IsVerbose = true; + public CancellationToken CancelToken => _cancelSource.Token; + public bool IsCancellationRequested => _cancelSource.IsCancellationRequested; + public void Cancel() => _cancelSource.Cancel(); - public readonly CancellationTokenSource Cancel = new(); + public StaleCliArguments Options = null!; + public bool IsVerbose; public void VerboseLine(string text) { diff --git a/src/Stale.Cli/StaleCli.cs b/src/Stale.Cli/StaleCli.cs index 7a0f3cb..b06426f 100644 --- a/src/Stale.Cli/StaleCli.cs +++ b/src/Stale.Cli/StaleCli.cs @@ -1,28 +1,65 @@ using System.Diagnostics; +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Channels; +using DocoptNet; using Vezel.Cathode; using Vezel.Cathode.Processes; const string programVersion = "0.1"; const int jsonVersion = 1; -var ctx = new Context(); +using var ctx = new Context(); + +// ReSharper disable AccessToDisposedClosure +// ^ talking about ctx here, it's ok, it outlives everything Terminal.Signaled += signalContext => { - if (signalContext.Signal == TerminalSignal.Interrupt) - ctx.Cancel.Cancel(); + if (signalContext.Signal != TerminalSignal.Interrupt) + return; + + ctx.Cancel(); }; try { { - ctx.IsVerbose = args.FirstOrDefault() == "--verbose"; - if (ctx.IsVerbose) + var isVerbose = false; + NPath? useCwd = null; + + // TODO: fix up this pre-config stuff, it's brittle, inconsistent error reporting (doesn't benefit from docopt parser checker) + for (var i = 0; i < args.Length; ++i) { + if (args[i] == "--verbose") + isVerbose = true; + else if (args[i] == "--cwd") + { + if (++i == args.Length) + throw new DocoptInputErrorException("--cwd needs a path"); + useCwd = args[i]; + } + else + { + args = args[i..]; + break; + } + } + + if (isVerbose) + { + ctx.IsVerbose = true; ctx.VerboseLine("Enabling verbose mode"); - args = args.Skip(1).ToArray(); + } + + if (useCwd != null) + { + if (ctx.IsVerbose) + ctx.VerboseLine($"Setting cwd to: {useCwd}"); + if (!useCwd.DirectoryExists()) + throw new DocoptInputErrorException($"--cwd path does not exist: {useCwd}"); + NPath.SetCurrentDirectory(useCwd); } var (exitCode, options) = StaleCliArguments.CreateParser().Parse( @@ -36,116 +73,225 @@ ctx.Options = options; } - ctx.VerboseDump(ctx.Options); + if (ctx.IsVerbose) + ctx.VerboseDump(ctx.Options); + + if (ctx.Options.CmdRecord) + return (int)await Record(ctx.Options.ArgRecorded, ctx.Options.ArgCommand!, ctx.Options.ArgArg.ToArray()); - if (ctx.Options.ArgCommand != null) + if (ctx.Options.CmdPlay) { - // TODO: consider checking if it's a console app - // (see IsWindowsApplication at PowerShell\src\System.Management.Automation\engine\NativeCommandProcessor.cs:1199) + double? ratio = null; + int? delay = null; - if (ctx.IsVerbose) + if (ctx.Options.OptSpeed != null) { - var msg = $"Command: `{CliUtility.CommandLineArgsToString(ctx.Options.ArgArg.Prepend(ctx.Options.ArgCommand))}`"; - if (ctx.Options.OptRecord != null) - msg += $" (record to '{ctx.Options.OptRecord}')"; - ctx.VerboseLine(msg); - } + var success = false; - var cmd = new ChildProcessBuilder() - .WithFileName(ctx.Options.ArgCommand) - .WithArguments(ctx.Options.ArgArg) - .WithRedirections(false, true, true) - .WithCreateWindow(false) - .WithWindowStyle(ProcessWindowStyle.Hidden) - .WithCancellationToken(ctx.Cancel.Token) - .WithThrowOnError(false) - .Run(); + var m = Regex.Match(ctx.Options.OptSpeed, @"(?[0-9.])x|(?\d+)ms"); + var ratioGroup = m.Groups["ratio"]; + var delayGroup = m.Groups["delay"]; - var captures = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); - - var open = 0; - - async void Write(TextReader reader, bool isStdErr) - { - Interlocked.Increment(ref open); - - while (!ctx.Cancel.IsCancellationRequested) + if (ratioGroup.Success) { - var line = await reader.ReadLineAsync(ctx.Cancel.Token); - if (line == null) + if (double.TryParse(ratioGroup.Value, out var value) && value > 0) { - if (Interlocked.Decrement(ref open) == 0) - captures.Writer.Complete(); - break; + ratio = value; + success = true; } + } + else if (delayGroup.Success) + { + if (int.TryParse(delayGroup.Value, out var value) && value >= 0) + { + delay = value; + success = true; + } + } - await captures.Writer.WriteAsync(new Capture(isStdErr, DateTime.Now, line), ctx.Cancel.Token); + if (!success) + { + throw new CliErrorException( + CliExitCode.ErrorUsage, + "Unable to parse speed argument; needs to be a ratio like 1.23x or a delay like 456ms"); } } - _ = Task.Run(() => Write(cmd.StandardOut.TextReader, false), ctx.Cancel.Token); - _ = Task.Run(() => Write(cmd.StandardError.TextReader, true), ctx.Cancel.Token); + if (ctx.IsVerbose) + { + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (ratio != null && ratio != 1.0) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {ratio}x speed"); + else if (delay == 0) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at maximum speed"); + else if (delay != null) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {delay}ms per line"); + else + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at original speed"); + } + + return (int)await Play(ctx.Options.ArgRecorded!, ratio, delay, ctx.CancelToken); + } + + // $$$ DO THE REAL PROGRAM HERE + + throw new UnreachableCodeException(); +} +catch (CliErrorException x) +{ + ctx.ErrorLine(x.Message); + return (int)x.Code; +} +catch (Exception x) +{ + ctx.Error(x); + return (int)CliExitCode.ErrorSoftware; +} + +async Task Record(string? recordedPath, string command, IReadOnlyList args) +{ + var (cmd, captures) = Exec(command, args); + + if (ctx.IsVerbose) + { + ctx.VerboseLine($">{cmd.Id} $ {command} {args}"); + ctx.VerboseLine($" (Recording to ${ctx.Options.ArgRecorded ?? "stdout"})"); + } - var jsonStream = ctx.Options.OptRecord != null - ? File.Create(ctx.Options.OptRecord) - : Terminal.StandardOut.Stream; + var jsonStream = recordedPath != null + ? File.Create(recordedPath) + : Terminal.StandardOut.Stream; - await using var writer = new StreamWriter(jsonStream); - writer.AutoFlush = true; + await using var writer = new StreamWriter(jsonStream); + writer.AutoFlush = true; + + await using var json = new Utf8JsonWriter(jsonStream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + json.WriteStartObject(); + json.WriteNumber("version", jsonVersion); + json.WriteStartArray("captures"); + json.Flush(); + writer.Write('\n'); + + await foreach (var capture in captures.ReadAllAsync(ctx.CancelToken)) + { + writer.Write('\n'); - await using var json = new Utf8JsonWriter(jsonStream); json.WriteStartObject(); - json.WriteNumber("version", jsonVersion); - json.WriteStartArray("captures"); + if (capture.IsStdErr) + json.WriteBoolean("isStdErr", true); + json.WriteString("when", capture.When); + json.WriteString("line", capture.Line); + json.WriteEndObject(); json.Flush(); - writer.Write('\n'); + } + + json.WriteEndArray(); + json.Flush(); + writer.Write("\n\n"); + + json.WriteNumber("exitcode", cmd.Completion.Result); + + json.WriteEndObject(); + json.Flush(); + + writer.Write('\n'); - await foreach (var capture in captures.Reader.ReadAllAsync(ctx.Cancel.Token)) + return CliExitCode.Success; +} + +async Task Play(string recordedPath, double? ratio, int? delay, CancellationToken cancel) +{ + using var reader = new StreamReader(File.OpenRead(recordedPath)); + + var json = await JsonDocument.ParseAsync(reader.BaseStream, default, cancel); + + var version = json.RootElement.GetProperty("version").GetInt32(); + if (version != jsonVersion) + throw new CliErrorException(CliExitCode.ErrorDataErr, $"Recorded file '{recordedPath}' is version {{version}}, expected {{jsonVersion}}"); + + var captures = json.RootElement.GetProperty("captures"); + var exitCode = json.RootElement.GetProperty("exitcode").GetInt32(); + + if (ratio == null && delay == null) + ratio = 1; + + DateTime? last = null; + + foreach (var capture in captures.EnumerateArray()) + { + var isStdErr = capture.TryGetProperty("isStdErr", out var isStdErrEl) && isStdErrEl.GetBoolean(); + var when = capture.GetProperty("when").GetDateTime(); + var line = capture.GetProperty("line").GetString(); + + if (ratio != null) { - writer.Write('\n'); - - json.WriteStartObject(); - if (capture.IsStdErr) - json.WriteBoolean("isStdErr", true); - json.WriteString("when", capture.When); - json.WriteString("line", capture.Line); - json.WriteEndObject(); - json.Flush(); + if (last != null) + { + var delta = (when - last.Value).TotalMilliseconds; + await Task.Delay((int)(delta * ratio.Value), cancel); // returns CompletedTask if delay (int ms) is 0 + } + last = when; } + else if (delay != 0) + await Task.Delay(delay!.Value, cancel); - json.WriteEndArray(); - json.Flush(); - writer.Write("\n\n"); + if (isStdErr) + Terminal.StandardError.WriteLine(line); + else + Terminal.StandardOut.WriteLine(line); + } - json.WriteNumber("exitcode", cmd.Completion.Result); + return (CliExitCode)exitCode; +} - json.WriteEndObject(); - json.Flush(); +(ChildProcess cmd, ChannelReader reader) Exec(NPath command, IReadOnlyList args) +{ + // TODO: consider checking if it's a console app + // (see IsWindowsApplication at PowerShell\src\System.Management.Automation\engine\NativeCommandProcessor.cs:1199) - writer.Write('\n'); + var (commandPath, extraArgs) = ShellExecUtility.ResolveShellCommand(command); + if (extraArgs.Any()) + args = [..extraArgs, ..args]; - return (int)CliExitCode.Success; - } + var cmd = new ChildProcessBuilder() + .WithFileName(commandPath) + .WithArguments(args) + .WithRedirections(false, true, true) + .WithCreateWindow(false) + .WithWindowStyle(ProcessWindowStyle.Hidden) + .WithCancellationToken(ctx.CancelToken) + .WithThrowOnError(false) + .Run(); - if (ctx.Options.OptPlay != null) + var captures = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + var open = 0; + + async void Write(TextReader reader, bool isStdErr) { - ctx.OutLine($"Play: {ctx.Options.OptPlay}"); - if (ctx.Options.OptSpeed != null) + Interlocked.Increment(ref open); + + while (!ctx.IsCancellationRequested) { - if (ctx.Options.OptSpeed.EndsWith('x')) - ctx.OutLine($" -> at {ctx.Options.OptSpeed} speed"); - else - ctx.OutLine($" -> at {ctx.Options.OptSpeed} msec per line"); + var line = await reader.ReadLineAsync(ctx.CancelToken); + if (line == null) + { + if (Interlocked.Decrement(ref open) == 0) + captures.Writer.Complete(); + break; + } + + await captures.Writer.WriteAsync(new Capture(isStdErr, DateTime.Now, line), ctx.CancelToken); } - return (int)CliExitCode.Success; } - throw new UnreachableCodeException(); + _ = Task.Run(() => Write(cmd.StandardOut.TextReader, false), ctx.CancelToken); + _ = Task.Run(() => Write(cmd.StandardError.TextReader, true), ctx.CancelToken); + + return (cmd, captures.Reader); } -catch (Exception x) + +readonly record struct Capture(bool IsStdErr, DateTime When, string Line) { - ctx.Error(x); - return (int)CliExitCode.ErrorSoftware; + public override string ToString() => $"{(IsStdErr ? "! " : "")}{Line.SimpleEscape()} ({When:g})"; } - -readonly record struct Capture(bool IsStdErr, DateTime When, string Line); diff --git a/src/Stale.Cli/StaleCli.docopt.txt b/src/Stale.Cli/StaleCli.docopt.txt index 8d742c7..1049f23 100644 --- a/src/Stale.Cli/StaleCli.docopt.txt +++ b/src/Stale.Cli/StaleCli.docopt.txt @@ -1,22 +1,27 @@ {0}, the streaming output processor v{1} Usage: - {0} [--record RECORDED] -- COMMAND [ARG...] - {0} --play RECORDED [--speed SPEED] + {0} -- COMMAND [ARG...] + {0} record [RECORDED] -- COMMAND [ARG...] + {0} play RECORDED [--speed SPEED] {0} -h | --help {0} --version -Description: - Run COMMAND with optional ARG list and monitor+render its output. +Description + The main behavior is to run COMMAND with optional ARG list and monitor+render its output. + +Commands: + record Run COMMAND with optional ARG list and store it to RECORDED (defaults to stdout) for later playback. + play Deserialize output from RECORDED and play back as if it was running live. Useful for deterministic testing. Options: - --record RECORDED Serialize output into RECORDED for later playback. - --play RECORDED Deserialize output from RECORDED and play back as if it was running live. Useful for deterministic - testing. - --speed SPEED Set playback speed. Can either be a number like "10ms" (10 milliseconds delay per line) or "3.2x" - (3.2x the speed of the original). + --speed SPEED Set playback speed. Can either be: + - A time like "10ms" (10 milliseconds delay per line) in which case it ignores the original capture + timings and plays back at a fixed rate per line. Set to 0ms to play at maximum speed. + - A ratio like "3.2x" where it will play back the original timings but sped up/down by the multiplier + (> 1 is faster, < 1 is slower). Set to 1x to attempt to play back at the original speed. Special: - --verbose Log extra things - - This can be applied to any command line as long as it appears before anything else. + --verbose Log a lot of detail about what is happening. This can be applied to any command line as long as it appears + before anything else. + --cwd Use this as the working dir for {0}. Defaults to shell's working dir.