Skip to main content

lifeloop/telemetry/
mod.rs

1//! Lifecycle telemetry readers.
2//!
3//! Lifeloop owns lifecycle-relevant telemetry parsing for harness adapters.
4//! Clients (CCD, RLM, and other lifecycle clients) consume neutral
5//! [`PressureObservation`] snapshots instead of writing per-harness log
6//! readers themselves.
7//!
8//! # Boundary (issue #5)
9//!
10//! This module owns:
11//! * parsing harness-native session/telemetry artifacts into a neutral
12//!   [`PressureObservation`] (adapter id, observed time, model name, token
13//!   counts, context window, compaction signal),
14//! * the env-var resolution rules used to locate those artifacts and to
15//!   override their parsed values (with `LIFELOOP_*` aliases winning over
16//!   the compatibility `CCD_*` inputs),
17//! * a neutral telemetry [`TelemetryError`] type whose variants name the
18//!   lifecycle failure classes (telemetry_unavailable, hook_protocol_error,
19//!   internal_error).
20//!
21//! This module does **not** own:
22//! * receipt emission (callers translate [`TelemetryError`] into a
23//!   [`crate::LifecycleReceipt`]),
24//! * the placement/payload pipeline (issue #4 owns asset rendering),
25//! * adapter manifest registration (issue #6 owns the manifest registry —
26//!   this module merely reports `support` states the registry can attach
27//!   to a `context_pressure` claim),
28//! * the hook protocol command strings (issue #3),
29//! * lifecycle routing (issue #7),
30//! * any client-side state, prompt semantics, or continuity vocabulary.
31//!   Specifically: no memory, recall, promotion, compaction policy, radar,
32//!   or governance reasoning. Lifeloop reports the *signal*; clients
33//!   decide what it means.
34//!
35//! # CCD compatibility
36//!
37//! Existing `CCD_*` env vars (e.g. `CCD_CLAUDE_HOME`,
38//! `CCD_CONTEXT_WINDOW_TOKENS`, `CCD_HOST_MODEL`) are honored as
39//! compatibility inputs through `lifeloop.v0.x`. Each has a
40//! `LIFELOOP_*` alias; when both are set, the `LIFELOOP_*` value wins
41//! and a single bounded warning is recorded per resolved key per
42//! process. Removal criteria are tracked in
43//! `docs/tombstones/lifeloop.v0.md`.
44
45use std::path::{Path, PathBuf};
46use std::sync::Mutex;
47use std::time::{SystemTime, UNIX_EPOCH};
48
49use serde::{Deserialize, Serialize};
50
51pub mod claude;
52pub mod codex;
53pub mod gemini;
54pub mod host;
55pub mod opencode;
56
57pub(crate) const MAX_TELEMETRY_LOG_BYTES: u64 = 64 * 1024 * 1024;
58
59// ============================================================================
60// Neutral observation types
61// ============================================================================
62
63/// A neutral lifecycle-pressure observation extracted from harness
64/// telemetry. Carries everything a `context.pressure_observed` event or a
65/// receipt `telemetry_summary` needs, with no harness-private fields.
66///
67/// Wire-stable field names; `Option`s are `skip_serializing_if` so absent
68/// signals stay absent on the wire (callers must not rely on `null`
69/// vs missing for these — they are not required-nullable).
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct PressureObservation {
73    /// Stable adapter id (matches `AdapterManifest::adapter_id`, e.g.
74    /// `"claude"`, `"codex"`, `"gemini"`, `"opencode"`).
75    pub adapter_id: String,
76    /// Adapter version when discoverable from the telemetry source.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub adapter_version: Option<String>,
79    /// Seconds since UNIX epoch at which this observation was sourced
80    /// (typically the underlying log/session-file mtime).
81    pub observed_at_epoch_s: u64,
82    /// Model identifier when surfaced by the telemetry source or env.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub model_name: Option<String>,
85    /// Most recent prompt-side token count (window-relative).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub total_tokens: Option<u64>,
88    /// Adapter-reported context window in tokens.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub context_window_tokens: Option<u64>,
91    /// Percentage of the context window consumed (0..=100), when both
92    /// numerator and denominator are available.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub context_used_pct: Option<u8>,
95    /// `Some(true)` if the adapter signaled a compaction/compression event
96    /// in the observed session window. `None` means "no signal" (not
97    /// "definitely false").
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub compaction_signal: Option<bool>,
100    /// Granular usage breakdown, when the source provides it.
101    #[serde(skip_serializing_if = "TokenUsage::is_empty", default)]
102    pub usage: TokenUsage,
103}
104
105/// Per-side token usage breakdown. All fields default to zero so the type
106/// stays format-agnostic for `Deserialize`.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct TokenUsage {
110    #[serde(default, skip_serializing_if = "is_zero_u64")]
111    pub input_tokens: u64,
112    #[serde(default, skip_serializing_if = "is_zero_u64")]
113    pub output_tokens: u64,
114    #[serde(default, skip_serializing_if = "is_zero_u64")]
115    pub cache_creation_input_tokens: u64,
116    #[serde(default, skip_serializing_if = "is_zero_u64")]
117    pub cache_read_input_tokens: u64,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub blended_total_tokens: Option<u64>,
120}
121
122impl TokenUsage {
123    pub fn is_empty(&self) -> bool {
124        self.input_tokens == 0
125            && self.output_tokens == 0
126            && self.cache_creation_input_tokens == 0
127            && self.cache_read_input_tokens == 0
128            && self.blended_total_tokens.unwrap_or(0) == 0
129    }
130}
131
132fn is_zero_u64(value: &u64) -> bool {
133    *value == 0
134}
135
136// ============================================================================
137// Errors
138// ============================================================================
139
140/// Telemetry-side errors. The variants name the failure classes the spec
141/// uses for telemetry-derived receipts:
142///
143/// * [`TelemetryError::Unavailable`] → `telemetry_unavailable`
144/// * [`TelemetryError::HookProtocol`] → `hook_protocol_error`
145/// * [`TelemetryError::Internal`] → `internal_error` (the only one of the
146///   three currently named in `docs/specs/lifecycle-contract/body.md`).
147///
148/// The first two are *pending* as `LifecycleReceipt::failure_class`
149/// variants — they are added to the receipt enum in a follow-up issue
150/// once the spec body names them. Until then, callers wishing to emit a
151/// failed receipt for these conditions should use `internal_error` and
152/// attach the precise class via `warnings`.
153#[derive(Debug)]
154pub enum TelemetryError {
155    /// The telemetry source (file, directory, db) was missing, empty, or
156    /// stale. Distinct from `internal_error` because it's an expected,
157    /// observable absence rather than a Lifeloop bug.
158    Unavailable(String),
159    /// The shape of a parsed telemetry artifact violated the adapter's
160    /// hook protocol contract (e.g. expected JSONL but lines were not
161    /// objects). Distinct from `Unavailable` because something *was*
162    /// there and it was wrong.
163    HookProtocol(String),
164    /// Lifeloop failed unexpectedly while parsing telemetry.
165    Internal(String),
166}
167
168impl TelemetryError {
169    /// Stable wire string for the failure class this error maps to.
170    pub fn failure_class(&self) -> &'static str {
171        match self {
172            Self::Unavailable(_) => "telemetry_unavailable",
173            Self::HookProtocol(_) => "hook_protocol_error",
174            Self::Internal(_) => "internal_error",
175        }
176    }
177}
178
179impl std::fmt::Display for TelemetryError {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match self {
182            Self::Unavailable(msg) => write!(f, "telemetry_unavailable: {msg}"),
183            Self::HookProtocol(msg) => write!(f, "hook_protocol_error: {msg}"),
184            Self::Internal(msg) => write!(f, "internal_error: {msg}"),
185        }
186    }
187}
188
189impl std::error::Error for TelemetryError {}
190
191impl From<std::io::Error> for TelemetryError {
192    fn from(error: std::io::Error) -> Self {
193        match error.kind() {
194            std::io::ErrorKind::NotFound => Self::Unavailable(error.to_string()),
195            _ => Self::Internal(error.to_string()),
196        }
197    }
198}
199
200pub type TelemetryResult<T> = Result<T, TelemetryError>;
201
202pub(crate) fn read_file_bounded(path: &Path, label: &str) -> TelemetryResult<Vec<u8>> {
203    let metadata = path.metadata().map_err(TelemetryError::from)?;
204    if metadata.len() > MAX_TELEMETRY_LOG_BYTES {
205        return Err(TelemetryError::Unavailable(format!(
206            "{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
207            path.display()
208        )));
209    }
210    let file = std::fs::File::open(path).map_err(TelemetryError::from)?;
211    let mut bytes = Vec::new();
212    use std::io::Read;
213    file.take(MAX_TELEMETRY_LOG_BYTES + 1)
214        .read_to_end(&mut bytes)
215        .map_err(TelemetryError::from)?;
216    if bytes.len() as u64 > MAX_TELEMETRY_LOG_BYTES {
217        return Err(TelemetryError::Unavailable(format!(
218            "{label} exceeds {MAX_TELEMETRY_LOG_BYTES} bytes: {}",
219            path.display()
220        )));
221    }
222    Ok(bytes)
223}
224
225pub(crate) fn read_file_to_string_bounded(path: &Path, label: &str) -> TelemetryResult<String> {
226    let bytes = read_file_bounded(path, label)?;
227    String::from_utf8(bytes).map_err(|err| {
228        TelemetryError::HookProtocol(format!("{label} is not UTF-8: {}: {err}", path.display()))
229    })
230}
231
232// ============================================================================
233// Env var resolution: LIFELOOP_* wins over CCD_*, with bounded warning.
234// ============================================================================
235
236/// A single CCD→Lifeloop env-var alias.
237///
238/// Each adapter declares the keys it cares about as a `&'static [EnvAlias]`.
239/// When both the `lifeloop` and `ccd_compat` keys are set in the process
240/// environment, the `lifeloop` value wins and [`EnvWarningSink`] records a
241/// single bounded warning per resolved key per process.
242#[derive(Debug, Clone, Copy)]
243pub struct EnvAlias {
244    pub lifeloop: &'static str,
245    pub ccd_compat: &'static str,
246}
247
248/// Sink for env-precedence warnings.
249///
250/// One global instance ([`env_warning_sink`]) deduplicates warnings by the
251/// `lifeloop` key name so a process that resolves the same alias many
252/// times only emits one warning. Tests can inspect [`drain`] to assert
253/// precedence behavior.
254///
255/// [`drain`]: EnvWarningSink::drain
256#[derive(Debug, Default)]
257pub struct EnvWarningSink {
258    inner: Mutex<EnvWarningInner>,
259}
260
261#[derive(Debug, Default)]
262struct EnvWarningInner {
263    seen: Vec<String>,
264    queued: Vec<EnvPrecedenceWarning>,
265}
266
267/// Bounded warning emitted once per resolved alias when both
268/// `LIFELOOP_*` and `CCD_*` are set. The `lifeloop` value wins; the
269/// `ccd_compat` value is shadowed.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct EnvPrecedenceWarning {
272    pub lifeloop_key: &'static str,
273    pub ccd_compat_key: &'static str,
274}
275
276impl EnvWarningSink {
277    fn note(&self, alias: EnvAlias) {
278        let mut inner = self.inner.lock().expect("env warning sink poisoned");
279        if inner.seen.iter().any(|k| k == alias.lifeloop) {
280            return;
281        }
282        inner.seen.push(alias.lifeloop.to_string());
283        inner.queued.push(EnvPrecedenceWarning {
284            lifeloop_key: alias.lifeloop,
285            ccd_compat_key: alias.ccd_compat,
286        });
287    }
288
289    /// Drain queued warnings (FIFO). Each warning is yielded at most
290    /// once: a key that has already been drained will not appear again
291    /// in a later call, even if its alias resolves repeatedly.
292    pub fn drain(&self) -> Vec<EnvPrecedenceWarning> {
293        let mut inner = self.inner.lock().expect("env warning sink poisoned");
294        std::mem::take(&mut inner.queued)
295    }
296
297    /// Test-only: forget all dedupe state. Production code never calls
298    /// this; the sink is intended to live for the process lifetime.
299    #[doc(hidden)]
300    pub fn reset_for_tests(&self) {
301        let mut inner = self.inner.lock().expect("env warning sink poisoned");
302        inner.seen.clear();
303        inner.queued.clear();
304    }
305}
306
307/// Process-wide warning sink used by [`resolve_env_string`] and
308/// [`resolve_env_u64`]. Callers can `drain` to surface these warnings on
309/// their preferred channel.
310pub fn env_warning_sink() -> &'static EnvWarningSink {
311    use std::sync::OnceLock;
312    static SINK: OnceLock<EnvWarningSink> = OnceLock::new();
313    SINK.get_or_init(EnvWarningSink::default)
314}
315
316/// Resolve an env-var string through the alias list. The first alias
317/// whose `lifeloop` key is set wins; otherwise the first alias whose
318/// `ccd_compat` key is set wins. Whenever an alias has *both* sides set,
319/// a precedence warning is recorded (once per `lifeloop` key per
320/// process) regardless of which alias actually carried the resolution.
321pub fn resolve_env_string(aliases: &[EnvAlias]) -> Option<String> {
322    resolve_env_string_with(aliases, &|name| std::env::var(name).ok())
323}
324
325/// Like [`resolve_env_string`] but reads through a closure (for tests
326/// that don't want to mutate process env).
327pub fn resolve_env_string_with(
328    aliases: &[EnvAlias],
329    read: &dyn Fn(&str) -> Option<String>,
330) -> Option<String> {
331    let mut chosen: Option<String> = None;
332
333    for alias in aliases {
334        let lifeloop_value = read(alias.lifeloop)
335            .map(|v| v.trim().to_owned())
336            .filter(|v| !v.is_empty());
337        let ccd_value = read(alias.ccd_compat)
338            .map(|v| v.trim().to_owned())
339            .filter(|v| !v.is_empty());
340
341        if lifeloop_value.is_some() && ccd_value.is_some() {
342            env_warning_sink().note(*alias);
343        }
344
345        if chosen.is_none() {
346            chosen = lifeloop_value.or(ccd_value);
347        }
348    }
349
350    chosen
351}
352
353/// Resolve an env alias as `u64`, parsing the string form.
354pub fn resolve_env_u64(aliases: &[EnvAlias]) -> Option<u64> {
355    resolve_env_string(aliases).and_then(|v| v.parse().ok())
356}
357
358/// `Lifeloop`/`CCD` general context-window fallback. Adapter readers
359/// should consult their adapter-specific alias first, then fall back to
360/// this. The general fallback exists so users with custom or local
361/// setups don't have to set a runtime-prefixed env var.
362pub const GENERAL_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
363    lifeloop: "LIFELOOP_CONTEXT_WINDOW_TOKENS",
364    ccd_compat: "CCD_CONTEXT_WINDOW_TOKENS",
365}];
366
367/// Generic host-model fallback used by every adapter when a more
368/// specific alias does not resolve.
369pub const GENERAL_HOST_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
370    lifeloop: "LIFELOOP_HOST_MODEL",
371    ccd_compat: "CCD_HOST_MODEL",
372}];
373
374pub fn general_context_window() -> Option<u64> {
375    resolve_env_u64(GENERAL_CONTEXT_WINDOW_ALIASES)
376}
377
378pub fn general_host_model() -> Option<String> {
379    resolve_env_string(GENERAL_HOST_MODEL_ALIASES)
380}
381
382// ============================================================================
383// Filesystem helpers (shared by per-adapter readers)
384// ============================================================================
385
386/// Seconds since UNIX epoch for the file's mtime, or `None` if the file
387/// is missing.
388pub fn file_mtime_epoch_s(path: &Path) -> TelemetryResult<Option<u64>> {
389    let metadata = match std::fs::metadata(path) {
390        Ok(m) => m,
391        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
392        Err(error) => return Err(TelemetryError::Internal(error.to_string())),
393    };
394    let modified = metadata
395        .modified()
396        .map_err(|e| TelemetryError::Internal(e.to_string()))?;
397    let epoch_s = modified
398        .duration_since(UNIX_EPOCH)
399        .map_err(|e| TelemetryError::Internal(format!("mtime before UNIX_EPOCH: {e}")))?
400        .as_secs();
401    Ok(Some(epoch_s))
402}
403
404/// Default recency window (seconds) for considering a telemetry artifact
405/// fresh enough to drive a `context.pressure_observed` event. Mirrors the
406/// 30-minute threshold the CCD readers used.
407pub const RECENT_ACTIVITY_SECS: u64 = 30 * 60;
408
409pub fn now_epoch_s() -> TelemetryResult<u64> {
410    SystemTime::now()
411        .duration_since(UNIX_EPOCH)
412        .map(|d| d.as_secs())
413        .map_err(|e| TelemetryError::Internal(format!("system clock before UNIX_EPOCH: {e}")))
414}
415
416pub fn is_recent(epoch_s: u64) -> TelemetryResult<bool> {
417    Ok(now_epoch_s()?.saturating_sub(epoch_s) <= RECENT_ACTIVITY_SECS)
418}
419
420pub fn home_dir() -> TelemetryResult<PathBuf> {
421    match std::env::var_os("HOME") {
422        Some(home) => Ok(PathBuf::from(home)),
423        None => Err(TelemetryError::Unavailable(
424            "HOME environment variable is not set".into(),
425        )),
426    }
427}
428
429pub fn compute_pct(total_tokens: u64, context_window: Option<u64>) -> Option<u8> {
430    let cw = context_window?;
431    if cw == 0 {
432        return None;
433    }
434    Some(((total_tokens.saturating_mul(100)) / cw).min(100) as u8)
435}
436
437// ============================================================================
438// JSON probing helpers (shared by per-adapter readers)
439// ============================================================================
440
441/// Recursively search a JSON value for the first matching string by
442/// candidate key names. Descends into objects and arrays depth-first.
443pub fn string_key(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
444    match value {
445        serde_json::Value::Object(map) => {
446            for key in keys {
447                if let Some(serde_json::Value::String(found)) = map.get(*key) {
448                    return Some(found.clone());
449                }
450            }
451            for child in map.values() {
452                if let Some(found) = string_key(child, keys) {
453                    return Some(found);
454                }
455            }
456            None
457        }
458        serde_json::Value::Array(items) => items.iter().find_map(|i| string_key(i, keys)),
459        _ => None,
460    }
461}
462
463/// Recursively search a JSON value for the first matching numeric value
464/// by candidate key names. Accepts both JSON numbers and stringified
465/// integers.
466pub fn number_key(value: &serde_json::Value, keys: &[&str]) -> Option<u64> {
467    match value {
468        serde_json::Value::Object(map) => {
469            for key in keys {
470                if let Some(found) = map.get(*key)
471                    && let Some(number) = as_u64(found)
472                {
473                    return Some(number);
474                }
475            }
476            for child in map.values() {
477                if let Some(found) = number_key(child, keys) {
478                    return Some(found);
479                }
480            }
481            None
482        }
483        serde_json::Value::Array(items) => items.iter().find_map(|i| number_key(i, keys)),
484        _ => None,
485    }
486}
487
488/// Coerce a JSON value to u64, accepting both numbers and parseable
489/// strings.
490pub fn as_u64(value: &serde_json::Value) -> Option<u64> {
491    match value {
492        serde_json::Value::Number(number) => number.as_u64(),
493        serde_json::Value::String(text) => text.parse().ok(),
494        _ => None,
495    }
496}
497
498// ============================================================================
499// Tests
500// ============================================================================
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::sync::Mutex;
506
507    // Tests below that touch the global `env_warning_sink` must serialize:
508    // cargo test runs unit tests in parallel by default and the sink's
509    // reset/drain pair races otherwise. A process-local mutex keeps the
510    // test set dependency-free.
511    static ENV_SINK_LOCK: Mutex<()> = Mutex::new(());
512    use serde_json::json;
513
514    #[test]
515    fn as_u64_accepts_numbers_and_strings() {
516        assert_eq!(as_u64(&json!(42)), Some(42));
517        assert_eq!(as_u64(&json!("1024")), Some(1024));
518        assert_eq!(as_u64(&json!(-1)), None);
519        assert_eq!(as_u64(&json!(1.5)), None);
520        assert_eq!(as_u64(&json!("hello")), None);
521    }
522
523    #[test]
524    fn string_key_descends() {
525        let v = json!({"outer": {"inner": {"target": "found"}}});
526        assert_eq!(string_key(&v, &["target"]), Some("found".into()));
527    }
528
529    #[test]
530    fn number_key_descends() {
531        let v = json!({"usage": {"prompt_tokens": 200}});
532        assert_eq!(number_key(&v, &["prompt_tokens"]), Some(200));
533    }
534
535    #[test]
536    fn telemetry_error_failure_classes_are_stable() {
537        assert_eq!(
538            TelemetryError::Unavailable("x".into()).failure_class(),
539            "telemetry_unavailable"
540        );
541        assert_eq!(
542            TelemetryError::HookProtocol("x".into()).failure_class(),
543            "hook_protocol_error"
544        );
545        assert_eq!(
546            TelemetryError::Internal("x".into()).failure_class(),
547            "internal_error"
548        );
549    }
550
551    #[test]
552    fn bounded_file_read_rejects_oversized_logs() {
553        let dir = tempfile::tempdir().expect("tempdir");
554        let path = dir.path().join("huge.jsonl");
555        let file = std::fs::File::create(&path).expect("create file");
556        file.set_len(MAX_TELEMETRY_LOG_BYTES + 1)
557            .expect("sparse file length");
558
559        let err = read_file_bounded(&path, "test log").unwrap_err();
560        assert!(matches!(err, TelemetryError::Unavailable(_)));
561    }
562
563    #[test]
564    fn pressure_observation_serializes_minimally() {
565        let obs = PressureObservation {
566            adapter_id: "claude".into(),
567            adapter_version: None,
568            observed_at_epoch_s: 100,
569            model_name: None,
570            total_tokens: Some(500),
571            context_window_tokens: Some(1000),
572            context_used_pct: Some(50),
573            compaction_signal: None,
574            usage: TokenUsage::default(),
575        };
576        let json = serde_json::to_value(&obs).unwrap();
577        assert_eq!(json["adapter_id"], "claude");
578        assert_eq!(json["observed_at_epoch_s"], 100);
579        assert_eq!(json["total_tokens"], 500);
580        assert!(json.get("adapter_version").is_none());
581        assert!(json.get("compaction_signal").is_none());
582        assert!(json.get("usage").is_none());
583    }
584
585    #[test]
586    fn resolve_env_string_with_lifeloop_winning() {
587        let _g = ENV_SINK_LOCK.lock().unwrap();
588        env_warning_sink().reset_for_tests();
589        let aliases = &[EnvAlias {
590            lifeloop: "LIFELOOP_TEST_X",
591            ccd_compat: "CCD_TEST_X",
592        }];
593        let read = |name: &str| -> Option<String> {
594            match name {
595                "LIFELOOP_TEST_X" => Some("ll".into()),
596                "CCD_TEST_X" => Some("ccd".into()),
597                _ => None,
598            }
599        };
600        assert_eq!(resolve_env_string_with(aliases, &read), Some("ll".into()));
601        let warnings = env_warning_sink().drain();
602        assert_eq!(warnings.len(), 1);
603        assert_eq!(warnings[0].lifeloop_key, "LIFELOOP_TEST_X");
604        assert_eq!(warnings[0].ccd_compat_key, "CCD_TEST_X");
605    }
606
607    #[test]
608    fn resolve_env_string_falls_back_to_ccd() {
609        let _g = ENV_SINK_LOCK.lock().unwrap();
610        env_warning_sink().reset_for_tests();
611        let aliases = &[EnvAlias {
612            lifeloop: "LIFELOOP_TEST_Y",
613            ccd_compat: "CCD_TEST_Y",
614        }];
615        let read = |name: &str| -> Option<String> {
616            match name {
617                "CCD_TEST_Y" => Some("ccd-only".into()),
618                _ => None,
619            }
620        };
621        assert_eq!(
622            resolve_env_string_with(aliases, &read),
623            Some("ccd-only".into())
624        );
625        assert!(env_warning_sink().drain().is_empty());
626    }
627
628    #[test]
629    fn warning_is_bounded_to_one_per_key() {
630        let _g = ENV_SINK_LOCK.lock().unwrap();
631        env_warning_sink().reset_for_tests();
632        let aliases = &[EnvAlias {
633            lifeloop: "LIFELOOP_TEST_Z",
634            ccd_compat: "CCD_TEST_Z",
635        }];
636        let read = |name: &str| -> Option<String> {
637            match name {
638                "LIFELOOP_TEST_Z" => Some("ll".into()),
639                "CCD_TEST_Z" => Some("ccd".into()),
640                _ => None,
641            }
642        };
643        for _ in 0..5 {
644            let _ = resolve_env_string_with(aliases, &read);
645        }
646        let warnings = env_warning_sink().drain();
647        assert_eq!(warnings.len(), 1);
648        // After drain, the seen-set still holds the key, so subsequent
649        // resolutions must NOT requeue it.
650        for _ in 0..3 {
651            let _ = resolve_env_string_with(aliases, &read);
652        }
653        assert!(env_warning_sink().drain().is_empty());
654    }
655}