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