gitforge 0.2.0

uniform access to git forges (gitlab and github)
Documentation
// Copyright 2021 Citrix
// SPDX-License-Identifier: MIT OR Apache-2.0
// There is NO WARRANTY.

use crate::prelude::*;

use hash_map::Entry as E;

#[derive(Debug)]
pub struct GitRemoteUrlParsed<'s> {
  pub scheme: &'s str, // "" means was special ssh syntax
  pub user: &'s str, // "" means absent, not distinct from specified as empty
  pub host: &'s str,
  pub path: &'s str,
}

#[throws(FE)]
pub fn parse_git_remote_url<'u>(url: &'u str, expect_host: Option<&str>)
                                -> GitRemoteUrlParsed<'u>
{
  // Implmeents the parsing described in git-push(1) under "GIT URLSu
  let p = (||{
    let (scheme, rhs) = url.split_once(':').ok_or("no colon")?;
    if scheme.contains('/') { throw!("slash before colon, local path") };

    let (scheme, (userhost, path)) =
      if let Some(rhs) = rhs.strip_prefix("//") {
        // empirically, this means git thinks this is a URL with a scheme
        if rhs.contains(':') { throw!("seems to contain port numbe (colon)"); }
        (scheme,
         rhs.split_once('/').ok_or("no slash after host")?)
      } else {
        // Empirically, this is the special ssh syntax
        let userhost = scheme;
        let path = rhs.trim_start_matches('/');
        ("", (userhost, path))
      };

    let (user, host) = userhost.rsplit_once('@').unwrap_or(("", userhost));

    Ok::<_,String>(GitRemoteUrlParsed { scheme, user, host, path })
  })().map_err(|s| FE::UnsupportedRemoteUrlSyntax(
    s,
    format!("{:?}", url),
  ))?;

  if let Some(e) = expect_host { if p.host != e { throw!(
      FE::UrlHostMismatch{ url: p.host.to_owned(), forge: e.to_owned() }
  ) } }

  p
}

#[derive(Clone,Debug)]
pub struct IdCacheData<F,Id>
where F: Forge + ?Sized,
      Id: Clone + Debug + Hash + Eq + PartialEq
{
  name2id: HashMap<Arc<String>, Id>,
  id2name: HashMap<Id, Arc<String>>,
  forge: PhantomData<F>,
}

impl<F,Id> Default for IdCacheData<F,Id>
where F: Forge + ?Sized,
      Id: Clone + Debug + Hash + Eq + PartialEq
{
  fn default() -> Self { Self::new() }
}

impl<F,Id> IdCacheData<F,Id>
where F: Forge + ?Sized,
      Id: Clone + Debug + Hash + Eq + PartialEq
{
  pub fn new() -> Self { IdCacheData {
    name2id: default(),
    id2name: default(),
    forge:   default(),
  } }

  pub fn clear(&mut self) {
    self.name2id.clear();
    self.id2name.clear();
  }
}

pub trait IdCache<Id>: Forge
where Id: Clone + Debug + Hash + Eq + PartialEq + 'static
{
  const UNKNOWN: RemoteObjectKind;

  fn name2id_lookup(&mut self, name: &str) -> Result<Option<Id>, FE>;
  fn id2name_lookup(&mut self, id: Id) -> Result<Option<String>, FE>;
  fn id_cache(&mut self) -> &mut IdCacheData<Self, Id>;

  #[throws(FE)]
  fn name2id(&mut self, name: &str) -> Option<Id> {
    let name = Arc::new(name.to_string()); // ideally, use RawEntryBuilder
    if let Some(id) = self.id_cache().name2id.get(&name) {
      return Some(id.clone());
    }
    let id = self.name2id_lookup(&name)?;
    if let Some(id) = &id {
      let cache = self.id_cache();
      // let name = name.to_owned().into();

      match cache.id2name.entry(id.clone()) {
        E::Occupied(oe) => throw!(FE::AncillaryOperationFailed(anyhow!(
          "inconsistent name->id->name mapping: {:?} -> {:?} -> {:?}",
          name, &id, oe.get()
        ))),
        E::Vacant(ve) => {
          ve.insert(name.clone())
        }
      };
      
      cache.name2id.insert(name, id.clone());
    }
    id
  }

  #[throws(FE)]
  fn id2name(&mut self, id: Id) -> Option<&Arc<String>> {
    if let Some(_name) = self.id_cache().id2name.get(&id) {
      // work around https://github.com/rust-lang/rust/issues/54663
      let name = self.id_cache().id2name.get(&id) .unwrap();
      return Some(name);
    }

    let name = self.id2name_lookup(id.clone())?;
    if let Some(name) = name {
      let name: Arc<String> = name.into();
      let cache = self.id_cache();

      match cache.name2id.entry(name.clone()) {
        E::Occupied(oe) => throw!(FE::AncillaryOperationFailed(anyhow!(
          "inconsistent id->name->id mapping: {:?} -> {:?} -> {:?}",
          &id, &name, oe.get()
        ))),
        E::Vacant(ve) => {
          ve.insert(id.clone())
        }
      };

      let name: &Arc<String> = match cache.id2name.entry(id) {
        E::Occupied(_oe) => unreachable!(),
        E::Vacant(ve) => ve.insert(name),
      };

      Some(name)
    } else {
      None
    }
  }

  #[throws(FE)]
  fn name2id_required(&mut self, name: &str) -> Id {
    self.name2id(name)?.ok_or_else(
      || FE::NameNotFound(Self::UNKNOWN, name.into())
    )?
  }

  #[throws(FE)]
  fn id2name_required(&mut self, id: Id) -> &Arc<String> {
    self.id2name(id.clone())?.ok_or_else(
      || FE::IdNotFound(Self::UNKNOWN, format!("{:?}", id))
    )?
  }
}

macro_rules! filter_compare {
  { $q_field:expr, $mr_field:expr } => {
    if let Some(specified) = &$q_field {
      if specified != &$mr_field { return false }
    }
  }
}

pub fn filter_mergerequests<'q>(q: &'q Req_MergeRequests)
                            -> impl Fn(&Resp_MergeRequest) -> bool + 'q
{ move |mr| {
  if q.target_repo != mr.target.repo { return false }
  if let Some(statuses) = &q.statuses {
    if ! statuses.contains(&mr.state.status) { return false }
  }
  filter_compare!{ q.number,        mr.number        }
  filter_compare!{ q.author,        mr.author        }
  filter_compare!{ q.source_repo,   mr.source.repo   }
  filter_compare!{ q.source_branch, mr.source.branch }
  filter_compare!{ q.target_branch, mr.target.branch }
  true
}}