diff --git a/src/apps/fastcache-cc/LauncherCli.cpp b/src/apps/fastcache-cc/LauncherCli.cpp index 48d662a4..644dc280 100644 --- a/src/apps/fastcache-cc/LauncherCli.cpp +++ b/src/apps/fastcache-cc/LauncherCli.cpp @@ -32,6 +32,12 @@ namespace .arity = Arity::None, .operands = " [options]", .summary = "Report cache statistics for this machine." }, + FlagSpec { .action = Action::HtmlStats, + .primary = "--html-stats", + .aliases = NoAliases, + .arity = Arity::None, + .operands = " [options]", + .summary = "Render cache statistics as a self-contained HTML dashboard." }, FlagSpec { .action = Action::ZeroStats, .primary = "--zero-stats", .aliases = ZeroStatsAliases, @@ -62,6 +68,22 @@ namespace .summary = "Report only this cohort." }, }; + /// The sub-options accepted after `--html-stats`. + constexpr std::array HtmlStatsOptionTable { + FlagSpec { .action = Action::Cohort, + .primary = "--cohort", + .aliases = NoAliases, + .arity = Arity::Value, + .operands = " ", + .summary = "Report only this cohort." }, + FlagSpec { .action = Action::OutputPath, + .primary = "--out", + .aliases = NoAliases, + .arity = Arity::Value, + .operands = " ", + .summary = "Write the dashboard here instead of the default report path." }, + }; + /// True when `token` was meant as a launcher option rather than as the /// compiler to front. /// @@ -106,7 +128,7 @@ namespace /// @return The command. [[nodiscard]] Command Selected(Action action) { - return { .action = action, .cohortFilter = {}, .diagnostic = {} }; + return { .action = action, .cohortFilter = {}, .outputPath = {}, .diagnostic = {} }; } /// A rejected command line. @@ -114,7 +136,7 @@ namespace /// @return A `UsageError` command carrying `diagnostic`. [[nodiscard]] Command Rejected(std::string diagnostic) { - return { .action = Action::UsageError, .cohortFilter = {}, .diagnostic = std::move(diagnostic) }; + return { .action = Action::UsageError, .cohortFilter = {}, .outputPath = {}, .diagnostic = std::move(diagnostic) }; } /// The `FASTCACHE_*` variables, in the order `--help` documents them. @@ -190,9 +212,9 @@ namespace } // namespace -Command ParseStatsOptions(std::span args, std::span options) +Command ParseStatsOptions(std::span args, std::span options, Action baseAction) { - Command cmd = Selected(Action::ShowStats); + Command cmd = Selected(baseAction); // Walk a shrinking span rather than an index so consuming a flag's value // is an explicit step; a value that is never consumed has already been a @@ -235,6 +257,8 @@ Command ParseStatsOptions(std::span args, std::spanaction == Action::Cohort) cmd.cohortFilter = value; + else if (option->action == Action::OutputPath) + cmd.outputPath = value; } return cmd; } @@ -244,6 +268,11 @@ std::span TopLevelFlags() noexcept return TopLevelTable; } +std::span HtmlStatsOptions() noexcept +{ + return HtmlStatsOptionTable; +} + std::span StatsOptions() noexcept { return StatsOptionTable; @@ -267,6 +296,8 @@ Command ParseTopLevel(std::span args) { if (flag->action == Action::ShowStats) return ParseStatsOptions(args.subspan(1), StatsOptions()); + if (flag->action == Action::HtmlStats) + return ParseStatsOptions(args.subspan(1), HtmlStatsOptions(), Action::HtmlStats); return Selected(flag->action); } @@ -296,6 +327,10 @@ std::string HelpText(UsageColor color) for (auto const& spec: StatsOptions()) statsRows.Add(RenderForms(spec), spec.summary); + UsageRows htmlStatsRows; + for (auto const& spec: HtmlStatsOptions()) + htmlStatsRows.Add(RenderForms(spec), spec.summary); + UsageRows environmentRows; for (auto const& spec: LauncherEnvironment()) environmentRows.Add(std::string { spec.name }, spec.summary); @@ -303,6 +338,7 @@ std::string HelpText(UsageColor color) auto const blocks = std::to_array({ { .entries = usageRows.Rows() }, { .entries = statsRows.Rows() }, + { .entries = htmlStatsRows.Rows() }, { .entries = environmentRows.Rows() }, { .text = StateDirectoryNote, .textIndent = 2 }, { .entries = StateDirectoryRows }, @@ -315,10 +351,11 @@ std::string HelpText(UsageColor color) { .subject = "fastcache-cc - a compiler launcher over the fastcached compile cache." }, { .title = "USAGE", .blocks = allBlocks.subspan(0, 1) }, { .title = "STATS OPTIONS", .blocks = allBlocks.subspan(1, 1) }, + { .title = "HTML STATS OPTIONS", .blocks = allBlocks.subspan(2, 1) }, // The three ENVIRONMENT blocks share one section so its two runs of rows // keep a common column even though prose sits between them. - { .title = "ENVIRONMENT", .blocks = allBlocks.subspan(2, 3) }, - { .blocks = allBlocks.subspan(5, 2) }, + { .title = "ENVIRONMENT", .blocks = allBlocks.subspan(3, 3) }, + { .blocks = allBlocks.subspan(6, 2) }, }); return RenderUsage({ .sections = sections }, color); diff --git a/src/apps/fastcache-cc/LauncherCli.hpp b/src/apps/fastcache-cc/LauncherCli.hpp index 7ea6432a..b965f759 100644 --- a/src/apps/fastcache-cc/LauncherCli.hpp +++ b/src/apps/fastcache-cc/LauncherCli.hpp @@ -14,16 +14,20 @@ namespace FastCache::Cc /// What the launcher was asked to do. /// -/// Every value except `Cohort` is a possible `Command::action`; `Cohort` names a -/// `--show-stats` sub-option and is only ever seen inside `StatsOptions()`. +/// Every value except `Cohort` and `OutputPath` is a possible +/// `Command::action`; those two name sub-options (`--show-stats`'s and +/// `--html-stats`'s respectively) and are only ever seen inside +/// `StatsOptions()`/`HtmlStatsOptions()`. enum class Action : std::uint8_t { Compile, ///< The default: front a real compile. Help, ///< Print the usage text. Version, ///< Print the launcher version. - ShowStats, ///< Report the recorded statistics. + ShowStats, ///< Report the recorded statistics as plain text. + HtmlStats, ///< Render the recorded statistics as a self-contained HTML dashboard. ZeroStats, ///< Discard the statistics log. Cohort, ///< Stats sub-option: restrict the report to one cohort. + OutputPath, ///< `--html-stats` sub-option: where to write the dashboard. NoArguments, ///< Invoked with nothing at all — usage, to stderr. UsageError, ///< Unknown option or a missing option value. }; @@ -55,6 +59,15 @@ struct FlagSpec /// @return A view of the static table; never empty. [[nodiscard]] std::span StatsOptions() noexcept; +/// The sub-options accepted after `--html-stats`. +/// +/// A separate table from `StatsOptions()` rather than a superset: `--out` +/// means nothing after `--show-stats` (it writes to stdout, always), so +/// accepting it there would silently ignore a flag the caller thought did +/// something. +/// @return A view of the static table; never empty. +[[nodiscard]] std::span HtmlStatsOptions() noexcept; + /// Look a token up in a flag table, matching the primary spelling or any alias. /// @param table The table to search. /// @param token The command-line token to match. @@ -67,6 +80,7 @@ struct Command { Action action { Action::Compile }; ///< The selected action. std::string cohortFilter; ///< From `--cohort`; empty means no filtering. + std::string outputPath; ///< From `--html-stats`'s `--out`; empty means the default path. std::string diagnostic; ///< Why parsing failed; set iff `action == UsageError`. }; @@ -83,15 +97,19 @@ struct Command /// @return The resolved command. [[nodiscard]] Command ParseTopLevel(std::span args); -/// Parse the sub-options that may follow `--show-stats`. +/// Parse the sub-options that may follow `--show-stats` or `--html-stats`. /// /// The option table is a parameter rather than a lookup so the generic /// table-driven paths stay exercisable independently of which options happen to -/// exist today; `ParseTopLevel` passes `StatsOptions()`. -/// @param args The arguments after the `--show-stats` token itself. +/// exist today; `ParseTopLevel` passes `StatsOptions()` or `HtmlStatsOptions()`. +/// @param args The arguments after the `--show-stats`/`--html-stats` token itself. /// @param options The table to match each token against. -/// @return A `ShowStats` command, or a usage error. -[[nodiscard]] Command ParseStatsOptions(std::span args, std::span options); +/// @param baseAction The action the returned command carries on success — +/// `ShowStats` for `--show-stats`, `HtmlStats` for `--html-stats`. +/// @return A command with the given action, or a usage error. +[[nodiscard]] Command ParseStatsOptions(std::span args, + std::span options, + Action baseAction = Action::ShowStats); /// One environment variable the launcher reads. /// diff --git a/src/apps/fastcache-cc/LauncherCli_test.cpp b/src/apps/fastcache-cc/LauncherCli_test.cpp index de82c6ee..d49206af 100644 --- a/src/apps/fastcache-cc/LauncherCli_test.cpp +++ b/src/apps/fastcache-cc/LauncherCli_test.cpp @@ -35,7 +35,7 @@ Command Parse(std::vector const& argv) TEST_CASE("every accepted flag and alias appears in the help text") { auto const help = HelpText(); - for (auto const& table: { TopLevelFlags(), StatsOptions() }) + for (auto const& table: { TopLevelFlags(), StatsOptions(), HtmlStatsOptions() }) { for (auto const& spec: table) { @@ -74,6 +74,25 @@ TEST_CASE("the short stats aliases match their long forms") CHECK(Parse({ "--zero-stats" }).action == Action::ZeroStats); } +TEST_CASE("the html-stats flag dispatches with no options set") +{ + auto const cmd = Parse({ "--html-stats" }); + CHECK(cmd.action == Action::HtmlStats); + CHECK(cmd.cohortFilter.empty()); + CHECK(cmd.outputPath.empty()); +} + +TEST_CASE("html-stats accepts --out and --cohort like --show-stats does") +{ + auto const cmd = Parse({ "--html-stats", "--out", "report.html", "--cohort", "ci-main" }); + CHECK(cmd.action == Action::HtmlStats); + CHECK(cmd.outputPath == "report.html"); + CHECK(cmd.cohortFilter == "ci-main"); + + auto const joined = Parse({ "--html-stats", "--out=report.html" }); + CHECK(joined.outputPath == "report.html"); +} + TEST_CASE("help is reachable by all three spellings") { CHECK(Parse({ "--help" }).action == Action::Help); diff --git a/src/apps/fastcache-cc/Stats.cpp b/src/apps/fastcache-cc/Stats.cpp index 3d1e93f5..27931057 100644 --- a/src/apps/fastcache-cc/Stats.cpp +++ b/src/apps/fastcache-cc/Stats.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,47 @@ namespace constexpr char FieldSeparator = '\t'; + /// SGR escapes for the terminal report's hit/miss/warning cues. + /// + /// A dedicated palette rather than reusing `Cli::UsagePalette`: that one + /// names *usage-doc* roles (heading, term) that have nothing to do with + /// outcome coloring, and folding unrelated concepts into one struct would + /// make neither caller's intent legible. Every field is empty in the plain + /// palette, the same trick `UsagePalette` uses, so one render path serves + /// both and color never disturbs the sparkline/column alignment. + struct StatsPalette + { + std::string_view reset; + std::string_view good; ///< Hits, direct hits — the cache doing its job. + std::string_view bad; ///< Unavailable / "CACHE NOT REACHED". + std::string_view neutral; ///< Misses, uncacheable — expected, not alarming. + }; + + constexpr StatsPalette ColoredStatsPalette { + .reset = "\x1b[0m", + .good = "\x1b[32m", // green + .bad = "\x1b[31m", // red + .neutral = "\x1b[33m", // yellow + }; + constexpr StatsPalette PlainStatsPalette {}; + + /// @param color Whether to emit ANSI SGR escapes. + /// @return The matching palette. + [[nodiscard]] constexpr StatsPalette const& StatsPaletteFor(UsageColor color) noexcept + { + return color == UsageColor::Colored ? ColoredStatsPalette : PlainStatsPalette; + } + + /// Wrap `text` in `color`/`reset` when `color` is non-empty; otherwise + /// return it unchanged. Centralizes the "only colorize when there is a + /// color to apply" check every call site would otherwise repeat. + [[nodiscard]] std::string Colorize(std::string_view text, std::string_view color, std::string_view reset) + { + if (color.empty()) + return std::string { text }; + return std::string { color } + std::string { text } + std::string { reset }; + } + /// Replace characters that would corrupt the one-record-per-line format. [[nodiscard]] std::string Sanitize(std::string_view text) { @@ -118,6 +160,40 @@ namespace return Outcome::Unavailable; } + /// Rebuild a `Record` from one log line's already-split fields. + /// + /// Every trailing field predates the one before it in the log's history — + /// the phase columns, the direct-hit flag, the timestamp — so each is only + /// read when present, exactly mirroring what `AppendRecord` would have + /// written at that time. This is the one place that decodes a line; both + /// `ParseLog` and (through it) `FormatReport` fold over its output rather + /// than re-reading fields themselves. + [[nodiscard]] Record DecodeFields(std::vector const& fields) + { + Record record; + record.outcome = ParseOutcome(fields[0]); + record.cohort = std::string { fields[1] }; + record.valueBytes = ParseUnsigned(fields[2]); + record.elapsedMs = ParseUnsigned(fields[3]); + if (fields.size() >= 5) + record.source = std::string { fields[4] }; + if (fields.size() >= 6) + record.detail = std::string { fields[5] }; + if (fields.size() >= 8) + { + record.preprocessMs = ParseUnsigned(fields[6]); + record.cacheMs = ParseUnsigned(fields[7]); + record.hasPhaseColumns = true; + } + if (fields.size() >= 9) + record.directMs = ParseUnsigned(fields[8]); + if (fields.size() >= 10) + record.directHit = fields[9] == "1"; + if (fields.size() >= 11) + record.timestampUnixSeconds = ParseUnsigned(fields[10]); + return record; + } + /// Split a log line into its tab-separated fields. [[nodiscard]] std::vector SplitFields(std::string_view line) { @@ -249,7 +325,7 @@ namespace << " p95=" << FormatMs(Percentile(sorted, 0.95)) << " max=" << FormatMs(high) << '\n'; } - void AppendTallyLines(std::ostringstream& out, Tally const& tally) + void AppendTallyLines(std::ostringstream& out, Tally const& tally, StatsPalette const& palette) { // Rate the cache against the compiles it could actually serve. Dividing by // every invocation blends "the cache did not have it" with "the cache was @@ -257,17 +333,19 @@ namespace auto const servable = tally.hits + tally.misses; out << " compiles : " << tally.Total() << '\n' - << " hits : " << tally.hits << " (" << Percent(tally.hits, servable) << " of " << servable - << " cacheable)\n"; + << " hits : " << Colorize(std::to_string(tally.hits), palette.good, palette.reset) << " (" + << Percent(tally.hits, servable) << " of " << servable << " cacheable)\n"; if (tally.directHits > 0) out << " via direct : " << tally.directHits << " (" << Percent(tally.directHits, tally.hits) << " of hits, no preprocess)\n"; - out << " misses : " << tally.misses << '\n'; + out << " misses : " << Colorize(std::to_string(tally.misses), palette.neutral, palette.reset) << '\n'; if (tally.uncacheable > 0) - out << " uncacheable : " << tally.uncacheable << '\n'; + out << " uncacheable : " << Colorize(std::to_string(tally.uncacheable), palette.neutral, palette.reset) + << '\n'; if (tally.unavailable > 0) - out << " unavailable : " << tally.unavailable << " (" << Percent(tally.unavailable, tally.Total()) - << " of all compiles -- CACHE NOT REACHED)\n"; + out << " unavailable : " << Colorize(std::to_string(tally.unavailable), palette.bad, palette.reset) << " (" + << Percent(tally.unavailable, tally.Total()) << " of all compiles -- " + << Colorize("CACHE NOT REACHED", palette.bad, palette.reset) << ")\n"; if (!tally.reasons.empty()) { @@ -275,7 +353,7 @@ namespace std::vector> ranked { tally.reasons.begin(), tally.reasons.end() }; std::ranges::sort(ranked, [](auto const& a, auto const& b) { return a.second > b.second; }); for (auto const& [reason, count]: ranked) - out << " " << count << "x " << reason << '\n'; + out << " " << Colorize(std::to_string(count) + "x", palette.bad, palette.reset) << " " << reason << '\n'; } if (!tally.hitMs.empty() || !tally.missMs.empty()) @@ -346,6 +424,8 @@ void AppendRecord(Record const& record) line += std::to_string(record.directMs); line += FieldSeparator; line += record.directHit ? "1" : "0"; + line += FieldSeparator; + line += std::to_string(record.timestampUnixSeconds); line += '\n'; #if defined(_WIN32) @@ -379,20 +459,17 @@ void AppendRecord(Record const& record) #endif } -std::string FormatReport(std::string_view cohortFilter) +std::vector ParseLog(std::string_view cohortFilter) { auto const path = LogPath(); if (path.empty()) - return "fastcache-cc: no state directory available; statistics are disabled.\n"; + return {}; std::ifstream input { path, std::ios::binary }; if (!input) - return "fastcache-cc: no statistics recorded yet (" + path + ").\n"; - - Tally overall; - std::map byCohort; - std::map neverCached; + return {}; + std::vector records; std::string line; while (std::getline(input, line)) { @@ -405,54 +482,87 @@ std::string FormatReport(std::string_view cohortFilter) if (fields.size() < 4) continue; - std::string const cohort { fields[1] }; - if (!cohortFilter.empty() && cohort != cohortFilter) + if (!cohortFilter.empty() && fields[1] != cohortFilter) continue; - auto const outcome = ParseOutcome(fields[0]); - auto const elapsed = ParseUnsigned(fields[3]); - std::string const reason { fields.size() >= 6 ? fields[5] : std::string_view {} }; + records.push_back(DecodeFields(fields)); + } + return records; +} - for (Tally* tally: { &overall, &byCohort[cohort] }) +namespace +{ + /// Fold parsed records into the overall tally, the per-cohort tallies, and + /// the never-cached attribution — the three views every report renders. + struct FoldedLog + { + Tally overall; + std::map byCohort; + std::map neverCached; + }; + + [[nodiscard]] FoldedLog FoldRecords(std::vector const& records) + { + FoldedLog folded; + for (auto const& record: records) { - switch (outcome) + for (Tally* tally: { &folded.overall, &folded.byCohort[record.cohort] }) { - case Outcome::Hit: - ++tally->hits; - tally->hitMs.push_back(elapsed); - // Older log lines predate the phase columns; absent fields simply - // leave the phase histograms empty rather than skewing them to 0. - if (fields.size() >= 8) - { - tally->hitPreprocessMs.push_back(ParseUnsigned(fields[6])); - tally->hitCacheMs.push_back(ParseUnsigned(fields[7])); - } - if (fields.size() >= 10 && fields[9] == "1") - { - ++tally->directHits; - tally->directMs.push_back(ParseUnsigned(fields[8])); - } - break; - case Outcome::Miss: - ++tally->misses; - tally->missMs.push_back(elapsed); - break; - case Outcome::Uncacheable: - ++tally->uncacheable; - break; - case Outcome::Unavailable: - ++tally->unavailable; - break; + switch (record.outcome) + { + case Outcome::Hit: + ++tally->hits; + tally->hitMs.push_back(record.elapsedMs); + // Older log lines predate the phase columns; leave the phase + // histograms empty for them rather than skew them with a false + // zero (see Record::hasPhaseColumns). + if (record.hasPhaseColumns) + { + tally->hitPreprocessMs.push_back(record.preprocessMs); + tally->hitCacheMs.push_back(record.cacheMs); + } + if (record.directHit) + { + ++tally->directHits; + tally->directMs.push_back(record.directMs); + } + break; + case Outcome::Miss: + ++tally->misses; + tally->missMs.push_back(record.elapsedMs); + break; + case Outcome::Uncacheable: + ++tally->uncacheable; + break; + case Outcome::Unavailable: + ++tally->unavailable; + break; + } + if (!record.detail.empty()) + ++tally->reasons[record.detail]; } - if (!reason.empty()) - ++tally->reasons[reason]; - } - // Attribute the never-cached translation units so a permanently - // uncacheable file is visible rather than hidden in a percentage. - if ((outcome == Outcome::Uncacheable || outcome == Outcome::Unavailable) && fields.size() >= 5 && !fields[4].empty()) - ++neverCached[std::string { fields[4] }]; + // Attribute the never-cached translation units so a permanently + // uncacheable file is visible rather than hidden in a percentage. + if ((record.outcome == Outcome::Uncacheable || record.outcome == Outcome::Unavailable) && !record.source.empty()) + ++folded.neverCached[record.source]; + } + return folded; } +} // namespace + +std::string FormatReport(std::string_view cohortFilter, UsageColor color) +{ + auto const path = LogPath(); + if (path.empty()) + return "fastcache-cc: no state directory available; statistics are disabled.\n"; + + std::ifstream const probe { path, std::ios::binary }; + if (!probe) + return "fastcache-cc: no statistics recorded yet (" + path + ").\n"; + + auto const records = ParseLog(cohortFilter); + auto const [overall, byCohort, neverCached] = FoldRecords(records); if (overall.Total() == 0) { @@ -461,13 +571,14 @@ std::string FormatReport(std::string_view cohortFilter) return "fastcache-cc: no records for cohort '" + std::string { cohortFilter } + "'.\n"; } + auto const& palette = StatsPaletteFor(color); std::ostringstream out; out << "fastcache-cc statistics (" << path << ")\n\n"; if (cohortFilter.empty()) out << "all cohorts\n"; else out << "cohort " << cohortFilter << '\n'; - AppendTallyLines(out, overall); + AppendTallyLines(out, overall, palette); if (cohortFilter.empty() && byCohort.size() > 1) { @@ -476,7 +587,7 @@ std::string FormatReport(std::string_view cohortFilter) { out << "\n " << (cohort.empty() ? "(unset)" : cohort) << '\n'; std::ostringstream nested; - AppendTallyLines(nested, tally); + AppendTallyLines(nested, tally, palette); std::istringstream lines { nested.str() }; std::string nestedLine; while (std::getline(lines, nestedLine)) @@ -514,4 +625,420 @@ bool ResetLog() return !std::filesystem::exists(path); } +namespace +{ + /// Escape the five characters HTML gives meaning to. Every value folded + /// into the dashboard — a cohort name, a fall-back reason, a translation + /// unit path — comes from the invocations log, which a compile can steer + /// (a path or a fallback detail string), so nothing is trusted verbatim. + [[nodiscard]] std::string EscapeHtml(std::string_view text) + { + std::string out; + out.reserve(text.size()); + for (char const c: text) + { + switch (c) + { + case '&': + out += "&"; + break; + case '<': + out += "<"; + break; + case '>': + out += ">"; + break; + case '"': + out += """; + break; + case '\'': + out += "'"; + break; + default: + out += c; + } + } + return out; + } + + /// One rendered SVG bar, in the `` attributes the template writes + /// verbatim: `x`/`y`/`w`(idth)/`h`(eight), already formatted as compact + /// decimal text so the renderer never round-trips through iostream twice. + struct Bar + { + std::string x, y, w, h; + }; + + /// Render a double as fixed decimal text with a bounded fractional part, + /// trimming a trailing ".0" so whole pixel coordinates stay short. + [[nodiscard]] std::string FormatCoord(double value) + { + std::array buffer {}; + auto const written = std::snprintf(buffer.data(), buffer.size(), "%.1f", value); + return written > 0 ? std::string { buffer.data(), static_cast(written) } : "0"; + } + + /// Bucket one latency sample set into `binCount` bars spanning its own + /// min..max, mirroring AppendHistogram's data-derived-range principle so + /// the HTML chart and the terminal sparkline never disagree about shape. + /// @param samples Raw millisecond samples; may be empty. + /// @param binCount Number of bars to emit. + /// @param chartWidth Total SVG width the bars are laid out across. + /// @param chartHeight Total SVG height; each bar grows up from the bottom. + /// @return The bars, plus the low/high/p50/p95/max labels for the caption. + struct HistogramSvg + { + std::vector bars; + std::uint64_t low {}, high {}, p50 {}, p95 {}; + }; + + [[nodiscard]] HistogramSvg BuildHistogramSvg(std::vector const& samples, + std::size_t binCount, + double chartWidth, + double chartHeight) + { + HistogramSvg result; + if (samples.empty()) + return result; + + std::vector sorted { samples }; + std::ranges::sort(sorted); + result.low = sorted.front(); + result.high = sorted.back(); + result.p50 = Percentile(sorted, 0.50); + result.p95 = Percentile(sorted, 0.95); + + std::vector counts(binCount, 0); + auto const span = result.high - result.low; + for (auto const sample: samples) + { + std::size_t index = 0; + if (span > 0) + { + auto const offset = static_cast(sample - result.low) / static_cast(span); + index = static_cast(offset * static_cast(binCount)); + if (index >= binCount) + index = binCount - 1; + } + ++counts[index]; + } + + auto const peak = *std::ranges::max_element(counts); + if (peak == 0) + return result; + + constexpr double Gap = 2.0; + auto const barWidth = (chartWidth - (Gap * static_cast(binCount - 1))) / static_cast(binCount); + constexpr double MinBarHeight = 3.0; // a present-but-empty bucket still shows a sliver + for (std::size_t i = 0; i < binCount; ++i) + { + // Parenthesized to defeat windows.h's function-style max() macro: + // this TU is not built with NOMINMAX (it deliberately avoids + // linking the FastCache library, which is where that define lives). + auto const scaledHeight = chartHeight * (static_cast(counts[i]) / static_cast(peak)); + auto const barHeight = counts[i] == 0 ? 0.0 : (std::max) (MinBarHeight, scaledHeight); + result.bars.push_back({ + .x = FormatCoord(static_cast(i) * (barWidth + Gap)), + .y = FormatCoord(chartHeight - barHeight), + .w = FormatCoord(barWidth), + .h = FormatCoord(barHeight), + }); + } + return result; + } + + /// One day's worth of trend data: the hit rate among that day's cacheable + /// compiles, and the raw compile volume. + struct TrendDay + { + std::uint64_t hits {}, servable {}, volume {}; + }; + + /// Bucket every record with a known timestamp (Record::timestampUnixSeconds + /// != 0) into UTC calendar days, oldest first. Records with no timestamp — + /// pre-upgrade log lines — are excluded rather than plotted at a false + /// "day zero" (see Record::timestampUnixSeconds). + /// @param records The records to bucket. + /// @return Days in chronological order, each holding that day's tally. + [[nodiscard]] std::vector> BucketByDay(std::vector const& records) + { + constexpr std::int64_t SecondsPerDay = 86400; + std::map byDay; + for (auto const& record: records) + { + if (record.timestampUnixSeconds == 0) + continue; + auto const day = static_cast(record.timestampUnixSeconds) / SecondsPerDay; + auto& bucket = byDay[day]; + ++bucket.volume; + if (record.outcome == Outcome::Hit) + { + ++bucket.hits; + ++bucket.servable; + } + else if (record.outcome == Outcome::Miss) + ++bucket.servable; + } + return { byDay.begin(), byDay.end() }; + } + + /// Render the trend chart's ``/`` markup, or an empty + /// string when there is no timestamped data to plot (every record predates + /// the timestamp column, or the log is otherwise empty of it). + [[nodiscard]] std::string RenderTrendSvg(std::vector const& records) + { + auto const days = BucketByDay(records); + if (days.empty()) + return "

