use crate::ir::WarbleIr;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderFlavor {
Programmatic,
Prompt,
}
pub const DEFAULT_RENDER_FLAVOR: RenderFlavor = RenderFlavor::Programmatic;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContextInjectionMode {
SchemaOnly,
}
pub const DEFAULT_CONTEXT_INJECTION: ContextInjectionMode = ContextInjectionMode::SchemaOnly;
impl ContextInjectionMode {
pub fn as_str(&self) -> &'static str {
match self {
ContextInjectionMode::SchemaOnly => "schema-only",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextInjection {
mode: ContextInjectionMode,
schema_digest: String,
introspected: bool,
absent: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ContextInjectionReport {
pub mode: &'static str,
pub schema_digest_fingerprint: String,
pub knowledge_fingerprint: Option<String>,
pub knowledge_chars: usize,
}
impl ContextInjection {
pub fn from_ir(ir: &WarbleIr, mode: ContextInjectionMode) -> Self {
let introspected = ir.context_binding.resolved.is_some();
let schema_digest = build_schema_digest(ir.context_binding.resolved.as_ref());
Self {
mode,
schema_digest,
introspected,
absent: ir.context_binding.absent,
}
}
pub(crate) fn absent(&self) -> bool {
self.absent
}
pub fn mode(&self) -> ContextInjectionMode {
self.mode
}
pub fn introspected(&self) -> bool {
self.introspected
}
pub fn prompt_section(&self) -> String {
if self.absent {
return String::new();
}
let knowledge = match self.mode {
ContextInjectionMode::SchemaOnly if !self.introspected => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files.".to_string(),
ContextInjectionMode::SchemaOnly => "Knowledge rules are intentionally excluded for this run. Do NOT call a context-instruction tool or read project knowledge files; answer from the injected schema and the question only.".to_string(),
};
if !self.introspected {
return format!(
"## Injected context\n\nContext injection mode: `{}`. The bound semantic layer was NOT introspected here — it is held elsewhere. You therefore know NOTHING about which models, metrics or dimensions exist. Never tell the user that something does not exist, and never rule a question out, on the strength of this absence; put the question to whatever tool answers it and report what comes back.\n\n{}",
self.mode.as_str(),
knowledge
);
}
format!(
"## Injected context\n\nContext injection mode: `{}`. Use this compiled schema digest before calling a semantic-introspection tool; introspect only when the question needs details absent from the digest.\n\n<schema_digest>\n{}\n</schema_digest>\n\n{}",
self.mode.as_str(),
self.schema_digest,
knowledge
)
}
pub fn report(&self) -> ContextInjectionReport {
ContextInjectionReport {
mode: if self.absent {
"none"
} else {
self.mode.as_str()
},
schema_digest_fingerprint: fingerprint(&self.schema_digest),
knowledge_fingerprint: None,
knowledge_chars: 0,
}
}
}
fn fingerprint(value: &str) -> String {
let mut hash = 0xcbf29ce484222325_u64;
for byte in value.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("fnv1a64:{hash:016x}")
}
fn canonical_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(v) => v.to_string(),
serde_json::Value::Number(v) => v.to_string(),
serde_json::Value::String(v) => serde_json::to_string(v).expect("string serializes"),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(canonical_json)
.collect::<Vec<_>>()
.join(",")
),
serde_json::Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_by_key(|(key, _)| *key);
format!(
"{{{}}}",
entries
.into_iter()
.map(|(key, value)| format!(
"{}:{}",
serde_json::to_string(key).expect("key serializes"),
canonical_json(value)
))
.collect::<Vec<_>>()
.join(",")
)
}
}
}
fn sorted_entries(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
let mut entries = value
.and_then(|v| v.get(key))
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|entry| match entry {
serde_json::Value::String(s) => s.clone(),
other => canonical_json(other),
})
.collect::<Vec<_>>();
entries.sort();
entries
}
fn digest_line(label: &str, entries: Vec<String>) -> String {
if entries.is_empty() {
format!("{label}: (none)")
} else {
format!("{label}: {}", entries.join(", "))
}
}
fn build_schema_digest(resolved: Option<&serde_json::Value>) -> String {
let lineage = resolved
.and_then(|v| v.get("lineage"))
.map(canonical_json)
.unwrap_or_else(|| "unavailable".to_string());
[
digest_line("Models", sorted_entries(resolved, "models")),
digest_line("Metrics", sorted_entries(resolved, "metrics")),
digest_line("Dimensions", sorted_entries(resolved, "dimensions")),
digest_line(
"Time dimensions",
sorted_entries(resolved, "time_dimensions"),
),
format!("Lineage: {lineage}"),
]
.join("\n")
}
impl RenderFlavor {
pub fn as_str(&self) -> &'static str {
match self {
RenderFlavor::Programmatic => "programmatic",
RenderFlavor::Prompt => "prompt",
}
}
pub fn parse(value: &str) -> Option<RenderFlavor> {
match value {
"programmatic" => Some(RenderFlavor::Programmatic),
"prompt" => Some(RenderFlavor::Prompt),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HybridRealization {
#[default]
BashScript,
McpServer,
}
impl HybridRealization {
pub fn parse(value: &str) -> Option<HybridRealization> {
match value {
"bash-script" => Some(HybridRealization::BashScript),
"mcp-server" => Some(HybridRealization::McpServer),
_ => None,
}
}
}