use clap::ValueEnum;
use super::services::{bridge, sessions, snowflake, worktrees};
pub const SERVICES_ENV: &str = "OMNI_DEV_DAEMON_SERVICES";
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum DaemonServiceKind {
#[value(name = "browser-bridge")]
Bridge,
#[value(name = "snowflake")]
Snowflake,
#[value(name = "worktrees")]
Worktrees,
#[value(name = "sessions")]
Sessions,
}
impl DaemonServiceKind {
pub const ALL: [Self; 4] = [
Self::Bridge,
Self::Snowflake,
Self::Worktrees,
Self::Sessions,
];
pub fn to_name(self) -> &'static str {
match self {
Self::Bridge => bridge::SERVICE_NAME,
Self::Snowflake => snowflake::SERVICE_NAME,
Self::Worktrees => worktrees::SERVICE_NAME,
Self::Sessions => sessions::SERVICE_NAME,
}
}
fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.to_name() == name)
}
}
fn known_names() -> String {
DaemonServiceKind::ALL
.iter()
.map(|k| k.to_name())
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServiceSelection {
All,
Only(Vec<DaemonServiceKind>),
}
impl ServiceSelection {
pub fn includes(&self, kind: DaemonServiceKind) -> bool {
match self {
Self::All => true,
Self::Only(kinds) => kinds.contains(&kind),
}
}
pub fn to_csv(&self) -> Option<String> {
match self {
Self::All => None,
Self::Only(kinds) => Some(
kinds
.iter()
.map(|k| k.to_name())
.collect::<Vec<_>>()
.join(","),
),
}
}
pub fn resolve(flag: &[DaemonServiceKind], env: Option<&str>) -> Self {
if !flag.is_empty() {
return Self::from_kinds(flag.iter().copied());
}
if let Some(raw) = env {
let mut kinds = Vec::new();
for token in raw.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
if let Some(kind) = DaemonServiceKind::from_name(token) {
kinds.push(kind);
} else {
tracing::warn!(
"ignoring unknown service `{token}` in {SERVICES_ENV} (known: {})",
known_names()
);
}
}
if !kinds.is_empty() {
return Self::from_kinds(kinds.into_iter());
}
}
Self::All
}
pub fn from_flag_or_env(flag: &[DaemonServiceKind]) -> Self {
Self::resolve(flag, std::env::var(SERVICES_ENV).ok().as_deref())
}
pub fn from_service_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
Self::from_kinds(names.into_iter().filter_map(DaemonServiceKind::from_name))
}
fn from_kinds(kinds: impl Iterator<Item = DaemonServiceKind>) -> Self {
let mut seen: Vec<DaemonServiceKind> = Vec::new();
for k in kinds {
if !seen.contains(&k) {
seen.push(k);
}
}
if seen.is_empty() || seen.len() == DaemonServiceKind::ALL.len() {
Self::All
} else {
Self::Only(seen)
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use DaemonServiceKind::*;
#[test]
fn value_names_match_the_service_constants() {
for (kind, expected) in [
(Bridge, bridge::SERVICE_NAME),
(Snowflake, snowflake::SERVICE_NAME),
(Worktrees, worktrees::SERVICE_NAME),
(Sessions, sessions::SERVICE_NAME),
] {
assert_eq!(kind.to_name(), expected);
assert_eq!(
kind.to_possible_value().unwrap().get_name(),
expected,
"clap value name drifted from SERVICE_NAME for {kind:?}"
);
}
}
#[test]
fn flag_wins_over_env() {
assert_eq!(
ServiceSelection::resolve(&[Worktrees], Some("sessions")),
ServiceSelection::Only(vec![Worktrees])
);
}
#[test]
fn env_is_read_when_the_flag_is_empty() {
assert_eq!(
ServiceSelection::resolve(&[], Some("worktrees,sessions")),
ServiceSelection::Only(vec![Worktrees, Sessions])
);
}
#[test]
fn unknown_and_blank_env_tokens_are_skipped() {
assert_eq!(
ServiceSelection::resolve(&[], Some(" worktrees , bogus ,, sessions ")),
ServiceSelection::Only(vec![Worktrees, Sessions])
);
}
#[test]
fn an_empty_or_all_unknown_selection_is_all() {
assert_eq!(ServiceSelection::resolve(&[], None), ServiceSelection::All);
assert_eq!(
ServiceSelection::resolve(&[], Some("")),
ServiceSelection::All
);
assert_eq!(
ServiceSelection::resolve(&[], Some("nope,also-nope")),
ServiceSelection::All
);
}
#[test]
fn selections_are_deduped_in_first_seen_order() {
assert_eq!(
ServiceSelection::resolve(&[Sessions, Worktrees, Sessions], None),
ServiceSelection::Only(vec![Sessions, Worktrees])
);
assert_eq!(
ServiceSelection::resolve(&[], Some("worktrees,worktrees")),
ServiceSelection::Only(vec![Worktrees])
);
}
#[test]
fn a_full_known_set_collapses_to_all() {
assert_eq!(
ServiceSelection::resolve(&[Bridge, Snowflake, Worktrees, Sessions], None),
ServiceSelection::All
);
assert_eq!(
ServiceSelection::resolve(&[], Some("browser-bridge,snowflake,worktrees,sessions")),
ServiceSelection::All
);
assert_eq!(
ServiceSelection::from_service_names([
"browser-bridge",
"snowflake",
"worktrees",
"sessions",
"github",
]),
ServiceSelection::All
);
assert_eq!(
ServiceSelection::resolve(&[Sessions, Worktrees, Snowflake, Bridge, Bridge], None),
ServiceSelection::All
);
}
#[test]
fn to_csv_round_trips_and_omits_for_all() {
assert_eq!(
ServiceSelection::Only(vec![Worktrees, Sessions]).to_csv(),
Some("worktrees,sessions".to_string())
);
assert_eq!(ServiceSelection::All.to_csv(), None);
}
#[test]
fn includes_matches_membership() {
assert!(ServiceSelection::All.includes(Bridge));
let only = ServiceSelection::Only(vec![Worktrees]);
assert!(only.includes(Worktrees));
assert!(!only.includes(Bridge));
}
#[test]
fn from_service_names_maps_and_ignores_unknowns() {
assert_eq!(
ServiceSelection::from_service_names(["worktrees", "browser-bridge"]),
ServiceSelection::Only(vec![Worktrees, Bridge])
);
assert_eq!(
ServiceSelection::from_service_names(["mystery"]),
ServiceSelection::All
);
assert_eq!(
ServiceSelection::from_service_names(std::iter::empty::<&str>()),
ServiceSelection::All
);
}
}