use super::layout_error::ProtoLayoutError;
use crate::helpers::write_json_file_atomic;
use crate::tool_context::ToolContext;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use starbase_styles::color;
use starbase_utils::fs;
use starbase_utils::json::{self, JsonError};
use std::collections::BTreeMap;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use tracing::{debug, instrument, warn};
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(default)]
pub struct Shim {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub after_args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "alt_bin")]
pub alt_exe: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub before_args: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", alias = "parent")]
pub context: Option<ToolContext>,
#[serde(skip_serializing_if = "FxHashMap::is_empty")]
pub env_vars: FxHashMap<String, String>,
}
pub type ShimsMap = BTreeMap<String, Shim>;
pub struct ShimRegistry {
pub shims: ShimsMap,
changed: ShimsMap,
path: PathBuf,
}
impl ShimRegistry {
pub fn load_from<P: AsRef<Path>>(dir: P) -> Result<Self, ProtoLayoutError> {
Self::load(dir.as_ref().join("registry.json"))
}
#[instrument(name = "load_shim_registry")]
pub fn load<P: AsRef<Path> + Debug>(path: P) -> Result<Self, ProtoLayoutError> {
let path = path.as_ref();
debug!(file = ?path, "Loading shims registry");
Ok(Self {
shims: read_shims_map(path)?,
path: path.to_path_buf(),
changed: ShimsMap::default(),
})
}
pub fn get(&self, key: &str) -> Option<&Shim> {
self.changed.get(key).or_else(|| self.shims.get(key))
}
#[instrument(name = "update_shim_registry", skip(self))]
pub fn update(&mut self, key: String, value: Shim) -> Result<(), ProtoLayoutError> {
if apply_shim_update(&mut self.shims, &key, &value) {
self.changed.insert(key, value);
}
Ok(())
}
#[instrument(name = "save_shim_registry", skip(self))]
pub fn save(&mut self) -> Result<(), ProtoLayoutError> {
if self.changed.is_empty() {
return Ok(());
}
debug!(file = ?self.path, "Saving shim registry");
let _lock = fs::lock_file(self.path.with_extension("lock"))?;
let mut shims = read_shims_map(&self.path)?;
let mut dirty = false;
for (key, value) in &self.changed {
if apply_shim_update(&mut shims, key, value) {
dirty = true;
}
}
if dirty {
write_json_file_atomic(&self.path, &shims)?;
}
self.shims = shims;
self.changed.clear();
Ok(())
}
}
fn read_shims_map(path: &Path) -> Result<ShimsMap, ProtoLayoutError> {
if !path.exists() {
return Ok(ShimsMap::default());
}
let content = fs::read_file_with_lock(path)?;
if content.trim().is_empty() {
warn!(
file = ?path,
"Shims registry file is unexpectedly empty, possibly from an interrupted write; treating it as an empty registry"
);
return Ok(ShimsMap::default());
}
let shims: ShimsMap =
json::serde_json::from_str(&content).map_err(|error| JsonError::ReadFile {
path: path.to_path_buf(),
error: Box::new(error),
})?;
Ok(shims)
}
fn apply_shim_update(shims: &mut ShimsMap, key: &str, value: &Shim) -> bool {
if let Some(current) = shims.get(key) {
if current == value {
return false;
}
match detect_shim_conflict(key, value, current) {
Some(ShimConflict::Ignored { owner, provider }) => {
warn!(
shim = key,
owner = owner.as_str(),
provider = provider.as_str(),
"Shim {} is already provided by {}, ignoring the duplicate from {}",
color::file(key),
color::id(&owner),
color::id(&provider),
);
return false;
}
Some(ShimConflict::Reclaimed { provider }) => {
debug!(
shim = key,
provider = provider.as_str(),
"Shim {} reclaimed by its owning tool from {}",
color::file(key),
color::id(&provider)
);
}
None => {}
}
}
shims.insert(key.to_owned(), value.clone());
true
}
enum ShimConflict {
Ignored { owner: String, provider: String },
Reclaimed { provider: String },
}
fn detect_shim_conflict(name: &str, incoming: &Shim, existing: &Shim) -> Option<ShimConflict> {
match (&incoming.context, &existing.context) {
(Some(provider), None) => Some(ShimConflict::Ignored {
owner: name.to_owned(),
provider: provider.as_str().to_owned(),
}),
(Some(provider), Some(owner)) if provider != owner => Some(ShimConflict::Ignored {
owner: owner.as_str().to_owned(),
provider: provider.as_str().to_owned(),
}),
(None, Some(provider)) => Some(ShimConflict::Reclaimed {
provider: provider.as_str().to_owned(),
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::Id;
fn primary() -> Shim {
Shim::default()
}
fn secondary(tool: &str) -> Shim {
Shim {
context: Some(ToolContext::new(Id::raw(tool))),
..Default::default()
}
}
#[test]
fn no_conflict_between_same_primary() {
assert!(detect_shim_conflict("go", &primary(), &primary()).is_none());
}
#[test]
fn no_conflict_between_same_secondary_owner() {
assert!(detect_shim_conflict("dlv", &secondary("go"), &secondary("go")).is_none());
}
#[test]
fn secondary_loses_to_existing_primary() {
assert!(matches!(
detect_shim_conflict("go", &secondary("xyz"), &primary()),
Some(ShimConflict::Ignored { owner, provider }) if owner == "go" && provider == "xyz"
));
}
#[test]
fn secondary_loses_to_existing_secondary() {
assert!(matches!(
detect_shim_conflict("foo", &secondary("b"), &secondary("a")),
Some(ShimConflict::Ignored { owner, provider }) if owner == "a" && provider == "b"
));
}
#[test]
fn primary_reclaims_from_secondary() {
assert!(matches!(
detect_shim_conflict("go", &primary(), &secondary("xyz")),
Some(ShimConflict::Reclaimed { provider }) if provider == "xyz"
));
}
}