Skip to main content

caboodle/
tool_bundles.rs

1//! Each tool's OWN hook bundle, read from the installed tool (aegis-5s32or.5).
2//!
3//! The stack tools now ship their hook definitions themselves:
4//! `<tool> hooks bundle` prints the tool's `st.hook-bundle/1`, versioned with
5//! the tool. Tambo registers that bundle, so the hooks a host runs are the
6//! ones its installed tool version declares. An older tool without the command
7//! (or one that prints something that is not its own bundle) falls back to the
8//! copy Tambo ships in `hook-bundles/`. The source is reported either way.
9
10use serde_json::Value;
11
12/// Where a resolved bundle came from.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Source {
15    /// `<tool> hooks bundle` on this host.
16    Tool,
17    /// The copy shipped in Tambo's `hook-bundles/`.
18    Shipped,
19}
20
21impl Source {
22    /// Report label.
23    #[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/// The executable that prints a bundle's hooks.
33#[must_use]
34pub fn binary(bundle: &str) -> &str {
35    match bundle {
36        "desire-path" => "dp",
37        other => other,
38    }
39}
40
41/// `<bin> hooks bundle` stdout, or `None` when the command is absent or fails.
42#[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
53/// Accept `text` only if it is a `st.hook-bundle/1` named `name`.
54fn 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
62/// Resolve the selected bundles: the tool's own when it answers with its own
63/// bundle, else the shipped copy. `run` is injectable for tests.
64pub 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/// [`resolve_with`] against the installed tools.
80#[must_use]
81pub fn resolve(shipped: &[(&'static str, &'static str)]) -> Vec<(String, String, Source)> {
82    resolve_with(shipped, run_hooks_bundle)
83}
84
85/// Borrowed `(name, json)` pairs for the register/verify functions.
86#[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        // bobbin: no `hooks bundle` command; dp: answers with someone else's bundle.
128        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}