use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde_json::Value;
pub const FROM: &str = "bd";
pub const TO: &str = "sd";
pub fn wanted<'a>(tools: impl IntoIterator<Item = &'a str>) -> bool {
let tools: Vec<&str> = tools.into_iter().collect();
tools.contains(&"seeds") && tools.contains(&"desire-path")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Existing {
Absent,
Ours,
Other(String),
}
pub fn existing(aliases_json: &[u8]) -> Result<Existing> {
let text = String::from_utf8_lossy(aliases_json);
let rules: Value = serde_json::from_str(text.trim())
.with_context(|| format!("parse `dp aliases --json` output: {}", text.trim()))?;
let rules = match rules {
Value::Null => return Ok(Existing::Absent),
Value::Array(rules) => rules,
other => bail!("`dp aliases --json` returned {other}, expected a list of rules"),
};
for rule in &rules {
let is_bd_command = rule.get("match_kind").and_then(Value::as_str) == Some("command")
&& rule.get("command").and_then(Value::as_str) == Some(FROM);
if !is_bd_command {
continue;
}
return Ok(match rule.get("to").and_then(Value::as_str) {
Some(TO) => Existing::Ours,
Some(other) => Existing::Other(other.to_owned()),
None => Existing::Other("<no target>".to_owned()),
});
}
Ok(Existing::Absent)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Set,
AlreadySet,
KeptLocal(String),
NoDp,
}
impl Outcome {
pub fn line(&self) -> String {
match self {
Self::Set => format!("{FROM} -> {TO}: set"),
Self::AlreadySet => format!("{FROM} -> {TO}: present"),
Self::KeptLocal(target) => format!(
"{FROM} -> {TO}: NOT set; this host already routes {FROM} -> {target}, \
a deliberate local choice that caboodle never overwrites \
(remove it with `dp alias --delete --cmd {FROM} --replace {target}` to adopt {TO})"
),
Self::NoDp => format!(
"{FROM} -> {TO}: note: `dp` is not on PATH, so the alias was not set; \
rerun apply once Desire Path is on PATH"
),
}
}
}
pub fn locate() -> Option<PathBuf> {
let path = std::env::var_os("PATH");
crate::adapter::which_all("dp", path.as_deref())
.into_iter()
.next()
}
fn run(dp: &Path, db: Option<&Path>, args: &[&str]) -> Result<std::process::Output> {
let mut argv: Vec<OsString> = Vec::new();
if let Some(db) = db {
argv.push("--db".into());
argv.push(db.as_os_str().to_owned());
}
argv.extend(args.iter().map(OsString::from));
crate::adapter::checked(dp, argv, None)
}
fn read(dp: &Path, db: Option<&Path>) -> Result<Existing> {
let out = run(dp, db, &["aliases", "--json"]).context("read Desire Path aliases")?;
existing(&out.stdout)
}
pub fn apply(dp: Option<&Path>, db: Option<&Path>) -> Result<Outcome> {
let Some(dp) = dp else {
return Ok(Outcome::NoDp);
};
match read(dp, db)? {
Existing::Ours => return Ok(Outcome::AlreadySet),
Existing::Other(target) => return Ok(Outcome::KeptLocal(target)),
Existing::Absent => {}
}
run(
dp,
db,
&[
"alias",
"--cmd",
FROM,
"--replace",
TO,
"--message",
"seeds (sd) is this stack's beads-compatible tracker",
],
)
.context("set the Desire Path bd -> sd alias")?;
match read(dp, db)? {
Existing::Ours => Ok(Outcome::Set),
other => bail!("set `dp alias --cmd {FROM} --replace {TO}` but dp now reports {other:?}"),
}
}
pub fn verify(dp: Option<&Path>, db: Option<&Path>) -> Result<Outcome> {
let Some(dp) = dp else {
bail!("`dp` is not on PATH; cannot read the {FROM} -> {TO} alias");
};
match read(dp, db)? {
Existing::Ours => Ok(Outcome::AlreadySet),
Existing::Other(target) => Ok(Outcome::KeptLocal(target)),
Existing::Absent => bail!(
"Desire Path has no {FROM} command alias; `tambo apply` sets {FROM} -> {TO} for plans with seeds and desire-path"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
const LIVE: &str = r#"[
{"from":"(^|[[:space:]|;&])b[dr] comment ","to":"${1}br comments add ","tool":"Bash","param":"command","match_kind":"regex"},
{"from":"bd","to":"br","tool":"Bash","param":"command","command":"bd","match_kind":"command"},
{"from":"python","to":"python3","tool":"Bash","param":"command","command":"python","match_kind":"command"}
]"#;
#[test]
fn wanted_needs_both_members() {
assert!(wanted(["quipu", "seeds", "desire-path"]));
assert!(!wanted(["seeds"]));
assert!(!wanted(["desire-path", "bobbin"]));
}
#[test]
fn existing_reads_the_command_rule_only() {
assert_eq!(
existing(LIVE.as_bytes()).unwrap(),
Existing::Other("br".into())
);
let ours = r#"[{"from":"bd","to":"sd","command":"bd","match_kind":"command"}]"#;
assert_eq!(existing(ours.as_bytes()).unwrap(), Existing::Ours);
let regex_only = r#"[{"from":"bd x","to":"sd x","match_kind":"regex"}]"#;
assert_eq!(existing(regex_only.as_bytes()).unwrap(), Existing::Absent);
assert_eq!(existing(b"null\n").unwrap(), Existing::Absent);
assert_eq!(existing(b"[]").unwrap(), Existing::Absent);
assert!(existing(b"not json").is_err());
}
#[test]
fn absent_dp_is_a_note_on_apply_and_a_failure_on_verify() {
assert_eq!(apply(None, None).unwrap(), Outcome::NoDp);
assert!(verify(None, None).is_err());
}
#[cfg(unix)]
fn fake_dp(dir: &Path) -> PathBuf {
let script = dir.join("dp");
crate::test_exec::write_executable(
&script,
r#"#!/bin/sh
store="$(dirname "$0")/bd.rule"
if [ "$1" = "--db" ]; then shift 2; fi
case "$1" in
aliases)
if [ -s "$store" ]; then
printf '[{"from":"bd","to":"%s","command":"bd","match_kind":"command"}]\n' "$(cat "$store")"
else
echo null
fi ;;
alias)
shift
while [ $# -gt 0 ]; do
case "$1" in --replace) printf '%s' "$2" > "$store"; shift 2 ;; *) shift ;; esac
done ;;
*) exit 2 ;;
esac
"#,
);
script
}
#[cfg(unix)]
#[test]
fn sets_on_a_clean_db_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let dp = fake_dp(dir.path());
assert!(
verify(Some(&dp), None).is_err(),
"control: clean db has no alias"
);
assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::Set);
assert_eq!(apply(Some(&dp), None).unwrap(), Outcome::AlreadySet);
assert_eq!(verify(Some(&dp), None).unwrap(), Outcome::AlreadySet);
assert_eq!(
std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
"sd"
);
}
#[cfg(unix)]
#[test]
fn keeps_a_different_local_target() {
let dir = tempfile::tempdir().unwrap();
let dp = fake_dp(dir.path());
std::fs::write(dir.path().join("bd.rule"), "br").unwrap();
assert_eq!(
apply(Some(&dp), None).unwrap(),
Outcome::KeptLocal("br".into())
);
assert_eq!(
std::fs::read_to_string(dir.path().join("bd.rule")).unwrap(),
"br"
);
assert_eq!(
verify(Some(&dp), None).unwrap(),
Outcome::KeptLocal("br".into())
);
assert!(Outcome::KeptLocal("br".into())
.line()
.contains("never overwrites"));
}
#[test]
#[ignore]
fn real_dp_both_arms() {
let dp = locate().expect("real_dp_both_arms needs dp on PATH");
let clean = tempfile::tempdir().unwrap();
let db = clean.path().join("d.db");
assert_eq!(apply(Some(&dp), Some(&db)).unwrap(), Outcome::Set);
assert_eq!(verify(Some(&dp), Some(&db)).unwrap(), Outcome::AlreadySet);
let local = tempfile::tempdir().unwrap();
let db = local.path().join("d.db");
run(&dp, Some(&db), &["alias", "--cmd", "bd", "--replace", "br"]).unwrap();
assert_eq!(
apply(Some(&dp), Some(&db)).unwrap(),
Outcome::KeptLocal("br".into())
);
assert_eq!(read(&dp, Some(&db)).unwrap(), Existing::Other("br".into()));
}
}