fslutils 0.1.1

Utility functions for inspecting fossil checkouts.
Documentation
//! Utility functions for inspecting fossil repository checkout state.

mod err;

use std::{
  env,
  io::{self, BufRead, BufReader},
  path::{Path, PathBuf},
  process::Command
};

use hashbrown::{HashMap, HashSet};

pub use err::Error;


/// Attempt to detect a fossil repository.
///
/// # Errors
/// [`Error::IO`]
pub fn detect_repo(dir: Option<&Path>) -> Result<bool, Error> {
  let magic_files = &[".fslckout", ".fos", "_FOSSIL_"];

  let curdir = if let Some(dir) = dir {
    dir.to_path_buf()
  } else {
    env::current_dir()?
  };
  let mut checkdir: &Path = curdir.as_ref();
  let found = 'outer: loop {
    for n in magic_files {
      let fname = checkdir.join(n);
      if fname.exists() {
        break 'outer true;
      }
    }
    let Some(parentdir) = checkdir.parent() else {
      break false;
    };
    checkdir = parentdir;
  };
  Ok(found)
}

/// Run `fossil info` and construct a `HashMap` from the output.
///
/// While most fields are directly added as key/value pairs in the output map,
/// it is not a 1:1 representation of all fields.  `reposittory`, `local-root`,
/// `config-db`, `tags`, `comment` will be larglely exactly as expected.
///
/// `checkout` and `parent` will each be split up into two different entries.
/// `checkout-hash` will contain the hash, and `checkout-ts` will contain the
/// timestamp.  The same split will be done for `parent`.
///
/// # Errors
/// [`Error::IO`] will be returned if the `fossil info` command could not be
/// launched.  [`Error::Encoding`] means `fossil info` output is not utf-8
/// compliant.
pub fn infomap(dir: Option<&Path>) -> Result<HashMap<String, String>, Error> {
  //
  // Run `fossil info`
  //
  let mut cmd = Command::new("fossil");
  cmd.arg("info");
  if let Some(ref dir) = dir {
    cmd.current_dir(dir);
  }
  let output = cmd.output()?;

  //
  // Parse output into HashMap
  //
  let reader = std::io::Cursor::new(output.stdout);
  let reader = BufReader::new(reader);

  let mut infomap = HashMap::new();

  for line in reader.lines() {
    let Ok(line) = line else {
      // Ignore any line that cannot be converted to utf-8
      continue;
    };
    let Some((key, value)) = line.split_once(':') else {
      // Ignore any line that does not contain a ':'
      continue;
    };
    let key = key.trim();
    let value = value.trim();

    match key {
      "project-name" | "project-code" | "repository" | "local-root"
      | "config-db" | "tags" | "comment" => {
        infomap.insert(key.to_string(), value.to_string());
      }
      "checkout" | "parent" => {
        // checkout and parent status variables require some transformation
        let (hash, timestamp) = parse_checkout(value);

        let k = format!("{key}-hash");
        infomap.insert(k, hash.to_string());

        let k = format!("{key}-ts");
        infomap.insert(k, timestamp.to_string());
      }
      _ => {}
    }
  }

  Ok(infomap)
}


#[derive(Debug)]
#[non_exhaustive]
pub struct Status {
  pub prjname: String,
  pub prjcode: String,
  pub local_root: PathBuf,
  pub checkout_hash: String,
  pub checkout_ts: String,
  pub tags: HashSet<String>,
  pub uncommitted: bool,
  pub untracked: bool
}

/// Return a [`Status`] buffer containing some information about the current
/// state of the local checkout.
///
/// # Errors
/// [`Error::IO`] will be returned if the `fossil info` command could not be
/// launched.  [`Error::Encoding`] means `fossil info` output is not utf-8
/// compliant.
pub fn status(dir: Option<&Path>) -> Result<Status, Error> {
  let mut infomap = infomap(dir)?;

  let prjname = infomap
    .remove("project-name")
    .ok_or_else(|| Error::missing("project-name"))?;

  let prjcode = infomap
    .remove("project-code")
    .ok_or_else(|| Error::missing("project-code"))?;

  let local_root = infomap
    .remove("local-root")
    .map(PathBuf::from)
    .ok_or_else(|| Error::missing("local-root"))?;

  let checkout_hash = infomap
    .remove("checkout-hash")
    .ok_or_else(|| Error::missing("checkout-hash"))?;

  let checkout_ts = infomap
    .remove("checkout-ts")
    .ok_or_else(|| Error::missing("checkout-ts"))?;

  let tags = infomap.remove("tags").map_or_else(HashSet::new, |tags| {
    tags.split(',').map(|x| x.trim().to_string()).collect()
  });

  let uncommitted = has_changes(dir)?;
  let untracked = has_extras(dir)?;

  Ok(Status {
    prjname,
    prjcode,
    local_root,
    checkout_hash,
    checkout_ts,
    tags,
    uncommitted,
    untracked
  })
}

fn parse_checkout(value: &str) -> (&str, &str) {
  let (hash, timestamp) = value
    .split_once(' ')
    .expect("Unable to split hash/timestamp");
  (hash.trim(), timestamp.trim())
}

/// Determine whether there are uncommitted changes in the work tree by running
/// `fossil changes`.
///
/// # Errors
/// [`Error::IO`] will be returned if the `fossil changes` command could not be
/// launched.  [`Error::Encoding`] means `fossil changes` output is not utf-8
/// compliant.
pub fn has_changes(dir: Option<&Path>) -> Result<bool, Error> {
  let mut cmd = Command::new("fossil");
  cmd.arg("changes");
  if let Some(ref dir) = dir {
    cmd.current_dir(dir);
  }
  let output = cmd.output()?;

  let buf = std::str::from_utf8(&output.stdout)
    .map_err(|_| Error::encoding("`fossil changes` output is not utf-8"))?;
  let cursor = io::Cursor::new(buf);
  let lines_iter = cursor.lines().map_while(Result::ok);
  let lines = lines_iter.count();
  Ok(lines != 0)
}

/// Determine whether there are untracked files  in the work tree by running
/// `fossil extras`.
///
/// # Errors
/// [`Error::IO`] will be returned if the `fossil changes` command could not be
/// launched.  [`Error::Encoding`] means `fossil changes` output is not utf-8
/// compliant.
pub fn has_extras(dir: Option<&Path>) -> Result<bool, Error> {
  let mut cmd = Command::new("fossil");
  cmd.arg("extras");
  if let Some(ref dir) = dir {
    cmd.current_dir(dir);
  }
  let output = cmd.output()?;

  let buf = std::str::from_utf8(&output.stdout)
    .map_err(|_| Error::encoding("`fossil extras` output is not utf-8"))?;
  let cursor = io::Cursor::new(buf);
  let lines_iter = cursor.lines().map_while(Result::ok);
  let lines = lines_iter.count();
  Ok(lines != 0)
}

// vim: set ft=rust et sw=2 ts=2 sts=2 cinoptions=2 tw=79 :