use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use serde::Serialize;
use crate::config::{DomainName, InvalidName};
use crate::launch::{
SESSION_ACTIVE_ENVIRONMENT, SESSION_CONTEXT_ENVIRONMENT, SESSION_CONTEXT_SOURCE_ENVIRONMENT,
SESSION_DOMAINS_ENVIRONMENT, SESSION_FAMILY_ENVIRONMENT, SESSION_HARNESS_ENVIRONMENT,
SESSION_INVOCATION_ENVIRONMENT, SESSION_MARKER_VALUE, SESSION_MODEL_ENVIRONMENT,
SESSION_VERSION, SESSION_VERSION_ENVIRONMENT,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CurrentSession {
pub active: bool,
pub version: Option<String>,
pub invocation: Option<String>,
pub harness: Option<String>,
pub context: Option<String>,
pub context_source: Option<String>,
pub domains: Option<Vec<String>>,
pub model: Option<String>,
pub family: Option<String>,
}
impl CurrentSession {
pub fn from_environment(
environment: &BTreeMap<OsString, OsString>,
) -> Result<Self, SessionError> {
let marker = optional_value(environment, SESSION_ACTIVE_ENVIRONMENT)?;
let Some(marker) = marker else {
return Ok(Self::inactive());
};
if marker != SESSION_MARKER_VALUE {
return Err(SessionError::InvalidMarker(marker));
}
let version = required_value(environment, SESSION_VERSION_ENVIRONMENT)?;
if version != SESSION_VERSION {
return Err(SessionError::UnsupportedVersion(version));
}
Ok(Self {
active: true,
version: Some(version),
invocation: Some(required_value(environment, SESSION_INVOCATION_ENVIRONMENT)?),
harness: Some(required_value(environment, SESSION_HARNESS_ENVIRONMENT)?),
context: Some(required_value(environment, SESSION_CONTEXT_ENVIRONMENT)?),
context_source: Some(required_value(
environment,
SESSION_CONTEXT_SOURCE_ENVIRONMENT,
)?),
domains: optional_domains(environment)?,
model: optional_value(environment, SESSION_MODEL_ENVIRONMENT)?,
family: optional_value(environment, SESSION_FAMILY_ENVIRONMENT)?,
})
}
fn inactive() -> Self {
Self {
active: false,
version: None,
invocation: None,
harness: None,
context: None,
context_source: None,
domains: None,
model: None,
family: None,
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SessionError {
#[error("environment variable {0} is not valid UTF-8")]
NonUtf8(&'static str),
#[error("{SESSION_ACTIVE_ENVIRONMENT} must be `{SESSION_MARKER_VALUE}`, not `{0}`")]
InvalidMarker(String),
#[error("active clanker session is missing {0}")]
Missing(&'static str),
#[error("unsupported clanker session marker version `{0}`")]
UnsupportedVersion(String),
#[error("invalid domain stack `{value}` from {SESSION_DOMAINS_ENVIRONMENT}: {error}")]
InvalidDomains {
value: String,
error: InvalidName,
},
}
fn optional_domains(
environment: &BTreeMap<OsString, OsString>,
) -> Result<Option<Vec<String>>, SessionError> {
let Some(value) = optional_value(environment, SESSION_DOMAINS_ENVIRONMENT)? else {
return Ok(None);
};
value
.split(',')
.map(|domain| {
DomainName::new(domain)
.map(|domain| domain.to_string())
.map_err(|error| SessionError::InvalidDomains {
value: value.clone(),
error,
})
})
.collect::<Result<Vec<_>, _>>()
.map(Some)
}
fn optional_value(
environment: &BTreeMap<OsString, OsString>,
name: &'static str,
) -> Result<Option<String>, SessionError> {
environment
.get(OsStr::new(name))
.map(|value| {
value
.to_str()
.map(str::to_string)
.ok_or(SessionError::NonUtf8(name))
})
.transpose()
.map(|value| value.filter(|value| !value.is_empty()))
}
fn required_value(
environment: &BTreeMap<OsString, OsString>,
name: &'static str,
) -> Result<String, SessionError> {
optional_value(environment, name)?.ok_or(SessionError::Missing(name))
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(values: &[(&str, &str)]) -> BTreeMap<OsString, OsString> {
values
.iter()
.map(|(key, value)| (OsString::from(key), OsString::from(value)))
.collect()
}
#[test]
fn absent_marker_reports_inactive_session() {
let session = CurrentSession::from_environment(&BTreeMap::new()).unwrap();
assert!(!session.active);
assert_eq!(session.context, None);
assert_eq!(session.domains, None);
}
#[test]
fn parses_active_session_and_empty_optional_markers() {
let session = CurrentSession::from_environment(&environment(&[
(SESSION_ACTIVE_ENVIRONMENT, SESSION_MARKER_VALUE),
(SESSION_VERSION_ENVIRONMENT, SESSION_VERSION),
(SESSION_INVOCATION_ENVIRONMENT, "clanker claude"),
(SESSION_HARNESS_ENVIRONMENT, "claude"),
(SESSION_CONTEXT_ENVIRONMENT, "work"),
(SESSION_CONTEXT_SOURCE_ENVIRONMENT, ".clanker"),
(SESSION_DOMAINS_ENVIRONMENT, "eng,research"),
(SESSION_MODEL_ENVIRONMENT, ""),
(SESSION_FAMILY_ENVIRONMENT, "claude"),
]))
.unwrap();
assert!(session.active);
assert_eq!(session.context.as_deref(), Some("work"));
assert_eq!(
session.domains,
Some(vec!["eng".to_string(), "research".to_string()])
);
assert_eq!(session.model, None);
}
#[test]
fn active_session_requires_complete_supported_markers() {
let missing = CurrentSession::from_environment(&environment(&[
(SESSION_ACTIVE_ENVIRONMENT, SESSION_MARKER_VALUE),
(SESSION_VERSION_ENVIRONMENT, SESSION_VERSION),
]))
.unwrap_err();
assert_eq!(
missing,
SessionError::Missing(SESSION_INVOCATION_ENVIRONMENT)
);
let invalid = CurrentSession::from_environment(&environment(&[
(SESSION_ACTIVE_ENVIRONMENT, SESSION_MARKER_VALUE),
(SESSION_VERSION_ENVIRONMENT, "unsupported"),
]))
.unwrap_err();
assert_eq!(
invalid,
SessionError::UnsupportedVersion("unsupported".to_string())
);
}
#[test]
fn rejects_invalid_domain_stack_markers() {
let invalid = CurrentSession::from_environment(&environment(&[
(SESSION_ACTIVE_ENVIRONMENT, SESSION_MARKER_VALUE),
(SESSION_VERSION_ENVIRONMENT, SESSION_VERSION),
(SESSION_INVOCATION_ENVIRONMENT, "clanker claude"),
(SESSION_HARNESS_ENVIRONMENT, "claude"),
(SESSION_CONTEXT_ENVIRONMENT, "work"),
(SESSION_CONTEXT_SOURCE_ENVIRONMENT, ".clanker"),
(SESSION_DOMAINS_ENVIRONMENT, "eng,,research"),
]))
.unwrap_err();
assert!(matches!(invalid, SessionError::InvalidDomains { .. }));
}
}