Skip to main content

polydat_core/library/
context.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Context state nodes: non-deterministic, session-scoped values.
5//!
6//! These nodes produce values from the execution environment rather
7//! than the coordinate space. They break the deterministic model
8//! and should be used deliberately.
9//!
10//! SRD-80b Phase E migration. All authoring goes through
11//! `#[polydat_node]`. Three shapes appear here:
12//!
13//! * Pure clock / OS reads (`current_epoch_millis`, `thread_id`) —
14//!   plain body, marked `Nondeterministic`.
15//! * Construction-frozen captures (`tmp_dir`, `env_or`) — use
16//!   `#[poly_const(setup_fn, from = ())]` (or `from = <const_arg>`
17//!   when the capture depends on a const) to compute the cached
18//!   value once at construction. The body just reads the cache.
19//! * Fallible construction (`env`) — body returns
20//!   `Result<String, String>`. The macro emits `try_new` and
21//!   propagates `Err` as a workload-compile error via the build
22//!   closure.
23
24use std::sync::atomic::{AtomicU64, Ordering};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27/// Current wall-clock time in epoch milliseconds.
28///
29/// Signature: `() -> (u64)`. Non-deterministic — clock read per eval.
30#[crate::polydat_node(
31    category = Context,
32    purity = Nondeterministic("reads system clock"),
33)]
34fn current_epoch_millis() -> u64 {
35    SystemTime::now()
36        .duration_since(UNIX_EPOCH)
37        .unwrap()
38        .as_millis() as u64
39}
40
41/// Current OS thread numeric identifier.
42///
43/// Signature: `() -> (u64)`. Non-deterministic — value depends on
44/// the scheduling thread.
45#[crate::polydat_node(
46    category = Context,
47    purity = Nondeterministic("OS thread identity varies across fibers"),
48)]
49fn thread_id() -> u64 {
50    thread_local! {
51        // `ThreadId` is opaque; the numeric id is extracted once per
52        // thread via the Debug formatter (`ThreadId(N)`).
53        static THREAD_ID: u64 = {
54            let id = std::thread::current().id();
55            let id_str = format!("{id:?}");
56            let num = id_str.trim_start_matches("ThreadId(").trim_end_matches(')');
57            num.parse().unwrap_or(0)
58        };
59    }
60    THREAD_ID.with(|id| *id)
61}
62
63/// Environment variable read, frozen at construction.
64///
65/// Signature: `env(name: const str) -> str`. Reads the named env
66/// var once at workload-compile time; the captured value is
67/// returned on every eval. Errors at construction when the
68/// variable is unset — use `env_or` for a defaulted form.
69///
70/// SRD-80b Phase E: fallible construction. The body returns
71/// `Result<String, String>`; the macro runs it once inside
72/// `try_new`, caches the Ok value, and propagates Err as a
73/// build-time error.
74#[crate::polydat_node(category = Context)]
75fn env(name: Const<&str>) -> Result<String, String> {
76    let var = name.0;
77    std::env::var(var).map_err(|_| {
78        format!(
79            "env('{var}'): environment variable not set; \
80         use env_or('{var}', '<default>') if a fallback is acceptable",
81        )
82    })
83}
84
85/// Environment variable read with default, frozen at construction.
86///
87/// Signature: `env_or(name: const str, default: const str) -> str`.
88/// Reads the named env var at construction; falls back to the
89/// literal `default` when the variable is unset. The captured
90/// value is constant for the session.
91#[crate::polydat_node(category = Context)]
92fn env_or(
93    name: Const<&str>,
94    default: Const<&str>,
95    #[poly_const(capture_env_opt, from = name)] captured: &Option<String>,
96) -> String {
97    match captured {
98        Some(v) => v.clone(),
99        None => default.0.to_string(),
100    }
101}
102
103/// Setup helper for `env_or`: read the env var into `Option<String>`.
104/// `None` indicates the var is unset; the body picks the default.
105fn capture_env_opt(name: &str) -> Option<String> {
106    std::env::var(name).ok()
107}
108
109/// System temp directory, frozen at construction.
110///
111/// Signature: `tmp_dir() -> str`.
112#[crate::polydat_node(category = Context)]
113fn tmp_dir(#[poly_const(capture_tmp_dir, from = ())] path: &String) -> String {
114    path.clone()
115}
116
117/// Setup helper for `tmp_dir`: capture `std::env::temp_dir()` as
118/// a UTF-8 string. Falls back to `/tmp` on non-UTF-8 paths
119/// (extremely rare on modern systems).
120fn capture_tmp_dir() -> String {
121    std::env::temp_dir()
122        .to_str()
123        .map(String::from)
124        .unwrap_or_else(|| "/tmp".to_string())
125}
126
127/// Monotonic counter (non-deterministic). SRD-80 PR B.11 migration.
128///
129/// Returns 0, 1, 2, ... across all calls. Thread-safe via AtomicU64.
130#[crate::polydat_node(
131    category = Context,
132    purity = Nondeterministic("monotonic counter incremented per call"),
133)]
134fn counter(
135    #[poly_default(0u64)] start: Const<u64>,
136    #[poly_const(AtomicU64::new, from = start)] count: &AtomicU64,
137) -> u64 {
138    count.fetch_add(1, Ordering::Relaxed)
139}
140
141/// Cursor limit: passes the input value through unchanged.
142///
143/// The compiler inserts this node when a cursor carries a `limit`,
144/// shadowing the cursor's ordinal wire with it. The clamp itself is
145/// the cursor system's, which reads `max_items` from this node's
146/// const slot; the node exists so that the clamp is visible in the
147/// graph rather than applied invisibly beside it.
148///
149/// Signature: `limit(input: u64, max_items: u64) -> u64`
150#[crate::polydat_node(category = Context)]
151fn limit(input: u64, max_items: Const<u64>) -> u64 {
152    let _ = max_items;
153    input
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::ast::{PolydatNode, Value};
160
161    #[test]
162    fn current_epoch_millis_reasonable() {
163        let node = CurrentEpochMillis::new();
164        let mut out = [Value::None];
165        node.eval(&[], &mut out);
166        let millis = out[0].as_u64();
167        // Should be after 2024-01-01 (1704067200000)
168        assert!(millis > 1_704_067_200_000);
169    }
170
171    #[test]
172    fn counter_increments() {
173        let node = Counter::new(0);
174        let mut out = [Value::None];
175        node.eval(&[], &mut out);
176        assert_eq!(out[0].as_u64(), 0);
177        node.eval(&[], &mut out);
178        assert_eq!(out[0].as_u64(), 1);
179        node.eval(&[], &mut out);
180        assert_eq!(out[0].as_u64(), 2);
181    }
182
183    #[test]
184    fn counter_starting_at() {
185        let node = Counter::new(100);
186        let mut out = [Value::None];
187        node.eval(&[], &mut out);
188        assert_eq!(out[0].as_u64(), 100);
189        node.eval(&[], &mut out);
190        assert_eq!(out[0].as_u64(), 101);
191    }
192
193    /// Generate a unique env-var name per test so concurrent test
194    /// threads can't collide on the same key. The process env is
195    /// global state; using fixed names like `TEST_VAR` makes
196    /// tests order-dependent.
197    fn unique_var(tag: &str) -> String {
198        use std::time::{SystemTime, UNIX_EPOCH};
199        let nanos = SystemTime::now()
200            .duration_since(UNIX_EPOCH)
201            .unwrap()
202            .as_nanos();
203        format!("__NBRS_TEST_{tag}_{nanos:x}")
204    }
205
206    #[test]
207    fn env_captures_value_at_construction() {
208        let var = unique_var("ENV");
209        unsafe {
210            std::env::set_var(&var, "captured-value");
211        }
212        let node = Env::try_new(var.clone()).expect("env should read the set var");
213        // Mutating the env after construction must NOT change the
214        // node's output — the value is frozen at construction.
215        unsafe {
216            std::env::set_var(&var, "later-value");
217        }
218        let mut out = [Value::None];
219        node.eval(&[], &mut out);
220        assert_eq!(out[0].as_str().to_string(), "captured-value");
221        unsafe {
222            std::env::remove_var(&var);
223        }
224    }
225
226    #[test]
227    fn env_errors_when_var_unset() {
228        let var = unique_var("ENV_MISSING");
229        unsafe {
230            std::env::remove_var(&var);
231        }
232        match Env::try_new(var.clone()) {
233            Ok(_) => panic!("Env::try_new should fail when the var is unset"),
234            Err(err) => {
235                assert!(
236                    err.contains(&var),
237                    "error should name the missing var: {err}"
238                );
239                assert!(
240                    err.contains("env_or"),
241                    "error should suggest env_or as the defaulted alternative: {err}"
242                );
243            }
244        }
245    }
246
247    #[test]
248    fn env_or_uses_default_when_var_unset() {
249        let var = unique_var("ENV_OR_MISSING");
250        unsafe {
251            std::env::remove_var(&var);
252        }
253        let node = EnvOr::new(var.clone(), "fallback".to_string());
254        let mut out = [Value::None];
255        node.eval(&[], &mut out);
256        assert_eq!(out[0].as_str().to_string(), "fallback");
257    }
258
259    #[test]
260    fn env_or_uses_var_value_when_set() {
261        let var = unique_var("ENV_OR_SET");
262        unsafe {
263            std::env::set_var(&var, "real-value");
264        }
265        let node = EnvOr::new(var.clone(), "fallback".to_string());
266        let mut out = [Value::None];
267        node.eval(&[], &mut out);
268        assert_eq!(out[0].as_str().to_string(), "real-value");
269        unsafe {
270            std::env::remove_var(&var);
271        }
272    }
273
274    #[test]
275    fn env_or_captures_at_construction_not_each_eval() {
276        let var = unique_var("ENV_OR_FROZEN");
277        unsafe {
278            std::env::set_var(&var, "first");
279        }
280        let node = EnvOr::new(var.clone(), "ignored-default".to_string());
281        unsafe {
282            std::env::set_var(&var, "second");
283        }
284        let mut out = [Value::None];
285        node.eval(&[], &mut out);
286        assert_eq!(
287            out[0].as_str().to_string(),
288            "first",
289            "env_or must freeze its value at construction; later env mutations are invisible"
290        );
291        unsafe {
292            std::env::remove_var(&var);
293        }
294    }
295
296    #[test]
297    fn tmp_dir_returns_a_path() {
298        let node = TmpDir::new();
299        let mut out = [Value::None];
300        node.eval(&[], &mut out);
301        let s = out[0].as_str().to_string();
302        assert!(!s.is_empty(), "tmp_dir() should produce a non-empty path");
303    }
304
305    #[test]
306    fn tmp_dir_is_stable_across_evals() {
307        let node = TmpDir::new();
308        let mut a = [Value::None];
309        let mut b = [Value::None];
310        node.eval(&[], &mut a);
311        node.eval(&[], &mut b);
312        assert_eq!(a[0].as_str(), b[0].as_str());
313    }
314
315    /// DSL-level integration: env_or / tmp_dir resolve through the
316    /// registry and produce kernels that compile cleanly.
317    #[test]
318    fn env_or_compiles_through_dsl() {
319        let var = unique_var("DSL_ENV_OR");
320        unsafe {
321            std::env::set_var(&var, "x-value");
322        }
323        let src = format!("v := env_or(\"{var}\", \"fallback\")\n",);
324        let kernel = crate::dsl::compile_polydat_interpreter(&src).expect("compile env_or");
325        unsafe {
326            std::env::remove_var(&var);
327        }
328        // The output should be the captured value. We can't read
329        // the kernel's outputs directly without an eval pass; the
330        // shape check (compiled cleanly, registered in DSL) is
331        // what this test asserts.
332        let names = kernel.program().output_names();
333        assert!(names.contains(&"v"), "expected output 'v' in {names:?}");
334    }
335
336    #[test]
337    fn tmp_dir_compiles_through_dsl_in_string_template() {
338        // Confirms the existing string-template machinery accepts
339        // function calls like `{tmp_dir()}` in Polydat string literals
340        // — no new syntax needed for the resumable-test-fixture
341        // workload's path composition.
342        let src = "path := \"{tmp_dir()}/data\"\n";
343        let kernel = crate::dsl::compile_polydat_interpreter(src)
344            .expect("compile tmp_dir() interpolated in a string");
345        let names = kernel.program().output_names();
346        assert!(
347            names.contains(&"path"),
348            "expected output 'path' in {names:?}"
349        );
350    }
351}