Skip to main content

aether_project/
aether_settings.rs

1use utils::path::aether_home;
2
3use crate::agent_config::AgentConfig;
4use crate::error::SettingsError;
5use crate::{McpSourceSpec, PromptSource};
6use aether_core::core::Prompt;
7use llm::ProviderConnectionOverrides;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs::read_to_string;
12use std::path::{Path, PathBuf};
13use utils::variables::{VarError, Vars};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
17pub enum CredentialsStoreConfig {
18    /// Holds credentials in the OS keyring
19    Keyring,
20
21    /// Holds credentials in-memory and only for the lifetime of the
22    /// process. Intended for tests and ephemeral runs that must not touch the OS
23    /// keychain.
24    Memory,
25
26    /// Holds credentials in an encrypted file
27    EncryptedFile {
28        /// File path for the encrypted credential blob. Defaults to
29        /// `credentials.enc` in the Aether home directory when unset.
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        path: Option<PathBuf>,
32        /// Environment variable name to read the passphrase from. Uses
33        /// `AETHER_CREDENTIALS_PASSWORD` when unset.
34        #[serde(default, skip_serializing_if = "Option::is_none", rename = "passwordEnv")]
35        password_env: Option<String>,
36    },
37}
38
39const PROJECT_SETTINGS_PATH: &str = ".aether/settings.json";
40const USER_SETTINGS_FILENAME: &str = "settings.json";
41
42pub fn user_settings_path() -> Option<PathBuf> {
43    aether_home().map(|home| home.join(USER_SETTINGS_FILENAME))
44}
45
46pub fn user_settings_exist() -> bool {
47    user_settings_path().is_some_and(|p| p.is_file())
48}
49
50pub fn project_settings_path(project_root: &Path) -> PathBuf {
51    project_root.join(PROJECT_SETTINGS_PATH)
52}
53
54pub fn project_settings_exist(project_root: &Path) -> bool {
55    project_settings_path(project_root).is_file()
56}
57
58#[doc = include_str!("docs/aether_settings.md")]
59#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct AetherSettings {
62    /// Name of the agent to launch by default. Must match a `name` in `agents`.
63    /// When unset, Aether falls back to the first user-invocable agent.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub agent: Option<String>,
66    /// Default prompt sources shared by all agents. An agent inherits these only
67    /// when its own `prompts` array is empty.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub prompts: Vec<PromptSource>,
70    /// Default MCP sources shared by all agents. An agent inherits these only when
71    /// its own `mcps` array is empty.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub mcps: Vec<McpSourceSpec>,
74    /// Provider connection overrides (credentials, base URLs, inference profiles)
75    /// applied to every agent unless overridden per-agent.
76    #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
77    pub providers: ProviderConnectionOverrides,
78    /// Credential storage backend for OAuth tokens. Defaults to the OS keyring
79    /// when unset.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub credentials_store: Option<CredentialsStoreConfig>,
82    /// OpenTelemetry `GenAI` telemetry configuration. Its presence enables telemetry.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub telemetry: Option<TelemetrySettings>,
85    /// The agents defined for this project. At least one agent is required.
86    #[schemars(length(min = 1))]
87    pub agents: Vec<AgentConfig>,
88}
89
90/// One settings layer's OpenTelemetry configuration. Every field is optional:
91/// a field left unset inherits the value from lower-precedence settings layers
92/// and falls back to its documented default when no layer sets it. Read
93/// resolved values through the accessor methods.
94#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct TelemetrySettings {
97    /// `service.name` resource attribute. Defaults to `aether`.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub service_name: Option<String>,
100    /// Trace sampling ratio between 0.0 and 1.0. Defaults to 1.0.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub sample_ratio: Option<f64>,
103    /// Per-attribute content capture, mapping 1:1 onto the opt-in `GenAI`
104    /// content attributes. All default to `false`.
105    #[serde(default, skip_serializing_if = "is_default")]
106    pub content: TelemetryContentSettings,
107    /// Trace signal toggle. Enabled by default.
108    #[serde(default, skip_serializing_if = "is_default")]
109    pub traces: TelemetrySignalSettings,
110    /// Metric signal toggle. Enabled by default.
111    #[serde(default, skip_serializing_if = "is_default")]
112    pub metrics: TelemetrySignalSettings,
113    #[serde(default, skip_serializing_if = "is_default")]
114    pub otlp: OtlpTelemetrySettings,
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct TelemetrySignalSettings {
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub enabled: Option<bool>,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
125#[serde(rename_all = "camelCase", deny_unknown_fields)]
126pub struct TelemetryContentSettings {
127    /// Set `gen_ai.system_instructions` on chat spans.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub system_instructions: Option<bool>,
130    /// Set `gen_ai.input.messages` on turn and chat spans.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub input_messages: Option<bool>,
133    /// Set `gen_ai.output.messages` on turn and chat spans.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub output_messages: Option<bool>,
136    /// Set `gen_ai.tool.definitions` on chat spans.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub tool_definitions: Option<bool>,
139    /// Set `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` on tool spans.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub tool_calls: Option<bool>,
142}
143
144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
145#[serde(rename_all = "camelCase", deny_unknown_fields)]
146pub struct OtlpTelemetrySettings {
147    /// Base URL for an OTLP/HTTP collector. Aether appends `/v1/traces` or
148    /// `/v1/metrics` for the respective signal.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub endpoint: Option<String>,
151    /// Exact OTLP/HTTP trace export URL. Overrides the traces URL derived from
152    /// `endpoint`, for providers that require a signal-specific endpoint.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub traces_endpoint: Option<String>,
155    /// Exact OTLP/HTTP metric export URL. Overrides the metrics URL derived from
156    /// `endpoint`, for providers that require a signal-specific endpoint.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub metrics_endpoint: Option<String>,
159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160    pub headers: BTreeMap<String, String>,
161}
162
163impl TelemetrySettings {
164    pub fn effective_enabled(&self) -> bool {
165        self.traces_enabled() || self.metrics_enabled()
166    }
167
168    pub fn service_name(&self) -> &str {
169        self.service_name.as_deref().unwrap_or("aether")
170    }
171
172    pub fn sample_ratio(&self) -> f64 {
173        self.sample_ratio.unwrap_or(1.0)
174    }
175
176    pub fn traces_enabled(&self) -> bool {
177        self.traces.enabled.unwrap_or(true)
178    }
179
180    pub fn metrics_enabled(&self) -> bool {
181        self.metrics.enabled.unwrap_or(true)
182    }
183
184    fn merge(&mut self, next: Self) {
185        merge_field(&mut self.service_name, next.service_name);
186        merge_field(&mut self.sample_ratio, next.sample_ratio);
187        self.content.merge(&next.content);
188        merge_field(&mut self.traces.enabled, next.traces.enabled);
189        merge_field(&mut self.metrics.enabled, next.metrics.enabled);
190        merge_field(&mut self.otlp.endpoint, next.otlp.endpoint);
191        merge_field(&mut self.otlp.traces_endpoint, next.otlp.traces_endpoint);
192        merge_field(&mut self.otlp.metrics_endpoint, next.otlp.metrics_endpoint);
193        self.otlp.headers.extend(next.otlp.headers);
194    }
195}
196
197impl TelemetryContentSettings {
198    fn merge(&mut self, next: &Self) {
199        merge_field(&mut self.system_instructions, next.system_instructions);
200        merge_field(&mut self.input_messages, next.input_messages);
201        merge_field(&mut self.output_messages, next.output_messages);
202        merge_field(&mut self.tool_definitions, next.tool_definitions);
203        merge_field(&mut self.tool_calls, next.tool_calls);
204    }
205}
206
207impl OtlpTelemetrySettings {
208    pub fn resolved_headers(&self, vars: &Vars) -> Result<BTreeMap<String, String>, VarError> {
209        self.headers.iter().map(|(name, value)| Ok((name.clone(), vars.expand(value)?))).collect()
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct SettingsFileSource {
215    pub path: PathBuf,
216    pub root: PathBuf,
217}
218
219#[derive(Debug, Clone)]
220pub enum AetherSettingsSource {
221    File(SettingsFileSource),
222    OptionalFile(SettingsFileSource),
223    Json(String),
224    Value(Box<AetherSettings>),
225}
226
227impl SettingsFileSource {
228    pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
229        Self { path: path.into(), root: root.into() }
230    }
231}
232
233impl AetherSettings {
234    pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
235        Self::load(project_root, default_sources(project_root))
236    }
237
238    pub fn load(
239        project_root: &Path,
240        sources: impl IntoIterator<Item = AetherSettingsSource>,
241    ) -> Result<Self, SettingsError> {
242        sources.into_iter().try_fold(Self::default(), |config, source| {
243            let next = Self::load_source(project_root, source)?;
244            Ok(config.merge(next))
245        })
246    }
247
248    pub fn merge(mut self, next: Self) -> Self {
249        merge_field(&mut self.agent, next.agent);
250        if !next.prompts.is_empty() {
251            self.prompts = next.prompts;
252        }
253        if !next.mcps.is_empty() {
254            self.mcps = next.mcps;
255        }
256        self.providers.merge(next.providers);
257
258        merge_field(&mut self.credentials_store, next.credentials_store);
259        if let Some(next_telemetry) = next.telemetry {
260            self.telemetry.get_or_insert_default().merge(next_telemetry);
261        }
262
263        for next_agent in next.agents {
264            if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
265                *existing = next_agent;
266            } else {
267                self.agents.push(next_agent);
268            }
269        }
270
271        self
272    }
273
274    /// Replace every file- and glob-backed prompt and MCP source with its
275    /// inlined contents, resolving paths against `root`.
276    ///
277    /// The result serializes to a self-contained settings document with no
278    /// external file references, suitable for shipping to a machine or
279    /// container that does not have the authoring repository mounted.
280    pub fn inline_resources(&mut self, root: &Path) -> Result<(), SettingsError> {
281        for (prompts, mcps) in self.resource_sources_mut() {
282            *prompts = inline_prompt_sources(prompts, root)?;
283            *mcps = inline_mcp_sources(mcps, root)?;
284        }
285        Ok(())
286    }
287
288    fn resource_sources_mut(&mut self) -> impl Iterator<Item = (&mut Vec<PromptSource>, &mut Vec<McpSourceSpec>)> {
289        std::iter::once((&mut self.prompts, &mut self.mcps))
290            .chain(self.agents.iter_mut().map(|agent| (&mut agent.prompts, &mut agent.mcps)))
291    }
292
293    fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
294        match source {
295            AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
296            AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
297            AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
298            AetherSettingsSource::Value(settings) => Ok(*settings),
299        }
300    }
301}
302
303impl TryFrom<&str> for AetherSettings {
304    type Error = SettingsError;
305
306    fn try_from(content: &str) -> Result<Self, Self::Error> {
307        serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
308    }
309}
310
311fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
312    default_sources_for_home(project_root, aether_home().as_deref())
313}
314
315fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
316    let mut sources = Vec::new();
317    if let Some(aether_home) = aether_home {
318        sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(USER_SETTINGS_FILENAME, aether_home)));
319    }
320    sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
321    sources
322}
323
324fn load_file_source(
325    project_root: &Path,
326    source: SettingsFileSource,
327    missing_is_empty: bool,
328) -> Result<AetherSettings, SettingsError> {
329    let root = resolve_against(project_root, source.root);
330    let path = resolve_against(&root, source.path);
331    let settings = load_file(&path, missing_is_empty)?;
332    let source_root = (root != project_root).then_some(root.as_path());
333    Ok(normalize_resource_paths(settings, source_root))
334}
335
336fn resolve_against(base: &Path, path: PathBuf) -> PathBuf {
337    if path.is_absolute() { path } else { base.join(path) }
338}
339
340fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
341    match read_to_string(path) {
342        Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
343        Ok(content) => AetherSettings::try_from(content.as_str()),
344        Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
345        Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
346    }
347}
348
349fn normalize_resource_paths(mut settings: AetherSettings, source_root: Option<&Path>) -> AetherSettings {
350    let Some(root) = source_root else { return settings };
351    for (prompts, mcps) in settings.resource_sources_mut() {
352        promote_prompt_sources(prompts, root);
353        promote_mcp_sources(mcps, root);
354    }
355    settings
356}
357
358fn inline_prompt_sources(sources: &[PromptSource], root: &Path) -> Result<Vec<PromptSource>, SettingsError> {
359    let mut inlined = Vec::new();
360    for prompt in Prompt::from_sources(root, sources)? {
361        let text = match prompt {
362            Prompt::Text(text) => text,
363            Prompt::File { path, .. } => read_to_string(&path)
364                .map_err(|e| SettingsError::IoError(format!("Failed to read prompt '{}': {e}", path.display())))?,
365            Prompt::McpInstructions(_) => continue,
366        };
367        inlined.push(PromptSource::Text { text });
368    }
369    Ok(inlined)
370}
371
372fn inline_mcp_sources(sources: &[McpSourceSpec], root: &Path) -> Result<Vec<McpSourceSpec>, SettingsError> {
373    sources
374        .iter()
375        .filter_map(|spec| spec.load(root).transpose())
376        .map(|config| config.map(McpSourceSpec::Inline))
377        .collect()
378}
379
380fn promote_prompt_sources(sources: &mut [PromptSource], source_root: &Path) {
381    for source in sources {
382        match source {
383            PromptSource::File { path, .. } | PromptSource::Glob { pattern: path, .. } => {
384                path.promote_relative(source_root);
385            }
386            PromptSource::Text { .. } => {}
387        }
388    }
389}
390
391fn promote_mcp_sources(sources: &mut [McpSourceSpec], source_root: &Path) {
392    for source in sources {
393        if let McpSourceSpec::File(file) = source {
394            file.path.promote_relative(source_root);
395        }
396    }
397}
398
399fn is_default<T: Default + PartialEq>(value: &T) -> bool {
400    value == &T::default()
401}
402
403fn merge_field<T>(current: &mut Option<T>, next: Option<T>) {
404    if next.is_some() {
405        *current = next;
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::testing::{
413        agent_config, agent_json, agent_json_with, has_prompt_file, home, mcp_json, project, settings_agent,
414        settings_with_mcps,
415    };
416    use crate::{AgentCatalog, McpFileSpec, McpSourceSpec, PromptSource};
417    use aether_core::mcp::McpConfig;
418    use serde_json::json;
419    use std::collections::BTreeMap;
420
421    #[test]
422    fn telemetry_is_disabled_when_absent() {
423        assert!(AetherSettings::default().telemetry.is_none());
424
425        let settings = AetherSettings::try_from(r#"{ "telemetry": {}, "agents": [] }"#).unwrap();
426        assert!(settings.telemetry.unwrap().effective_enabled());
427    }
428
429    #[test]
430    #[allow(clippy::float_cmp)]
431    fn parses_telemetry_camel_case_and_http_protobuf() {
432        let config = serde_json::from_value::<AetherSettings>(json!({
433            "telemetry": {
434                "serviceName": "aether-test",
435                "sampleRatio": 0.5,
436                "content": {
437                    "systemInstructions": true,
438                    "inputMessages": true,
439                    "outputMessages": false,
440                    "toolDefinitions": true,
441                    "toolCalls": true
442                },
443                "traces": { "enabled": true },
444                "metrics": { "enabled": false },
445                "otlp": {
446                    "endpoint": "http://localhost:4318",
447                    "headers": { "authorization": "Bearer token" }
448                }
449            },
450            "agents": [agent_json("alpha", "Alpha")]
451        }))
452        .unwrap();
453
454        let telemetry = config.telemetry.as_ref().unwrap();
455        assert_eq!(telemetry.service_name(), "aether-test");
456        assert_eq!(telemetry.sample_ratio(), 0.5);
457        let content = &telemetry.content;
458        assert_eq!(content.system_instructions, Some(true));
459        assert_eq!(content.input_messages, Some(true));
460        assert_eq!(content.output_messages, Some(false));
461        assert_eq!(content.tool_definitions, Some(true));
462        assert_eq!(content.tool_calls, Some(true));
463        assert!(telemetry.traces_enabled());
464        assert!(!telemetry.metrics_enabled());
465        assert_eq!(telemetry.otlp.headers.get("authorization").map(String::as_str), Some("Bearer token"));
466    }
467
468    #[test]
469    fn telemetry_with_no_enabled_signals_does_not_require_endpoint() {
470        let config = serde_json::from_value::<AetherSettings>(json!({
471            "telemetry": { "traces": { "enabled": false }, "metrics": { "enabled": false } },
472            "agents": [agent_json("alpha", "Alpha")]
473        }))
474        .unwrap();
475
476        assert!(!config.telemetry.unwrap().effective_enabled());
477    }
478
479    #[test]
480    fn telemetry_overlays_merge_fields_and_allow_explicit_defaults() {
481        let config = AetherSettings::load(
482            Path::new("/project"),
483            [
484                AetherSettingsSource::Json(
485                    r#"{
486                        "telemetry": {
487                            "content": { "systemInstructions": true, "inputMessages": true },
488                            "traces": { "enabled": true },
489                            "metrics": { "enabled": true },
490                            "otlp": {
491                                "endpoint": "http://localhost:4318",
492                                "tracesEndpoint": "https://traces.example.com/export",
493                                "metricsEndpoint": "https://metrics.example.com/export"
494                            }
495                        },
496                        "agents": []
497                    }"#
498                    .to_string(),
499                ),
500                AetherSettingsSource::Json(
501                    r#"{
502                        "telemetry": {
503                            "content": { "systemInstructions": false },
504                            "metrics": { "enabled": false }
505                        },
506                        "agents": []
507                    }"#
508                    .to_string(),
509                ),
510            ],
511        )
512        .unwrap();
513
514        let telemetry = config.telemetry.unwrap();
515        let content = &telemetry.content;
516        assert_eq!(content.system_instructions, Some(false), "explicit false remains distinguishable from omission");
517        assert_eq!(content.input_messages, Some(true), "omitted nested fields remain inherited");
518        assert!(!content.tool_calls.unwrap_or_default());
519        assert!(telemetry.traces_enabled(), "omitted nested fields remain inherited");
520        assert!(!telemetry.metrics_enabled(), "nested overrides merge independently");
521        assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
522        assert_eq!(telemetry.otlp.traces_endpoint.as_deref(), Some("https://traces.example.com/export"));
523        assert_eq!(telemetry.otlp.metrics_endpoint.as_deref(), Some("https://metrics.example.com/export"));
524    }
525
526    #[test]
527    fn telemetry_overlay_merges_otlp_headers_per_key() {
528        let config = AetherSettings::load(
529            Path::new("/project"),
530            [
531                AetherSettingsSource::Json(
532                    r#"{
533                        "telemetry": {
534                            "otlp": {
535                                "endpoint": "http://localhost:4318",
536                                "headers": { "authorization": "Bearer base", "x-base": "1" }
537                            }
538                        },
539                        "agents": []
540                    }"#
541                    .to_string(),
542                ),
543                AetherSettingsSource::Json(
544                    r#"{
545                        "telemetry": {
546                            "otlp": {
547                                "headers": { "authorization": "Bearer overlay", "x-overlay": "2" }
548                            }
549                        },
550                        "agents": []
551                    }"#
552                    .to_string(),
553                ),
554            ],
555        )
556        .unwrap();
557
558        let headers = config.telemetry.unwrap().otlp.headers;
559        assert_eq!(headers.get("authorization").map(String::as_str), Some("Bearer overlay"));
560        assert_eq!(headers.get("x-base").map(String::as_str), Some("1"), "base-layer headers survive an overlay");
561        assert_eq!(headers.get("x-overlay").map(String::as_str), Some("2"));
562    }
563
564    #[test]
565    fn telemetry_overlay_inherits_unspecified_parent_fields() {
566        let config = AetherSettings::load(
567            Path::new("/project"),
568            [
569                AetherSettingsSource::Json(
570                    r#"{
571                        "telemetry": {
572                            "sampleRatio": 0.25,
573                            "otlp": { "endpoint": "http://localhost:4318" }
574                        },
575                        "agents": []
576                    }"#
577                    .to_string(),
578                ),
579                AetherSettingsSource::Json(
580                    r#"{ "telemetry": { "content": { "toolCalls": true } }, "agents": [] }"#.to_string(),
581                ),
582            ],
583        )
584        .unwrap();
585
586        let telemetry = config.telemetry.unwrap();
587        assert_eq!(telemetry.content.tool_calls, Some(true));
588        assert!((telemetry.sample_ratio() - 0.25).abs() < f64::EPSILON);
589        assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
590    }
591
592    #[test]
593    fn project_settings_path_points_at_project_aether_settings() {
594        assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
595    }
596
597    #[test]
598    fn project_settings_exist_checks_project_settings_file() {
599        let project = project();
600        assert!(!project_settings_exist(project.root()));
601        project.write(PROJECT_SETTINGS_PATH, "{}");
602        assert!(project_settings_exist(project.root()));
603    }
604
605    #[test]
606    fn resolves_selected_agent() {
607        let dir = project().file("PROMPT.md", "Be helpful");
608        let config = AetherSettings {
609            agent: Some("beta".to_string()),
610            agents: vec![agent_config("alpha"), agent_config("beta")],
611            ..AetherSettings::default()
612        };
613
614        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
615
616        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
617    }
618
619    #[test]
620    fn rejects_selected_agent_that_is_not_user_invocable() {
621        let mut internal = agent_config("internal");
622        internal.user_invocable = false;
623        internal.agent_invocable = true;
624        let config =
625            AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
626
627        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
628
629        assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
630    }
631
632    #[test]
633    fn settings_file_paths_are_project_relative() {
634        let config_json =
635            json!({ "agents": [agent_json_with("alpha", "Alpha", json!({ "prompts": ["PROMPT.md"] }))] }).to_string();
636        let dir = project().file("PROMPT.md", "Be helpful").file("nested/config.json", &config_json);
637
638        let config = AetherSettings::load(
639            dir.root(),
640            [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.root()))],
641        )
642        .unwrap();
643        let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
644
645        assert_eq!(catalog.all()[0].name, "alpha");
646    }
647
648    #[test]
649    fn load_merges_sources_with_rightmost_agent_winning() {
650        let dir = project();
651        let base = AetherSettings {
652            agent: Some("alpha".to_string()),
653            prompts: vec![PromptSource::file("BASE.md")],
654            agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
655            ..AetherSettings::default()
656        };
657        let override_config = AetherSettings {
658            agent: Some("beta".to_string()),
659            prompts: vec![PromptSource::file("OVERRIDE.md")],
660            agents: vec![
661                AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
662                agent_config("beta"),
663            ],
664            ..AetherSettings::default()
665        };
666
667        let config = AetherSettings::load(
668            dir.root(),
669            [AetherSettingsSource::Value(Box::new(base)), AetherSettingsSource::Value(Box::new(override_config))],
670        )
671        .unwrap();
672
673        assert_eq!(
674            config,
675            AetherSettings {
676                agent: Some("beta".to_string()),
677                prompts: vec![PromptSource::file("OVERRIDE.md")],
678                agents: vec![
679                    AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
680                    agent_config("beta"),
681                ],
682                ..AetherSettings::default()
683            }
684        );
685    }
686
687    #[test]
688    fn load_default_merges_user_and_project_settings_with_project_winning() {
689        let project = project();
690        let home = home().file(
691            ".aether/settings.json",
692            json!({
693                "agent": "shared",
694                "prompts": ["USER.md"],
695                "agents": [agent_json("shared", "User shared"), agent_json("user-only", "User only")]
696            })
697            .to_string(),
698        );
699        project.write(
700            ".aether/settings.json",
701            json!({
702                "agent": "project-only",
703                "prompts": ["PROJECT.md"],
704                "agents": [agent_json("shared", "Project shared"), agent_json("project-only", "Project only")]
705            })
706            .to_string(),
707        );
708
709        let aether_home = home.path(".aether");
710        let config = load_default_from_home(project.root(), &aether_home).unwrap();
711        assert_eq!(
712            config,
713            AetherSettings {
714                agent: Some("project-only".to_string()),
715                prompts: vec![PromptSource::file("PROJECT.md")],
716                agents: vec![
717                    settings_agent("shared", "Project shared"),
718                    settings_agent("user-only", "User only"),
719                    settings_agent("project-only", "Project only"),
720                ],
721                ..AetherSettings::default()
722            }
723        );
724    }
725
726    #[test]
727    fn load_default_uses_user_settings_when_project_settings_are_missing() {
728        let project = project();
729        let home = home()
730            .file(".aether/settings.json", json!({ "agents": [agent_json("user-only", "User only")] }).to_string());
731
732        let aether_home = home.path(".aether");
733        let config = load_default_from_home(project.root(), &aether_home).unwrap();
734        assert_eq!(
735            config,
736            AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
737        );
738    }
739
740    #[test]
741    fn load_default_resolves_user_agent_paths_from_aether_home() {
742        let project = project();
743        let home = home()
744            .file(".aether/agents/user.md", "User instructions")
745            .file(".aether/mcp/user.json", mcp_json("user"))
746            .file(
747                ".aether/settings.json",
748                json!({
749                    "agents": [agent_json_with(
750                        "user-only",
751                        "User only",
752                        json!({ "prompts": ["agents/user.md"], "mcps": ["mcp/user.json"] }),
753                    )]
754                })
755                .to_string(),
756            );
757
758        let aether_home = home.path(".aether");
759        let config = load_default_from_home(project.root(), &aether_home).unwrap();
760        let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
761        let spec = catalog.resolve("user-only").unwrap();
762
763        assert!(has_prompt_file(&spec, aether_home.join("agents/user.md")));
764        assert_eq!(spec.mcp.servers.keys().collect::<Vec<_>>(), ["user"]);
765    }
766
767    #[test]
768    fn load_default_uses_project_settings_when_user_settings_are_missing() {
769        let home = home();
770        let project = project().file(
771            ".aether/settings.json",
772            json!({ "agents": [agent_json("project-only", "Project only")] }).to_string(),
773        );
774
775        let aether_home = home.path(".aether");
776        let config = load_default_from_home(project.root(), &aether_home).unwrap();
777
778        assert_eq!(
779            config,
780            AetherSettings {
781                agents: vec![settings_agent("project-only", "Project only")],
782                ..AetherSettings::default()
783            }
784        );
785    }
786
787    #[test]
788    fn load_default_returns_default_when_user_and_project_settings_are_missing() {
789        let project = project();
790        let home = home();
791        let aether_home = home.path(".aether");
792        let config = load_default_from_home(project.root(), &aether_home).unwrap();
793        assert_eq!(config, AetherSettings::default());
794    }
795
796    #[test]
797    fn load_default_rejects_malformed_user_settings() {
798        let project = project();
799        let home = home().file(".aether/settings.json", "{not-json");
800        let aether_home = home.path(".aether");
801        let err = load_default_from_home(project.root(), &aether_home).unwrap_err();
802        assert!(matches!(err, SettingsError::ParseError(_)));
803    }
804
805    #[test]
806    fn strict_file_source_errors_when_missing() {
807        let project = project();
808        let err = AetherSettings::load(
809            project.root(),
810            [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.root()))],
811        )
812        .unwrap_err();
813
814        assert!(matches!(err, SettingsError::IoError(_)));
815    }
816
817    #[test]
818    fn optional_file_source_returns_default_when_missing() {
819        let project = project();
820        let config = AetherSettings::load(
821            project.root(),
822            [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.root()))],
823        )
824        .unwrap();
825
826        assert_eq!(config, AetherSettings::default());
827    }
828
829    #[test]
830    fn inline_resources_replaces_file_sources_with_their_contents() {
831        let project = project()
832            .file("BASE.md", "Be helpful")
833            .file("AGENT.md", "Edit carefully")
834            .file("mcp.json", r#"{"servers":{"coding":{"type":"stdio","command":"run"}}}"#);
835
836        let mut settings = AetherSettings {
837            prompts: vec![PromptSource::file("BASE.md")],
838            mcps: vec![McpSourceSpec::file("mcp.json")],
839            agents: vec![AgentConfig {
840                prompts: vec![PromptSource::file("AGENT.md")],
841                mcps: vec![McpSourceSpec::file("mcp.json")],
842                ..agent_config("alpha")
843            }],
844            ..AetherSettings::default()
845        };
846
847        settings.inline_resources(project.root()).unwrap();
848
849        assert_eq!(settings.prompts, vec![PromptSource::Text { text: "Be helpful".to_string() }]);
850        assert_eq!(settings.agents[0].prompts, vec![PromptSource::Text { text: "Edit carefully".to_string() }]);
851        assert!(matches!(&settings.mcps[0], McpSourceSpec::Inline(config) if config.servers.contains_key("coding")));
852        assert!(
853            matches!(&settings.agents[0].mcps[0], McpSourceSpec::Inline(config) if config.servers.contains_key("coding"))
854        );
855
856        let serialized = serde_json::to_string(&settings).unwrap();
857        assert!(!serialized.contains("BASE.md") && !serialized.contains("mcp.json"), "{serialized}");
858    }
859
860    #[test]
861    fn inline_resources_drops_optional_missing_sources() {
862        let project = project().file("PROMPT.md", "Agent prompt");
863        let mut settings = AetherSettings {
864            prompts: vec![PromptSource::file("absent.md").optional()],
865            mcps: vec![McpSourceSpec::File(McpFileSpec::new("absent.json").optional())],
866            agents: vec![agent_config("alpha")],
867            ..AetherSettings::default()
868        };
869
870        settings.inline_resources(project.root()).unwrap();
871
872        assert_eq!(settings.prompts.len(), 0);
873        assert_eq!(settings.mcps.len(), 0);
874    }
875
876    #[test]
877    fn inline_resources_errors_on_required_missing_mcp() {
878        let project = project();
879        let mut settings = AetherSettings {
880            mcps: vec![McpSourceSpec::file("absent.json")],
881            agents: vec![agent_config("alpha")],
882            ..AetherSettings::default()
883        };
884
885        let err = settings.inline_resources(project.root()).unwrap_err();
886        assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
887    }
888
889    #[test]
890    fn parses_top_level_prompt_and_mcp_defaults() {
891        let config = serde_json::from_value::<AetherSettings>(json!({
892            "prompts": [{ "type": "file", "path": "BASE.md" }],
893            "mcps": [{ "type": "file", "path": "mcp.json" }],
894            "agents": [agent_json("alpha", "Alpha")]
895        }))
896        .unwrap();
897
898        assert_eq!(
899            config,
900            AetherSettings {
901                prompts: vec![PromptSource::file("BASE.md")],
902                mcps: vec![McpSourceSpec::file("mcp.json")],
903                agents: vec![settings_agent("alpha", "Alpha")],
904                ..AetherSettings::default()
905            }
906        );
907    }
908
909    #[test]
910    fn parses_and_serializes_string_shorthand_for_file_sources() {
911        let config = serde_json::from_value::<AetherSettings>(json!({
912            "prompts": ["BASE.md"],
913            "mcps": ["mcp.json"],
914            "agents": [agent_json_with(
915                "alpha",
916                "Alpha",
917                json!({ "prompts": ["AGENT.md"], "mcps": ["agent-mcp.json"] }),
918            )]
919        }))
920        .unwrap();
921
922        assert_eq!(
923            config,
924            AetherSettings {
925                prompts: vec![PromptSource::file("BASE.md")],
926                mcps: vec![McpSourceSpec::file("mcp.json")],
927                agents: vec![AgentConfig {
928                    prompts: vec![PromptSource::file("AGENT.md")],
929                    mcps: vec![McpSourceSpec::file("agent-mcp.json")],
930                    ..settings_agent("alpha", "Alpha")
931                }],
932                ..AetherSettings::default()
933            }
934        );
935
936        let value = serde_json::to_value(&config).unwrap();
937        assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
938        assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
939        assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
940        assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
941    }
942
943    #[test]
944    fn serializes_deferred_mcp_file_as_typed_object() {
945        let source: McpSourceSpec = McpFileSpec::new("mcp.json").defer_tools().into();
946
947        let value = serde_json::to_value(source).unwrap();
948
949        assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "deferTools":true}));
950    }
951
952    #[test]
953    fn rejects_old_top_level_mcp_servers_field() {
954        let err = AetherSettings::try_from(
955            json!({
956                "mcpServers": ["mcp.json"],
957                "agents": [agent_json_with("alpha", "Alpha", json!({ "prompts": ["PROMPT.md"] }))]
958            })
959            .to_string()
960            .as_str(),
961        )
962        .unwrap_err();
963
964        assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
965    }
966
967    #[test]
968    fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
969        let project = project().file("AGENTS.md", "Agent instructions").file(".aether/mcp.json", mcp_json("project"));
970        let home = home().file(".aether/agents/planner/SYSTEM.md", "System instructions").file(
971            ".aether/settings.json",
972            json!({
973                "agents": [agent_json_with(
974                    "planner",
975                    "Plans work",
976                    json!({
977                        "prompts": [
978                            "agents/planner/SYSTEM.md",
979                            { "type": "file", "path": "${WORKSPACE}/AGENTS.md" }
980                        ],
981                        "mcps": [{ "type": "file", "path": "${WORKSPACE}/.aether/mcp.json" }]
982                    }),
983                )]
984            })
985            .to_string(),
986        );
987
988        let aether_home = home.path(".aether");
989        let config = load_default_from_home(project.root(), &aether_home).unwrap();
990        let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
991        let spec = catalog.resolve("planner").unwrap();
992
993        assert!(has_prompt_file(&spec, aether_home.join("agents/planner/SYSTEM.md")));
994        assert!(has_prompt_file(&spec, project.root().join("AGENTS.md")));
995        assert_eq!(spec.mcp.servers.keys().collect::<Vec<_>>(), ["project"]);
996    }
997
998    #[test]
999    fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
1000        let project = project().file("PROJECT.md", "Project prompt").file("AGENTS.md", "Agent prompt").file(
1001            ".aether/settings.json",
1002            json!({
1003                "agents": [agent_json_with(
1004                    "alpha",
1005                    "Alpha",
1006                    json!({ "prompts": ["PROJECT.md", { "type": "file", "path": "${WORKSPACE}/AGENTS.md" }] }),
1007                )]
1008            })
1009            .to_string(),
1010        );
1011
1012        let config = AetherSettings::load(
1013            project.root(),
1014            [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.root()))],
1015        )
1016        .unwrap();
1017
1018        assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
1019        assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1020    }
1021
1022    #[test]
1023    fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
1024        let project = project();
1025
1026        let json_config = AetherSettings::load(
1027            project.root(),
1028            [AetherSettingsSource::Json(
1029                json!({
1030                    "agents": [agent_json_with("alpha", "Alpha", json!({ "prompts": ["${WORKSPACE}/AGENTS.md"] }))]
1031                })
1032                .to_string(),
1033            )],
1034        )
1035        .unwrap();
1036
1037        assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1038
1039        let value_config = AetherSettings::load(
1040            project.root(),
1041            [AetherSettingsSource::Value(Box::new(AetherSettings {
1042                agents: vec![AgentConfig {
1043                    prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
1044                    ..agent_config("alpha")
1045                }],
1046                ..AetherSettings::default()
1047            }))],
1048        )
1049        .unwrap();
1050        assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1051    }
1052
1053    #[test]
1054    fn mcp_sources_without_loadable_servers_resolve_to_no_servers() {
1055        let project = project().file("PROMPT.md", "Be helpful");
1056        let sources: [McpSourceSpec; 3] = [
1057            McpSourceSpec::Inline(McpConfig::default()),
1058            McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into(),
1059            McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into(),
1060        ];
1061
1062        for source in sources {
1063            let catalog =
1064                AgentCatalog::from_settings(project.root(), settings_with_mcps(vec![source.clone()])).unwrap();
1065            assert!(catalog.resolve("alpha").unwrap().mcp.is_empty(), "{source:?}");
1066        }
1067    }
1068
1069    #[test]
1070    fn required_mcp_sources_that_cannot_load_error() {
1071        let project = project().file("PROMPT.md", "Be helpful");
1072        let error = |source| AgentCatalog::from_settings(project.root(), settings_with_mcps(vec![source])).unwrap_err();
1073
1074        assert!(matches!(
1075            error(McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")),
1076            SettingsError::UnresolvedMcpConfigVariable { .. }
1077        ));
1078        assert!(matches!(error(McpSourceSpec::file("nonexistent.json")), SettingsError::InvalidMcpConfigPath { .. }));
1079    }
1080
1081    #[test]
1082    fn optional_existing_mcp_source_preserves_defer_tools_flag() {
1083        let project = project().file("PROMPT.md", "Be helpful").file("mcp.json", mcp_json("lazy"));
1084        let settings = settings_with_mcps(vec![McpFileSpec::new("mcp.json").defer_tools().optional().into()]);
1085
1086        let spec = AgentCatalog::from_settings(project.root(), settings).unwrap().resolve("alpha").unwrap();
1087
1088        assert!(spec.mcp.servers["lazy"].defer_tools().defers_tools());
1089    }
1090
1091    #[test]
1092    fn optional_mcp_source_serializes_as_typed_object() {
1093        let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
1094        let value = serde_json::to_value(source).unwrap();
1095        assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
1096    }
1097
1098    #[test]
1099    fn all_optional_prompts_missing_errors_with_no_prompts() {
1100        let project = project();
1101        let config = AetherSettings {
1102            agents: vec![AgentConfig {
1103                prompts: vec![PromptSource::file("MISSING.md").optional()],
1104                ..agent_config("alpha")
1105            }],
1106            ..AetherSettings::default()
1107        };
1108
1109        let err = AgentCatalog::from_settings(project.root(), config).unwrap_err();
1110        assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
1111    }
1112
1113    #[test]
1114    fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
1115        let original = json!({
1116            "agents": [agent_json_with(
1117                "alpha",
1118                "Alpha",
1119                json!({
1120                    "prompts": [
1121                        "AGENTS.md",
1122                        "${WORKSPACE}/SYSTEM.md",
1123                        { "type": "file", "path": "${WORKSPACE}/.aether/rules.md", "optional": true },
1124                        { "type": "glob", "pattern": "${WORKSPACE}/.aether/rules/*.md" }
1125                    ],
1126                    "mcps": [
1127                        "mcp.json",
1128                        { "type": "file", "path": "${WORKSPACE}/.aether/mcp.json", "optional": true }
1129                    ]
1130                }),
1131            )]
1132        });
1133
1134        let settings: AetherSettings = serde_json::from_value(original).unwrap();
1135        let reparsed: AetherSettings = serde_json::from_value(serde_json::to_value(&settings).unwrap()).unwrap();
1136
1137        assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
1138    }
1139
1140    #[test]
1141    fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
1142        let project = project().file("AGENTS.md", "agents");
1143        let home = home().file(".aether/agents/planner/SYSTEM.md", "system").file(
1144            ".aether/settings.json",
1145            json!({
1146                "agents": [agent_json_with(
1147                    "planner",
1148                    "Plans",
1149                    json!({ "prompts": ["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"] }),
1150                )]
1151            })
1152            .to_string(),
1153        );
1154
1155        let aether_home = home.path(".aether");
1156        let settings = load_default_from_home(project.root(), &aether_home).unwrap();
1157
1158        let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
1159        assert_eq!(
1160            settings.agents[0].prompts,
1161            vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
1162            "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
1163        );
1164    }
1165
1166    fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
1167        AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
1168    }
1169
1170    #[test]
1171    fn parses_credentials_store_variants() {
1172        let encrypted_file = CredentialsStoreConfig::EncryptedFile {
1173            path: Some(PathBuf::from("/custom/creds.enc")),
1174            password_env: Some("MY_SECRET".to_string()),
1175        };
1176        let cases = [
1177            (json!({ "type": "keyring" }), CredentialsStoreConfig::Keyring),
1178            (json!({ "type": "memory" }), CredentialsStoreConfig::Memory),
1179            (
1180                json!({ "type": "encryptedFile", "path": "/custom/creds.enc", "passwordEnv": "MY_SECRET" }),
1181                encrypted_file,
1182            ),
1183        ];
1184
1185        for (store, expected) in cases {
1186            let config: AetherSettings =
1187                serde_json::from_value(json!({ "credentialsStore": store, "agents": [] })).unwrap();
1188            assert_eq!(config.credentials_store, Some(expected));
1189        }
1190    }
1191
1192    #[test]
1193    fn expands_otlp_header_environment_variables() {
1194        let settings = OtlpTelemetrySettings {
1195            headers: BTreeMap::from([
1196                ("authorization".to_string(), "Bearer $POSTHOG_PROJECT_TOKEN".to_string()),
1197                ("x-project".to_string(), "${POSTHOG_PROJECT_TOKEN}".to_string()),
1198                ("x-literal".to_string(), "$$POSTHOG_PROJECT_TOKEN".to_string()),
1199            ]),
1200            ..OtlpTelemetrySettings::default()
1201        };
1202        let vars = Vars::new().with_env_lookup(|name| (name == "POSTHOG_PROJECT_TOKEN").then(|| "token-value".into()));
1203
1204        assert_eq!(
1205            settings.resolved_headers(&vars).unwrap(),
1206            BTreeMap::from([
1207                ("authorization".to_string(), "Bearer token-value".to_string()),
1208                ("x-project".to_string(), "token-value".to_string()),
1209                ("x-literal".to_string(), "$POSTHOG_PROJECT_TOKEN".to_string()),
1210            ])
1211        );
1212    }
1213
1214    #[test]
1215    fn reports_missing_otlp_header_environment_variables() {
1216        let settings = OtlpTelemetrySettings {
1217            headers: BTreeMap::from([("authorization".to_string(), "Bearer $POSTHOG_PROJECT_TOKEN".to_string())]),
1218            ..OtlpTelemetrySettings::default()
1219        };
1220
1221        assert!(matches!(
1222            settings.resolved_headers(&Vars::new().with_env_lookup(|_| None)),
1223            Err(VarError::NotFound(variable)) if variable == "POSTHOG_PROJECT_TOKEN"
1224        ));
1225    }
1226}