pub mod ceremony_cli;
pub mod certmesh;
pub mod dns;
pub mod factory_reset;
pub mod health;
pub mod mdns;
pub mod proxy;
pub mod status;
pub mod token;
pub mod udp;
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
use crate::cli::Cli;
use crate::client::KoiClient;
pub(crate) const DEFAULT_TIMEOUT: u64 = 5;
pub(crate) enum Mode {
Standalone,
Client {
endpoint: String,
token: String,
},
}
pub(crate) fn detect_mode(cli: &Cli) -> Mode {
if cli.standalone {
return Mode::Standalone;
}
if let Some(endpoint) = &cli.endpoint {
return Mode::Client {
endpoint: endpoint.clone(),
token: String::new(),
};
}
if let Some(bc) = koi_config::breadcrumb::read_breadcrumb() {
let c = KoiClient::new(&bc.endpoint);
if c.health().is_ok() {
return Mode::Client {
endpoint: bc.endpoint,
token: bc.token,
};
}
}
Mode::Standalone
}
pub(crate) fn resolve_endpoint(cli: &Cli) -> anyhow::Result<(String, String)> {
if let Some(endpoint) = &cli.endpoint {
return Ok((endpoint.clone(), String::new()));
}
if let Some(bc) = koi_config::breadcrumb::read_breadcrumb() {
return Ok((bc.endpoint, bc.token));
}
anyhow::bail!("No daemon endpoint found. Is the daemon running? Use --endpoint to specify.")
}
pub(crate) async fn with_mode<T, LFut, CFut, L, C>(
mode: Mode,
local: L,
client_fn: C,
) -> anyhow::Result<T>
where
L: FnOnce() -> LFut,
C: FnOnce(KoiClient) -> CFut,
LFut: Future<Output = anyhow::Result<T>>,
CFut: Future<Output = anyhow::Result<T>>,
{
match mode {
Mode::Standalone => local().await,
Mode::Client { endpoint, token } => {
let client = KoiClient::with_token(&endpoint, &token);
client_fn(client).await
}
}
}
pub(crate) fn with_mode_sync<T, L, C>(mode: Mode, local: L, client_fn: C) -> anyhow::Result<T>
where
L: FnOnce() -> anyhow::Result<T>,
C: FnOnce(KoiClient) -> anyhow::Result<T>,
{
match mode {
Mode::Standalone => local(),
Mode::Client { endpoint, token } => {
let client = KoiClient::with_token(&endpoint, &token);
client_fn(client)
}
}
}
pub(crate) fn parse_txt(entries: &[String]) -> HashMap<String, String> {
entries
.iter()
.filter_map(|entry| {
entry
.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect()
}
pub(crate) fn effective_timeout(
explicit: Option<u64>,
default_secs: Option<u64>,
) -> Option<Duration> {
match explicit {
Some(0) => None,
Some(secs) => Some(Duration::from_secs(secs)),
None => default_secs.map(Duration::from_secs),
}
}
pub(crate) fn print_json<T: serde::Serialize>(value: &T) {
match serde_json::to_string(value) {
Ok(json) => println!("{json}"),
Err(e) => eprintln!("Error: failed to serialize response: {e}"),
}
}
pub(crate) fn build_register_payload(
name: &str,
service_type: &str,
port: u16,
ip: Option<&str>,
txt: &[String],
) -> koi_mdns::protocol::RegisterPayload {
koi_mdns::protocol::RegisterPayload {
name: name.to_string(),
service_type: service_type.to_string(),
port,
ip: ip.map(String::from),
lease_secs: None,
txt: parse_txt(txt),
}
}
pub(crate) fn print_register_success(result: &koi_mdns::protocol::RegistrationResult) {
println!(
"Registered \"{}\" ({}) on port {} [id: {}]",
result.name, result.service_type, result.port, result.id
);
eprintln!("Service is being advertised. Press Ctrl+C to unregister and exit.");
}
pub(crate) async fn wait_for_signal_or_timeout(timeout: Option<Duration>) {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = async {
match timeout {
Some(d) => tokio::time::sleep(d).await,
None => std::future::pending().await,
}
} => {}
}
}
pub(crate) async fn run_streaming<F, Fut>(
timeout: Option<u64>,
default_timeout: Option<u64>,
stream_fn: F,
) -> anyhow::Result<()>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = anyhow::Result<()>>,
{
let dur = effective_timeout(timeout, default_timeout);
tokio::select! {
result = stream_fn() => { result?; }
_ = tokio::signal::ctrl_c() => {}
_ = async {
match dur {
Some(d) => tokio::time::sleep(d).await,
None => std::future::pending().await,
}
} => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_txt_basic_key_value() {
let entries = vec!["version=1.0".to_string(), "env=prod".to_string()];
let txt = parse_txt(&entries);
assert_eq!(txt.get("version").unwrap(), "1.0");
assert_eq!(txt.get("env").unwrap(), "prod");
assert_eq!(txt.len(), 2);
}
#[test]
fn parse_txt_empty_input() {
let entries: Vec<String> = vec![];
let txt = parse_txt(&entries);
assert!(txt.is_empty());
}
#[test]
fn parse_txt_skips_entries_without_equals() {
let entries = vec!["noequals".to_string(), "valid=yes".to_string()];
let txt = parse_txt(&entries);
assert_eq!(txt.len(), 1);
assert_eq!(txt.get("valid").unwrap(), "yes");
}
#[test]
fn parse_txt_value_with_equals() {
let entries = vec!["path=/api/v1=test".to_string()];
let txt = parse_txt(&entries);
assert_eq!(txt.get("path").unwrap(), "/api/v1=test");
}
#[test]
fn parse_txt_empty_value() {
let entries = vec!["key=".to_string()];
let txt = parse_txt(&entries);
assert_eq!(txt.get("key").unwrap(), "");
}
#[test]
fn effective_timeout_explicit_zero_means_infinite() {
assert_eq!(effective_timeout(Some(0), Some(5)), None);
}
#[test]
fn effective_timeout_explicit_value_overrides_default() {
assert_eq!(
effective_timeout(Some(15), Some(5)),
Some(Duration::from_secs(15))
);
}
#[test]
fn effective_timeout_none_uses_default() {
assert_eq!(
effective_timeout(None, Some(5)),
Some(Duration::from_secs(5))
);
}
#[test]
fn effective_timeout_none_with_no_default_means_infinite() {
assert_eq!(effective_timeout(None, None), None);
}
#[test]
fn effective_timeout_explicit_zero_overrides_any_default() {
assert_eq!(effective_timeout(Some(0), Some(999)), None);
assert_eq!(effective_timeout(Some(0), None), None);
}
#[test]
fn build_register_payload_basic() {
let payload = build_register_payload("My App", "_http._tcp", 8080, None, &[]);
assert_eq!(payload.name, "My App");
assert_eq!(payload.service_type, "_http._tcp");
assert_eq!(payload.port, 8080);
assert!(payload.ip.is_none());
assert!(payload.lease_secs.is_none());
assert!(payload.txt.is_empty());
}
#[test]
fn build_register_payload_with_ip_and_txt() {
let txt = vec!["version=2.1".to_string(), "env=staging".to_string()];
let payload =
build_register_payload("My App", "_http._tcp", 9090, Some("192.168.1.42"), &txt);
assert_eq!(payload.ip.as_deref(), Some("192.168.1.42"));
assert_eq!(payload.txt.get("version").unwrap(), "2.1");
assert_eq!(payload.txt.get("env").unwrap(), "staging");
}
#[test]
fn build_register_payload_always_has_no_lease() {
let payload = build_register_payload("X", "_tcp", 80, None, &[]);
assert!(payload.lease_secs.is_none());
}
}