Skip to main content

leviath_core/
config.rs

1//! Plain configuration value types shared across crates.
2//!
3//! The full CLI [`Config`](../../leviath_cli/config/struct.Config.html) lives in
4//! `leviath-cli`, but a few plain sub-configs are also needed by the engine in
5//! `leviath-runtime` (e.g. title generation). Those live here so the runtime can
6//! reference them without a CLI dependency; the CLI re-exports them for compat.
7
8use serde::{Deserialize, Serialize};
9
10fn default_true() -> bool {
11    true
12}
13
14/// Configuration for auto-generating a short human-readable run title.
15///
16/// Example config:
17/// ```toml
18/// [title]
19/// enabled = true
20/// provider = "anthropic"
21/// model = "claude-haiku-4-5-20251001"
22/// ```
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct TitleConfig {
25    /// Whether to generate titles at all (default: true).
26    #[serde(default = "default_true")]
27    pub enabled: bool,
28
29    /// Provider to use for title generation.
30    /// Falls back to the run's own first-stage provider when absent.
31    pub provider: Option<String>,
32
33    /// Model to use for title generation.
34    /// Falls back to the run's own first-stage model when absent - but only if
35    /// `provider` is also absent or matches the run's provider, since one
36    /// provider's model name means nothing to another.
37    pub model: Option<String>,
38}
39
40impl Default for TitleConfig {
41    fn default() -> Self {
42        Self {
43            enabled: true,
44            provider: None,
45            model: None,
46        }
47    }
48}
49
50/// Configuration for structured observability export.
51///
52/// Off by default: telemetry costs a background export pipeline and most
53/// interactive users have the dashboard instead. When enabled, spans, metrics
54/// and log records for every run flow to the configured exporter.
55///
56/// Example config:
57/// ```toml
58/// [observability]
59/// enabled = true
60/// exporter = "otlp"
61/// endpoint = "http://localhost:4318"
62/// service_name = "leviath"
63/// ```
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ObservabilityConfig {
66    /// Whether to export telemetry at all (default: false).
67    #[serde(default)]
68    pub enabled: bool,
69
70    /// Which exporter to use (default: `otlp`).
71    #[serde(default)]
72    pub exporter: TelemetryExporterKind,
73
74    /// OTLP endpoint. Falls back to `OTEL_EXPORTER_OTLP_ENDPOINT`, then to
75    /// `http://localhost:4318` - the OTLP **HTTP** port. Leviath exports OTLP
76    /// over HTTP/protobuf, not gRPC, so a collector's 4317 gRPC endpoint will
77    /// not work here.
78    pub endpoint: Option<String>,
79
80    /// The `service.name` resource attribute. Falls back to
81    /// `OTEL_SERVICE_NAME`, then to `"leviath"`.
82    pub service_name: Option<String>,
83}
84
85/// Which telemetry exporter to build.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum TelemetryExporterKind {
89    /// OTLP over HTTP/protobuf to `endpoint`.
90    #[default]
91    Otlp,
92    /// Human-readable lines through the process logger (stderr).
93    Stdout,
94    /// Build no exporter even when `enabled` is set.
95    None,
96}
97
98impl TelemetryExporterKind {
99    /// True when this kind exports nothing.
100    pub fn is_none(self) -> bool {
101        matches!(self, Self::None)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_title_config_default() {
111        let cfg = TitleConfig::default();
112        assert!(cfg.enabled);
113        assert!(cfg.provider.is_none());
114        assert!(cfg.model.is_none());
115    }
116
117    #[test]
118    fn test_title_config_fields() {
119        let cfg = TitleConfig {
120            enabled: false,
121            provider: Some("anthropic".to_string()),
122            model: Some("claude-haiku-4-5-20251001".to_string()),
123        };
124        assert!(!cfg.enabled);
125        assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
126        assert_eq!(cfg.model.as_deref(), Some("claude-haiku-4-5-20251001"));
127    }
128
129    #[test]
130    fn test_title_config_clone_and_debug() {
131        let cfg = TitleConfig::default();
132        let cloned = cfg.clone();
133        assert_eq!(cloned.enabled, cfg.enabled);
134        // Debug impl is derived; exercise it so the derive is covered.
135        assert!(format!("{:?}", cfg).contains("TitleConfig"));
136    }
137
138    #[test]
139    fn test_title_config_serde_roundtrip() {
140        let cfg = TitleConfig {
141            enabled: true,
142            provider: Some("openai".to_string()),
143            model: Some("gpt-4o-mini".to_string()),
144        };
145        let json = serde_json::to_string(&cfg).unwrap();
146        let back: TitleConfig = serde_json::from_str(&json).unwrap();
147        assert_eq!(back.enabled, cfg.enabled);
148        assert_eq!(back.provider, cfg.provider);
149        assert_eq!(back.model, cfg.model);
150    }
151
152    #[test]
153    fn test_title_config_deserialize_defaults_enabled_true() {
154        // `enabled` omitted → default_true() supplies `true`.
155        let toml_str = r#"
156provider = "anthropic"
157model = "claude-haiku-4-5-20251001"
158"#;
159        let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
160        assert!(cfg.enabled);
161        assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
162    }
163
164    #[test]
165    fn test_title_config_deserialize_explicit_disabled() {
166        let toml_str = r#"
167enabled = false
168"#;
169        let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
170        assert!(!cfg.enabled);
171        assert!(cfg.provider.is_none());
172        assert!(cfg.model.is_none());
173    }
174
175    #[test]
176    fn observability_config_defaults_to_disabled_otlp() {
177        let cfg = ObservabilityConfig::default();
178        assert!(!cfg.enabled);
179        assert_eq!(cfg.exporter, TelemetryExporterKind::Otlp);
180        assert!(cfg.endpoint.is_none());
181        assert!(cfg.service_name.is_none());
182        // An empty TOML table and the hand-written Default must agree.
183        let parsed: ObservabilityConfig = toml::from_str("").unwrap();
184        assert_eq!(parsed, cfg);
185    }
186
187    #[test]
188    fn observability_config_full_roundtrip() {
189        let toml_str = r#"
190enabled = true
191exporter = "stdout"
192endpoint = "http://collector:4318"
193service_name = "leviath-prod"
194"#;
195        let cfg: ObservabilityConfig = toml::from_str(toml_str).unwrap();
196        assert!(cfg.enabled);
197        assert_eq!(cfg.exporter, TelemetryExporterKind::Stdout);
198        assert_eq!(cfg.endpoint.as_deref(), Some("http://collector:4318"));
199        assert_eq!(cfg.service_name.as_deref(), Some("leviath-prod"));
200        let serialized = toml::to_string(&cfg).unwrap();
201        let back: ObservabilityConfig = toml::from_str(&serialized).unwrap();
202        assert_eq!(back, cfg);
203        assert!(format!("{cfg:?}").contains("ObservabilityConfig"));
204    }
205
206    #[test]
207    fn telemetry_exporter_kind_round_trips_through_config_syntax() {
208        for (kind, text) in [
209            (TelemetryExporterKind::Otlp, "otlp"),
210            (TelemetryExporterKind::Stdout, "stdout"),
211            (TelemetryExporterKind::None, "none"),
212        ] {
213            let toml_str = format!("exporter = \"{text}\"");
214            let cfg: ObservabilityConfig = toml::from_str(&toml_str).unwrap();
215            assert_eq!(cfg.exporter, kind);
216            let serialized = toml::to_string(&cfg).unwrap();
217            assert!(serialized.contains(text), "{serialized} missing {text}");
218        }
219    }
220
221    #[test]
222    fn telemetry_exporter_kind_is_none_helper() {
223        assert!(TelemetryExporterKind::None.is_none());
224        assert!(!TelemetryExporterKind::Otlp.is_none());
225        assert!(!TelemetryExporterKind::Stdout.is_none());
226    }
227
228    #[test]
229    fn telemetry_exporter_kind_rejects_unknown_value() {
230        let err = toml::from_str::<ObservabilityConfig>("exporter = \"grpc\"");
231        assert!(err.is_err());
232    }
233}