Skip to main content

runmat_runtime/builtins/io/repl_fs/
setenv.rs

1//! MATLAB-compatible `setenv` builtin for RunMat.
2
3use std::any::Any;
4use std::env;
5use std::panic;
6
7use runmat_builtins::{CharArray, Value};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::spec::{
11    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12    ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
15
16const ERR_TOO_FEW_INPUTS: &str = "setenv: not enough input arguments";
17const ERR_TOO_MANY_INPUTS: &str = "setenv: too many input arguments";
18const ERR_NAME_TYPE: &str = "setenv: NAME must be a string scalar or character vector";
19const ERR_VALUE_TYPE: &str = "setenv: VALUE must be a string scalar or character vector";
20
21const MESSAGE_EMPTY_NAME: &str = "Environment variable name must not be empty.";
22const MESSAGE_NAME_HAS_EQUAL: &str = "Environment variable names must not contain '='.";
23const MESSAGE_NAME_HAS_NULL: &str = "Environment variable names must not contain null characters.";
24const MESSAGE_VALUE_HAS_NULL: &str =
25    "Environment variable values must not contain null characters.";
26const MESSAGE_OPERATION_FAILED: &str = "Unable to update environment variable: ";
27
28#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::setenv")]
29pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
30    name: "setenv",
31    op_kind: GpuOpKind::Custom("io"),
32    supported_precisions: &[],
33    broadcast: BroadcastSemantics::None,
34    provider_hooks: &[],
35    constant_strategy: ConstantStrategy::InlineLiteral,
36    residency: ResidencyPolicy::GatherImmediately,
37    nan_mode: ReductionNaN::Include,
38    two_pass_threshold: None,
39    workgroup_size: None,
40    accepts_nan_mode: false,
41    notes:
42        "Host-only environment mutation. GPU-resident arguments are gathered automatically before invoking the OS APIs.",
43};
44
45#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::setenv")]
46pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
47    name: "setenv",
48    shape: ShapeRequirements::Any,
49    constant_strategy: ConstantStrategy::InlineLiteral,
50    elementwise: None,
51    reduction: None,
52    emits_nan: false,
53    notes: "Environment updates terminate fusion; metadata registered for completeness.",
54};
55
56const BUILTIN_NAME: &str = "setenv";
57
58fn setenv_error(message: impl Into<String>) -> RuntimeError {
59    build_runtime_error(message)
60        .with_builtin(BUILTIN_NAME)
61        .build()
62}
63
64fn map_control_flow(err: RuntimeError) -> RuntimeError {
65    let identifier = err.identifier().map(str::to_string);
66    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
67        .with_builtin(BUILTIN_NAME)
68        .with_source(err);
69    if let Some(identifier) = identifier {
70        builder = builder.with_identifier(identifier);
71    }
72    builder.build()
73}
74
75#[runtime_builtin(
76    name = "setenv",
77    category = "io/repl_fs",
78    summary = "Set or clear environment variables with MATLAB-compatible status outputs.",
79    keywords = "setenv,environment variable,status,message,unset",
80    accel = "cpu",
81    suppress_auto_output = true,
82    type_resolver(crate::builtins::io::type_resolvers::setenv_type),
83    builtin_path = "crate::builtins::io::repl_fs::setenv"
84)]
85async fn setenv_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
86    let eval = evaluate(&args).await?;
87    if let Some(out_count) = crate::output_count::current_output_count() {
88        if out_count == 0 {
89            return Ok(Value::OutputList(Vec::new()));
90        }
91        return Ok(crate::output_count::output_list_with_padding(
92            out_count,
93            eval.outputs(),
94        ));
95    }
96    Ok(eval.first_output())
97}
98
99/// Evaluate `setenv` once and expose both outputs.
100pub async fn evaluate(args: &[Value]) -> BuiltinResult<SetenvResult> {
101    let gathered = gather_arguments(args).await?;
102    match gathered.len() {
103        0 | 1 => Err(setenv_error(ERR_TOO_FEW_INPUTS)),
104        2 => apply(&gathered[0], &gathered[1]),
105        _ => Err(setenv_error(ERR_TOO_MANY_INPUTS)),
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct SetenvResult {
111    status: f64,
112    message: String,
113}
114
115impl SetenvResult {
116    fn success() -> Self {
117        Self {
118            status: 0.0,
119            message: String::new(),
120        }
121    }
122
123    fn failure(message: String) -> Self {
124        Self {
125            status: 1.0,
126            message,
127        }
128    }
129
130    pub fn first_output(&self) -> Value {
131        Value::Num(self.status)
132    }
133
134    pub fn outputs(&self) -> Vec<Value> {
135        vec![Value::Num(self.status), char_array_value(&self.message)]
136    }
137
138    #[cfg(test)]
139    pub(crate) fn status(&self) -> f64 {
140        self.status
141    }
142
143    #[cfg(test)]
144    pub(crate) fn message(&self) -> &str {
145        &self.message
146    }
147}
148
149fn apply(name_value: &Value, value_value: &Value) -> BuiltinResult<SetenvResult> {
150    let name = extract_scalar_text(name_value, ERR_NAME_TYPE)?;
151    let value = extract_scalar_text(value_value, ERR_VALUE_TYPE)?;
152
153    if name.is_empty() {
154        return Ok(SetenvResult::failure(MESSAGE_EMPTY_NAME.to_string()));
155    }
156    if name.chars().any(|ch| ch == '=') {
157        return Ok(SetenvResult::failure(MESSAGE_NAME_HAS_EQUAL.to_string()));
158    }
159    if name.chars().any(|ch| ch == '\0') {
160        return Ok(SetenvResult::failure(MESSAGE_NAME_HAS_NULL.to_string()));
161    }
162    if value.chars().any(|ch| ch == '\0') {
163        return Ok(SetenvResult::failure(MESSAGE_VALUE_HAS_NULL.to_string()));
164    }
165
166    Ok(update_environment(&name, &value))
167}
168
169fn update_environment(name: &str, value: &str) -> SetenvResult {
170    if value.is_empty() {
171        match panic::catch_unwind(|| env::remove_var(name)) {
172            Ok(()) => SetenvResult::success(),
173            Err(payload) => SetenvResult::failure(format!(
174                "{}{}",
175                MESSAGE_OPERATION_FAILED,
176                panic_payload_to_string(payload)
177            )),
178        }
179    } else {
180        match panic::catch_unwind(|| env::set_var(name, value)) {
181            Ok(()) => SetenvResult::success(),
182            Err(payload) => SetenvResult::failure(format!(
183                "{}{}",
184                MESSAGE_OPERATION_FAILED,
185                panic_payload_to_string(payload)
186            )),
187        }
188    }
189}
190
191async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
192    let mut out = Vec::with_capacity(args.len());
193    for value in args {
194        out.push(
195            gather_if_needed_async(value)
196                .await
197                .map_err(map_control_flow)?,
198        );
199    }
200    Ok(out)
201}
202
203fn extract_scalar_text(value: &Value, error_message: &str) -> BuiltinResult<String> {
204    match value {
205        Value::String(text) => Ok(text.clone()),
206        Value::CharArray(array) => {
207            if array.rows != 1 {
208                return Err(setenv_error(error_message));
209            }
210            Ok(char_row_to_string(array))
211        }
212        Value::StringArray(array) => {
213            if array.data.len() == 1 {
214                Ok(array.data[0].clone())
215            } else {
216                Err(setenv_error(error_message))
217            }
218        }
219        _ => Err(setenv_error(error_message)),
220    }
221}
222
223fn char_row_to_string(array: &CharArray) -> String {
224    if array.cols == 0 {
225        return String::new();
226    }
227    let mut text = String::with_capacity(array.cols);
228    for col in 0..array.cols {
229        text.push(array.data[col]);
230    }
231    while text.ends_with(' ') {
232        text.pop();
233    }
234    text
235}
236
237fn char_array_value(text: &str) -> Value {
238    Value::CharArray(CharArray::new_row(text))
239}
240
241fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
242    match payload.downcast::<String>() {
243        Ok(msg) => *msg,
244        Err(payload) => match payload.downcast::<&'static str>() {
245            Ok(msg) => (*msg).to_string(),
246            Err(_) => "operation failed".to_string(),
247        },
248    }
249}
250
251#[cfg(test)]
252pub(crate) mod tests {
253    use super::*;
254    use crate::builtins::io::repl_fs::REPL_FS_TEST_LOCK;
255    use runmat_builtins::{CharArray, StringArray, Value};
256
257    fn setenv_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
258        futures::executor::block_on(super::setenv_builtin(args))
259    }
260
261    fn evaluate(args: &[Value]) -> BuiltinResult<SetenvResult> {
262        futures::executor::block_on(super::evaluate(args))
263    }
264
265    fn unique_name(suffix: &str) -> String {
266        format!("RUNMAT_TEST_SETENV_{}", suffix)
267    }
268
269    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
270    #[test]
271    fn setenv_sets_variable_and_returns_success() {
272        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
273        let name = unique_name("BASIC");
274        env::remove_var(&name);
275
276        let result = setenv_builtin(vec![
277            Value::String(name.clone()),
278            Value::String("value".to_string()),
279        ])
280        .expect("setenv");
281
282        assert_eq!(result, Value::Num(0.0));
283        assert_eq!(env::var(&name).unwrap(), "value");
284        env::remove_var(name);
285    }
286
287    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
288    #[test]
289    fn setenv_removes_variable_when_value_is_empty() {
290        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
291        let name = unique_name("REMOVE");
292        env::set_var(&name, "seed");
293
294        let result = setenv_builtin(vec![
295            Value::String(name.clone()),
296            Value::CharArray(CharArray::new_row("")),
297        ])
298        .expect("setenv");
299
300        assert_eq!(result, Value::Num(0.0));
301        assert!(env::var(&name).is_err());
302        env::remove_var(name);
303    }
304
305    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
306    #[test]
307    fn setenv_reports_failure_for_illegal_name() {
308        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
309        let eval = evaluate(&[
310            Value::String("INVALID=NAME".to_string()),
311            Value::String("value".to_string()),
312        ])
313        .expect("evaluate");
314
315        assert_eq!(eval.status(), 1.0);
316        assert_eq!(eval.message(), MESSAGE_NAME_HAS_EQUAL);
317    }
318
319    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
320    #[test]
321    fn setenv_reports_failure_for_empty_name() {
322        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
323        let eval = evaluate(&[
324            Value::String(String::new()),
325            Value::String("value".to_string()),
326        ])
327        .expect("evaluate");
328
329        assert_eq!(eval.status(), 1.0);
330        assert_eq!(eval.message(), MESSAGE_EMPTY_NAME);
331    }
332
333    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
334    #[test]
335    fn setenv_reports_failure_for_null_in_name() {
336        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
337        let eval = evaluate(&[
338            Value::String("BAD\0NAME".to_string()),
339            Value::String("value".to_string()),
340        ])
341        .expect("evaluate");
342
343        assert_eq!(eval.status(), 1.0);
344        assert_eq!(eval.message(), MESSAGE_NAME_HAS_NULL);
345    }
346
347    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
348    #[test]
349    fn setenv_reports_failure_for_null_in_value() {
350        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
351        let eval = evaluate(&[
352            Value::String("RUNMAT_NULL_VALUE".to_string()),
353            Value::String("abc\0def".to_string()),
354        ])
355        .expect("evaluate");
356
357        assert_eq!(eval.status(), 1.0);
358        assert_eq!(eval.message(), MESSAGE_VALUE_HAS_NULL);
359    }
360
361    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
362    #[test]
363    fn setenv_errors_when_name_is_not_text() {
364        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
365        let err =
366            setenv_builtin(vec![Value::Num(5.0), Value::String("value".to_string())]).unwrap_err();
367        assert_eq!(err.message(), ERR_NAME_TYPE);
368    }
369
370    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
371    #[test]
372    fn setenv_errors_when_value_is_not_text() {
373        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
374        let err = setenv_builtin(vec![
375            Value::String("RUNMAT_INVALID_VALUE".to_string()),
376            Value::Num(1.0),
377        ])
378        .unwrap_err();
379        assert_eq!(err.message(), ERR_VALUE_TYPE);
380    }
381
382    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
383    #[test]
384    fn setenv_accepts_scalar_string_array_arguments() {
385        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
386        let name = unique_name("STRING_ARRAY");
387        env::remove_var(&name);
388
389        let name_array =
390            StringArray::new(vec![name.clone()], vec![1]).expect("scalar string array name");
391        let value_array =
392            StringArray::new(vec!["VALUE".to_string()], vec![1]).expect("scalar string array");
393
394        let status = setenv_builtin(vec![
395            Value::StringArray(name_array),
396            Value::StringArray(value_array),
397        ])
398        .expect("setenv");
399
400        assert_eq!(status, Value::Num(0.0));
401        assert_eq!(env::var(&name).unwrap(), "VALUE");
402        env::remove_var(name);
403    }
404
405    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
406    #[test]
407    fn setenv_errors_for_string_array_with_multiple_elements() {
408        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
409        let array =
410            StringArray::new(vec!["A".to_string(), "B".to_string()], vec![2]).expect("array");
411        let err = setenv_builtin(vec![
412            Value::StringArray(array),
413            Value::String("value".to_string()),
414        ])
415        .unwrap_err();
416        assert_eq!(err.message(), ERR_NAME_TYPE);
417    }
418
419    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
420    #[test]
421    fn setenv_errors_for_char_array_with_multiple_rows() {
422        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
423        let array = CharArray::new(vec!['R', 'M'], 2, 1).expect("two-row char array");
424        let err = setenv_builtin(vec![
425            Value::CharArray(array),
426            Value::String("value".to_string()),
427        ])
428        .unwrap_err();
429        assert_eq!(err.message(), ERR_NAME_TYPE);
430    }
431
432    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
433    #[test]
434    fn setenv_char_array_input_trims_padding() {
435        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
436        let chars = vec!['F', 'O', 'O', ' '];
437        let array = CharArray::new(chars, 1, 4).unwrap();
438        let result = extract_scalar_text(&Value::CharArray(array), ERR_NAME_TYPE).unwrap();
439        assert_eq!(result, "FOO");
440    }
441
442    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
443    #[test]
444    fn setenv_outputs_success_message_is_empty_char_array() {
445        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
446        let name = unique_name("SUCCESS_MSG");
447        env::remove_var(&name);
448
449        let eval = evaluate(&[
450            Value::String(name.clone()),
451            Value::String("value".to_string()),
452        ])
453        .expect("evaluate");
454        let outputs = eval.outputs();
455        assert_eq!(outputs.len(), 2);
456        match &outputs[1] {
457            Value::CharArray(ca) => {
458                assert_eq!(ca.rows, 1);
459                assert_eq!(ca.cols, 0);
460            }
461            other => panic!("expected empty CharArray, got {other:?}"),
462        }
463
464        env::remove_var(name);
465    }
466
467    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
468    #[test]
469    fn setenv_outputs_return_status_and_message() {
470        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
471        let eval = evaluate(&[
472            Value::String("INVALID=NAME".to_string()),
473            Value::String("value".to_string()),
474        ])
475        .expect("evaluate");
476
477        let outputs = eval.outputs();
478        assert_eq!(outputs.len(), 2);
479        assert!(matches!(outputs[0], Value::Num(1.0)));
480        match &outputs[1] {
481            Value::CharArray(ca) => {
482                assert_eq!(ca.rows, 1);
483                let text: String = ca.data.iter().collect();
484                assert_eq!(text, MESSAGE_NAME_HAS_EQUAL);
485            }
486            other => panic!("expected CharArray message, got {other:?}"),
487        }
488    }
489}