No timestamped compiles yet.

"; + + constexpr double Width = 960; + constexpr double Height = 220; + constexpr double LeftPad = 26; + constexpr double RightPad = 10; + constexpr double TopPad = 14; + constexpr double BottomPad = 46; + auto const plotWidth = Width - LeftPad - RightPad; + auto const lineHeight = Height - TopPad - BottomPad; + auto const n = days.size(); + + auto const xAt = [&](std::size_t i) { + return LeftPad + (n <= 1 ? 0.0 : (plotWidth * static_cast(i)) / static_cast(n - 1)); + }; + auto const yAt = [&](double rate) { + return TopPad + (lineHeight * (1.0 - (rate / 100.0))); + }; + + std::ostringstream svg; + svg << R"()"; + + auto const maxVolume = + std::ranges::max_element(days, {}, [](auto const& entry) { return entry.second.volume; })->second.volume; + constexpr double BarBand = 30.0; + for (std::size_t i = 0; i < n; ++i) + { + auto const volume = days[i].second.volume; + auto const barHeight = + maxVolume == 0 ? 0.0 : BarBand * (static_cast(volume) / static_cast(maxVolume)); + constexpr double BarWidthFraction = 0.5; + auto const barWidth = (n == 0 ? 0.0 : plotWidth / static_cast(n)) * BarWidthFraction; + svg << R"()"; + } + + svg << R"((hits)) / static_cast(servable); + svg << FormatCoord(xAt(i)) << ',' << FormatCoord(yAt(rate)) << ' '; + } + svg << R"("/>)"; + for (std::size_t i = 0; i < n; ++i) + { + auto const [hits, servable, volume] = days[i].second; + auto const rate = servable == 0 ? 0.0 : (100.0 * static_cast(hits)) / static_cast(servable); + svg << R"()"; + } + svg << ""; + return svg.str(); + } + + /// Render one outcome tally card. + void AppendTallyCard(std::ostringstream& out, std::string_view label, std::uint64_t value, std::string_view cssClass) + { + out << R"(
)" << EscapeHtml(label) + << R"()" << value << "
"; + } + + /// Render one latency histogram section: title, SVG bars, and the + /// p50/p95/max caption. + void AppendHistogramSection(std::ostringstream& out, std::string_view title, std::vector const& samples) + { + if (samples.empty()) + return; + constexpr double ChartWidth = 480; + constexpr double ChartHeight = 40; + auto const hist = BuildHistogramSvg(samples, 24, ChartWidth, ChartHeight); + out << R"(
)" << EscapeHtml(title) << R"()" + << samples.size() << " samples, " << FormatMs(hist.low) << "-" << FormatMs(hist.high) + << R"(
)"; + for (auto const& bar: hist.bars) + out << R"()"; + out << R"(
p50 )" << FormatMs(hist.p50) << " · p95 " << FormatMs(hist.p95) + << " · max " << FormatMs(hist.high) << "
"; + } + + /// The dashboard's embedded stylesheet: terminal-native dark palette, + /// matching the approved design (headline hit rate, tally cards, trend + /// chart, histograms, cohort table, never-cached list). Kept as one + /// literal here rather than templated piece by piece, since none of it + /// varies per report. + constexpr std::string_view DashboardStyle = R"CSS( +:root{ + --bg:#0b0f0e; --panel:#101613; --border:#1e2b26; --border-soft:#172420; + --text:#e7f0ec; --text-dim:#9fb3ac; --text-faint:#62766f; + --mono:'SFMono-Regular',Consolas,monospace; --sans:system-ui,sans-serif; + --hit:#4ade80; --miss:#fb923c; --bad:#f87171; --uncache:#9ca89f; --accent:#67e8f9; +} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans)} +.wrap{min-height:100vh;padding:40px 48px 64px;display:flex;flex-direction:column;gap:28px;max-width:1280px;margin:0 auto} +.mono{font-family:var(--mono)} +.header{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;flex-wrap:wrap} +.title{font-family:var(--mono);font-size:15px;font-weight:600} +.logpath{font-family:var(--mono);font-size:12px;color:var(--text-faint)} +.headline{display:flex;align-items:baseline;gap:10px;font-family:var(--mono)} +.headline .label{font-size:12px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.08em} +.headline .rate{font-size:40px;font-weight:700;color:var(--hit)} +.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px} +.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:18px 18px 16px;display:flex;flex-direction:column;gap:10px} +.card-label{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-dim)} +.card-value{font-family:var(--mono);font-size:28px;font-weight:600} +.card.hit .card-value{color:var(--hit)} .card.miss .card-value{color:var(--miss)} +.card.uncache .card-value{color:var(--uncache)} .card.bad .card-value{color:var(--bad)} +.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:22px 24px} +.panel-title{font-size:13px;font-weight:600;margin-bottom:16px} +.trend-chart{width:100%;height:220px;display:block} +.trend-volume{fill:var(--border-soft)} +.trend-line{fill:none;stroke:var(--hit);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round} +.trend-point{fill:var(--bg);stroke:var(--hit);stroke-width:2} +.trend-empty{color:var(--text-faint);font-size:13px} +.two-col{display:grid;grid-template-columns:1.4fr 1fr;gap:16px;align-items:start} +.hist{display:flex;flex-direction:column;gap:6px;margin-bottom:14px} +.hist-title{font-family:var(--mono);font-size:12px;color:var(--text-dim);display:flex;justify-content:space-between} +.hist-meta{font-family:var(--mono);font-size:11px;color:var(--text-faint)} +.hist-chart{width:100%;height:40px;display:block} +.hist-chart rect{fill:var(--accent)} +.hist-caption{font-family:var(--mono);font-size:11px;color:var(--text-faint)} +.reasons{display:flex;flex-direction:column;gap:10px} +.reason-row{display:flex;justify-content:space-between;font-size:12px} +.reason-bar{height:5px;background:var(--border-soft);border-radius:3px;overflow:hidden;margin-top:5px} +.reason-fill{height:100%;background:var(--miss);border-radius:3px} +table{border-collapse:collapse;width:100%;font-size:13px} +th{text-align:left;font-weight:500;color:var(--text-faint);font-size:11px;text-transform:uppercase;letter-spacing:.05em;padding:0 14px 10px 0;border-bottom:1px solid var(--border)} +td{padding:12px 14px 12px 0;border-bottom:1px solid var(--border-soft);font-family:var(--mono)} +.bar-cell{min-width:120px} +.bar-track{height:6px;background:var(--border-soft);border-radius:3px;overflow:hidden;width:100px} +.bar-fill{height:100%;background:var(--hit);border-radius:3px} +.never-row{display:flex;justify-content:space-between;gap:16px;padding:9px 0;border-bottom:1px solid var(--border-soft);font-family:var(--mono);font-size:12px} +.footer{text-align:center;font-family:var(--mono);font-size:11px;color:var(--text-faint);padding-top:8px} +)CSS"; + +} // namespace + +std::string FormatHtmlReport(std::string_view cohortFilter) +{ + auto const path = LogPath(); + if (path.empty()) + return "fastcache-cc: no state directory available; statistics are disabled.\n"; + + std::ifstream const probe { path, std::ios::binary }; + if (!probe) + return "fastcache-cc: no statistics recorded yet (" + path + ").\n"; + + auto const records = ParseLog(cohortFilter); + auto const [overall, byCohort, neverCached] = FoldRecords(records); + + if (overall.Total() == 0) + { + if (cohortFilter.empty()) + return "fastcache-cc: no statistics recorded yet (" + path + ").\n"; + return "fastcache-cc: no records for cohort '" + std::string { cohortFilter } + "'.\n"; + } + + auto const servable = overall.hits + overall.misses; + auto const hitRate = Percent(overall.hits, servable); + + std::ostringstream out; + out << R"(fastcache-cc stats)" + << "
)"; + + out << R"(
fastcache-cc / stats
)" + << R"(
)" << EscapeHtml(path) << "
" + << R"(
hit rate)" << hitRate + << R"(of )" << servable << " cacheable
"; + + out << R"(
)"; + AppendTallyCard(out, "hits", overall.hits, "hit"); + AppendTallyCard(out, "misses", overall.misses, "miss"); + AppendTallyCard(out, "uncacheable", overall.uncacheable, "uncache"); + AppendTallyCard(out, "unavailable", overall.unavailable, "bad"); + out << "
"; + + out << R"(
hit rate over time
)" << RenderTrendSvg(records) << "
"; + + out << R"(
latency distributions
)"; + AppendHistogramSection(out, "hit latency", overall.hitMs); + AppendHistogramSection(out, "preprocess", overall.hitPreprocessMs); + AppendHistogramSection(out, "cache i/o", overall.hitCacheMs); + AppendHistogramSection(out, "miss latency", overall.missMs); + out << "
"; + + out << R"(
fall-back reasons
)"; + { + std::vector> ranked { overall.reasons.begin(), overall.reasons.end() }; + std::ranges::sort(ranked, [](auto const& a, auto const& b) { return a.second > b.second; }); + auto const worst = ranked.empty() ? 0 : ranked.front().second; + for (auto const& [reason, count]: ranked) + { + auto const pct = worst == 0 ? 0.0 : (100.0 * static_cast(count)) / static_cast(worst); + out << R"(
)" << EscapeHtml(reason) << R"()" << count + << R"(×
)"; + } + } + out << "
"; + + if (byCohort.size() > 1 || (byCohort.size() == 1 && !cohortFilter.empty())) + { + out << R"(
per-cohort comparison
)" + << R"()" + ""; + for (auto const& [cohort, tally]: byCohort) + { + auto const cohortServable = tally.hits + tally.misses; + auto const cohortRate = Percent(tally.hits, cohortServable); + auto const ratePct = + cohortServable == 0 ? 0.0 : (100.0 * static_cast(tally.hits)) / static_cast(cohortServable); + // Custom delimiter (html(...)html): the attribute value contains a + // literal `)"` (CSS var(--hit) followed by the closing quote), + // which would otherwise terminate a plain R"(...)" early. + out << ""; + } + out << "
cohortcompileshit rateunavailable
" << EscapeHtml(cohort.empty() ? "(unset)" : cohort) << "" << tally.Total() + << R"html()html" << cohortRate << R"()" + << R"(
)" << tally.unavailable << "
"; + } + + if (!neverCached.empty()) + { + out << R"(
never cached ()" << neverCached.size() + << " translation units)
"; + std::vector> ranked { neverCached.begin(), neverCached.end() }; + std::ranges::sort(ranked, [](auto const& a, auto const& b) { return a.second > b.second; }); + std::size_t shown = 0; + for (auto const& [source, count]: ranked) + { + if (shown++ >= 10) + { + out << R"(
… and )" << (ranked.size() - 10) << " more
"; + break; + } + out << R"(
)" << EscapeHtml(source) << R"()" << count + << "×
"; + } + out << "
"; + } + + out << R"()"; + out << "
"; + return out.str(); +} + } // namespace FastCache::Cc diff --git a/src/apps/fastcache-cc/Stats.hpp b/src/apps/fastcache-cc/Stats.hpp index 1c1704cf..4f863552 100644 --- a/src/apps/fastcache-cc/Stats.hpp +++ b/src/apps/fastcache-cc/Stats.hpp @@ -1,9 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include + #include #include #include +#include namespace FastCache::Cc { @@ -36,11 +39,24 @@ struct Record std::uint64_t preprocessMs {}; ///< Deriving the key (preprocess + compiler id). std::uint64_t cacheMs {}; ///< Talking to the daemon (connect + transfer). + /// Whether `preprocessMs`/`cacheMs` were actually recorded, as opposed to + /// defaulted to zero because this record came from a log line written + /// before those columns existed. Without this, a pre-upgrade hit would + /// plot as a real zero-millisecond preprocess/cache sample rather than + /// being left out of that histogram entirely. + bool hasPhaseColumns {}; + /// Direct mode: validating the header manifest instead of preprocessing. When /// `directHit` is set, `preprocessMs` is zero because no preprocess ran — that /// substitution is the whole point, so the report separates the two. std::uint64_t directMs {}; bool directHit {}; + + /// Wall-clock time the invocation was recorded, as seconds since the Unix + /// epoch. Zero means "unknown" — either a pre-upgrade log line (the column + /// did not exist yet) or a caller that never set it — and is excluded from + /// any time-bucketed view rather than plotted as an epoch-zero data point. + std::uint64_t timestampUnixSeconds {}; }; /// Append one record to the per-user log, creating it on first use. @@ -60,8 +76,30 @@ void AppendRecord(Record const& record); /// breakdown, hit rate, latency distributions, the fall-back reasons, and the /// translation units that never hit. /// @param cohortFilter When non-empty, report only this cohort. +/// @param color Whether to emit ANSI SGR escapes; see StdoutSupportsColor. /// @return The formatted report, or an explanatory line when the log is absent. -[[nodiscard]] std::string FormatReport(std::string_view cohortFilter); +[[nodiscard]] std::string FormatReport(std::string_view cohortFilter, UsageColor color = UsageColor::Plain); + +/// Read every record from the log, applying the same cohort filter and +/// tolerance for short (pre-upgrade) lines that `FormatReport` does. +/// +/// Exposed separately from `FormatReport` so a caller that wants the raw +/// records — the HTML report's trend chart, or a test — does not have to +/// scrape them back out of formatted text. +/// @param cohortFilter When non-empty, return only this cohort's records. +/// @return The parsed records, in file order. Empty when the log is absent +/// or empty. +[[nodiscard]] std::vector ParseLog(std::string_view cohortFilter); + +/// Render the same data as `FormatReport`, as a self-contained HTML dashboard +/// (inline CSS/JS, no network dependency): headline hit rate, per-outcome +/// tallies, a hit-rate-over-time trend, latency histograms, ranked fall-back +/// reasons, a per-cohort comparison table, and the translation units that +/// never hit. +/// @param cohortFilter When non-empty, report only this cohort. +/// @return The complete HTML document, or an explanatory plain-text line when +/// the log is absent (mirroring `FormatReport`'s empty-log message). +[[nodiscard]] std::string FormatHtmlReport(std::string_view cohortFilter); /// Delete the log. @return True when the log is gone afterwards. [[nodiscard]] bool ResetLog(); diff --git a/src/apps/fastcache-cc/Stats_test.cpp b/src/apps/fastcache-cc/Stats_test.cpp index 74c2b952..435573d0 100644 --- a/src/apps/fastcache-cc/Stats_test.cpp +++ b/src/apps/fastcache-cc/Stats_test.cpp @@ -13,16 +13,45 @@ #include #include +#include +#include #include #include #include +#if defined(_WIN32) + #include +#else + #include +#endif + using namespace FastCache::Cc; namespace { -/// Monotonic counter so no two tests share a state directory. +/// This process's id, so two test *processes* racing under a parallel test +/// runner (`ctest -j`) can never compute the same directory name. +/// +/// `catch_discover_tests` registers one CTest test per TEST_CASE, each its own +/// process invocation of this binary — so a monotonic counter starting at 1 in +/// every process is not actually monotonic across the run: two concurrently +/// running single-test processes both take their first ScopedStateDir at +/// counter value 1 and collide on the same directory, one deleting or +/// overwriting the log the other is mid-write on. This was a real, reproduced +/// flake (`ctest -R FormatReport -j8` fails a run in a small majority of +/// tries), not a hypothetical. +[[nodiscard]] unsigned long ProcessId() noexcept +{ +#if defined(_WIN32) + return ::GetCurrentProcessId(); +#else + return static_cast(::getpid()); +#endif +} + +/// Monotonic counter so no two ScopedStateDir instances *within this process* +/// share a directory; combined with the process id for cross-process safety. [[nodiscard]] int CounterNext() { static int counter = 0; @@ -36,7 +65,8 @@ class ScopedStateDir public: ScopedStateDir() { - auto const unique = std::filesystem::temp_directory_path() / ("fastcache-cc-test-" + std::to_string(CounterNext())); + auto const unique = + std::filesystem::temp_directory_path() / std::format("fastcache-cc-test-{}-{}", ProcessId(), CounterNext()); std::filesystem::create_directories(unique); _dir = unique.string(); @@ -210,6 +240,83 @@ TEST_CASE("ResetLog empties the recorded statistics") CHECK(FormatReport("").contains("no statistics recorded yet")); } +TEST_CASE("FormatHtmlReport explains an empty log instead of an empty document") +{ + ScopedStateDir const scoped; + auto const report = FormatHtmlReport(""); + CHECK(report.contains("no statistics recorded yet")); + // Explicitly NOT html in the empty case: mirrors FormatReport's plain-text + // explanation, and a caller piping this to a browser or a log sees a + // one-line message either way, not a broken half-page. + CHECK_FALSE(report.contains("")); + CHECK(report.contains("")); + // No external network dependency: everything the page needs travels in + // the file, so it opens correctly from a detached copy (attached to a CI + // run, emailed, opened offline). + CHECK_FALSE(report.contains("http://")); + CHECK_FALSE(report.contains("https://")); +} + +TEST_CASE("FormatHtmlReport surfaces the headline hit rate and tallies") +{ + ScopedStateDir const scoped; + AppendRecord(MakeRecord(Outcome::Hit, "main", "a.cpp", 10)); + AppendRecord(MakeRecord(Outcome::Hit, "main", "b.cpp", 12)); + AppendRecord(MakeRecord(Outcome::Miss, "main", "c.cpp", 200)); + + auto const report = FormatHtmlReport(""); + CHECK(report.contains("66.7%")); // 2 hits of 3 cacheable + CHECK(report.contains(">2<")); // hits tally + CHECK(report.contains(">1<")); // misses tally +} + +TEST_CASE("FormatHtmlReport lists every cohort in the comparison table") +{ + ScopedStateDir const scoped; + AppendRecord(MakeRecord(Outcome::Hit, "alpha", "a.cpp", 10)); + AppendRecord(MakeRecord(Outcome::Miss, "beta", "b.cpp", 20)); + + auto const report = FormatHtmlReport(""); + CHECK(report.contains("alpha")); + CHECK(report.contains("beta")); +} + +TEST_CASE("FormatHtmlReport lists the fall-back reasons and never-cached files") +{ + ScopedStateDir const scoped; + auto record = MakeRecord(Outcome::Unavailable, "main", "a.cpp", 5); + record.detail = "connect failed"; + AppendRecord(record); + AppendRecord(MakeRecord(Outcome::Uncacheable, "main", "volatile.cpp", 40)); + + auto const report = FormatHtmlReport(""); + CHECK(report.contains("connect failed")); + CHECK(report.contains("volatile.cpp")); +} + +TEST_CASE("FormatHtmlReport restricts the fold to one cohort when filtered") +{ + ScopedStateDir const scoped; + AppendRecord(MakeRecord(Outcome::Hit, "alpha", "a.cpp", 10)); + AppendRecord(MakeRecord(Outcome::Miss, "beta", "b.cpp", 20)); + + auto const alpha = FormatHtmlReport("alpha"); + CHECK(alpha.contains("alpha")); + CHECK_FALSE(alpha.contains(">beta<")); + + CHECK(FormatHtmlReport("gamma").contains("no records for cohort 'gamma'")); +} + TEST_CASE("LogPath points inside the configured state directory") { ScopedStateDir const scoped; @@ -217,3 +324,65 @@ TEST_CASE("LogPath points inside the configured state directory") CHECK_FALSE(path.empty()); CHECK(path.contains("fastcache-cc")); } + +TEST_CASE("AppendRecord round-trips the timestamp through ParseLog") +{ + ScopedStateDir const scoped; + auto record = MakeRecord(Outcome::Hit, "main", "a.cpp", 10); + record.timestampUnixSeconds = 1'700'000'000; + AppendRecord(record); + + auto const entries = ParseLog(""); + REQUIRE(entries.size() == 1); + CHECK(entries.front().timestampUnixSeconds == 1'700'000'000); +} + +TEST_CASE("FormatReport emits no ANSI escapes when plain") +{ + ScopedStateDir const scoped; + AppendRecord(MakeRecord(Outcome::Hit, "main", "a.cpp", 10)); + + auto const report = FormatReport("", FastCache::UsageColor::Plain); + CHECK_FALSE(report.contains("\x1b[")); +} + +TEST_CASE("FormatReport colors the hit count when colored") +{ + ScopedStateDir const scoped; + AppendRecord(MakeRecord(Outcome::Hit, "main", "a.cpp", 10)); + + auto const report = FormatReport("", FastCache::UsageColor::Colored); + CHECK(report.contains("\x1b[")); + // The colored count still contains the plain digits, so a caller stripping + // ANSI escapes recovers byte-identical text to the plain report. + CHECK(report.contains("1")); +} + +TEST_CASE("FormatReport colors the unavailable count as a warning") +{ + ScopedStateDir const scoped; + auto record = MakeRecord(Outcome::Unavailable, "main", "a.cpp", 5); + record.detail = "connect failed"; + AppendRecord(record); + + auto const report = FormatReport("", FastCache::UsageColor::Colored); + CHECK(report.contains("CACHE NOT REACHED")); + CHECK(report.contains("\x1b[")); +} + +TEST_CASE("ParseLog defaults the timestamp to zero for pre-upgrade lines") +{ + ScopedStateDir const scoped; + // Nine tab-separated fields: the shape written before the timestamp column + // existed. The parser must not misread a missing trailing field as 0 being + // a real recorded time — it is simply absent. + auto const path = LogPath(); + { + std::ofstream out { path, std::ios::binary | std::ios::app }; + out << "HIT\tmain\t0\t10\ta.cpp\t\t0\t0\t0\t0\n"; + } + + auto const entries = ParseLog(""); + REQUIRE(entries.size() == 1); + CHECK(entries.front().timestampUnixSeconds == 0); +} diff --git a/src/apps/fastcache-cc/main.cpp b/src/apps/fastcache-cc/main.cpp index b527300e..ddd97fbe 100644 --- a/src/apps/fastcache-cc/main.cpp +++ b/src/apps/fastcache-cc/main.cpp @@ -993,7 +993,69 @@ void RecordManifest(Config const& cfg, /// @param cohortFilter Restrict the report to this cohort; empty reports all. [[nodiscard]] int RunStatsReport(std::string_view cohortFilter) { - std::cout << Cc::FormatReport(cohortFilter); + // The color decision is made here, not inside Stats.cpp, so that module + // stays free of ambient probes -- the same split --help already uses. + std::cout << Cc::FormatReport( + cohortFilter, FastCache::StdoutSupportsColor() ? FastCache::UsageColor::Colored : FastCache::UsageColor::Plain); + return 0; +} + +/// Default location for `--html-stats`'s dashboard when `--out` names none: +/// alongside the statistics log itself, so both live under the same per-user +/// state directory rather than the current working directory (which for a +/// launcher invoked from inside a build could be anywhere). +/// @return The default report path, or empty when there is no state directory. +[[nodiscard]] std::string DefaultHtmlReportPath() +{ + auto const logPath = Cc::LogPath(); + if (logPath.empty()) + return {}; + return (std::filesystem::path { logPath }.parent_path() / "report.html").string(); +} + +/// Render the HTML dashboard (`--html-stats`) and write it to disk. +/// @param cohortFilter Restrict the report to this cohort; empty reports all. +/// @param outputPath Where to write it; empty means DefaultHtmlReportPath(). +/// @return Process exit code. +[[nodiscard]] int RunHtmlStatsReport(std::string_view cohortFilter, std::string_view outputPath) +{ + auto const report = Cc::FormatHtmlReport(cohortFilter); + + // The empty-log/empty-cohort case returns the same short plain-text + // message FormatReport does (see FormatHtmlReport's doc comment) -- + // printed rather than written to a file, matching --show-stats's own + // behaviour for the same condition. + if (!report.starts_with("")) + { + std::cout << report; + return 0; + } + + std::string const destination { !outputPath.empty() ? std::string { outputPath } : DefaultHtmlReportPath() }; + if (destination.empty()) + { + std::cerr << "fastcache-cc: no state directory available to write the dashboard to; pass --out.\n"; + return 1; + } + + std::error_code ec; + if (auto const parent = std::filesystem::path { destination }.parent_path(); !parent.empty()) + std::filesystem::create_directories(parent, ec); + + std::ofstream out { destination, std::ios::binary | std::ios::trunc }; + if (!out) + { + std::cerr << "fastcache-cc: could not write dashboard to '" << destination << "'.\n"; + return 1; + } + out << report; + if (!out) + { + std::cerr << "fastcache-cc: could not write dashboard to '" << destination << "'.\n"; + return 1; + } + + std::cout << "fastcache-cc: dashboard written to " << destination << '\n'; return 0; } @@ -1041,13 +1103,17 @@ int main(int argc, char** argv) return 0; case Cc::Action::ShowStats: return RunStatsReport(command.cohortFilter); + case Cc::Action::HtmlStats: + return RunHtmlStatsReport(command.cohortFilter, command.outputPath); case Cc::Action::ZeroStats: return ClearStats(); - // A stats sub-option, never returned as a top-level action. Handled + // Stats sub-options, never returned as a top-level action. Handled // explicitly so the switch stays exhaustive without silently treating - // "--cohort" as a compiler to spawn. + // "--cohort"/"--out" as a compiler to spawn. case Cc::Action::Cohort: - return ReportUsageError("--cohort is only valid after --show-stats"); + return ReportUsageError("--cohort is only valid after --show-stats or --html-stats"); + case Cc::Action::OutputPath: + return ReportUsageError("--out is only valid after --html-stats"); case Cc::Action::Compile: break; }