use std::env;
pub const ENV_ANALYTICS: &str = "MESH_LLM_ANALYTICS";
pub const ENV_DO_NOT_TRACK: &str = "DO_NOT_TRACK";
pub const ENV_POSTHOG_KEY: &str = "MESH_LLM_POSTHOG_KEY";
pub const ENV_POSTHOG_HOST: &str = "MESH_LLM_POSTHOG_HOST";
pub const DEFAULT_POSTHOG_HOST: &str = "https://us.i.posthog.com";
const BUILT_IN_POSTHOG_KEY: Option<&str> = option_env!("MESH_LLM_POSTHOG_KEY");
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Disposition {
EnabledByDefault,
EnabledByEnv,
DisabledByEnv,
DisabledByDoNotTrack,
DisabledByConfig,
DisabledConfigUnreadable,
DisabledNoKey,
DisabledInCi,
}
impl Disposition {
#[must_use]
pub const fn is_enabled(self) -> bool {
matches!(self, Self::EnabledByDefault | Self::EnabledByEnv)
}
#[must_use]
pub const fn explain(self) -> &'static str {
match self {
Self::EnabledByDefault => "enabled (default; run `mesh-llm analytics disable` to stop)",
Self::EnabledByEnv => "enabled by MESH_LLM_ANALYTICS",
Self::DisabledByEnv => "disabled by MESH_LLM_ANALYTICS",
Self::DisabledByDoNotTrack => "disabled by DO_NOT_TRACK",
Self::DisabledByConfig => "disabled by [analytics] enabled = false in config.toml",
Self::DisabledConfigUnreadable => {
"disabled: the config file could not be read, so the recorded preference is unknown"
}
Self::DisabledNoKey => {
"disabled: no analytics key is available (none compiled in, and MESH_LLM_POSTHOG_KEY is unset or empty)"
}
Self::DisabledInCi => "disabled: continuous integration environment detected",
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ConfigPreference {
#[default]
Unstated,
Stated(bool),
Unreadable,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ConsentInputs {
pub env_override: Option<String>,
pub do_not_track: bool,
pub config: ConfigPreference,
pub has_key: bool,
pub in_ci: bool,
}
impl ConsentInputs {
#[must_use]
pub fn from_env(config: ConfigPreference) -> Self {
Self {
env_override: env::var(ENV_ANALYTICS).ok(),
do_not_track: env::var(ENV_DO_NOT_TRACK)
.ok()
.is_some_and(|value| is_truthy(&value)),
config,
has_key: project_key().is_some(),
in_ci: detect_ci(),
}
}
#[must_use]
pub fn resolve(&self) -> Disposition {
if !self.has_key {
return Disposition::DisabledNoKey;
}
if let Some(raw) = self.env_override.as_deref() {
return if is_truthy(raw) {
Disposition::EnabledByEnv
} else {
Disposition::DisabledByEnv
};
}
if self.do_not_track {
return Disposition::DisabledByDoNotTrack;
}
match self.config {
ConfigPreference::Stated(false) => return Disposition::DisabledByConfig,
ConfigPreference::Unreadable => return Disposition::DisabledConfigUnreadable,
ConfigPreference::Stated(true) | ConfigPreference::Unstated => {}
}
if self.in_ci {
return Disposition::DisabledInCi;
}
Disposition::EnabledByDefault
}
}
#[must_use]
pub fn project_key() -> Option<String> {
if let Ok(key) = env::var(ENV_POSTHOG_KEY) {
let key = key.trim().to_owned();
if !key.is_empty() {
return Some(key);
}
}
BUILT_IN_POSTHOG_KEY
.map(str::trim)
.filter(|key| !key.is_empty())
.map(str::to_owned)
}
fn normalize_host(raw: &str) -> Option<String> {
let host = raw.trim().trim_end_matches('/');
(!host.is_empty()).then(|| host.to_owned())
}
#[must_use]
pub fn ingestion_host() -> String {
env::var(ENV_POSTHOG_HOST)
.ok()
.and_then(|host| normalize_host(&host))
.unwrap_or_else(|| DEFAULT_POSTHOG_HOST.to_owned())
}
fn is_truthy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
pub(crate) const CI_MARKERS: &[&str] = &[
"CI",
"CONTINUOUS_INTEGRATION",
"GITHUB_ACTIONS",
"GITLAB_CI",
"BUILDKITE",
"CIRCLECI",
"TRAVIS",
"JENKINS_URL",
"TEAMCITY_VERSION",
];
pub(crate) fn detect_ci() -> bool {
CI_MARKERS.iter().any(|marker| {
env::var(marker).is_ok_and(|value| {
let value = value.trim();
!value.is_empty() && !matches!(value.to_ascii_lowercase().as_str(), "0" | "false")
})
})
}
#[cfg(test)]
#[path = "consent/tests.rs"]
mod tests;