//! Environment-driven configuration. Everything optional degrades gracefully: //! without an app atproto account the system runs in "local mode" (squares and //! glosses indexed in the AppView under `local:` URIs instead of being published //! as records); without LLM credentials the gloss ensemble falls back to the //! deterministic heuristic interpreter. use std::env; #[derive(Clone, Debug)] pub struct Config { /// Socket address to bind, e.g. "127.0.0.1:8080". pub bind: String, /// Public base URL of this deployment; determines OAuth client_id. /// A localhost URL selects the atproto loopback-client flow. pub public_url: String, /// SQLite database path. pub db_path: String, /// Jetstream websocket endpoint. pub jetstream_url: String, /// App atproto account (owns square + gloss records), via app password. pub app_handle: Option, pub app_password: Option, /// Anthropic API key for interpreter LLMs. pub anthropic_api_key: Option, /// Comma-separated interpreter model IDs (the gloss ensemble). pub gloss_models: Vec, /// Optional OpenAI-compatible embeddings endpoint for canonicalization. pub embeddings_url: Option, pub embeddings_api_key: Option, pub embeddings_model: String, /// Slow-loop refit interval in seconds (also runs once at startup). pub slow_loop_interval_secs: u64, } impl Config { pub fn from_env() -> Self { let public_url = env::var("IDEO_PUBLIC_URL") .unwrap_or_else(|_| "http://127.0.0.1:8080".into()) .trim_end_matches('/') .to_string(); Self { bind: env::var("IDEO_BIND").unwrap_or_else(|_| "127.0.0.1:8080".into()), public_url, db_path: env::var("IDEO_DB").unwrap_or_else(|_| "ideonomics.db".into()), jetstream_url: env::var("JETSTREAM_URL") .unwrap_or_else(|_| "wss://jetstream2.us-east.bsky.network/subscribe".into()), app_handle: env::var("APP_ATPROTO_HANDLE").ok().filter(|s| !s.is_empty()), app_password: env::var("APP_ATPROTO_PASSWORD").ok().filter(|s| !s.is_empty()), anthropic_api_key: env::var("ANTHROPIC_API_KEY").ok().filter(|s| !s.is_empty()), gloss_models: env::var("GLOSS_MODELS") .unwrap_or_else(|_| "claude-haiku-4-5-20251001,claude-sonnet-5".into()) .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), embeddings_url: env::var("EMBEDDINGS_URL").ok().filter(|s| !s.is_empty()), embeddings_api_key: env::var("EMBEDDINGS_API_KEY").ok().filter(|s| !s.is_empty()), embeddings_model: env::var("EMBEDDINGS_MODEL") .unwrap_or_else(|_| "text-embedding-3-small".into()), slow_loop_interval_secs: env::var("SLOW_LOOP_INTERVAL_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(86_400), } } /// True when the public URL is a loopback address, selecting the atproto /// localhost client_id convention instead of a hosted client-metadata.json. pub fn is_loopback(&self) -> bool { self.public_url.starts_with("http://127.0.0.1") || self.public_url.starts_with("http://localhost") || self.public_url.starts_with("http://[::1]") } pub fn redirect_uri(&self) -> String { format!("{}/oauth/callback", self.public_url) } pub fn client_metadata_url(&self) -> String { format!("{}/client-metadata.json", self.public_url) } }