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; `caboodle 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 use std::os::unix::fs::PermissionsExt;
211 let script = dir.join("dp");
212 std::fs::write(
213 &script,
214 r#"#!/bin/sh
215store="$(dirname "$0")/bd.rule"
216if [ "$1" = "--db" ]; then shift 2; fi
217case "$1" in
218 aliases)
219 if [ -s "$store" ]; then
220 printf '[{"from":"bd","to":"%s","command":"bd","match_kind":"command"}]\n' "$(cat "$store")"
221 else
222 echo null
223 fi ;;
224 alias)
225 shift
226 while [ $# -gt 0 ]; do
227 case "$1" in --replace) printf '%s' "$2" > "$store"; shift 2 ;; *) shift ;; esac
228 done ;;
229 *) exit 2 ;;
230esac
231"#,
232 )
233 .unwrap();
234 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
235 script
236 }
237
238 #[cfg(unix)]
239 #[test]
240 fn sets_on_a_clean_db_and_is_idempotent() {
241 let dir = tempfile::tempdir().unwrap();
242 let dp = fake_dp(dir.path());
243 assert!(
244 verify(Some(&dp), None).is_err(),
245 "control: clean db has no alias"
246 );
247 assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::Set);
248 assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::AlreadySet);
249 assert_eq!(verify(Some(&dp), None).unwrap(), Outcome::AlreadySet);
250 assert_eq!(
251 std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
252 "sd"
253 );
254 }
255
256 #[cfg(unix)]
257 #[test]
258 fn keeps_a_different_local_target() {
259 let dir = tempfile::tempdir().unwrap();
260 let dp = fake_dp(dir.path());
261 std::fs::write(dir.path().join("bd.rule"), "br").unwrap();
262 assert_eq!(
263 apply(Some(&dp), None).unwrap(),
264 Outcome::KeptLocal("br".into())
265 );
266 assert_eq!(
267 std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
268 "br"
269 );
270 assert_eq!(
271 verify(Some(&dp), None).unwrap(),
272 Outcome::KeptLocal("br".into())
273 );
274 assert!(Outcome::KeptLocal("br".into())
275 .line()
276 .contains("never overwrites"));
277 }
278
279 #[test]
282 #[ignore]
283 fn real_dp_both_arms() {
284 let dp = locate().expect("real_dp_both_arms needs dp on PATH");
285 let clean = tempfile::tempdir().unwrap();
286 let db = clean.path().join("d.db");
287 assert_eq!(apply(Some(&dp), Some(&db)).unwrap(), Outcome::Set);
288 assert_eq!(verify(Some(&dp), Some(&db)).unwrap(), Outcome::AlreadySet);
289
290 let local = tempfile::tempdir().unwrap();
291 let db = local.path().join("d.db");
292 run(&dp, Some(&db), &["alias", "--cmd", "bd", "--replace", "br"]).unwrap();
293 assert_eq!(
294 apply(Some(&dp), Some(&db)).unwrap(),
295 Outcome::KeptLocal("br".into())
296 );
297 assert_eq!(read(&dp, Some(&db)).unwrap(), Existing::Other("br".into()));
298 }
299}