Skip to main content

caboodle/
dp_alias.rs

1//! The `bd` -> `sd` command alias in Desire Path, for plans that install both
2//! seeds and Desire Path (aegis-fbaso4).
3//!
4//! seeds is the beads-compatible tracker (`sd`). An agent that types `bd` from
5//! habit is redirected to `sd` by a Desire Path command-substitution rule.
6//!
7//! NEVER OVERWRITE. `dp alias --cmd bd --replace sd` silently REPLACES an
8//! existing `bd` rule (measured on a scratch db: `bd -> br` became `bd -> sd`
9//! with exit 0 and one row left). A host that already routes `bd` somewhere
10//! else made that choice deliberately — the aegis fleet routes `bd -> br`, its
11//! live tracker, and rewriting it to `sd` would send every bead command to a
12//! store nobody reads. So the existing rules are READ first, and a different
13//! `bd` target is reported and left alone.
14
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17
18use anyhow::{bail, Context, Result};
19use serde_json::Value;
20
21pub const FROM: &str = "bd";
22pub const TO: &str = "sd";
23
24/// The alias belongs to plans that select BOTH seeds and Desire Path.
25pub fn wanted<'a>(tools: impl IntoIterator<Item = &'a str>) -> bool {
26    let tools: Vec<&str> = tools.into_iter().collect();
27    tools.contains(&"seeds") && tools.contains(&"desire-path")
28}
29
30/// What `dp aliases --json` already says about the `bd` command.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Existing {
33    Absent,
34    Ours,
35    /// A `bd` command rule with a different target: a deliberate local choice.
36    Other(String),
37}
38
39/// Read the `bd` command rule out of `dp aliases --json` output. Only a
40/// `match_kind: "command"` rule for command `bd` counts: regex and literal
41/// rules that merely mention bd are different kinds and are not ours to judge.
42pub fn existing(aliases_json: &[u8]) -> Result<Existing> {
43    let text = String::from_utf8_lossy(aliases_json);
44    // dp prints `null`, not `[]`, when no rule exists; both mean none.
45    let rules: Value = serde_json::from_str(text.trim())
46        .with_context(|| format!("parse `dp aliases --json` output: {}", text.trim()))?;
47    let rules = match rules {
48        Value::Null => return Ok(Existing::Absent),
49        Value::Array(rules) => rules,
50        other => bail!("`dp aliases --json` returned {other}, expected a list of rules"),
51    };
52    for rule in &rules {
53        let is_bd_command = rule.get("match_kind").and_then(Value::as_str) == Some("command")
54            && rule.get("command").and_then(Value::as_str) == Some(FROM);
55        if !is_bd_command {
56            continue;
57        }
58        return Ok(match rule.get("to").and_then(Value::as_str) {
59            Some(TO) => Existing::Ours,
60            Some(other) => Existing::Other(other.to_owned()),
61            None => Existing::Other("<no target>".to_owned()),
62        });
63    }
64    Ok(Existing::Absent)
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum Outcome {
69    Set,
70    AlreadySet,
71    KeptLocal(String),
72    NoDp,
73}
74
75impl Outcome {
76    pub fn line(&self) -> String {
77        match self {
78            Self::Set => format!("{FROM} -> {TO}: set"),
79            Self::AlreadySet => format!("{FROM} -> {TO}: present"),
80            Self::KeptLocal(target) => format!(
81                "{FROM} -> {TO}: NOT set; this host already routes {FROM} -> {target}, \
82                 a deliberate local choice that caboodle never overwrites \
83                 (remove it with `dp alias --delete --cmd {FROM} --replace {target}` to adopt {TO})"
84            ),
85            Self::NoDp => format!(
86                "{FROM} -> {TO}: note: `dp` is not on PATH, so the alias was not set; \
87                 rerun apply once Desire Path is on PATH"
88            ),
89        }
90    }
91}
92
93/// The `dp` that PATH runs, if any.
94pub fn locate() -> Option<PathBuf> {
95    let path = std::env::var_os("PATH");
96    crate::adapter::which_all("dp", path.as_deref())
97        .into_iter()
98        .next()
99}
100
101fn run(dp: &Path, db: Option<&Path>, args: &[&str]) -> Result<std::process::Output> {
102    let mut argv: Vec<OsString> = Vec::new();
103    if let Some(db) = db {
104        argv.push("--db".into());
105        argv.push(db.as_os_str().to_owned());
106    }
107    argv.extend(args.iter().map(OsString::from));
108    crate::adapter::checked(dp, argv, None)
109}
110
111fn read(dp: &Path, db: Option<&Path>) -> Result<Existing> {
112    let out = run(dp, db, &["aliases", "--json"]).context("read Desire Path aliases")?;
113    existing(&out.stdout)
114}
115
116/// Set `bd -> sd` unless a `bd` rule already exists. `db` overrides dp's
117/// default database (tests); production passes `None`.
118pub fn apply(dp: Option<&Path>, db: Option<&Path>) -> Result<Outcome> {
119    let Some(dp) = dp else {
120        return Ok(Outcome::NoDp);
121    };
122    match read(dp, db)? {
123        Existing::Ours => return Ok(Outcome::AlreadySet),
124        Existing::Other(target) => return Ok(Outcome::KeptLocal(target)),
125        Existing::Absent => {}
126    }
127    run(
128        dp,
129        db,
130        &[
131            "alias",
132            "--cmd",
133            FROM,
134            "--replace",
135            TO,
136            "--message",
137            "seeds (sd) is this stack's beads-compatible tracker",
138        ],
139    )
140    .context("set the Desire Path bd -> sd alias")?;
141    // Verify by effect: the rule must now read back as ours.
142    match read(dp, db)? {
143        Existing::Ours => Ok(Outcome::Set),
144        other => bail!("set `dp alias --cmd {FROM} --replace {TO}` but dp now reports {other:?}"),
145    }
146}
147
148/// Assert the alias: ours passes, a different local target passes with a note,
149/// and absence fails (apply would have set it).
150pub fn verify(dp: Option<&Path>, db: Option<&Path>) -> Result<Outcome> {
151    let Some(dp) = dp else {
152        bail!("`dp` is not on PATH; cannot read the {FROM} -> {TO} alias");
153    };
154    match read(dp, db)? {
155        Existing::Ours => Ok(Outcome::AlreadySet),
156        Existing::Other(target) => Ok(Outcome::KeptLocal(target)),
157        Existing::Absent => bail!(
158            "Desire Path has no {FROM} command alias; `tambo apply` sets {FROM} -> {TO} for plans with seeds and desire-path"
159        ),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    // The shape measured on a live host (aegis-fbaso4 baseline), including a
168    // regex rule that mentions bd and must NOT be mistaken for the command rule.
169    const LIVE: &str = r#"[
170      {"from":"(^|[[:space:]|;&])b[dr] comment ","to":"${1}br comments add ","tool":"Bash","param":"command","match_kind":"regex"},
171      {"from":"bd","to":"br","tool":"Bash","param":"command","command":"bd","match_kind":"command"},
172      {"from":"python","to":"python3","tool":"Bash","param":"command","command":"python","match_kind":"command"}
173    ]"#;
174
175    #[test]
176    fn wanted_needs_both_members() {
177        assert!(wanted(["quipu", "seeds", "desire-path"]));
178        assert!(!wanted(["seeds"]));
179        assert!(!wanted(["desire-path", "bobbin"]));
180    }
181
182    #[test]
183    fn existing_reads_the_command_rule_only() {
184        assert_eq!(
185            existing(LIVE.as_bytes()).unwrap(),
186            Existing::Other("br".into())
187        );
188        let ours = r#"[{"from":"bd","to":"sd","command":"bd","match_kind":"command"}]"#;
189        assert_eq!(existing(ours.as_bytes()).unwrap(), Existing::Ours);
190        // A regex rule mentioning bd alone is not a bd command alias.
191        let regex_only = r#"[{"from":"bd x","to":"sd x","match_kind":"regex"}]"#;
192        assert_eq!(existing(regex_only.as_bytes()).unwrap(), Existing::Absent);
193        assert_eq!(existing(b"null\n").unwrap(), Existing::Absent);
194        assert_eq!(existing(b"[]").unwrap(), Existing::Absent);
195        assert!(existing(b"not json").is_err());
196    }
197
198    #[test]
199    fn absent_dp_is_a_note_on_apply_and_a_failure_on_verify() {
200        assert_eq!(apply(None, None).unwrap(), Outcome::NoDp);
201        assert!(verify(None, None).is_err());
202    }
203
204    /// A stand-in `dp` that keeps one `bd` rule in a file and, like the real
205    /// dp (measured), OVERWRITES it on every `alias --cmd bd --replace X`.
206    /// The never-overwrite arm is therefore only green because apply reads
207    /// first: delete that read and `keeps_a_different_local_target` fails.
208    #[cfg(unix)]
209    fn fake_dp(dir: &Path) -> PathBuf {
210        let script = dir.join("dp");
211        crate::test_exec::write_executable(
212            &script,
213            r#"#!/bin/sh
214store="$(dirname "$0")/bd.rule"
215if [ "$1" = "--db" ]; then shift 2; fi
216case "$1" in
217  aliases)
218    if [ -s "$store" ]; then
219      printf '[{"from":"bd","to":"%s","command":"bd","match_kind":"command"}]\n' "$(cat "$store")"
220    else
221      echo null
222    fi ;;
223  alias)
224    shift
225    while [ $# -gt 0 ]; do
226      case "$1" in --replace) printf '%s' "$2" > "$store"; shift 2 ;; *) shift ;; esac
227    done ;;
228  *) exit 2 ;;
229esac
230"#,
231        );
232        script
233    }
234
235    #[cfg(unix)]
236    #[test]
237    fn sets_on_a_clean_db_and_is_idempotent() {
238        let dir = tempfile::tempdir().unwrap();
239        let dp = fake_dp(dir.path());
240        assert!(
241            verify(Some(&dp), None).is_err(),
242            "control: clean db has no alias"
243        );
244        assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::Set);
245        assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::AlreadySet);
246        assert_eq!(verify(Some(&dp), None).unwrap(), Outcome::AlreadySet);
247        assert_eq!(
248            std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
249            "sd"
250        );
251    }
252
253    #[cfg(unix)]
254    #[test]
255    fn keeps_a_different_local_target() {
256        let dir = tempfile::tempdir().unwrap();
257        let dp = fake_dp(dir.path());
258        std::fs::write(dir.path().join("bd.rule"), "br").unwrap();
259        assert_eq!(
260            apply(Some(&dp), None).unwrap(),
261            Outcome::KeptLocal("br".into())
262        );
263        assert_eq!(
264            std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
265            "br"
266        );
267        assert_eq!(
268            verify(Some(&dp), None).unwrap(),
269            Outcome::KeptLocal("br".into())
270        );
271        assert!(Outcome::KeptLocal("br".into())
272            .line()
273            .contains("never overwrites"));
274    }
275
276    /// Both arms against the REAL dp on a scratch database. Ignored by default
277    /// because CI has no dp; run with `cargo test -- --ignored real_dp`.
278    #[test]
279    #[ignore]
280    fn real_dp_both_arms() {
281        let dp = locate().expect("real_dp_both_arms needs dp on PATH");
282        let clean = tempfile::tempdir().unwrap();
283        let db = clean.path().join("d.db");
284        assert_eq!(apply(Some(&dp), Some(&db)).unwrap(), Outcome::Set);
285        assert_eq!(verify(Some(&dp), Some(&db)).unwrap(), Outcome::AlreadySet);
286
287        let local = tempfile::tempdir().unwrap();
288        let db = local.path().join("d.db");
289        run(&dp, Some(&db), &["alias", "--cmd", "bd", "--replace", "br"]).unwrap();
290        assert_eq!(
291            apply(Some(&dp), Some(&db)).unwrap(),
292            Outcome::KeptLocal("br".into())
293        );
294        assert_eq!(read(&dp, Some(&db)).unwrap(), Existing::Other("br".into()));
295    }
296}