use std::sync::OnceLock;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GeodynamoContext {
#[serde(default)]
pub context: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub guardrails: Vec<String>,
}
static ACTIVE_CONTEXT: OnceLock<String> = OnceLock::new();
pub fn set_active_context(text: String) {
if text.trim().is_empty() {
return;
}
let _ = ACTIVE_CONTEXT.set(text);
}
pub fn active_context() -> Option<&'static str> {
ACTIVE_CONTEXT
.get()
.map(String::as_str)
.filter(|text| !text.trim().is_empty())
}
pub fn frame_prompt_with_context(prompt: &str) -> Option<String> {
let context = active_context()?;
Some(format!(
"<team-context>\n\
The following context was configured by your team lead and applies to \
all work in this session. Treat it as governing guidance.\n\n\
{context}\n\
</team-context>\n\n\
{prompt}"
))
}
pub fn project_slug(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut pending_dash = false;
for ch in name.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
if pending_dash && !slug.is_empty() {
slug.push('-');
}
pending_dash = false;
slug.push(ch);
} else {
pending_dash = true;
}
}
let trimmed = slug.trim_matches('-');
if trimmed.is_empty() {
"project".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn fetch_context(
base_url: &str,
token: &str,
project_name: &str,
) -> Result<GeodynamoContext, String> {
let base = base_url.trim().trim_end_matches('/');
if !(base.starts_with("http://") || base.starts_with("https://")) {
return Err(format!(
"geodynamo_url must be an absolute http(s) URL, got {base:?}"
));
}
let token = token.trim();
if token.is_empty() {
return Err("geodynamo context token is empty".to_string());
}
let url = format!(
"{base}/contexts/{}/context.json",
project_slug(project_name)
);
let response = ureq::get(&url)
.set("Authorization", &format!("Bearer {token}"))
.set("Accept", "application/json")
.timeout(std::time::Duration::from_secs(10))
.call()
.map_err(|err| match err {
ureq::Error::Status(code, _) => {
format!("geodynamo returned HTTP {code} for {url}")
}
other => format!("geodynamo request to {url} failed: {other}"),
})?;
response
.into_json::<GeodynamoContext>()
.map_err(|err| format!("could not parse geodynamo context from {url}: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_slug_matches_geodynamo_rules() {
assert_eq!(project_slug("midi-vibe"), "midi-vibe");
assert_eq!(project_slug("Cortex Enigma"), "cortex-enigma");
assert_eq!(project_slug(" Aurora!! "), "aurora");
assert_eq!(project_slug("a/b/c"), "a-b-c");
assert_eq!(project_slug("keep.dots_and-dashes"), "keep.dots_and-dashes");
assert_eq!(project_slug("***"), "project");
assert_eq!(project_slug(""), "project");
}
#[test]
fn frame_prompt_is_noop_without_context() {
assert!(frame_prompt_with_context("hello").is_none());
}
#[test]
fn frame_prompt_includes_context_and_prompt() {
let framed = format!(
"<team-context>\nThe following context was configured by your team \
lead and applies to all work in this session. Treat it as governing \
guidance.\n\n{ctx}\n</team-context>\n\n{prompt}",
ctx = "Domain rules here",
prompt = "Do the thing",
);
assert!(framed.contains("Domain rules here"));
assert!(framed.contains("Do the thing"));
assert!(framed.starts_with("<team-context>"));
}
#[test]
fn context_deserializes_ignoring_unknown_fields() {
let raw = r#"{
"schema": "geodynamo.project-context.v1",
"repo": "geoffsee/midi-vibe",
"projectName": "midi-vibe",
"context": "Drive the sequencer feature set.",
"title": "Sequencer",
"guardrails": ["Stay in scope"],
"priority": "high"
}"#;
let parsed: GeodynamoContext = serde_json::from_str(raw).expect("parse");
assert_eq!(parsed.context, "Drive the sequencer feature set.");
assert_eq!(parsed.title, "Sequencer");
assert_eq!(parsed.guardrails, vec!["Stay in scope".to_string()]);
}
}