vcs_version 0.1.0

Helper functions to get version information from VCS
Documentation
use regex::Regex;
use std::env;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{ BufRead, BufReader };
use std::process::Command;

/// Run mercurial with the given arguments and return the output.
fn run_hg<S: AsRef<OsStr> + std::fmt::Debug> (args: &[S]) -> Option<String> {
   // On Windows, `hg.bat` is buggy and can't process the arguments
   // properly -> assume the `HG` environment variable points to the
   // actual `hg` script, and try to parse its shebang to find the
   // correct Python interpreter or search for it in the PATH.
   let hg = &env::var ("HG").unwrap_or_else (|_| "hg".into());
   let hg = which::which (hg).ok()?;
   let hg = match hg.extension() {
      Some (e) if e.to_ascii_lowercase() == "bat" => {
         hg.with_extension ("")
      },
      _ => hg,
   };
   let mut cmd = (|| {
      if hg.exists() {
         let mut cmd = Command::new (
            BufReader::new (File::open (&hg).ok()?)
               .lines()
               .next()?
               .ok()?
               .strip_prefix ("#!")?);
         cmd.arg (&hg);
         Some (cmd)
      } else {
         None
      }
   })().unwrap_or_else (|| {
      // We didn't find the python interpreter -> call `hg` directly
      Command::new (&hg)
   });
   cmd.env ("HGRCPATH", "")
      .env ("LANG", "C")
      .args (args);
   cmd.output()
      .inspect (
         |o| if o.status.success() {
            println!("{cmd:?} OK:\n{}",
                     String::from_utf8_lossy (&o.stdout));
         } else {
            println!("{cmd:?} ERROR:\n{}",
                     String::from_utf8_lossy (&o.stderr));
         })
      .inspect_err (|e| println!("{cmd:?} ERROR:\n{e:?}"))
      .ok()
      .and_then (|output| String::from_utf8 (output.stdout).ok())
}

/// Run git with the given arguments and return the output.
fn run_git<S: AsRef<OsStr>> (args: &[S]) -> Option<String> {
   Command::new ("git")
      .env ("GIT_CONFIG_NOSYSTEM", "1")
      .env ("XDG_CONFG_HOME", "")
      .env ("HOME", "")
      .env ("LANG", "C")
      .args (args)
      .output()
      .ok()
      .and_then (|output| String::from_utf8 (output.stdout).ok())
}

/// Return today's date as `YYMMDD`.
fn mkdate() -> String {
   use time::{ OffsetDateTime, macros::format_description };
   OffsetDateTime::now_utc()
      .format (format_description!("[year repr:last_two][month][day]"))
      .unwrap()
}

