use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SelectionReason {
#[default]
Scored,
Pinned,
Explicit,
Unknown,
}
impl SelectionReason {
pub const fn from_abi(raw: u32) -> Self {
match raw {
pmpx_plugin_abi::PMPX_REASON_SCORED => Self::Scored,
pmpx_plugin_abi::PMPX_REASON_PINNED => Self::Pinned,
pmpx_plugin_abi::PMPX_REASON_EXPLICIT => Self::Explicit,
_ => Self::Unknown,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Scored => "scored",
Self::Pinned => "pinned",
Self::Explicit => "explicit",
Self::Unknown => "unknown",
}
}
pub const fn to_abi(self) -> u32 {
match self {
Self::Scored | Self::Unknown => pmpx_plugin_abi::PMPX_REASON_SCORED,
Self::Pinned => pmpx_plugin_abi::PMPX_REASON_PINNED,
Self::Explicit => pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
}
}
}
impl std::fmt::Display for SelectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextFile {
pub name: String,
pub bytes: Vec<u8>,
}
impl ContextFile {
pub fn as_str(&self) -> Option<&str> {
std::str::from_utf8(&self.bytes).ok()
}
}
#[derive(Debug, Clone)]
enum Files<'a> {
Host {
context: *const pmpx_plugin_abi::PmpxContext,
marker: PhantomData<&'a ()>,
},
Table(BTreeMap<String, Vec<u8>>),
}
#[derive(Debug, Clone)]
pub struct Context<'a> {
pub project_root: PathBuf,
pub start_dir: PathBuf,
pub matched: Vec<String>,
pub config_files: Vec<PathBuf>,
pub pins: BTreeMap<String, String>,
pub reason: SelectionReason,
pub score: u32,
files: Files<'a>,
}
impl Default for Context<'_> {
fn default() -> Self {
Self {
project_root: PathBuf::new(),
start_dir: PathBuf::new(),
matched: Vec::new(),
config_files: Vec::new(),
pins: BTreeMap::new(),
reason: SelectionReason::Scored,
score: 0,
files: Files::Table(BTreeMap::new()),
}
}
}
impl<'a> Context<'a> {
pub fn builder() -> ContextBuilder {
ContextBuilder {
context: Context::default(),
}
}
pub(crate) unsafe fn from_host(context: *const pmpx_plugin_abi::PmpxContext) -> Context<'a> {
let raw = unsafe { &*context };
Context {
project_root: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
&unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_ROOT, 0) }
.unwrap_or_default(),
)),
start_dir: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
&unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_START_DIR, 0) }
.unwrap_or_default(),
)),
matched: unsafe { read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_MATCHED) }
.into_iter()
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.collect(),
config_files: unsafe {
read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_CONFIG_FILES)
}
.into_iter()
.map(|bytes| PathBuf::from(pmpx_plugin_abi::bytes_to_os(&bytes)))
.collect(),
pins: {
let count = unsafe { key_count(raw, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN) };
let mut pins = BTreeMap::new();
for index in 0..count {
let family =
unsafe { read_name(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
let plugin =
unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
if let (Some(family), Some(plugin)) = (family, plugin) {
pins.insert(
String::from_utf8_lossy(&family).into_owned(),
String::from_utf8_lossy(&plugin).into_owned(),
);
}
}
pins
},
reason: SelectionReason::from_abi(raw.reason),
score: raw.score,
files: Files::Host {
context,
marker: PhantomData,
},
}
}
pub fn has_matched(&self, file: &str) -> bool {
self.matched.iter().any(|m| m == file)
}
pub fn was_pinned(&self) -> bool {
self.reason == SelectionReason::Pinned
}
pub fn pinned_for(&self, family: &str) -> Option<&str> {
self.pins.get(family).map(String::as_str)
}
pub fn file(&self, name: &str) -> Option<ContextFile> {
let bytes = match &self.files {
Files::Table(table) => table.get(name).cloned(),
Files::Host { context, .. } => {
let key = format!("{}{name}", pmpx_plugin_abi::PMPX_KEY_FILE_PREFIX);
unsafe { read_key(*context, &key, 0) }
}
}?;
Some(ContextFile {
name: name.to_string(),
bytes,
})
}
pub fn file_str(&self, name: &str) -> Option<String> {
let file = self.file(name)?;
file.as_str().map(str::to_string)
}
pub(crate) fn describe(&self, verb: crate::Verb, args_len: usize) -> String {
let pins: Vec<String> = self
.pins
.iter()
.map(|(family, plugin)| format!("{family}={plugin}"))
.collect();
let configs: Vec<String> = self
.config_files
.iter()
.map(|path| path.display().to_string())
.collect();
format!(
"context: root={} start={} matched=[{}] verb={} args={} reason={} score={} pins=[{}] config=[{}]",
self.project_root.display(),
self.start_dir.display(),
self.matched.join(" "),
verb,
args_len,
self.reason,
self.score,
pins.join(" "),
configs.join(" "),
)
}
}
pub struct ContextBuilder {
context: Context<'static>,
}
impl ContextBuilder {
pub fn project_root(mut self, root: impl Into<PathBuf>) -> Self {
self.context.project_root = root.into();
self
}
pub fn start_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.context.start_dir = dir.into();
self
}
pub fn matched<I, S>(mut self, files: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.context.matched = files.into_iter().map(Into::into).collect();
self
}
pub fn config_files<I, P>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: Into<PathBuf>,
{
self.context.config_files = paths.into_iter().map(Into::into).collect();
self
}
pub fn pin(mut self, family: &str, plugin: &str) -> Self {
self.context
.pins
.insert(family.to_string(), plugin.to_string());
self
}
pub fn reason(mut self, reason: SelectionReason) -> Self {
self.context.reason = reason;
self
}
pub fn score(mut self, score: u32) -> Self {
self.context.score = score;
self
}
pub fn file(mut self, name: &str, contents: impl Into<Vec<u8>>) -> Self {
if let Files::Table(table) = &mut self.context.files {
table.insert(name.to_string(), contents.into());
}
self
}
pub fn build(self) -> Context<'static> {
self.context
}
}
unsafe fn key_count(context: &pmpx_plugin_abi::PmpxContext, key: &str) -> usize {
let count = context.count;
let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
unsafe { count(context, key) }
}
unsafe fn read_key(
context: *const pmpx_plugin_abi::PmpxContext,
key: &str,
index: usize,
) -> Option<Vec<u8>> {
let raw = unsafe { &*context };
if unsafe { key_count(raw, key) } <= index {
return None;
}
let get = raw.get;
let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
let value = unsafe { get(context, key, index) };
unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
}
unsafe fn read_list(context: *const pmpx_plugin_abi::PmpxContext, key: &str) -> Vec<Vec<u8>> {
let raw = unsafe { &*context };
let count = unsafe { key_count(raw, key) };
if count > pmpx_plugin_abi::PMPX_MAX_ITEMS {
return Vec::new();
}
let mut out = Vec::with_capacity(count);
for index in 0..count {
if let Some(bytes) = unsafe { read_key(context, key, index) } {
out.push(bytes);
}
}
out
}
unsafe fn read_name(
context: *const pmpx_plugin_abi::PmpxContext,
key: &str,
index: usize,
) -> Option<Vec<u8>> {
let raw = unsafe { &*context };
if unsafe { key_count(raw, key) } <= index {
return None;
}
let name = raw.name;
let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
let value = unsafe { name(context, key, index) };
unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hand_built_context_answers_like_a_host_would() {
let context = Context::builder()
.project_root("/work/project")
.start_dir("/work/project/packages/api")
.matched(["package.json", "pnpm-lock.yaml"])
.config_files(["/work/project/.pmpx.toml"])
.pin("node", "pnpm")
.reason(SelectionReason::Pinned)
.score(110)
.file("package.json", "{\"name\":\"x\"}")
.build();
assert_eq!(context.project_root, PathBuf::from("/work/project"));
assert_eq!(
context.start_dir,
PathBuf::from("/work/project/packages/api"),
"the invocation directory is not the root"
);
assert!(context.has_matched("package.json"));
assert!(!context.has_matched("Cargo.toml"));
assert!(context.was_pinned());
assert_eq!(context.pinned_for("node"), Some("pnpm"));
assert_eq!(context.pinned_for("rust"), None);
assert_eq!(context.score, 110);
}
#[test]
fn only_declared_files_are_answered() {
let context = Context::builder()
.file("package.json", "{\"name\":\"x\"}")
.build();
assert_eq!(
context.file_str("package.json").as_deref(),
Some("{\"name\":\"x\"}")
);
assert_eq!(
context.file("package.json").map(|f| f.bytes),
Some(b"{\"name\":\"x\"}".to_vec())
);
assert!(context.file("Cargo.toml").is_none(), "not declared");
assert!(context.file_str("package.json").is_some());
}
#[test]
fn a_file_that_is_not_utf8_has_no_text() {
let context = Context::builder().file("binary", [0xffu8, 0xfe]).build();
assert!(context.file("binary").is_some(), "the bytes are there");
assert!(
context.file_str("binary").is_none(),
"but they are not text"
);
}
#[test]
fn the_default_context_knows_nothing() {
let context = Context::default();
assert!(context.project_root.as_os_str().is_empty());
assert!(context.matched.is_empty());
assert!(context.pins.is_empty());
assert!(context.file("anything").is_none());
assert_eq!(context.reason, SelectionReason::Scored);
assert_eq!(context.score, 0);
}
#[test]
fn the_reason_round_trips_and_has_a_word() {
for (reason, number) in [
(SelectionReason::Scored, pmpx_plugin_abi::PMPX_REASON_SCORED),
(SelectionReason::Pinned, pmpx_plugin_abi::PMPX_REASON_PINNED),
(
SelectionReason::Explicit,
pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
),
] {
assert_eq!(SelectionReason::from_abi(number), reason);
assert_eq!(reason.to_abi(), number);
assert!(!reason.as_str().is_empty());
}
assert_eq!(SelectionReason::from_abi(999), SelectionReason::Unknown);
assert_eq!(
SelectionReason::Unknown.to_abi(),
pmpx_plugin_abi::PMPX_REASON_SCORED
);
}
#[test]
fn the_description_names_what_the_host_said() {
let context = Context::builder()
.project_root("/work/project")
.start_dir("/work/packages/api")
.matched(["package.json"])
.pin("node", "pnpm")
.config_files(["/work/.pmpx.toml"])
.reason(SelectionReason::Pinned)
.score(110)
.build();
let line = context.describe(crate::Verb::Install, 2);
assert!(line.contains("/work/project"), "{line}");
assert!(line.contains("start=/work/packages/api"), "{line}");
assert!(line.contains("package.json"), "{line}");
assert!(line.contains("verb=install"), "{line}");
assert!(line.contains("args=2"), "{line}");
assert!(line.contains("reason=pinned"), "{line}");
assert!(line.contains("score=110"), "{line}");
assert!(line.contains("node=pnpm"), "{line}");
assert!(line.contains(".pmpx.toml"), "{line}");
}
}