diff --git a/.beads/.gitignore b/.beads/.gitignore index 31bd94b..eb82c48 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,11 +1,27 @@ # Dolt database (managed by Dolt, not git) dolt/ -dolt-access.lock # Runtime files bd.sock +bd.sock.startlock sync-state.json last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key # Local version tracking (prevents upgrade notification spam after git ops) .local_version @@ -17,9 +33,30 @@ redirect # Sync state (local-only, per-machine) # These files are machine-specific and should not be shared across clones .sync.lock -.jsonl.lock -sync_base.jsonl export-state/ +export-state.json + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env # Legacy files (from pre-Dolt versions) *.db @@ -29,19 +66,7 @@ export-state/ *.db-shm db.sqlite bd.db -daemon.lock -daemon.log -daemon-*.log.gz -daemon.pid -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. -# They would override fork protection in .git/info/exclude, allowing -# contributors to accidentally commit upstream issue databases. -# The JSONL files (issues.jsonl, interactions.jsonl) and config files -# are tracked by git by default since no pattern above ignores them. +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/dolt-backup-state.json b/.beads/dolt-backup-state.json new file mode 100644 index 0000000..6f38981 --- /dev/null +++ b/.beads/dolt-backup-state.json @@ -0,0 +1,4 @@ +{ + "last_sync": "2026-05-04T02:02:18.677980026Z", + "duration": "20.148657ms" +} \ No newline at end of file diff --git a/.beads/dolt-backup.json b/.beads/dolt-backup.json new file mode 100644 index 0000000..af554a5 --- /dev/null +++ b/.beads/dolt-backup.json @@ -0,0 +1,5 @@ +{ + "backup_url": "file:///tmp/devloop-beads-backup", + "backup_name": "default", + "created_at": "2026-05-04T02:02:05.569697804Z" +} \ No newline at end of file diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout index 08ed09e..9b5a906 100755 --- a/.beads/hooks/post-checkout +++ b/.beads/hooks/post-checkout @@ -4,11 +4,11 @@ # # bd (beads) post-checkout hook - thin shim # -# This shim delegates to 'bd hook post-checkout' which contains +# This shim delegates to 'bd hooks run post-checkout' which contains # the actual hook logic. This pattern ensures hook behavior is always # in sync with the installed bd version - no manual updates needed. # -# The 'bd hook' command (singular) supports: +# The 'bd hooks run' command supports: # - Guard against frequent firing (only imports if JSONL changed) # - Per-worktree state tracking # - Dolt branch-then-merge pattern @@ -20,4 +20,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook post-checkout "$@" +exec bd hooks run post-checkout "$@" diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge index 8b45cb8..e003aa6 100755 --- a/.beads/hooks/post-merge +++ b/.beads/hooks/post-merge @@ -4,11 +4,11 @@ # # bd (beads) post-merge hook - thin shim # -# This shim delegates to 'bd hook post-merge' which contains +# This shim delegates to 'bd hooks run post-merge' which contains # the actual hook logic. This pattern ensures hook behavior is always # in sync with the installed bd version - no manual updates needed. # -# The 'bd hook' command (singular) supports: +# The 'bd hooks run' command supports: # - Branch-then-merge pattern for Dolt (cell-level conflict resolution) # - Per-worktree state tracking # - Hook chaining configuration @@ -21,4 +21,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook post-merge "$@" +exec bd hooks run post-merge "$@" diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit index f14e85d..4dc9931 100755 --- a/.beads/hooks/pre-commit +++ b/.beads/hooks/pre-commit @@ -4,11 +4,11 @@ # # bd (beads) pre-commit hook — thin shim # -# Delegates to 'bd hook pre-commit' which contains the actual hook logic. +# Delegates to 'bd hooks run pre-commit' which contains the actual hook logic. # This pattern ensures hook behavior is always in sync with the installed # bd version — no manual updates needed. # -# The 'bd hook' command supports: +# The 'bd hooks run' command supports: # - Per-worktree export state tracking # - Dolt in-process export (no lock deadlocks) # - Sync-branch routing @@ -22,4 +22,4 @@ if ! command -v bd >/dev/null 2>&1; then exit 0 fi -exec bd hook pre-commit "$@" +exec bd hooks run pre-commit "$@" diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..c36fce8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,4 @@ +{"id":"int-7230a6ae","kind":"field_change","created_at":"2026-05-04T04:43:24.260954052Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-0600ed25","kind":"field_change","created_at":"2026-05-04T04:44:14.236762188Z","actor":"Daniel Vianna","issue_id":"devloop-hrb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Documented in README and behavior reference"}} +{"id":"int-76f6d530","kind":"field_change","created_at":"2026-05-04T04:54:41.658928411Z","actor":"Daniel Vianna","issue_id":"devloop-a2l","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-a662fcf5","kind":"field_change","created_at":"2026-05-04T04:55:43.073552254Z","actor":"Daniel Vianna","issue_id":"devloop-a2l","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"0.9.0 changelog section prepared for release"}} diff --git a/.beads/metadata.json b/.beads/metadata.json index 336a727..4d00e84 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,6 +1,7 @@ { "database": "dolt", - "jsonl_export": "issues.jsonl", "backend": "dolt", - "dolt_database": "beads_devloop" + "dolt_mode": "embedded", + "dolt_database": "beads_devloop", + "project_id": "c77ed6d5-2ff6-43aa-aada-aab14edfcda0" } \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..963a538 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f38a862..9feab12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ /target /examples/*/state.json **/.#* + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key + +# Codex droppings +**/.codex \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 497a40b..49e09b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to `devloop` will be recorded in this file. ## [Unreleased] +## [0.9.0] - 2026-05-04 + +### Added +- Added shell-free parent-environment interpolation for process + command arguments, process environment values, and HTTP probe URLs, + so client configs can share values such as `CONTAINER_PORT` without + repo-local wrapper scripts. + ## [0.8.0] - 2026-04-08 ### Added diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 86a1baf..8a679e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "devloop" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index eeba7c9..fb56773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devloop" -version = "0.8.0" +version = "0.9.0" edition = "2024" [dependencies] diff --git a/docs/behavior.md b/docs/behavior.md index 41f57b9..d4f8d65 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -108,6 +108,14 @@ Managed processes are long-running child commands. - Managed child processes inherit the ambient environment unless the process config explicitly overrides individual variables such as `env.RUST_LOG`. +- Before a process is spawned, `devloop` expands `$NAME` and `${NAME}` + references in process command arguments and configured process + environment values from its own parent environment. Use `$$` for a + literal dollar sign. +- HTTP readiness and liveness probe URLs use the same expansion when the + probe is checked. +- Missing or malformed environment references fail loudly with the + field name so the configuration error is visible. Liveness probes are checked on the configured interval while the process is running. If a liveness probe fails and the restart policy allows it, diff --git a/docs/configuration.md b/docs/configuration.md index 4986bca..513c457 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,7 +86,7 @@ command = ["cargo", "run"] cwd = "." autostart = false restart = "always" -env = { PORT = "8080" } +env = { PORT = "$CONTAINER_PORT" } output = { inherit = true, body_style = "plain" } ``` @@ -102,6 +102,41 @@ output = { inherit = true, body_style = "plain" } - `liveness`: optional liveness probe. - `output`: inherited-output behavior and output-derived state rules. +### Environment interpolation + +Process `command` arguments, process `env` values, and HTTP probe URLs +can reference variables from the parent `devloop` environment: + +```toml +[process.server] +command = ["cargo", "run"] +env = { PORT = "$CONTAINER_PORT" } + +[process.server.readiness] +kind = "http" +url = "http://127.0.0.1:$CONTAINER_PORT/" + +[process.tunnel] +command = [ + "cloudflared", + "tunnel", + "--url", + "http://127.0.0.1:$CONTAINER_PORT", +] + +[process.chromium] +command = [ + "chromium-browser", + "--remote-debugging-port=9222", + "http://localhost:$CONTAINER_PORT", +] +``` + +Supported forms are `$NAME` and `${NAME}`. Use `$$` for a literal dollar +sign. Expansion is performed by `devloop` before spawning a process or +checking an HTTP probe; it does not invoke a shell. Missing or malformed +references fail loudly with the field and variable name. + ### Output config ```toml @@ -153,7 +188,7 @@ Rule keys: ```toml [process.server.readiness] kind = "http" -url = "http://127.0.0.1:8080/" +url = "http://127.0.0.1:$CONTAINER_PORT/" interval_ms = 500 timeout_ms = 30000 ``` diff --git a/src/env_expand.rs b/src/env_expand.rs new file mode 100644 index 0000000..4784322 --- /dev/null +++ b/src/env_expand.rs @@ -0,0 +1,196 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, anyhow}; + +pub(crate) fn expand_value(value: &str, field: &str) -> Result { + let mut rendered = String::with_capacity(value.len()); + let mut chars = value.char_indices().peekable(); + + while let Some((_, ch)) = chars.next() { + if ch != '$' { + rendered.push(ch); + continue; + } + + let Some((_, next)) = chars.peek().copied() else { + return Err(anyhow!("{field} contains trailing '$'")); + }; + + if next == '$' { + chars.next(); + rendered.push('$'); + continue; + } + + let name = parse_reference_name(field, next, &mut chars)?; + let replacement = std::env::var(&name) + .with_context(|| format!("{field} references missing environment variable '{name}'"))?; + rendered.push_str(&replacement); + } + + Ok(rendered) +} + +pub(crate) fn expand_vec(values: &[String], field: &str) -> Result> { + values + .iter() + .enumerate() + .map(|(index, value)| expand_value(value, &format!("{field}[{index}]"))) + .collect() +} + +pub(crate) fn expand_map( + values: &BTreeMap, + field: &str, +) -> Result> { + values + .iter() + .map(|(key, value)| Ok((key.clone(), expand_value(value, &format!("{field}.{key}"))?))) + .collect() +} + +fn parse_reference_name( + field: &str, + first: char, + chars: &mut std::iter::Peekable>, +) -> Result { + if first == '{' { + chars.next(); + let mut name = String::new(); + let mut closed = false; + for (_, candidate) in chars.by_ref() { + if candidate == '}' { + closed = true; + break; + } + name.push(candidate); + } + if !closed { + return Err(anyhow!("{field} contains unclosed environment reference")); + } + if !is_valid_env_name(&name) { + return Err(anyhow!( + "{field} contains invalid environment variable name '{name}'" + )); + } + return Ok(name); + } + + if is_env_name_start(first) { + let mut name = String::new(); + while let Some((_, candidate)) = chars.peek().copied() { + if !is_env_name_continue(candidate) { + break; + } + chars.next(); + name.push(candidate); + } + return Ok(name); + } + + Err(anyhow!( + "{field} contains invalid environment reference '${first}'" + )) +} + +fn is_valid_env_name(name: &str) -> bool { + let mut chars = name.chars(); + chars.next().is_some_and(is_env_name_start) && chars.all(is_env_name_continue) +} + +fn is_env_name_start(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphabetic() +} + +fn is_env_name_continue(ch: char) -> bool { + is_env_name_start(ch) || ch.is_ascii_digit() +} + +#[cfg(test)] +mod tests { + use super::{expand_map, expand_value, expand_vec}; + use crate::test_support::EnvVarGuard; + use std::collections::BTreeMap; + + #[test] + fn expands_bare_environment_references() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + assert_eq!( + expand_value("http://127.0.0.1:$CONTAINER_PORT/", "probe.url").expect("expand value"), + "http://127.0.0.1:18080/" + ); + } + + #[test] + fn expands_braced_environment_references() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + assert_eq!( + expand_value("${CONTAINER_PORT}", "env.PORT").expect("expand value"), + "18080" + ); + } + + #[test] + fn keeps_literals_without_references() { + assert_eq!( + expand_value("http://127.0.0.1:8080/", "probe.url").expect("expand value"), + "http://127.0.0.1:8080/" + ); + } + + #[test] + fn escapes_literal_dollars() { + let _guard = EnvVarGuard::set("NAME", Some("value")); + + assert_eq!( + expand_value("cost=$$5 name=$NAME", "command[1]").expect("expand value"), + "cost=$5 name=value" + ); + } + + #[test] + fn reports_missing_environment_variables_with_context() { + let _guard = EnvVarGuard::set("MISSING_DEVLOOP_TEST_VAR", None); + let error = + expand_value("$MISSING_DEVLOOP_TEST_VAR", "command[1]").expect_err("missing var"); + + assert!(error.to_string().contains("command[1]")); + assert!(error.to_string().contains("MISSING_DEVLOOP_TEST_VAR")); + } + + #[test] + fn reports_malformed_references() { + let error = expand_value("${", "env.PORT").expect_err("malformed var"); + + assert!(error.to_string().contains("unclosed environment reference")); + } + + #[test] + fn expands_vectors_with_index_context() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let expanded = expand_vec( + &[ + "cloudflared".into(), + "http://127.0.0.1:$CONTAINER_PORT".into(), + ], + "command", + ) + .expect("expand vec"); + + assert_eq!(expanded, vec!["cloudflared", "http://127.0.0.1:18080"]); + } + + #[test] + fn expands_maps_with_key_context() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let expanded = expand_map( + &BTreeMap::from([("PORT".into(), "$CONTAINER_PORT".into())]), + "env", + ) + .expect("expand map"); + + assert_eq!(expanded["PORT"], "18080"); + } +} diff --git a/src/main.rs b/src/main.rs index 87cb103..d4ba9c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod browser_reload; mod config; mod core; mod engine; +mod env_expand; mod external_events; mod output; mod processes; diff --git a/src/processes.rs b/src/processes.rs index 2c81327..2545e5b 100644 --- a/src/processes.rs +++ b/src/processes.rs @@ -18,6 +18,7 @@ use crate::config::{ ProcessSpec, }; use crate::core::{ProcessEffect, ProcessSupervisor}; +use crate::env_expand; use crate::external_events::{ExternalEventEnvironment, apply_external_event_env}; use crate::output::{ dim_start, format_output_prefix_with_style, should_colorize_output, style_reset, @@ -305,8 +306,16 @@ impl<'a> ProcessManager<'a> { continue; }; let now_ms = self.clock_start.elapsed().as_millis() as u64; - let healthy = match check_probe(&self.client, &process, liveness, state).await { - Ok(()) => true, + let healthy = match expand_probe_env(&process, liveness) { + Ok(liveness) => { + match check_probe(&self.client, &process, &liveness, state).await { + Ok(()) => true, + Err(error) => { + warn!("liveness probe failed for {}: {}", process, error); + false + } + } + } Err(error) => { warn!("liveness probe failed for {}: {}", process, error); false @@ -803,6 +812,7 @@ fn configure_command( cwd: PathBuf, context: CommandContext<'_>, ) -> Result { + let command = env_expand::expand_vec(command, "command")?; let Some(program) = command.first() else { return Err(anyhow!("command must not be empty")); }; @@ -810,7 +820,7 @@ fn configure_command( let mut cmd = Command::new(program); cmd.args(&command[1..]); cmd.current_dir(cwd); - let mut full_env = context.env.clone(); + let mut full_env = env_expand::expand_map(context.env, "env")?; apply_external_event_env(&mut full_env, context.external_event_env); apply_browser_reload_env(&mut full_env, context.browser_reload_env); cmd.envs(full_env); @@ -919,24 +929,51 @@ async fn wait_for_probe( probe: &ProbeSpec, state: &SessionState, ) -> Result<()> { + let expanded_probe = expand_probe_env(name, probe)?; let started = std::time::Instant::now(); - let timeout = match probe { + let timeout = match &expanded_probe { ProbeSpec::Http { timeout_ms, .. } | ProbeSpec::StateKey { timeout_ms, .. } => { Duration::from_millis(*timeout_ms) } }; - let interval = Duration::from_millis(probe.interval()); + let interval = Duration::from_millis(expanded_probe.interval()); loop { - if check_probe(client, name, probe, state).await.is_ok() { + if check_probe(client, name, &expanded_probe, state) + .await + .is_ok() + { return Ok(()); } if started.elapsed() >= timeout { - return Err(timeout_error(name, probe)); + return Err(timeout_error(name, &expanded_probe)); } sleep(interval).await; } } +fn expand_probe_env(process: &str, probe: &ProbeSpec) -> Result { + match probe { + ProbeSpec::Http { + url, + interval_ms, + timeout_ms, + } => Ok(ProbeSpec::Http { + url: env_expand::expand_value(url, &format!("process '{process}' http probe url"))?, + interval_ms: *interval_ms, + timeout_ms: *timeout_ms, + }), + ProbeSpec::StateKey { + key, + interval_ms, + timeout_ms, + } => Ok(ProbeSpec::StateKey { + key: key.clone(), + interval_ms: *interval_ms, + timeout_ms: *timeout_ms, + }), + } +} + async fn check_probe( client: &reqwest::Client, name: &str, @@ -984,7 +1021,7 @@ fn timeout_error(name: &str, probe: &ProbeSpec) -> anyhow::Error { mod tests { use super::*; use crate::config::{OutputExtract, ProbeSpec}; - use crate::test_support::RustLogGuard; + use crate::test_support::{EnvVarGuard, RustLogGuard}; use serde_json::Value; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -1384,6 +1421,73 @@ mod tests { assert_eq!(rust_log, "info,gcp_rust_blog=debug"); } + #[test] + fn configure_command_expands_parent_env_in_command_args_and_env_values() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + let mut env = BTreeMap::new(); + env.insert("PORT".into(), "$CONTAINER_PORT".into()); + + let command = configure_command( + &[ + "cloudflared".into(), + "tunnel".into(), + "--url".into(), + "http://127.0.0.1:$CONTAINER_PORT".into(), + ], + PathBuf::from("/tmp"), + CommandContext { + env: &env, + external_event_env: None, + browser_reload_env: None, + root: Path::new("/tmp"), + state_path: Path::new("/tmp/state.json"), + changed_files: &[], + workflow: "startup", + }, + ) + .expect("configure command"); + + let args = command.as_std().get_args().collect::>(); + assert_eq!( + args, + vec![ + std::ffi::OsStr::new("tunnel"), + std::ffi::OsStr::new("--url"), + std::ffi::OsStr::new("http://127.0.0.1:18080") + ] + ); + let port = command + .as_std() + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PORT")) + .and_then(|(_, value)| value) + .expect("PORT should be set"); + assert_eq!(port, std::ffi::OsStr::new("18080")); + } + + #[test] + fn configure_command_reports_missing_env_references() { + let _guard = EnvVarGuard::set("MISSING_DEVLOOP_TEST_VAR", None); + + let error = configure_command( + &["echo".into(), "$MISSING_DEVLOOP_TEST_VAR".into()], + PathBuf::from("/tmp"), + CommandContext { + env: &BTreeMap::new(), + external_event_env: None, + browser_reload_env: None, + root: Path::new("/tmp"), + state_path: Path::new("/tmp/state.json"), + changed_files: &[], + workflow: "startup", + }, + ) + .expect_err("missing env should fail"); + + assert!(error.to_string().contains("command[1]")); + assert!(error.to_string().contains("MISSING_DEVLOOP_TEST_VAR")); + } + #[test] fn configure_command_injects_external_event_environment() { let env = BTreeMap::new(); @@ -1553,4 +1657,24 @@ mod tests { std::fs::remove_file(state_path).expect("cleanup state file"); } + + #[test] + fn expands_http_probe_urls_from_parent_env() { + let _guard = EnvVarGuard::set("CONTAINER_PORT", Some("18080")); + + let expanded = expand_probe_env( + "server", + &ProbeSpec::Http { + url: "http://127.0.0.1:$CONTAINER_PORT/".into(), + interval_ms: 100, + timeout_ms: 1000, + }, + ) + .expect("expand probe"); + + match expanded { + ProbeSpec::Http { url, .. } => assert_eq!(url, "http://127.0.0.1:18080/"), + ProbeSpec::StateKey { .. } => panic!("expected http probe"), + } + } } diff --git a/src/test_support.rs b/src/test_support.rs index 80751b5..d298d0e 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,43 +1,57 @@ use std::ffi::{OsStr, OsString}; use std::sync::{Mutex, MutexGuard, OnceLock}; -fn rust_log_lock() -> &'static Mutex<()> { +fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } -pub(crate) struct RustLogGuard { +pub(crate) struct EnvVarGuard { _lock: MutexGuard<'static, ()>, + key: &'static str, original: Option, } -impl RustLogGuard { - pub(crate) fn set(value: Option<&str>) -> Self { - let lock = rust_log_lock().lock().expect("lock RUST_LOG test mutex"); - let original = std::env::var_os("RUST_LOG"); +impl EnvVarGuard { + pub(crate) fn set(key: &'static str, value: Option<&str>) -> Self { + let lock = env_lock().lock().expect("lock test env mutex"); + let original = std::env::var_os(key); match value { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), + Some(value) => set_test_env_var(key, value), + None => remove_test_env_var(key), } Self { _lock: lock, + key, original, } } } -impl Drop for RustLogGuard { +impl Drop for EnvVarGuard { fn drop(&mut self) { match &self.original { - Some(value) => set_test_env_var("RUST_LOG", value), - None => remove_test_env_var("RUST_LOG"), + Some(value) => set_test_env_var(self.key, value), + None => remove_test_env_var(self.key), + } + } +} + +pub(crate) struct RustLogGuard { + _guard: EnvVarGuard, +} + +impl RustLogGuard { + pub(crate) fn set(value: Option<&str>) -> Self { + Self { + _guard: EnvVarGuard::set("RUST_LOG", value), } } } fn set_test_env_var(key: &str, value: impl AsRef) { - // SAFETY: all test-time RUST_LOG mutation goes through the shared - // `rust_log_lock`, so no concurrent unit test can race on this + // SAFETY: all test-time environment mutation goes through the shared + // `env_lock`, so no concurrent unit test can race on this // process-global environment state. unsafe { std::env::set_var(key, value); @@ -45,8 +59,8 @@ fn set_test_env_var(key: &str, value: impl AsRef) { } fn remove_test_env_var(key: &str) { - // SAFETY: all test-time RUST_LOG mutation goes through the shared - // `rust_log_lock`, so removing the variable cannot race with another + // SAFETY: all test-time environment mutation goes through the shared + // `env_lock`, so removing the variable cannot race with another // unit test in this process. unsafe { std::env::remove_var(key);