mk 0.7.0

Yet another simple task runner 🦀
Documentation
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{
  Hash,
  Hasher,
};
use std::path::{
  Path,
  PathBuf,
};

use anyhow::Context as _;
use glob::glob;
use hashbrown::HashMap;
use serde::{
  Deserialize,
  Serialize,
};

use crate::file::ToUtf8 as _;
use crate::utils::resolve_path;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
  pub fingerprint: String,
  pub outputs: Vec<String>,
  pub updated_at: String,
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CacheStore {
  pub tasks: HashMap<String, CacheEntry>,
}

impl CacheStore {
  pub fn load() -> anyhow::Result<Self> {
    Self::load_in_dir(Path::new("."))
  }

  pub fn load_in_dir(base_dir: &Path) -> anyhow::Result<Self> {
    let path = cache_path_in_dir(base_dir);
    if !path.exists() {
      return Ok(Self::default());
    }

    let contents = fs::read_to_string(&path).with_context(|| {
      format!(
        "Failed to read cache file - {}",
        path.to_utf8().unwrap_or("<non-utf8-path>")
      )
    })?;
    Ok(serde_json::from_str(&contents)?)
  }

  pub fn save(&self) -> anyhow::Result<()> {
    self.save_in_dir(Path::new("."))
  }

  pub fn save_in_dir(&self, base_dir: &Path) -> anyhow::Result<()> {
    let path = cache_path_in_dir(base_dir);
    if let Some(parent) = path.parent() {
      fs::create_dir_all(parent)?;
    }

    fs::write(&path, serde_json::to_string_pretty(self)?).with_context(|| {
      format!(
        "Failed to write cache file - {}",
        path.to_utf8().unwrap_or("<non-utf8-path>")
      )
    })?;
    Ok(())
  }

  pub fn remove() -> anyhow::Result<()> {
    Self::remove_in_dir(Path::new("."))
  }

  pub fn remove_in_dir(base_dir: &Path) -> anyhow::Result<()> {
    let path = cache_path_in_dir(base_dir);
    if path.exists() {
      fs::remove_file(&path).with_context(|| {
        format!(
          "Failed to remove cache file - {}",
          path.to_utf8().unwrap_or("<non-utf8-path>")
        )
      })?;
    }
    Ok(())
  }
}

pub fn cache_path() -> PathBuf {
  cache_path_in_dir(Path::new("."))
}

pub fn cache_path_in_dir(base_dir: &Path) -> PathBuf {
  base_dir.join(".mk").join("cache.json")
}

pub fn expand_patterns(patterns: &[String]) -> anyhow::Result<Vec<PathBuf>> {
  expand_patterns_in_dir(Path::new("."), patterns)
}

pub fn expand_patterns_in_dir(base_dir: &Path, patterns: &[String]) -> anyhow::Result<Vec<PathBuf>> {
  let mut paths = Vec::new();

  for pattern in patterns {
    let mut matched = false;
    let resolved_pattern = resolve_path(base_dir, pattern);
    let resolved_pattern = resolved_pattern.to_string_lossy().into_owned();
    for entry in glob(&resolved_pattern)? {
      matched = true;
      let path = entry?;
      paths.push(path);
    }

    if !matched {
      paths.push(resolve_path(base_dir, pattern));
    }
  }

  paths.sort();
  paths.dedup();
  Ok(paths)
}

pub fn compute_fingerprint(
  task_name: &str,
  task_debug: &str,
  env_vars: &[(String, String)],
  inputs: &[PathBuf],
  env_files: &[PathBuf],
  outputs: &[PathBuf],
) -> anyhow::Result<String> {
  let mut hasher = DefaultHasher::new();

  task_name.hash(&mut hasher);
  task_debug.hash(&mut hasher);
  outputs.hash(&mut hasher);

  for (key, value) in env_vars {
    key.hash(&mut hasher);
    value.hash(&mut hasher);
  }

  for path in inputs {
    path.to_string_lossy().hash(&mut hasher);
    hash_path(path, &mut hasher)?;
  }

  for path in env_files {
    path.to_string_lossy().hash(&mut hasher);
    hash_path(path, &mut hasher)?;
  }

  Ok(format!("{:016x}", hasher.finish()))
}

fn hash_path(path: &Path, hasher: &mut DefaultHasher) -> anyhow::Result<()> {
  if !path.exists() {
    "missing".hash(hasher);
    return Ok(());
  }

  let metadata = fs::metadata(path)?;
  metadata.len().hash(hasher);

  if metadata.is_file() {
    let bytes = fs::read(path)?;
    bytes.hash(hasher);
  } else {
    let modified = metadata.modified().ok();
    format!("{modified:?}").hash(hasher);
  }

  Ok(())
}