1 /**
2     A library for creating pleasant command-line output for repetitive tasks,
3     where a callback must iterate over an array of arguments.
4 
5     This library assumes support for ANSI Escape Codes. If your terminal does
6     not support ANSI Escape Codes, then tough luck.
7 
8     Authors:
9     $(UL
10         $(LI $(PERSON Jonathan M. Wilbur, jonathan@wilbur.space, http://jonathan.wilbur.space))
11     )
12     Copyright: Copyright (C) Jonathan M. Wilbur
13     License: $(LINK https://mit-license.org/, MIT License)
14     See_Also:
15         $(LINK2 https://en.wikipedia.org/wiki/ANSI_escape_code, ANSI Escape Codes)
16 */
17 module idiot;
18 import core.thread : Thread;
19 import core.time : dur, Duration;
20 import std.array : appender, Appender, replace;
21 import std.conv : text;
22 import std.datetime.date : DateTime;
23 import std.datetime.stopwatch : AutoStart, StopWatch;
24 import std.datetime.systime : Clock, SysTime;
25 import std.format : formattedWrite;
26 import std.random : uniform;
27 import std.stdio : stdout, write, writeln, writefln;
28 
29 immutable string ANSI_BLUE = "\x1B[34m";
30 immutable string ANSI_CYAN = "\x1B[36m";
31 immutable string ANSI_GREEN = "\x1B[32m";
32 immutable string ANSI_YELLOW = "\x1B[33m";
33 immutable string ANSI_RED = "\x1B[31m";
34 immutable string ANSI_ERROR = "\x1B[33;41m";
35 immutable string ANSI_RESET = "\x1B[0m";
36 immutable string ANSI_DELETE_LINE = "\x1B[2K\r";
37 
38 // TODO: Ctrl-C signal reception: https://forum.dlang.org/post/hzzbypitcpvssxohrkmg@forum.dlang.org
39 
40 ///
41 public
42 enum IdiotIterationStatus : ubyte
43 {
44     notStarted,
45     waiting,
46     working,
47     success,
48     failure,
49     errored
50 }
51 
52 ///
53 public
54 struct IdiotIteration(T)
55 {
56     IdiotIterationStatus status;
57     SysTime startTime;
58     SysTime endTime;
59     Duration duration;
60     T argument;
61 }
62 
63 ///
64 public
65 struct IdiotRun(T)
66 {
67     SysTime startTime;
68     SysTime endTime;
69     Duration duration;
70     size_t success;
71     size_t failure;
72     size_t errored;
73     IdiotIteration!(T)[] iterations;
74 }
75 
76 ///
77 public
78 class Idiot(T)
79 {
80     // Output configuration
81     public bool showFraction = true;
82     public bool showPercent = true;
83     public bool showMarginalTime = true;
84     public bool showCumulativeTime = true;
85 
86     // Execution configuration
87     public bool continueOnSuccess = true; // if false, only executes until success occurs
88     public bool continueOnFailure = true;
89     public bool continueOnException = true;
90     public bool delegate (size_t, T[]) callback;
91     public size_t millisecondsToPauseInBetweenIterations = 0u;
92     public size_t maximumMillisecondsOfAdditionalRandomPause = 0u;
93 
94     // Execution history
95     IdiotRun!(T)[] runs;
96 
97     /// Constructor that accepts a delegate
98     public nothrow @safe
99     this (in bool delegate (size_t, T[]) callback)
100     {
101         this.callback = callback;
102     }
103 
104     /// Constructor that accepts a function
105     public nothrow @system
106     this (in bool function (size_t, T[]) callback)
107     {
108         import std.functional : toDelegate;
109         this.callback = toDelegate(callback);
110     }
111 
112     /// Executes the callback over all elements in the supplied $(D arguments) array.
113     public
114     void execute(T[] arguments)
115     {
116         this.runs ~= IdiotRun!(T)();
117         this.runs[$-1].startTime = Clock.currTime();
118         StopWatch cumulativeStopWatch = StopWatch(AutoStart.yes);
119         for (size_t i = 0u; i < arguments.length; i++)
120         {
121             this.runs[$-1].iterations ~= IdiotIteration!(T)();
122             this.runs[$-1].iterations[$-1].startTime = Clock.currTime();
123             StopWatch iterationStopWatch = StopWatch(AutoStart.yes);
124 
125             this.runs[$-1].iterations[$-1].status = IdiotIterationStatus.waiting;
126             write
127             (
128                 "[ " ~ ANSI_CYAN ~ "WAITING" ~ ANSI_RESET ~ " ]",
129                 this.fraction(i+1, arguments.length),
130                 this.percent(i+1, arguments.length),
131                 this.marginalTime(),
132                 this.cumulativeTime(cumulativeStopWatch.peek()),
133                 "\t",
134                 text(arguments[i])
135             );
136 
137             /* NOTE:
138                 Standard Output (stdout) is buffered on Windows terminals, and
139                 flushed when a newline is encountered, so flush() is necessary
140                 when you are trying to write less than a full line.
141             */
142             version (Windows) stdout.flush(); // REVIEW: I believe this needs to change on Linux too...
143 
144             size_t millisecondsToPause =
145                 this.millisecondsToPauseInBetweenIterations +
146                 uniform(0u, this.maximumMillisecondsOfAdditionalRandomPause);
147             Thread currentThread = Thread.getThis();
148             currentThread.sleep(dur!("msecs")(millisecondsToPause));
149 
150             this.runs[$-1].iterations[$-1].status = IdiotIterationStatus.working;
151             write
152             (
153                 ANSI_DELETE_LINE,
154                 "[ " ~ ANSI_YELLOW ~ "WORKING" ~ ANSI_RESET ~ " ]",
155                 this.fraction(i+1, arguments.length),
156                 this.percent(i+1, arguments.length),
157                 this.marginalTime(),
158                 this.cumulativeTime(cumulativeStopWatch.peek()),
159                 "\t",
160                 text(arguments[i])
161             );
162 
163             /* NOTE:
164                 Standard Output (stdout) is buffered on Windows terminals, and
165                 flushed when a newline is encountered, so flush() is necessary
166                 when you are trying to write less than a full line.
167             */
168             version (Windows) stdout.flush(); // REVIEW: I believe this needs to change on Linux too...
169 
170             bool result;
171             try
172             {
173                 result = callback(i, arguments);
174             }
175             catch (Exception e)
176             {
177                 if (this.continueOnException)
178                 {
179                     this.runs[$-1].iterations[$-1].status = IdiotIterationStatus.errored;
180                     this.runs[$-1].errored++;
181 
182                     writeln
183                     (
184                         ANSI_DELETE_LINE,
185                         "[ " ~ ANSI_ERROR ~ "ERRORED" ~ ANSI_RESET ~ " ]",
186                         this.fraction(i+1, arguments.length),
187                         this.percent(i+1, arguments.length),
188                         this.marginalTime(),
189                         this.cumulativeTime(cumulativeStopWatch.peek()),
190                         "\t",
191                         text(arguments[i])
192                     );
193                     continue;
194                 }
195                 else throw e;
196             }
197             finally
198             {
199                 iterationStopWatch.stop();
200                 this.runs[$-1].iterations[$-1].duration = iterationStopWatch.peek();
201             }
202 
203             if (result) // SUCCESS
204             {
205                 this.runs[$-1].iterations[$-1].status = IdiotIterationStatus.success;
206                 this.runs[$-1].success++;
207 
208                 writeln
209                 (
210                     ANSI_DELETE_LINE,
211                     "[ " ~ ANSI_GREEN ~ "SUCCESS" ~ ANSI_RESET ~ " ]",
212                     this.fraction(i+1, arguments.length),
213                     this.percent(i+1, arguments.length),
214                     this.marginalTime(this.runs[$-1].iterations[$-1].duration),
215                     this.cumulativeTime(cumulativeStopWatch.peek()),
216                     "\t",
217                     text(arguments[i])
218                 );
219                 if (!this.continueOnSuccess) break;
220             }
221             else // FAILURE
222             {
223                 this.runs[$-1].iterations[$-1].status = IdiotIterationStatus.failure;
224                 this.runs[$-1].failure++;
225 
226                 writeln
227                 (
228                     ANSI_DELETE_LINE,
229                     "[ " ~ ANSI_RED ~ "FAILURE" ~ ANSI_RESET ~ " ]",
230                     this.fraction(i+1, arguments.length),
231                     this.percent(i+1, arguments.length),
232                     this.marginalTime(this.runs[$-1].iterations[$-1].duration),
233                     this.cumulativeTime(cumulativeStopWatch.peek()),
234                     "\t",
235                     text(arguments[i])
236                 );
237                 if (!this.continueOnFailure) break;
238             }
239         }
240         cumulativeStopWatch.stop();
241 
242         // Save
243         this.runs[$-1].duration = cumulativeStopWatch.peek();
244         this.runs[$-1].endTime = Clock.currTime();
245 
246         this.writeEndOfRunReport();
247     }
248 
249     private
250     string fraction (size_t numerator, size_t denominator)
251     {
252         if (!this.showFraction) return "";
253         import std.math : floor, log10;
254         size_t digitsNeeded = cast(size_t) (cast(real) denominator).log10().floor()+1;
255 
256         Appender!string writer = appender!string();
257         string formatString = " [ %" ~ text(digitsNeeded) ~ "d / %d ]";
258         formattedWrite(writer, formatString, numerator, denominator);
259         return writer.data;
260     }
261 
262     private
263     string percent (size_t numerator, size_t denominator)
264     {
265         if (!this.showPercent) return "";
266         float percent = ((cast(float) numerator / cast(float) denominator) * 100.0);
267 
268         Appender!string writer = appender!string();
269         formattedWrite(writer, " [ %8.4f%% ]", percent);
270         return writer.data;
271     }
272 
273     private
274     string marginalTime (Duration duration = Duration())
275     {
276         if (!this.showMarginalTime) return "";
277 
278         bool fasterThanLastIteration =
279         (
280             (this.runs[$-1].iterations.length >= 2u) &&
281             (duration > this.runs[$-1].iterations[$-2].duration)
282         ) ? true : false;
283 
284         ubyte hours;
285         ubyte minutes;
286         ubyte seconds;
287         this.runs[$-1].duration.split!("hours", "minutes", "seconds")(hours, minutes, seconds);
288         Appender!string writer = appender!string();
289         if (fasterThanLastIteration)
290             formattedWrite(writer, " [ MT %s%02d:%02d:%02d%s ]", ANSI_CYAN, hours, minutes, seconds, ANSI_RESET);
291         else
292             formattedWrite(writer, " [ MT %s%02d:%02d:%02d%s ]", ANSI_BLUE, hours, minutes, seconds, ANSI_RESET);
293         return writer.data;
294     }
295 
296     private
297     string cumulativeTime (Duration duration = Duration())
298     {
299         if (!this.showCumulativeTime) return "";
300 
301         ushort days;
302         ubyte hours;
303         ubyte minutes;
304         ubyte seconds;
305         duration.split!("days", "hours", "minutes", "seconds")(days, hours, minutes, seconds);
306         Appender!string writer = appender!string();
307         formattedWrite(writer, " [ CT %04d:%02d:%02d:%02d ]", days, hours, minutes, seconds);
308         return writer.data;
309     }
310 
311     private
312     void writeEndOfRunReport ()
313     {
314         writeln();
315 
316         // Print start and end times
317         writeln("Started: ", (cast(DateTime) this.runs[$-1].startTime).toSimpleString());
318         writeln("Ended: ", (cast(DateTime) this.runs[$-1].endTime).toSimpleString());
319 
320         // Print total time
321         ushort days;
322         ubyte hours;
323         ubyte minutes;
324         ubyte seconds;
325         ushort milliseconds;
326         this.runs[$-1].duration.split!("days", "hours", "minutes", "seconds", "msecs")(days, hours, minutes, seconds, milliseconds);
327         writefln("Total Time taken: %d Days, %d Hours, %d Minutes, %d Seconds, %d Milliseconds", days, hours, minutes, seconds, milliseconds);
328 
329         // Average time
330         long averageMilliseconds = this.runs[$-1].duration.total!"msecs" / this.runs[$-1].iterations.length;
331         if (averageMilliseconds > 86_400_000u)
332             writeln("Average time per iteration: ", (averageMilliseconds / 3_600_000u), " Hours");
333         else if (averageMilliseconds > 3_600_000u) // Display in minutes
334             writeln("Average time per iteration: ", (averageMilliseconds / 60_000u), " Minutes");
335         else if (averageMilliseconds > 60_000u) // Display in seconds
336             writeln("Average time per iteration: ", (averageMilliseconds / 1_000u), " Seconds");
337         else
338             writeln("Average time per iteration: ", (averageMilliseconds), " Milliseconds");
339 
340         // Pass / Fail
341         writeln("Successful iterations: ", this.runs[$-1].success);
342         writeln("Failed iterations: ", this.runs[$-1].failure);
343         writeln("Errored iterations: ", this.runs[$-1].errored);
344         writeln("Successful Rate: ", (cast(float) (this.runs[$-1].success) / cast(float) (this.runs[$-1].iterations.length)) * 100.0, "%");
345     }
346 }