tftio-clanker 0.2.0

Launch AI harnesses with runtime-configured context, domains, models, and prompts
Documentation
//! Launch-time clanker session metadata inherited by a harness process.

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,
};

/// Launch metadata reported by `clanker current`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CurrentSession {
    /// Whether marker-capable clanker launched the current process.
    pub active: bool,
    /// Marker contract version.
    pub version: Option<String>,
    /// Invocation string used to create the launch plan.
    pub invocation: Option<String>,
    /// Selected harness name.
    pub harness: Option<String>,
    /// Resolved context name.
    pub context: Option<String>,
    /// Source that selected the context.
    pub context_source: Option<String>,
    /// Resolved domains, when command mode selected them.
    pub domains: Option<Vec<String>>,
    /// Resolved model, when one was applied.
    pub model: Option<String>,
    /// Resolved prompt family, when command mode selected one.
    pub family: Option<String>,
}

impl CurrentSession {
    /// Parse inherited clanker markers without consulting current configuration.
    ///
    /// # Errors
    /// Returns [`SessionError`] when present markers are malformed, unsupported,
    /// or not valid UTF-8.
    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,
        }
    }
}

/// Invalid inherited clanker session metadata.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SessionError {
    /// A marker value is not valid UTF-8.
    #[error("environment variable {0} is not valid UTF-8")]
    NonUtf8(&'static str),
    /// The active marker has an unexpected value.
    #[error("{SESSION_ACTIVE_ENVIRONMENT} must be `{SESSION_MARKER_VALUE}`, not `{0}`")]
    InvalidMarker(String),
    /// An active session lacks a required marker.
    #[error("active clanker session is missing {0}")]
    Missing(&'static str),
    /// The marker contract version is not supported by this binary.
    #[error("unsupported clanker session marker version `{0}`")]
    UnsupportedVersion(String),
    /// The ordered domain marker contains an invalid domain name.
    #[error("invalid domain stack `{value}` from {SESSION_DOMAINS_ENVIRONMENT}: {error}")]
    InvalidDomains {
        /// Full rejected marker value.
        value: String,
        /// Name parse failure.
        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 { .. }));
    }
}