/// Get the version from a mercurial repository.
///
/// Version  numbers  follow the  Python  PEP440  conventions. If  the
/// current folder corresponds to a version tag, then return that tag.
/// Otherwise, identify  the closest  tag and return  a string  of the
/// form _tag_.dev_N_+_hash_. In both cases, if the current folder has
/// been  modified, then  add the  current date  as `YYYYMMDD`  to the
/// local version label.
pub fn get_mercurial_version_tag() -> Option<String> {
   let output = run_hg (&[ "id", "-i", "-t" ]);
   let mut iter = output.iter().flat_map (|s| s.split_whitespace()).fuse();
   let hash = match iter.next() {
      Some (hash) => hash,
      _ => { return None },
   };

   let clean = !hash.ends_with ("+");

   if let Some (version)
      = iter.find (|s| s.chars().next()
                   .map (|c| ('0' <= c) && (c <= '9'))
                   .unwrap_or (false))
   {
      Some (if clean { version.into() }
            else { format!("{}+{}", version, mkdate()) })
   } else {
      // The current folder does not correspond to a version tag.
      // Find the closest tag and build the version from that. Note
      // that this may return a wrong version number if the closest
      // tag is not a version tag.
      let version = run_hg (
         &[ "parents",
             "--template",
             r#"{latesttag("re:[0-9]+(:?\.[0-9]+)*")%'{tag}.dev{distance}'}+{node|short}"# ]);
      if clean { version }
      else { version.map (|s| format!("{}.{}", s, mkdate())) }
   }
}

/// Get the version from a Git repository.
///
/// Version  numbers  follow the  Python  PEP440  conventions. If  the
/// current folder corresponds to a version tag, then return that tag.
/// Otherwise, identify  the closest  tag and return  a string  of the
/// form _tag_.dev_N_+_hash_. In both cases, if the current folder has
/// been  modified, then  add the  current date  as `YYYYMMDD`  to the
/// local version label.
pub fn get_git_version_tag() -> Option<String> {
   let output = run_git (&[ "describe", "--long", "--tags", "--dirty=+" ])?;
   let output = output.trim();

   let cap = Regex::new (r"^(.*)-([0-9]+)-g([0-9a-f]{7})(\+)?$")
      .unwrap()
      .captures (&output)?;
   let tag  = cap.get (1)?;
   let dist = cap.get (2)?;
   let hash = cap.get (3)?;
   let clean = cap.get (4).is_none();

   Some (
      if dist.as_str() == "0" {
         if clean { tag.as_str().into() }
         else { format!("{}+{}", tag.as_str(), mkdate()) }
      } else {
         if clean {
            format!("{}.dev{}+git{}",
                    tag.as_str(), dist.as_str(), hash.as_str())
         } else {
            format!("{}.dev{}+git{}.{}",
                    tag.as_str(), dist.as_str(), hash.as_str(), mkdate())
         }
      })
}

/// Get the version from Mercurial archive information.
///
/// The   Mercurial   `archive`   command   creates   a   file   named
/// `.hg_archival.txt`  that contains  information about  the archived
/// version. This function  tries to use this information  to create a
/// version string  similar to what  `get_mercurial_version_tag` would
/// have created for this version.
pub fn get_mercurial_archived_version_tag() -> Option<String> {
   let version_re = Regex::new (r"[0-9]+(\.[0-9]+)*|null").unwrap();

   let mut tag       = None;
   let mut latesttag = None;
   let mut distance  = None;
   let mut node      = None;

   for l in File::open (".hg_archival.txt")
      .iter()
      .flat_map (|f| BufReader::new (f).lines())
      .filter_map (|l| l.ok())
   {
      let mut split = l.splitn (2, ':');
      match split.next() {
         Some ("tag") => tag = tag
            .or_else (
               || split
                  .next()
                  .map (str::trim)
                  .filter (|v| version_re.is_match (v))
                  .map (String::from)),
         Some ("latesttag") => latesttag = latesttag
            .or_else (
               || split
                  .next()
                  .map (str::trim)
                  .filter (|v| version_re.is_match (v))
                  .map (String::from)),
         Some ("latesttagdistance") => distance = distance
            .or_else (|| split.next().map (str::trim).map (String::from)),
         Some ("node") => node = node
            .or_else (|| split.next().map (str::trim).map (String::from)),
         _ => {}
      }
   }
   tag.map (|tag| format!("{}+archive.{}", tag, mkdate()))
      .or_else (|| Some (
         format!("{}.dev{}+archive.{:.12}.{}",
                 latesttag?, distance?, node?, mkdate())))
}

/// Get the version information.
///
/// This function will  first try to get the version  from a Mercurial
/// repository. If that  fails, it will try to get  the version from a
/// Git repository. If both fail, it  will try to get the version from
/// `.hg_archival.txt` file.  If all  fail, it  will take  the version
/// from `Cargo.toml`.
pub fn get_version() -> String {
   get_mercurial_version_tag()
      .or_else (get_git_version_tag)
      .or_else (get_mercurial_archived_version_tag)
      .unwrap_or_else (
         || format!("{}+cargo.{}",
                    env::var ("CARGO_PKG_VERSION").unwrap(),
                    mkdate())
            .into())
}