1use std::collections::BTreeMap;
16use std::marker::PhantomData;
17use std::path::PathBuf;
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub enum SelectionReason {
22 #[default]
27 Scored,
28 Pinned,
30 Explicit,
32 Unknown,
37}
38
39impl SelectionReason {
40 pub const fn from_abi(raw: u32) -> Self {
42 match raw {
43 pmpx_plugin_abi::PMPX_REASON_SCORED => Self::Scored,
44 pmpx_plugin_abi::PMPX_REASON_PINNED => Self::Pinned,
45 pmpx_plugin_abi::PMPX_REASON_EXPLICIT => Self::Explicit,
46 _ => Self::Unknown,
47 }
48 }
49
50 pub const fn as_str(self) -> &'static str {
52 match self {
53 Self::Scored => "scored",
54 Self::Pinned => "pinned",
55 Self::Explicit => "explicit",
56 Self::Unknown => "unknown",
57 }
58 }
59
60 pub const fn to_abi(self) -> u32 {
66 match self {
67 Self::Scored | Self::Unknown => pmpx_plugin_abi::PMPX_REASON_SCORED,
68 Self::Pinned => pmpx_plugin_abi::PMPX_REASON_PINNED,
69 Self::Explicit => pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
70 }
71 }
72}
73
74impl std::fmt::Display for SelectionReason {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ContextFile {
87 pub name: String,
89 pub bytes: Vec<u8>,
91}
92
93impl ContextFile {
94 pub fn as_str(&self) -> Option<&str> {
96 std::str::from_utf8(&self.bytes).ok()
97 }
98}
99
100#[derive(Debug, Clone)]
102enum Files<'a> {
103 Host {
106 context: *const pmpx_plugin_abi::PmpxContext,
107 marker: PhantomData<&'a ()>,
109 },
110 Table(BTreeMap<String, Vec<u8>>),
112}
113
114#[derive(Debug, Clone)]
116pub struct Context<'a> {
117 pub project_root: PathBuf,
119
120 pub start_dir: PathBuf,
128
129 pub matched: Vec<String>,
135
136 pub config_files: Vec<PathBuf>,
138
139 pub pins: BTreeMap<String, String>,
144
145 pub reason: SelectionReason,
147
148 pub score: u32,
151
152 files: Files<'a>,
153}
154
155impl Default for Context<'_> {
156 fn default() -> Self {
157 Self {
158 project_root: PathBuf::new(),
159 start_dir: PathBuf::new(),
160 matched: Vec::new(),
161 config_files: Vec::new(),
162 pins: BTreeMap::new(),
163 reason: SelectionReason::Scored,
164 score: 0,
165 files: Files::Table(BTreeMap::new()),
166 }
167 }
168}
169
170impl<'a> Context<'a> {
171 pub fn builder() -> ContextBuilder {
181 ContextBuilder {
182 context: Context::default(),
183 }
184 }
185
186 pub(crate) unsafe fn from_host(context: *const pmpx_plugin_abi::PmpxContext) -> Context<'a> {
192 let raw = unsafe { &*context };
194
195 Context {
196 project_root: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
198 &unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_ROOT, 0) }
199 .unwrap_or_default(),
200 )),
201 start_dir: PathBuf::from(pmpx_plugin_abi::bytes_to_os(
202 &unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_START_DIR, 0) }
203 .unwrap_or_default(),
204 )),
205 matched: unsafe { read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_MATCHED) }
207 .into_iter()
208 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
209 .collect(),
210 config_files: unsafe {
211 read_list(context, pmpx_plugin_abi::PMPX_KEY_PROJECT_CONFIG_FILES)
212 }
213 .into_iter()
214 .map(|bytes| PathBuf::from(pmpx_plugin_abi::bytes_to_os(&bytes)))
215 .collect(),
216 pins: {
217 let count = unsafe { key_count(raw, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN) };
219 let mut pins = BTreeMap::new();
220 for index in 0..count {
221 let family =
222 unsafe { read_name(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
223 let plugin =
224 unsafe { read_key(context, pmpx_plugin_abi::PMPX_KEY_CONFIG_PIN, index) };
225 if let (Some(family), Some(plugin)) = (family, plugin) {
226 pins.insert(
227 String::from_utf8_lossy(&family).into_owned(),
228 String::from_utf8_lossy(&plugin).into_owned(),
229 );
230 }
231 }
232 pins
233 },
234 reason: SelectionReason::from_abi(raw.reason),
235 score: raw.score,
236 files: Files::Host {
237 context,
238 marker: PhantomData,
239 },
240 }
241 }
242
243 pub fn has_matched(&self, file: &str) -> bool {
246 self.matched.iter().any(|m| m == file)
247 }
248
249 pub fn was_pinned(&self) -> bool {
251 self.reason == SelectionReason::Pinned
252 }
253
254 pub fn pinned_for(&self, family: &str) -> Option<&str> {
256 self.pins.get(family).map(String::as_str)
257 }
258
259 pub fn file(&self, name: &str) -> Option<ContextFile> {
266 let bytes = match &self.files {
267 Files::Table(table) => table.get(name).cloned(),
268 Files::Host { context, .. } => {
271 let key = format!("{}{name}", pmpx_plugin_abi::PMPX_KEY_FILE_PREFIX);
272 unsafe { read_key(*context, &key, 0) }
273 }
274 }?;
275
276 Some(ContextFile {
277 name: name.to_string(),
278 bytes,
279 })
280 }
281
282 pub fn file_str(&self, name: &str) -> Option<String> {
284 let file = self.file(name)?;
285 file.as_str().map(str::to_string)
286 }
287
288 pub(crate) fn describe(&self, verb: crate::Verb, args_len: usize) -> String {
290 let pins: Vec<String> = self
291 .pins
292 .iter()
293 .map(|(family, plugin)| format!("{family}={plugin}"))
294 .collect();
295 let configs: Vec<String> = self
296 .config_files
297 .iter()
298 .map(|path| path.display().to_string())
299 .collect();
300
301 format!(
302 "context: root={} start={} matched=[{}] verb={} args={} reason={} score={} pins=[{}] config=[{}]",
303 self.project_root.display(),
304 self.start_dir.display(),
305 self.matched.join(" "),
306 verb,
307 args_len,
308 self.reason,
309 self.score,
310 pins.join(" "),
311 configs.join(" "),
312 )
313 }
314}
315
316pub struct ContextBuilder {
318 context: Context<'static>,
319}
320
321impl ContextBuilder {
322 pub fn project_root(mut self, root: impl Into<PathBuf>) -> Self {
324 self.context.project_root = root.into();
325 self
326 }
327
328 pub fn start_dir(mut self, dir: impl Into<PathBuf>) -> Self {
330 self.context.start_dir = dir.into();
331 self
332 }
333
334 pub fn matched<I, S>(mut self, files: I) -> Self
336 where
337 I: IntoIterator<Item = S>,
338 S: Into<String>,
339 {
340 self.context.matched = files.into_iter().map(Into::into).collect();
341 self
342 }
343
344 pub fn config_files<I, P>(mut self, paths: I) -> Self
346 where
347 I: IntoIterator<Item = P>,
348 P: Into<PathBuf>,
349 {
350 self.context.config_files = paths.into_iter().map(Into::into).collect();
351 self
352 }
353
354 pub fn pin(mut self, family: &str, plugin: &str) -> Self {
356 self.context
357 .pins
358 .insert(family.to_string(), plugin.to_string());
359 self
360 }
361
362 pub fn reason(mut self, reason: SelectionReason) -> Self {
364 self.context.reason = reason;
365 self
366 }
367
368 pub fn score(mut self, score: u32) -> Self {
370 self.context.score = score;
371 self
372 }
373
374 pub fn file(mut self, name: &str, contents: impl Into<Vec<u8>>) -> Self {
376 if let Files::Table(table) = &mut self.context.files {
377 table.insert(name.to_string(), contents.into());
378 }
379 self
380 }
381
382 pub fn build(self) -> Context<'static> {
384 self.context
385 }
386}
387
388unsafe fn key_count(context: &pmpx_plugin_abi::PmpxContext, key: &str) -> usize {
393 let count = context.count;
394 let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
395 unsafe { count(context, key) }
397}
398
399unsafe fn read_key(
404 context: *const pmpx_plugin_abi::PmpxContext,
405 key: &str,
406 index: usize,
407) -> Option<Vec<u8>> {
408 let raw = unsafe { &*context };
410 if unsafe { key_count(raw, key) } <= index {
411 return None;
412 }
413
414 let get = raw.get;
415 let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
416 let value = unsafe { get(context, key, index) };
418 unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
420}
421
422unsafe fn read_list(context: *const pmpx_plugin_abi::PmpxContext, key: &str) -> Vec<Vec<u8>> {
427 let raw = unsafe { &*context };
429 let count = unsafe { key_count(raw, key) };
430
431 if count > pmpx_plugin_abi::PMPX_MAX_ITEMS {
434 return Vec::new();
435 }
436
437 let mut out = Vec::with_capacity(count);
438 for index in 0..count {
439 if let Some(bytes) = unsafe { read_key(context, key, index) } {
440 out.push(bytes);
441 }
442 }
443 out
444}
445
446unsafe fn read_name(
451 context: *const pmpx_plugin_abi::PmpxContext,
452 key: &str,
453 index: usize,
454) -> Option<Vec<u8>> {
455 let raw = unsafe { &*context };
457 if unsafe { key_count(raw, key) } <= index {
458 return None;
459 }
460
461 let name = raw.name;
462 let key = pmpx_plugin_abi::PmpxStr::new(key.as_ptr(), key.len());
463 let value = unsafe { name(context, key, index) };
465 unsafe { value.as_bytes() }.map(<[u8]>::to_vec)
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn a_hand_built_context_answers_like_a_host_would() {
475 let context = Context::builder()
476 .project_root("/work/project")
477 .start_dir("/work/project/packages/api")
478 .matched(["package.json", "pnpm-lock.yaml"])
479 .config_files(["/work/project/.pmpx.toml"])
480 .pin("node", "pnpm")
481 .reason(SelectionReason::Pinned)
482 .score(110)
483 .file("package.json", "{\"name\":\"x\"}")
484 .build();
485
486 assert_eq!(context.project_root, PathBuf::from("/work/project"));
487 assert_eq!(
488 context.start_dir,
489 PathBuf::from("/work/project/packages/api"),
490 "the invocation directory is not the root"
491 );
492 assert!(context.has_matched("package.json"));
493 assert!(!context.has_matched("Cargo.toml"));
494 assert!(context.was_pinned());
495 assert_eq!(context.pinned_for("node"), Some("pnpm"));
496 assert_eq!(context.pinned_for("rust"), None);
497 assert_eq!(context.score, 110);
498 }
499
500 #[test]
502 fn only_declared_files_are_answered() {
503 let context = Context::builder()
504 .file("package.json", "{\"name\":\"x\"}")
505 .build();
506
507 assert_eq!(
508 context.file_str("package.json").as_deref(),
509 Some("{\"name\":\"x\"}")
510 );
511 assert_eq!(
512 context.file("package.json").map(|f| f.bytes),
513 Some(b"{\"name\":\"x\"}".to_vec())
514 );
515 assert!(context.file("Cargo.toml").is_none(), "not declared");
516 assert!(context.file_str("package.json").is_some());
517 }
518
519 #[test]
520 fn a_file_that_is_not_utf8_has_no_text() {
521 let context = Context::builder().file("binary", [0xffu8, 0xfe]).build();
522
523 assert!(context.file("binary").is_some(), "the bytes are there");
524 assert!(
525 context.file_str("binary").is_none(),
526 "but they are not text"
527 );
528 }
529
530 #[test]
531 fn the_default_context_knows_nothing() {
532 let context = Context::default();
533
534 assert!(context.project_root.as_os_str().is_empty());
535 assert!(context.matched.is_empty());
536 assert!(context.pins.is_empty());
537 assert!(context.file("anything").is_none());
538 assert_eq!(context.reason, SelectionReason::Scored);
539 assert_eq!(context.score, 0);
540 }
541
542 #[test]
543 fn the_reason_round_trips_and_has_a_word() {
544 for (reason, number) in [
545 (SelectionReason::Scored, pmpx_plugin_abi::PMPX_REASON_SCORED),
546 (SelectionReason::Pinned, pmpx_plugin_abi::PMPX_REASON_PINNED),
547 (
548 SelectionReason::Explicit,
549 pmpx_plugin_abi::PMPX_REASON_EXPLICIT,
550 ),
551 ] {
552 assert_eq!(SelectionReason::from_abi(number), reason);
553 assert_eq!(reason.to_abi(), number);
554 assert!(!reason.as_str().is_empty());
555 }
556
557 assert_eq!(SelectionReason::from_abi(999), SelectionReason::Unknown);
559 assert_eq!(
560 SelectionReason::Unknown.to_abi(),
561 pmpx_plugin_abi::PMPX_REASON_SCORED
562 );
563 }
564
565 #[test]
566 fn the_description_names_what_the_host_said() {
567 let context = Context::builder()
568 .project_root("/work/project")
569 .start_dir("/work/packages/api")
570 .matched(["package.json"])
571 .pin("node", "pnpm")
572 .config_files(["/work/.pmpx.toml"])
573 .reason(SelectionReason::Pinned)
574 .score(110)
575 .build();
576
577 let line = context.describe(crate::Verb::Install, 2);
578
579 assert!(line.contains("/work/project"), "{line}");
580 assert!(line.contains("start=/work/packages/api"), "{line}");
581 assert!(line.contains("package.json"), "{line}");
582 assert!(line.contains("verb=install"), "{line}");
583 assert!(line.contains("args=2"), "{line}");
584 assert!(line.contains("reason=pinned"), "{line}");
585 assert!(line.contains("score=110"), "{line}");
586 assert!(line.contains("node=pnpm"), "{line}");
587 assert!(line.contains(".pmpx.toml"), "{line}");
588 }
589}