1use 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
24pub 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Existing {
33 Absent,
34 Ours,
35 Other(String),
37}
38
39pub fn existing(aliases_json: &[u8]) -> Result<Existing> {
43 let text = String::from_utf8_lossy(aliases_json);
44 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
93pub 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
116pub 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 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
148pub 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 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 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 #[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 #[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}