~neon/activity-graph

8f268732707cb9456e7c1ab53356a7d963df9568 — Jens Pitkanen 2 years ago 0f02541
Add the `server` command, separate file output to `generate`
8 files changed, 637 insertions(+), 97 deletions(-)

M Cargo.lock
M Cargo.toml
M README.md
M src/commits.rs
M src/log.rs
M src/main.rs
M src/render.rs
A src/server.rs
M Cargo.lock => Cargo.lock +348 -4
@@ 5,11 5,13 @@ name = "activity-graph"
version = "0.1.0"
dependencies = [
 "chrono",
 "hyper",
 "lazy_static",
 "pathdiff",
 "rayon",
 "structopt",
 "term_size",
 "tokio",
]

[[package]]


@@ 18,7 20,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
dependencies = [
 "winapi",
 "winapi 0.3.8",
]

[[package]]


@@ 29,7 31,7 @@ checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
 "hermit-abi",
 "libc",
 "winapi",
 "winapi 0.3.8",
]

[[package]]


@@ 45,6 47,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"

[[package]]
name = "bytes"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "130aac562c0dd69c56b3b1cc8ffd2e17be31d0b6c25b61c96b76231aa23e39e1"

[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 130,6 138,86 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3"

[[package]]
name = "fnv"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fad85553e09a6f881f739c29f0b00b0f01357c743266d478b68951ce23285f3"

[[package]]
name = "fuchsia-zircon"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82"
dependencies = [
 "bitflags",
 "fuchsia-zircon-sys",
]

[[package]]
name = "fuchsia-zircon-sys"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"

[[package]]
name = "futures-channel"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f366ad74c28cca6ba456d95e6422883cfb4b252a83bed929c83abfdbbf2967d5"
dependencies = [
 "futures-core",
]

[[package]]
name = "futures-core"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59f5fff90fd5d971f936ad674802482ba441b6f09ba5e15fd8b39145582ca399"

[[package]]
name = "futures-sink"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f2032893cb734c7a05d85ce0cc8b8c4075278e93b24b66f9de99d6eb0fa8acc"

[[package]]
name = "futures-task"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb66b5f09e22019b1ab0830f7785bcea8e7a42148683f99214f73f8ec21a626"

[[package]]
name = "futures-util"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8764574ff08b701a084482c3c7031349104b07ac897393010494beaa18ce32c6"
dependencies = [
 "futures-core",
 "futures-task",
 "pin-project",
 "pin-utils",
]

[[package]]
name = "h2"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79b7246d7e4b979c03fa093da39cfb3617a96bbeee6310af63991668d7e843ff"
dependencies = [
 "bytes",
 "fnv",
 "futures-core",
 "futures-sink",
 "futures-util",
 "http",
 "indexmap",
 "log",
 "slab",
 "tokio",
 "tokio-util",
]

[[package]]
name = "heck"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 148,6 236,91 @@ dependencies = [
]

[[package]]
name = "http"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d569972648b2c512421b5f2a405ad6ac9666547189d0c5477a3f200f3e02f9"
dependencies = [
 "bytes",
 "fnv",
 "itoa",
]

[[package]]
name = "http-body"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "httparse"
version = "1.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9"

[[package]]
name = "hyper"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96816e1d921eca64d208a85aab4f7798455a8e34229ee5a88c935bdee1b78b14"
dependencies = [
 "bytes",
 "futures-channel",
 "futures-core",
 "futures-util",
 "h2",
 "http",
 "http-body",
 "httparse",
 "itoa",
 "log",
 "net2",
 "pin-project",
 "time",
 "tokio",
 "tower-service",
 "want",
]

[[package]]
name = "indexmap"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076f042c5b7b98f31d205f1249267e12a6518c1481e9dae9764af19b707d2292"
dependencies = [
 "autocfg",
]

[[package]]
name = "iovec"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e"
dependencies = [
 "libc",
]

[[package]]
name = "itoa"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b7a7c0c47db5545ed3fef7468ee7bb5b74691498139e4b3f6a20685dc6dd8e"

[[package]]
name = "kernel32-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d"
dependencies = [
 "winapi 0.2.8",
 "winapi-build",
]

[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 160,12 333,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e85c08494b21a9054e7fe1374a732aeadaff3980b6990b94bfd3a70f690005"

[[package]]
name = "log"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7"
dependencies = [
 "cfg-if",
]

[[package]]
name = "maybe-uninit"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"

[[package]]
name = "memchr"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"

[[package]]
name = "memoffset"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 175,6 363,48 @@ dependencies = [
]

[[package]]
name = "mio"
version = "0.6.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce347092656428bc8eaf6201042cb551b8d67855af7374542a92a0fbfcac430"
dependencies = [
 "cfg-if",
 "fuchsia-zircon",
 "fuchsia-zircon-sys",
 "iovec",
 "kernel32-sys",
 "libc",
 "log",
 "miow",
 "net2",
 "slab",
 "winapi 0.2.8",
]

[[package]]
name = "miow"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c1f2f3b1cf331de6896aabf6e9d55dca90356cc9960cca7eaaf408a355ae919"
dependencies = [
 "kernel32-sys",
 "net2",
 "winapi 0.2.8",
 "ws2_32-sys",
]

[[package]]
name = "net2"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ba7c918ac76704fb42afcbbb43891e72731f3dcca3bef2a19786297baf14af7"
dependencies = [
 "cfg-if",
 "libc",
 "winapi 0.3.8",
]

[[package]]
name = "num-integer"
version = "0.1.42"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 210,6 440,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877630b3de15c0b64cc52f659345724fbf6bdad9bd9566699fc53688f3c34a34"

[[package]]
name = "pin-project"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50dab4a05419117fe00216df4731e387ae616cd2a5f5dda1d8b02d863ac63d"
dependencies = [
 "pin-project-internal",
]

[[package]]
name = "pin-project-internal"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c27e5ac1d4c76777afd4d47b8fe9c602b44bcf6f999d34e300bba5560c9837b"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "pin-project-lite"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7505eeebd78492e0f6108f7171c4948dbb120ee8119d9d77d0afa5469bef67f"

[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"

[[package]]
name = "proc-macro-error"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 284,6 546,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"

[[package]]
name = "slab"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"

[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 342,7 610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9"
dependencies = [
 "libc",
 "winapi",
 "winapi 0.3.8",
]

[[package]]


@@ 361,10 629,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438"
dependencies = [
 "libc",
 "winapi",
 "winapi 0.3.8",
]

[[package]]
name = "tokio"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c1d570eb1a36f0345a5ce9c6c6e665b70b73d11236912c0b477616aeec47b1"
dependencies = [
 "bytes",
 "fnv",
 "futures-core",
 "iovec",
 "lazy_static",
 "memchr",
 "mio",
 "num_cpus",
 "pin-project-lite",
 "slab",
]

[[package]]
name = "tokio-util"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499"
dependencies = [
 "bytes",
 "futures-core",
 "futures-sink",
 "log",
 "pin-project-lite",
 "tokio",
]

[[package]]
name = "tower-service"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e987b6bf443f4b5b3b6f38704195592cca41c5bb7aedd3c3693c7081f8289860"

[[package]]
name = "try-lock"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e604eb7b43c06650e854be16a2a03155743d3752dd1c943f6829e26b7a36e382"

[[package]]
name = "unicode-segmentation"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 395,6 707,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce"

[[package]]
name = "want"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
dependencies = [
 "log",
 "try-lock",
]

[[package]]
name = "winapi"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"

[[package]]
name = "winapi"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 405,6 733,12 @@ dependencies = [
]

[[package]]
name = "winapi-build"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc"

[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 415,3 749,13 @@ name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
name = "ws2_32-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e"
dependencies = [
 "winapi 0.2.8",
 "winapi-build",
]

M Cargo.toml => Cargo.toml +3 -0
@@ 15,6 15,9 @@ term_size = "*" # structopt uses some version of term size
lazy_static = "1.4.0"

rayon = { version = "1.3.0", optional = true }
hyper = { version = "0.13.5", optional = true }
tokio = { version = "*", optional = true, features = ["rt-threaded"] } # hyper provides version for tokio

[features]
default = ["rayon"]
server = ["hyper", "tokio"]

M README.md => README.md +34 -2
@@ 2,12 2,44 @@
Visualizes your commit activity in the git repositories found in a
given set of directories.

I'll fill this document out with further usage instructions once I've
written the whole thing.
This program has 3 general use-cases:

1. Printing out a nice visualization of your commits to stdout.

   ```
   activity-graph -r <dir-with-your-repos> -s
   ```

2. Generating a html file to be looked at / served via a file server.

   ```
   activity-graph -r <dir-with-your-repos> -o test.html [-c test.css]
   ```

3. Serving the generated html and css straight from ram via
   [`hyper`][hyper]:

   ```
   activity-graph -r <dir-with-your-repos> server --host 0.0.0.0:80
   ```

## Optional features

- `rayon` is *enabled* by default, but is optional. It allows for the
  parallellization of the underlying `git` commands, which causes a
  ~4x speedup on my system.

- `server` is *disabled* by default, and can be enabled to allow for
  running with the `--server` flag. This causes the program to stay
  alive until manual termination (Ctrl+C), serving the generated HTML
  on a configurable port and address (`--host`). The responses are
  always from a fast cache, and hits to the cache will cause the html
  to be regenerated depending on the `--cache-lifetime` parameter.

## License
I recommend writing your own, it's a fun little project. But even
though I would not recommend using this code, you may use it under the
terms of the [GNU GPLv3 license][license].

[hyper]: https://crates.io/crates/hyper "A fast HTTP 1/2 server written in Rust"
[license]: LICENSE.md "The GNU GPLv3 license text in Markdown."

M src/commits.rs => src/commits.rs +6 -6
@@ 9,10 9,10 @@ use std::sync::atomic::{AtomicU32, Ordering};

use crate::{log, ProjectMetadata};

pub fn find_dates<'a>(
pub fn find_dates(
    author: Option<&String>,
    repos: &'a HashSet<ProjectMetadata>,
) -> Vec<(DateTime<Utc>, &'a ProjectMetadata)> {
    repos: &HashSet<ProjectMetadata>,
) -> Vec<(DateTime<Utc>, ProjectMetadata)> {
    let commit_count = AtomicU32::new(0);
    let author_flag = author.as_ref().map(|author| format!("--author={}", author));



@@ 22,7 22,7 @@ pub fn find_dates<'a>(
    let repo_iter = repos.iter();

    let commit_dates = repo_iter.map(|repo| {
        let mut commit_dates: Vec<(DateTime<Utc>, &ProjectMetadata)> = Vec::new();
        let mut commit_dates: Vec<(DateTime<Utc>, ProjectMetadata)> = Vec::new();
        let path = &repo.path;
        let mut args = vec!["log", "--all", "--format=format:%ai", "--date=iso8601"];
        if let Some(author_flag) = &author_flag {


@@ 32,7 32,7 @@ pub fn find_dates<'a>(
        for date in commits.lines().filter_map(|date| date.parse().ok()) {
            let count = commit_count.fetch_add(1, Ordering::Relaxed) + 1;
            log::verbose_println(&format!("commits accounted for {}\r", count), true);
            commit_dates.push((date, repo));
            commit_dates.push((date, repo.clone()));
        }
        commit_dates
    });


@@ 41,7 41,7 @@ pub fn find_dates<'a>(
    let commit_dates = commit_dates.reduce(
        || Vec::new(),
        |mut a, b| {
            a.extend(&b);
            a.extend(b);
            a
        },
    );

M src/log.rs => src/log.rs +6 -4
@@ 3,7 3,7 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};

lazy_static::lazy_static! {
    static ref LAST_UPDATE_PRINT_TIME: Mutex<Instant> = Mutex::new(Instant::now());
    static ref LAST_UPDATE_PRINT_TIME: Mutex<Option<Instant>> = Mutex::new(None);
}

static LAST_PRINT_WAS_UPDATE: AtomicBool = AtomicBool::new(false);


@@ 24,11 24,10 @@ pub fn verbose_println(s: &str, updating_line: bool) {
            // Throttle the line updates to once per 20ms, 50 Hz is plenty real-time.
            if let Ok(mut last_update) = LAST_UPDATE_PRINT_TIME.lock() {
                let now = Instant::now();
                if now - *last_update < Duration::from_millis(20) {
                if last_update.is_some() && now - last_update.unwrap() < Duration::from_millis(20) {
                    return;
                } else {
                    *last_update = now;
                }
                *last_update = Some(now);
            }

            // Clear the line, then write the line, but limit it to the terminal width


@@ 43,6 42,9 @@ pub fn verbose_println(s: &str, updating_line: bool) {
            if was_update {
                // Clear the line
                eprint!("{:width$}\r", "", width = width);
                if let Ok(mut last_update) = LAST_UPDATE_PRINT_TIME.lock() {
                    *last_update = None;
                }
            }
            eprintln!("{}", s);
        }

M src/main.rs => src/main.rs +110 -56
@@ 3,13 3,17 @@ use structopt::StructOpt;

use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
#[cfg(feature = "server")]
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time;

mod commits;
mod find_repositories;
mod log;
mod render;
#[cfg(feature = "server")]
mod server;

#[derive(Clone, PartialEq, Eq, Hash)]
pub struct ProjectMetadata {


@@ 18,14 22,14 @@ pub struct ProjectMetadata {
}

#[derive(Clone, Default)]
pub struct Day<'a> {
    commits: Vec<&'a ProjectMetadata>,
pub struct Day {
    commits: Vec<ProjectMetadata>,
}

#[derive(Clone)]
pub struct Year<'a> {
pub struct Year {
    year: i32,
    days: Vec<Day<'a>>,
    days: Vec<Day>,
}

#[derive(StructOpt)]


@@ 33,14 37,22 @@ pub struct Year<'a> {
#[structopt(about)]
/// Generates a nice activity graph from a bunch of Git repositories
pub struct Args {
    #[structopt(subcommand)]
    command: Option<CommandArgs>,
    /// Prints verbose information
    #[structopt(short, long)]
    verbose: bool,
    /// Prints a visualization into stdout. This is implied if an
    /// output file is not specified
    /// Prints a visualization into stdout
    #[structopt(short, long)]
    stdout: bool,
    #[structopt(flatten)]
    gen: GenerationData,
    #[structopt(flatten)]
    ext: ExternalHtml,
}

#[derive(StructOpt, Default, Clone)]
pub struct GenerationData {
    /// Regex that matches the author(s) whose commits are being
    /// counted (if not set, all commits will be accounted for)
    #[structopt(short, long)]


@@ 49,18 61,14 @@ pub struct Args {
    /// set, there is no limit)
    #[structopt(short, long)]
    depth: Option<i32>,
    /// The file that the resulting html will be printed out to
    #[structopt(short, long)]
    output: Option<PathBuf>,
    /// The file that the stylesheet will be printed out to (if not
    /// set, it will be included in the html inside a style-element)
    #[structopt(short, long)]
    css: Option<PathBuf>,
    /// Path(s) to the directory (or directories) containing the
    /// repositories you want to include
    #[structopt(short, long)]
    repos: Vec<PathBuf>,
}

#[derive(StructOpt, Clone, Default)]
pub struct ExternalHtml {
    /// A html file that will be pasted in the <head> element
    #[structopt(long)]
    external_head: Option<PathBuf>,


@@ 74,58 82,88 @@ pub struct Args {
    external_footer: Option<PathBuf>,
}

#[derive(StructOpt)]
enum CommandArgs {
    /// Output the generated html into a file
    Generate {
        /// The file that the resulting html will be printed out to
        #[structopt(short = "o", long, default_value = "activity-graph.html")]
        html: PathBuf,
        /// The file that the stylesheet will be printed out to (if not
        /// set, it will be included in the html inside a style-element)
        #[structopt(short, long)]
        css: Option<PathBuf>,
    },

    #[cfg(feature = "server")]
    /// Run a server that serves the generated activity graph html
    Server {
        /// The address that the server is hosted on
        #[structopt(long, default_value = "127.0.0.1:80")]
        host: SocketAddr,
        /// The minimum amount of seconds between regenerating the
        /// html and css
        #[structopt(long, default_value = "1")]
        cache_lifetime: u64,
    },
}

fn main() {
    let start_time = time::Instant::now();
    let mut args = Args::from_args();

    if !args.verbose && args.output.is_none() {
        args.stdout = true;
    }

    let args = Args::from_args();
    log::set_verbosity(args.verbose);

    let repos = find_repositories::from_paths(&args.repos, args.depth);

    let mut commit_dates = commits::find_dates(args.author.as_ref(), &repos);
    commit_dates.sort_by(|(a, _), (b, _)| a.cmp(b));
    // This is the part that actually generates the commit
    // information, the rest is I/O. This is called again in the
    // server code though.
    let years = generate_years(&args.gen);

    let years = if commit_dates.len() > 0 {
        let get_year = |date: DateTime<Utc>| date.date().iso_week().year();
        let first_year = get_year(commit_dates[0].0);
        let last_year = get_year(commit_dates[commit_dates.len() - 1].0);
        render::gather_years(&commit_dates, first_year, last_year)
    } else {
        Vec::new()
    };

    let output_html = render::html(&args, &years);
    let mut writer = args
        .output
        .as_ref()
        .and_then(|path| File::create(path).ok())
        .map(|file| BufWriter::new(file));
    if let Some(writer) = &mut writer {
        if let Err(err) = writer.write(&output_html.as_bytes()) {
            eprintln!("error: encountered while writing out the html: {}", err);
        }
    if args.stdout {
        println!("{}", render::ascii(&years));
    }

    let output_css = render::css();
    let mut writer = args
        .css
        .as_ref()
        .and_then(|path| File::create(path).ok())
        .map(|file| BufWriter::new(file));
    if let Some(writer) = &mut writer {
        if let Err(err) = writer.write(&output_css.as_bytes()) {
            eprintln!("error: encountered while writing out the css: {}", err);
    if let Some(command) = &args.command {
        match command {
            CommandArgs::Generate { html, css } => {
                let write_to_file = |path: &Path, s: String, name: &str| {
                    let mut writer = File::create(path).map(|file| BufWriter::new(file));
                    match &mut writer {
                        Ok(writer) => {
                            if let Err(err) = writer.write(&s.as_bytes()) {
                                eprintln!(
                                    "error: encountered while writing out the {}: {}",
                                    name, err
                                );
                            }
                        }
                        Err(err) => {
                            eprintln!(
                                "error: encountered while creating the {} file: {}",
                                name, err
                            );
                        }
                    }
                };

                let output_html = render::html(&args.ext, &html, css.as_ref(), &years);
                write_to_file(&html, output_html, "html");

                if let Some(css) = css {
                    let output_css = render::css();
                    write_to_file(&css, output_css, "css");
                }
            }

            #[cfg(feature = "server")]
            CommandArgs::Server {
                host,
                cache_lifetime,
            } => {
                server::run(&args, *host, *cache_lifetime);
            }
        }
    }

    if args.stdout {
        println!("{}", render::ascii(&years));
    }

    log::verbose_println(
        &format!(
            "finished all tasks, this run of the program took {:?}",


@@ 134,3 172,19 @@ fn main() {
        false,
    );
}

pub fn generate_years(gen: &GenerationData) -> Vec<Year> {
    let repos = find_repositories::from_paths(&gen.repos, gen.depth);

    let mut commit_dates = commits::find_dates(gen.author.as_ref(), &repos);
    commit_dates.sort_by(|(a, _), (b, _)| a.cmp(b));

    if commit_dates.len() > 0 {
        let get_year = |date: DateTime<Utc>| date.date().iso_week().year();
        let first_year = get_year(commit_dates[0].0);
        let last_year = get_year(commit_dates[commit_dates.len() - 1].0);
        render::gather_years(commit_dates, first_year, last_year)
    } else {
        Vec::new()
    }
}

M src/render.rs => src/render.rs +19 -25
@@ 6,17 6,17 @@ use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Component, PathBuf};

use crate::{log, Args, Day, ProjectMetadata, Year};
use crate::{log, Day, ExternalHtml, ProjectMetadata, Year};

static HTML_HEAD: &str = include_str!("head.html");
static CSS: &str = include_str!("activity-graph.css");
static WEEKS: usize = 53;

pub fn gather_years<'a>(
    commit_dates: &[(DateTime<Utc>, &'a ProjectMetadata)],
pub fn gather_years(
    commit_dates: Vec<(DateTime<Utc>, ProjectMetadata)>,
    first_year: i32,
    last_year: i32,
) -> Vec<Year<'a>> {
) -> Vec<Year> {
    // Years is a vec containing vecs of years, which consist
    // of weekday-major grids of days: eg. the first row
    // represents all of the mondays in the year, in order.


@@ 28,16 28,16 @@ pub fn gather_years<'a>(
        });
    }

    let mut i = 0;
    let mut commit_dates = commit_dates.into_iter();
    let mut counted_commits = 0;
    for year in first_year..=last_year {
        // Loop through the years

        let days = &mut years[(year - first_year) as usize].days;
        while i < commit_dates.len() {
        while let Some((date, metadata)) = commit_dates.next() {
            // Loop through the days until the commit is from
            // next year or commits run out

            let (date, metadata) = &commit_dates[i];
            if date.iso_week().year() != year {
                break;
            }


@@ 45,42 45,36 @@ pub fn gather_years<'a>(
            let week_index = date.iso_week().week0() as usize;
            if week_index < WEEKS {
                let day = &mut days[weekday_index * WEEKS + week_index];
                day.commits.push(*metadata);
                day.commits.push(metadata);
                counted_commits += 1;
            }

            i += 1;
        }

        log::verbose_println(
            &format!(
                "prepared year {} for rendering, {} commits processed so far",
                year, i
                year, counted_commits
            ),
            false,
        );
        if i >= commit_dates.len() {
            break;
        }
    }
    years
}

/// Renders a HTML visualization of the commits based on the
/// arguments.
pub fn html(args: &Args, years: &[Year]) -> String {
pub fn html(ext: &ExternalHtml, html: &PathBuf, css: Option<&PathBuf>, years: &[Year]) -> String {
    // Prepare the html scaffolding around the tables
    let external_head = read_optional_file(&args.external_head).unwrap_or_else(String::new);
    let external_header = read_optional_file(&args.external_header).unwrap_or_else(String::new);
    let external_footer = read_optional_file(&args.external_footer).unwrap_or_else(String::new);
    let external_head = read_optional_file(&ext.external_head).unwrap_or_else(String::new);
    let external_header = read_optional_file(&ext.external_header).unwrap_or_else(String::new);
    let external_footer = read_optional_file(&ext.external_footer).unwrap_or_else(String::new);

    let mut style = None;
    if let (Some(css_path), Some(output_path)) = (&args.css, &args.output) {
        if let Some(base) = output_path.parent() {
            if let Some(relative_path) = pathdiff::diff_paths(&css_path, base) {
                // Add the <link> element instead of <style> if using external css
                let path = create_web_path(relative_path);
                style = Some(format!("<link href=\"{}\" rel=\"stylesheet\">", path));
            }
    if let (Some(base), Some(css_path)) = (html.parent(), &css) {
        if let Some(relative_path) = pathdiff::diff_paths(&css_path, base) {
            // Add the <link> element instead of <style> if using external css
            let path = create_web_path(relative_path);
            style = Some(format!("<link href=\"{}\" rel=\"stylesheet\">", path));
        }
    }
    if style.is_none() {

A src/server.rs => src/server.rs +111 -0
@@ 0,0 1,111 @@
use tokio::runtime::Runtime;
use tokio::task;
use hyper::{Body, Request, Response, Server, StatusCode};
use hyper::service::{make_service_fn, service_fn};

use std::net::SocketAddr;
use std::convert::Infallible;
use std::sync::RwLock;
use std::time::{Instant, Duration};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::{generate_years, log, render, Args, GenerationData, ExternalHtml};

lazy_static::lazy_static! {
    // These are set before the server is run, and only used in responses
    static ref GENERATION_DATA: RwLock<GenerationData> = RwLock::new(Default::default());
    static ref EXTERNAL_HTML: RwLock<ExternalHtml> = RwLock::new(Default::default());
    static ref LAST_CACHE: RwLock<Instant> = RwLock::new(Instant::now());
    static ref CACHE_LIFETIME: RwLock<Duration> = RwLock::new(Duration::from_secs(0));

    static ref CACHED_HTML: RwLock<String> = RwLock::new(String::new());
    static ref CACHED_CSS: RwLock<String> = RwLock::new(String::new());
}

static REFRESHING_CACHE: AtomicBool = AtomicBool::new(false);

static INDEX_PATHS: &[&str] = &["/", "/index.html", "/index.htm", ""];

pub fn run(args: &Args, host: SocketAddr, cache_lifetime: u64) {
    log::verbose_println(&format!("starting server on {}...", host), true);

    if let (Ok(mut gen), Ok(mut ext), Ok(mut lifetime), Ok(mut last_cache)) = (GENERATION_DATA.write(), EXTERNAL_HTML.write(), CACHE_LIFETIME.write(), LAST_CACHE.write()) {
        *gen = args.gen.clone();
        *ext = args.ext.clone();
        *lifetime = Duration::from_secs(cache_lifetime);
        *last_cache = Instant::now() - Duration::from_secs(cache_lifetime * 2);
    } else {
        unreachable!();
    }

    match Runtime::new() {
        Ok(mut runtime) => {
            runtime.block_on(async {
                let make_service = make_service_fn(|_conn| async {
                    Ok::<_, Infallible>(service_fn(handle))
                });
                let server = Server::bind(&host).serve(make_service);
                log::verbose_println(&format!("server started on {}", host), false);
                if let Err(err) = server.await {
                    eprintln!("error: hyper server encountered an error: {}", err);
                }
            });
        }
        Err(err) => {
            eprintln!("error: could not start tokio runtime: {}", err);
        }
    }
}

async fn handle(req: Request<Body>) -> Result<Response<Body>, Infallible> {
    let cache = if INDEX_PATHS.contains(&req.uri().path()) {
        refresh_caches().await;
        CACHED_HTML.read()
    } else if req.uri() == "/activity-graph.css" {
        refresh_caches().await;
        CACHED_CSS.read()
    } else {
        let mut response = Response::new(Body::from("404 Not Found"));
        *response.status_mut() = StatusCode::NOT_FOUND;
        return Ok(response);
    };
    if let Ok(cache) = cache {
        Ok(Response::new(Body::from(cache.clone())))
    } else {
        let mut response = Response::new(Body::from("internal server error"));
        *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
        Ok(response)
    }
}

async fn refresh_caches() {
    task::block_in_place(|| {
        let refresh_time = {
            let last_cache = LAST_CACHE.read().unwrap();
            let lifetime = CACHE_LIFETIME.read().unwrap();
            *last_cache + *lifetime
        };
        if Instant::now() >= refresh_time && !REFRESHING_CACHE.compare_and_swap(false, true, Ordering::Relaxed) {
            let start = Instant::now();
            if let (Ok(gen), Ok(ext)) = (GENERATION_DATA.read(), EXTERNAL_HTML.read()) {
                let years = generate_years(&gen);
                let html_path = PathBuf::from("/index");
                let css_path = PathBuf::from("/activity-graph.css");
                let output_html = render::html(&ext, &html_path, Some(&css_path), &years);
                let output_css = render::css();
                if let Ok(mut html) = CACHED_HTML.write() {
                    *html = output_html;
                }
                if let Ok(mut css) = CACHED_CSS.write() {
                    *css = output_css;
                }
                if let Ok(mut last_cache) = LAST_CACHE.write() {
                    *last_cache = Instant::now();
                }
            }
            log::verbose_println(&format!("updated cache, took {:?}", Instant::now() - start), false);
            REFRESHING_CACHE.store(false, Ordering::Relaxed);
        }
    })
}