1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package vogar;
18
19import com.google.common.collect.Lists;
20import java.util.Collection;
21import java.util.Collections;
22import java.util.Date;
23import java.util.HashMap;
24import java.util.List;
25import java.util.Map;
26import vogar.util.MarkResetConsole;
27
28/**
29 * Controls, formats and emits output to the command line. This class emits
30 * output in two modes:
31 * <ul>
32 *   <li><strong>Streaming</strong> output prints as it is received, but cannot
33 *       support multiple concurrent output streams.
34 *   <li><strong>Multiplexing</strong> buffers output until it is complete and
35 *       then prints it completely.
36 * </ul>
37 */
38public abstract class Console implements Log {
39    static final long DAY_MILLIS = 1000 * 60 * 60 * 24;
40    static final long HOUR_MILLIS = 1000 * 60 * 60;
41    static final long WARNING_HOURS = 12;
42    static final long FAILURE_HOURS = 48;
43
44    private boolean useColor;
45    private boolean ansi;
46    private boolean verbose;
47    protected String indent;
48    protected CurrentLine currentLine = CurrentLine.NEW;
49    protected final MarkResetConsole out = new MarkResetConsole(System.out);
50    protected MarkResetConsole.Mark currentVerboseMark;
51    protected MarkResetConsole.Mark currentStreamMark;
52
53    private Console() {}
54
55    public void setIndent(String indent) {
56        this.indent = indent;
57    }
58
59    public void setUseColor(
60      boolean useColor, int passColor, int skipColor, int failColor, int warnColor) {
61        this.useColor = useColor;
62        Color.PASS.setCode(passColor);
63        Color.SKIP.setCode(skipColor);
64        Color.FAIL.setCode(failColor);
65        Color.WARN.setCode(warnColor);
66        Color.COMMENT.setCode(34);
67    }
68
69    public void setAnsi(boolean ansi) {
70        this.ansi = ansi;
71    }
72
73    public void setVerbose(boolean verbose) {
74        this.verbose = verbose;
75    }
76
77    public boolean isVerbose() {
78        return verbose;
79    }
80
81    public synchronized void verbose(String s) {
82        /*
83         * terminal does't support overwriting output, so don't print
84         * verbose message unless requested.
85         */
86        if (!verbose && !ansi) {
87            return;
88        }
89        /*
90         * When writing verbose output in the middle of streamed output, keep
91         * the streamed mark location. That way we can remove the verbose output
92         * later without losing our position mid-line in the streamed output.
93         */
94        MarkResetConsole.Mark savedStreamMark = currentLine == CurrentLine.STREAMED_OUTPUT
95                ? out.mark()
96                : currentStreamMark;
97        newLine();
98        currentStreamMark = savedStreamMark;
99
100        currentVerboseMark = out.mark();
101        out.print(s);
102        currentLine = CurrentLine.VERBOSE;
103    }
104
105    public synchronized void warn(String message) {
106        warn(message, Collections.<String>emptyList());
107    }
108
109    /**
110     * Warns, and also puts a list of strings afterwards.
111     */
112    public synchronized void warn(String message, List<String> list) {
113        newLine();
114        out.println(colorString("Warning: " + message, Color.WARN));
115        for (String item : list) {
116            out.println(colorString(indent + item, Color.WARN));
117        }
118    }
119
120    public synchronized void info(String s) {
121        newLine();
122        out.println(s);
123    }
124
125    public synchronized void info(String message, Throwable throwable) {
126        newLine();
127        out.println(message);
128        throwable.printStackTrace(System.out);
129    }
130
131    /**
132     * Begins streaming output for the named action.
133     */
134    public void action(String name) {}
135
136    /**
137     * Begins streaming output for the named outcome.
138     */
139    public void outcome(String name) {}
140
141    /**
142     * Appends the action output immediately to the stream when streaming is on,
143     * or to a buffer when streaming is off. Buffered output will be held and
144     * printed only if the outcome is unsuccessful.
145     */
146    public abstract void streamOutput(String outcomeName, String output);
147
148    /**
149     * Hook to flush anything streamed via {@link #streamOutput}.
150     */
151    protected void flushBufferedOutput(String outcomeName) {}
152
153    /**
154     * Writes the action's outcome.
155     */
156    public synchronized void printResult(
157            String outcomeName, Result result, ResultValue resultValue, Expectation expectation) {
158        // when the result is interesting, include the description and bug number
159        if (result != Result.SUCCESS || resultValue != ResultValue.OK) {
160            if (!expectation.getDescription().isEmpty()) {
161                streamOutput(outcomeName, "\n" + colorString(expectation.getDescription(), Color.COMMENT));
162            }
163            if (expectation.getBug() != -1) {
164                streamOutput(outcomeName, "\n" + colorString("http://b/" + expectation.getBug(), Color.COMMENT));
165            }
166        }
167
168        flushBufferedOutput(outcomeName);
169
170        if (currentLine == CurrentLine.NAME) {
171            out.print(" ");
172        } else {
173            newLine(); // TODO: backup the cursor up to the name if there's no streaming output
174            out.print(indent + outcomeName + " ");
175        }
176
177        if (resultValue == ResultValue.OK) {
178            out.println(colorString("OK (" + result + ")", Color.PASS));
179        } else if (resultValue == ResultValue.FAIL) {
180            out.println(colorString("FAIL (" + result + ")", Color.FAIL));
181        } else if (resultValue == ResultValue.IGNORE) {
182            out.println(colorString("SKIP (" + result + ")", Color.WARN));
183        }
184
185        currentLine = CurrentLine.NEW;
186    }
187
188    public synchronized void summarizeOutcomes(Collection<AnnotatedOutcome> annotatedOutcomes) {
189        List<AnnotatedOutcome> annotatedOutcomesSorted =
190                AnnotatedOutcome.ORDER_BY_NAME.sortedCopy(annotatedOutcomes);
191
192        List<String> failures = Lists.newArrayList();
193        List<String> skips = Lists.newArrayList();
194        List<String> successes = Lists.newArrayList();
195        List<String> warnings = Lists.newArrayList();
196
197        // figure out whether each outcome is noteworthy, and add a message to the appropriate list
198        for (AnnotatedOutcome annotatedOutcome : annotatedOutcomesSorted) {
199            if (!annotatedOutcome.isNoteworthy()) {
200                continue;
201            }
202
203            Color color;
204            List<String> list;
205            ResultValue resultValue = annotatedOutcome.getResultValue();
206            if (resultValue == ResultValue.OK) {
207                color = Color.PASS;
208                list = successes;
209            } else if (resultValue == ResultValue.FAIL) {
210                color = Color.FAIL;
211                list = failures;
212            } else if (resultValue == ResultValue.WARNING) {
213                color = Color.WARN;
214                list = warnings;
215            } else {
216                color = Color.SKIP;
217                list = skips;
218            }
219
220            Long lastRun = annotatedOutcome.lastRun(null);
221            String timestamp;
222            if (lastRun == null) {
223                timestamp = colorString("unknown", Color.WARN);
224            } else {
225                timestamp = formatElapsedTime(new Date().getTime() - lastRun);
226            }
227
228            String brokeThisMessage = "";
229            ResultValue mostRecentResultValue = annotatedOutcome.getMostRecentResultValue(null);
230            if (mostRecentResultValue != null && resultValue != mostRecentResultValue) {
231                if (resultValue == ResultValue.OK) {
232                    brokeThisMessage = colorString(" (you might have fixed this)", Color.WARN);
233                } else {
234                    brokeThisMessage = colorString(" (you might have broken this)", Color.WARN);
235                }
236            } else if (mostRecentResultValue == null) {
237                brokeThisMessage = colorString(" (no test history available)", Color.WARN);
238            }
239
240            List<ResultValue> previousResultValues = annotatedOutcome.getPreviousResultValues();
241            int numPreviousResultValues = previousResultValues.size();
242            int numResultValuesToShow = Math.min(10, numPreviousResultValues);
243            List<ResultValue> previousResultValuesToShow = previousResultValues.subList(
244                    numPreviousResultValues - numResultValuesToShow, numPreviousResultValues);
245
246            StringBuilder sb = new StringBuilder();
247            sb.append(indent);
248            sb.append(colorString(annotatedOutcome.getOutcome().getName(), color));
249            if (!previousResultValuesToShow.isEmpty()) {
250                sb.append(String.format(" [last %d: %s] [last run: %s]",
251                        previousResultValuesToShow.size(),
252                        generateSparkLine(previousResultValuesToShow),
253                        timestamp));
254            }
255            sb.append(brokeThisMessage);
256            list.add(sb.toString());
257        }
258
259        newLine();
260        if (!successes.isEmpty()) {
261            out.println("Success summary:");
262            for (String success : successes) {
263                out.println(success);
264            }
265        }
266        if (!failures.isEmpty()) {
267            out.println("Failure summary:");
268            for (String failure : failures) {
269                out.println(failure);
270            }
271        }
272        if (!skips.isEmpty()) {
273            out.println("Skips summary:");
274            for (String skip : skips) {
275                out.println(skip);
276            }
277        }
278        if (!warnings.isEmpty()) {
279            out.println("Warnings summary:");
280            for (String warning : warnings) {
281                out.println(warning);
282            }
283        }
284    }
285
286    private String formatElapsedTime(long elapsedTime) {
287        if (elapsedTime < 0) {
288            throw new IllegalArgumentException("non-negative elapsed times only");
289        }
290
291        String formatted;
292        if (elapsedTime >= DAY_MILLIS) {
293            long days = elapsedTime / DAY_MILLIS;
294            formatted = String.format("%d days ago", days);
295        } else if (elapsedTime >= HOUR_MILLIS) {
296            long hours = elapsedTime / HOUR_MILLIS;
297            formatted = String.format("%d hours ago", hours);
298        } else {
299            formatted = "less than an hour ago";
300        }
301
302        Color color = elapsedTimeWarningColor(elapsedTime);
303        return colorString(formatted, color);
304    }
305
306    private Color elapsedTimeWarningColor(long elapsedTime) {
307        if (elapsedTime < WARNING_HOURS * HOUR_MILLIS) {
308            return Color.PASS;
309        } else if (elapsedTime < FAILURE_HOURS * HOUR_MILLIS) {
310            return Color.WARN;
311        } else {
312            return Color.FAIL;
313        }
314    }
315
316    private String generateSparkLine(List<ResultValue> resultValues) {
317        StringBuilder sb = new StringBuilder();
318        for (ResultValue resultValue : resultValues) {
319            if (resultValue == ResultValue.OK) {
320                sb.append(colorString("\u2713", Color.PASS));
321            } else if (resultValue == ResultValue.FAIL) {
322                sb.append(colorString("X", Color.FAIL));
323            } else {
324                sb.append(colorString("-", Color.WARN));
325            }
326        }
327        return sb.toString();
328    }
329
330    /**
331     * Prints the action output with appropriate indentation.
332     */
333    public synchronized void streamOutput(CharSequence streamedOutput) {
334        if (streamedOutput.length() == 0) {
335            return;
336        }
337
338        String[] lines = messageToLines(streamedOutput.toString());
339
340        if (currentLine == CurrentLine.VERBOSE && currentStreamMark != null && ansi) {
341            currentStreamMark.reset();
342            currentStreamMark = null;
343        } else if (currentLine != CurrentLine.STREAMED_OUTPUT) {
344            newLine();
345            out.print(indent);
346            out.print(indent);
347        }
348        out.print(lines[0]);
349        currentLine = CurrentLine.STREAMED_OUTPUT;
350
351        for (int i = 1; i < lines.length; i++) {
352            newLine();
353
354            if (lines[i].length() > 0) {
355                out.print(indent);
356                out.print(indent);
357                out.print(lines[i]);
358                currentLine = CurrentLine.STREAMED_OUTPUT;
359            }
360        }
361    }
362
363    /**
364     * Inserts a linebreak if necessary.
365     */
366    protected void newLine() {
367        currentStreamMark = null;
368
369        if (currentLine == CurrentLine.VERBOSE && !verbose && ansi) {
370            /*
371             * Verbose means we leave all verbose output on the screen.
372             * Otherwise we overwrite verbose output when new output arrives.
373             */
374            currentVerboseMark.reset();
375        } else if (currentLine != CurrentLine.NEW) {
376            out.print("\n");
377        }
378
379        currentLine = CurrentLine.NEW;
380    }
381
382    /**
383     * Status of a currently-in-progress line of output.
384     */
385    enum CurrentLine {
386
387        /**
388         * The line is blank.
389         */
390        NEW,
391
392        /**
393         * The line contains streamed application output. Additional streamed
394         * output may be appended without additional line separators or
395         * indentation.
396         */
397        STREAMED_OUTPUT,
398
399        /**
400         * The line contains the name of an action or outcome. The outcome's
401         * result (such as "OK") can be appended without additional line
402         * separators or indentation.
403         */
404        NAME,
405
406        /**
407         * The line contains verbose output, and may be overwritten.
408         */
409        VERBOSE,
410    }
411
412    /**
413     * Returns an array containing the lines of the given text.
414     */
415    private String[] messageToLines(String message) {
416        // pass Integer.MAX_VALUE so split doesn't trim trailing empty strings.
417        return message.split("\r\n|\r|\n", Integer.MAX_VALUE);
418    }
419
420    private enum Color {
421        PASS, FAIL, SKIP, WARN, COMMENT;
422
423        int code = 0;
424
425        public int getCode() {
426            return code;
427        }
428
429        public void setCode(int code) {
430            this.code = code;
431        }
432    }
433
434    protected String colorString(String message, Color color) {
435        return useColor ? ("\u001b[" + color.getCode() + ";1m" + message + "\u001b[0m") : message;
436    }
437
438    /**
439     * This console prints output as it's emitted. It supports at most one
440     * action at a time.
441     */
442    static class StreamingConsole extends Console {
443        private String currentName;
444
445        @Override public synchronized void action(String name) {
446            newLine();
447            out.print("Action " + name);
448            currentName = name;
449            currentLine = CurrentLine.NAME;
450        }
451
452        /**
453         * Prints the beginning of the named outcome.
454         */
455        @Override public synchronized void outcome(String name) {
456            // if the outcome and action names are the same, omit the outcome name
457            if (name.equals(currentName)) {
458                return;
459            }
460
461            currentName = name;
462            newLine();
463            out.print(indent + name);
464            currentLine = CurrentLine.NAME;
465        }
466
467        @Override public synchronized void streamOutput(String outcomeName, String output) {
468            streamOutput(output);
469        }
470    }
471
472    /**
473     * This console buffers output, only printing when a result is found. It
474     * supports multiple concurrent actions.
475     */
476    static class MultiplexingConsole extends Console {
477        private final Map<String, StringBuilder> bufferedOutputByOutcome = new HashMap<String, StringBuilder>();
478
479        @Override public synchronized void streamOutput(String outcomeName, String output) {
480            StringBuilder buffer = bufferedOutputByOutcome.get(outcomeName);
481            if (buffer == null) {
482                buffer = new StringBuilder();
483                bufferedOutputByOutcome.put(outcomeName, buffer);
484            }
485
486            buffer.append(output);
487        }
488
489        @Override protected synchronized void flushBufferedOutput(String outcomeName) {
490            newLine();
491            out.print(indent + outcomeName);
492            currentLine = CurrentLine.NAME;
493
494            StringBuilder buffer = bufferedOutputByOutcome.remove(outcomeName);
495            if (buffer != null) {
496                streamOutput(buffer);
497            }
498        }
499    }
500}
501