use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::control::{ControlError, ControlResult};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Capability {
SpawnArgs,
PaneYurai,
Freio,
}
impl Capability {
pub const ALL: &'static [Capability] = &[
Capability::SpawnArgs,
Capability::PaneYurai,
Capability::Freio,
];
#[must_use]
pub fn wire_name(self) -> &'static str {
match self {
Capability::SpawnArgs => "spawn-args",
Capability::PaneYurai => "pane-yurai",
Capability::Freio => "freio",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
Capability::ALL
.iter()
.copied()
.find(|c| c.wire_name() == s)
}
#[must_use]
pub fn advertised(self) -> bool {
match self {
Capability::SpawnArgs | Capability::PaneYurai | Capability::Freio => true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonHello {
pub daemon_version: String,
pub capabilities: Vec<String>,
}
impl DaemonHello {
#[must_use]
pub fn for_this_build(daemon_version: &str) -> Self {
Self {
daemon_version: daemon_version.to_owned(),
capabilities: Capability::ALL
.iter()
.copied()
.filter(|c| c.advertised())
.map(|c| c.wire_name().to_owned())
.collect(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DaemonIdentity {
version: Option<String>,
capabilities: BTreeSet<String>,
}
impl DaemonIdentity {
#[must_use]
pub fn pre_capability() -> Self {
Self {
version: None,
capabilities: BTreeSet::new(),
}
}
#[must_use]
pub fn from_hello(hello: DaemonHello) -> Self {
Self {
version: Some(hello.daemon_version),
capabilities: hello.capabilities.into_iter().collect(),
}
}
#[must_use]
pub fn local(version: &str) -> Self {
Self::from_hello(DaemonHello::for_this_build(version))
}
#[must_use]
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
#[must_use]
pub fn is_pre_capability(&self) -> bool {
self.version.is_none() && self.capabilities.is_empty()
}
#[must_use]
pub fn capability_names(&self) -> Vec<&str> {
self.capabilities.iter().map(String::as_str).collect()
}
#[must_use]
pub fn has(&self, cap: Capability) -> bool {
self.capabilities.contains(cap.wire_name())
}
pub fn require(&self, cap: Capability, detail: &str) -> ControlResult<()> {
if self.has(cap) {
return Ok(());
}
let who = match &self.version {
Some(v) => format!("daemon {v} does not advertise it"),
None => "the daemon predates capability negotiation and advertises nothing".to_owned(),
};
Err(ControlError::Unsupported {
capability: cap.wire_name(),
detail: format!("{detail} ({who}); restart the tear daemon on a build that has it"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_capability_wire_name_is_unique_and_round_trips() {
let mut seen = BTreeSet::new();
for cap in Capability::ALL.iter().copied() {
assert!(
seen.insert(cap.wire_name()),
"duplicate wire name {}",
cap.wire_name()
);
assert_eq!(Capability::from_wire(cap.wire_name()), Some(cap));
}
}
#[test]
fn an_unknown_wire_name_is_a_quiet_miss() {
assert_eq!(Capability::from_wire("spawn-cwd"), None);
assert_eq!(Capability::from_wire(""), None);
}
#[test]
fn all_lists_every_variant() {
for cap in Capability::ALL.iter().copied() {
match cap {
Capability::SpawnArgs => {
assert!(Capability::ALL.contains(&Capability::SpawnArgs));
}
Capability::PaneYurai => {
assert!(Capability::ALL.contains(&Capability::PaneYurai));
}
Capability::Freio => {
assert!(Capability::ALL.contains(&Capability::Freio));
}
}
}
assert_eq!(Capability::ALL.len(), 3, "update this count with the vocabulary");
}
#[test]
fn this_builds_hello_advertises_every_implemented_capability() {
let hello = DaemonHello::for_this_build("9.9.9");
assert_eq!(hello.daemon_version, "9.9.9");
assert_eq!(
hello.capabilities,
vec![
"spawn-args".to_owned(),
"pane-yurai".to_owned(),
"freio".to_owned(),
]
);
}
#[test]
fn pre_capability_has_nothing_and_no_version() {
let id = DaemonIdentity::pre_capability();
assert!(id.is_pre_capability());
assert_eq!(id.version(), None);
assert!(!id.has(Capability::SpawnArgs));
assert!(id.capability_names().is_empty());
}
#[test]
fn require_on_a_pre_capability_daemon_is_a_typed_unsupported() {
let id = DaemonIdentity::pre_capability();
let err = id
.require(Capability::SpawnArgs, "new_window was given 2 argument(s)")
.unwrap_err();
match err {
ControlError::Unsupported { capability, detail } => {
assert_eq!(capability, "spawn-args");
assert!(detail.contains("new_window was given 2 argument(s)"));
assert!(detail.contains("predates capability negotiation"));
assert!(detail.contains("restart the tear daemon"));
}
other => panic!("wrong error: {other:?}"),
}
}
#[test]
fn require_on_a_capable_daemon_is_ok() {
let id = DaemonIdentity::local("0.1.8");
assert_eq!(id.version(), Some("0.1.8"));
assert!(id.has(Capability::SpawnArgs));
assert!(id.require(Capability::SpawnArgs, "whatever").is_ok());
}
#[test]
fn a_newer_daemons_unknown_capability_is_kept_but_never_matches() {
let id = DaemonIdentity::from_hello(DaemonHello {
daemon_version: "3.0.0".into(),
capabilities: vec!["spawn-args".into(), "spawn-cwd".into()],
});
assert_eq!(id.capability_names(), vec!["spawn-args", "spawn-cwd"]);
assert!(id.has(Capability::SpawnArgs));
assert!(!id.is_pre_capability());
}
#[test]
fn an_answering_daemon_with_no_capabilities_still_reports_its_version() {
let id = DaemonIdentity::from_hello(DaemonHello {
daemon_version: "0.1.9".into(),
capabilities: vec![],
});
assert!(!id.is_pre_capability());
assert_eq!(id.version(), Some("0.1.9"));
let err = id.require(Capability::SpawnArgs, "x").unwrap_err();
assert!(format!("{err}").contains("daemon 0.1.9 does not advertise it"));
}
}