1use serde_json::Value;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Source {
15 Tool,
17 Shipped,
19}
20
21impl Source {
22 #[must_use]
24 pub fn label(self) -> &'static str {
25 match self {
26 Self::Tool => "tool",
27 Self::Shipped => "shipped copy",
28 }
29 }
30}
31
32#[must_use]
34pub fn binary(bundle: &str) -> &str {
35 match bundle {
36 "desire-path" => "dp",
37 other => other,
38 }
39}
40
41#[must_use]
43pub fn run_hooks_bundle(bin: &str) -> Option<String> {
44 let out = std::process::Command::new(bin)
45 .args(["hooks", "bundle"])
46 .output()
47 .ok()?;
48 out.status
49 .success()
50 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
51}
52
53fn own_bundle(name: &str, text: &str) -> Option<String> {
55 let v: Value = serde_json::from_str(text).ok()?;
56 (v.get("schema").and_then(Value::as_str) == Some("st.hook-bundle/1")
57 && v.get("name").and_then(Value::as_str) == Some(name)
58 && v.get("hooks").is_some_and(Value::is_array))
59 .then(|| text.to_string())
60}
61
62pub fn resolve_with(
65 shipped: &[(&'static str, &'static str)],
66 run: impl Fn(&str) -> Option<String>,
67) -> Vec<(String, String, Source)> {
68 shipped
69 .iter()
70 .map(
71 |(name, copy)| match run(binary(name)).and_then(|text| own_bundle(name, &text)) {
72 Some(json) => ((*name).to_string(), json, Source::Tool),
73 None => ((*name).to_string(), (*copy).to_string(), Source::Shipped),
74 },
75 )
76 .collect()
77}
78
79#[must_use]
81pub fn resolve(shipped: &[(&'static str, &'static str)]) -> Vec<(String, String, Source)> {
82 resolve_with(shipped, run_hooks_bundle)
83}
84
85#[must_use]
87pub fn as_pairs(resolved: &[(String, String, Source)]) -> Vec<(&str, &str)> {
88 resolved
89 .iter()
90 .map(|(n, j, _)| (n.as_str(), j.as_str()))
91 .collect()
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 const SHIPPED: &[(&str, &str)] = &[
99 (
100 "bobbin",
101 r#"{"schema":"st.hook-bundle/1","name":"bobbin","version":"old","hooks":[]}"#,
102 ),
103 (
104 "desire-path",
105 r#"{"schema":"st.hook-bundle/1","name":"desire-path","version":"old","hooks":[]}"#,
106 ),
107 ];
108
109 #[test]
110 fn the_tools_own_bundle_wins_and_dp_is_asked_as_dp() {
111 let asked = std::cell::RefCell::new(Vec::new());
112 let got = resolve_with(SHIPPED, |bin| {
113 asked.borrow_mut().push(bin.to_string());
114 let name = if bin == "dp" { "desire-path" } else { bin };
115 Some(format!(
116 r#"{{"schema":"st.hook-bundle/1","name":"{name}","version":"new","hooks":[]}}"#
117 ))
118 });
119 assert_eq!(*asked.borrow(), ["bobbin", "dp"]);
120 assert!(got
121 .iter()
122 .all(|(_, j, s)| *s == Source::Tool && j.contains("\"new\"")));
123 }
124
125 #[test]
126 fn an_older_tool_or_a_wrong_answer_falls_back_to_the_shipped_copy() {
127 let got = resolve_with(SHIPPED, |bin| {
129 (bin == "dp")
130 .then(|| r#"{"schema":"st.hook-bundle/1","name":"bobbin","hooks":[]}"#.to_string())
131 });
132 assert!(got
133 .iter()
134 .all(|(_, j, s)| *s == Source::Shipped && j.contains("\"old\"")));
135 assert_eq!(as_pairs(&got)[1].0, "desire-path");
136 }
137
138 #[test]
139 fn non_json_is_not_a_bundle() {
140 let got = resolve_with(&SHIPPED[..1], |_| {
141 Some("bobbin hooks: unknown command".into())
142 });
143 assert_eq!(got[0].2, Source::Shipped);
144 }
145}