1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct TitleConfig {
21 #[serde(default = "crate::default_true")]
23 pub enabled: bool,
24
25 pub provider: Option<String>,
28
29 pub model: Option<String>,
34}
35
36impl Default for TitleConfig {
37 fn default() -> Self {
38 Self {
39 enabled: true,
40 provider: None,
41 model: None,
42 }
43 }
44}
45
46pub const DEFAULT_LOG_FILE_MAX_BYTES: u64 = 5 * 1024 * 1024;
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct ObservabilityConfig {
70 #[serde(default)]
72 pub enabled: bool,
73
74 #[serde(default)]
76 pub exporter: TelemetryExporterKind,
77
78 pub endpoint: Option<String>,
83
84 pub service_name: Option<String>,
87
88 #[serde(default = "default_log_file_max_bytes")]
93 pub log_file_max_bytes: u64,
94}
95
96fn default_log_file_max_bytes() -> u64 {
97 DEFAULT_LOG_FILE_MAX_BYTES
98}
99
100impl Default for ObservabilityConfig {
101 fn default() -> Self {
105 Self {
106 enabled: false,
107 exporter: TelemetryExporterKind::Otlp,
108 endpoint: None,
109 service_name: None,
110 log_file_max_bytes: DEFAULT_LOG_FILE_MAX_BYTES,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum TelemetryExporterKind {
119 #[default]
121 Otlp,
122 Stdout,
124 None,
126}
127
128impl TelemetryExporterKind {
129 pub fn is_none(self) -> bool {
131 matches!(self, Self::None)
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct PromptHints {
146 pub batch_tool: bool,
148 pub shell: bool,
150}
151
152impl Default for PromptHints {
153 fn default() -> Self {
155 Self {
156 batch_tool: true,
157 shell: true,
158 }
159 }
160}
161
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
165pub struct PromptHintOverrides {
166 pub batch_tool: Option<bool>,
168 pub shell: Option<bool>,
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn prompt_hints_default_on_and_overrides_default_inherit() {
178 let hints = PromptHints::default();
182 assert!(hints.batch_tool);
183 assert!(hints.shell);
184 assert_eq!(
185 hints,
186 PromptHints {
187 batch_tool: true,
188 shell: true
189 }
190 );
191 assert_ne!(
192 hints,
193 PromptHints {
194 batch_tool: true,
195 shell: false
196 }
197 );
198
199 let overrides = PromptHintOverrides::default();
201 assert_eq!(overrides.batch_tool, None);
202 assert_eq!(overrides.shell, None);
203 assert_eq!(
204 overrides,
205 PromptHintOverrides {
206 batch_tool: None,
207 shell: None
208 }
209 );
210 assert!(format!("{hints:?} {overrides:?}").contains("batch_tool"));
211 }
212
213 #[test]
214 fn test_title_config_default() {
215 let cfg = TitleConfig::default();
216 assert!(cfg.enabled);
217 assert!(cfg.provider.is_none());
218 assert!(cfg.model.is_none());
219 }
220
221 #[test]
222 fn test_title_config_fields() {
223 let cfg = TitleConfig {
224 enabled: false,
225 provider: Some("anthropic".to_string()),
226 model: Some("claude-haiku-4-5-20251001".to_string()),
227 };
228 assert!(!cfg.enabled);
229 assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
230 assert_eq!(cfg.model.as_deref(), Some("claude-haiku-4-5-20251001"));
231 }
232
233 #[test]
234 fn test_title_config_clone_and_debug() {
235 let cfg = TitleConfig::default();
236 let cloned = cfg.clone();
237 assert_eq!(cloned.enabled, cfg.enabled);
238 assert!(format!("{:?}", cfg).contains("TitleConfig"));
240 }
241
242 #[test]
243 fn test_title_config_serde_roundtrip() {
244 let cfg = TitleConfig {
245 enabled: true,
246 provider: Some("openai".to_string()),
247 model: Some("gpt-4o-mini".to_string()),
248 };
249 let json = serde_json::to_string(&cfg).unwrap();
250 let back: TitleConfig = serde_json::from_str(&json).unwrap();
251 assert_eq!(back.enabled, cfg.enabled);
252 assert_eq!(back.provider, cfg.provider);
253 assert_eq!(back.model, cfg.model);
254 }
255
256 #[test]
257 fn test_title_config_deserialize_defaults_enabled_true() {
258 let toml_str = r#"
260provider = "anthropic"
261model = "claude-haiku-4-5-20251001"
262"#;
263 let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
264 assert!(cfg.enabled);
265 assert_eq!(cfg.provider.as_deref(), Some("anthropic"));
266 }
267
268 #[test]
269 fn test_title_config_deserialize_explicit_disabled() {
270 let toml_str = r#"
271enabled = false
272"#;
273 let cfg: TitleConfig = toml::from_str(toml_str).unwrap();
274 assert!(!cfg.enabled);
275 assert!(cfg.provider.is_none());
276 assert!(cfg.model.is_none());
277 }
278
279 #[test]
280 fn observability_config_defaults_to_disabled_otlp() {
281 let cfg = ObservabilityConfig::default();
282 assert!(!cfg.enabled);
283 assert_eq!(cfg.exporter, TelemetryExporterKind::Otlp);
284 assert!(cfg.endpoint.is_none());
285 assert!(cfg.service_name.is_none());
286 assert_eq!(cfg.log_file_max_bytes, DEFAULT_LOG_FILE_MAX_BYTES);
287 let parsed: ObservabilityConfig = toml::from_str("").unwrap();
289 assert_eq!(parsed, cfg);
290 }
291
292 #[test]
293 fn observability_config_full_roundtrip() {
294 let toml_str = r#"
295enabled = true
296exporter = "stdout"
297endpoint = "http://collector:4318"
298service_name = "leviath-prod"
299log_file_max_bytes = 1048576
300"#;
301 let cfg: ObservabilityConfig = toml::from_str(toml_str).unwrap();
302 assert!(cfg.enabled);
303 assert_eq!(cfg.exporter, TelemetryExporterKind::Stdout);
304 assert_eq!(cfg.endpoint.as_deref(), Some("http://collector:4318"));
305 assert_eq!(cfg.service_name.as_deref(), Some("leviath-prod"));
306 assert_eq!(cfg.log_file_max_bytes, 1_048_576);
307 let serialized = toml::to_string(&cfg).unwrap();
308 let back: ObservabilityConfig = toml::from_str(&serialized).unwrap();
309 assert_eq!(back, cfg);
310 assert!(format!("{cfg:?}").contains("ObservabilityConfig"));
311 }
312
313 #[test]
314 fn telemetry_exporter_kind_round_trips_through_config_syntax() {
315 for (kind, text) in [
316 (TelemetryExporterKind::Otlp, "otlp"),
317 (TelemetryExporterKind::Stdout, "stdout"),
318 (TelemetryExporterKind::None, "none"),
319 ] {
320 let toml_str = format!("exporter = \"{text}\"");
321 let cfg: ObservabilityConfig = toml::from_str(&toml_str).unwrap();
322 assert_eq!(cfg.exporter, kind);
323 let serialized = toml::to_string(&cfg).unwrap();
324 assert!(serialized.contains(text), "{serialized} missing {text}");
325 }
326 }
327
328 #[test]
329 fn telemetry_exporter_kind_is_none_helper() {
330 assert!(TelemetryExporterKind::None.is_none());
331 assert!(!TelemetryExporterKind::Otlp.is_none());
332 assert!(!TelemetryExporterKind::Stdout.is_none());
333 }
334
335 #[test]
336 fn telemetry_exporter_kind_rejects_unknown_value() {
337 let err = toml::from_str::<ObservabilityConfig>("exporter = \"grpc\"");
338 assert!(err.is_err());
339 }
340}