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};
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18const ERR_TOO_FEW_INPUTS: &str = "setenv: not enough input arguments";
19const ERR_TOO_MANY_INPUTS: &str = "setenv: too many input arguments";
20const ERR_NAME_TYPE: &str = "setenv: NAME must be a string scalar or character vector";
21const ERR_VALUE_TYPE: &str = "setenv: VALUE must be a string scalar or character vector";
22
23const MESSAGE_EMPTY_NAME: &str = "Environment variable name must not be empty.";
24const MESSAGE_NAME_HAS_EQUAL: &str = "Environment variable names must not contain '='.";
25const MESSAGE_NAME_HAS_NULL: &str = "Environment variable names must not contain null characters.";
26const MESSAGE_VALUE_HAS_NULL: &str =
27    "Environment variable values must not contain null characters.";
28const MESSAGE_OPERATION_FAILED: &str = "Unable to update environment variable: ";
29
30#[cfg(feature = "doc_export")]
31pub const DOC_MD: &str = r#"---
32title: "setenv"
33category: "io/repl_fs"
34keywords: ["setenv", "environment variable", "status", "message", "unset"]
35summary: "Set or clear environment variables with MATLAB-compatible status outputs."
36references:
37  - https://www.mathworks.com/help/matlab/ref/setenv.html
38gpu_support:
39  elementwise: false
40  reduction: false
41  precisions: []
42  broadcasting: "none"
43  notes: "Host-only operation. RunMat gathers GPU-resident inputs before mutating the process environment; providers do not expose hooks for this builtin."
44fusion:
45  elementwise: false
46  reduction: false
47  max_inputs: 2
48  constants: "inline"
49requires_feature: null
50tested:
51  unit: "builtins::io::repl_fs::setenv::tests"
52  integration: "builtins::io::repl_fs::setenv::tests::setenv_reports_failure_for_illegal_name"
53---
54
55# What does the `setenv` function do in MATLAB / RunMat?
56`setenv` updates the process environment. Provide a variable name and value to create or modify an
57entry, or pass an empty value to remove the variable. The builtin mirrors MATLAB by returning a
58status code and optional diagnostic message instead of throwing for platform-defined failures.
59
60## How does the `setenv` function behave in MATLAB / RunMat?
61- `status = setenv(name, value)` returns `0` when the update succeeds and `1` when the operating
62  system rejects the request. The status output is a double scalar, matching MATLAB.
63- `[status, message] = setenv(name, value)` returns the status plus a character vector describing
64  failures. On success, `message` is an empty `1×0` character array.
65- Set `value` to an empty string (`""`) or empty character vector (`''`) to remove the variable from
66  the current process environment.
67- Names must be string scalars or character vectors containing only valid environment variable
68  characters. MATLAB raises an error when `name` is not text; RunMat mirrors this check.
69- Character vector inputs trim trailing padding spaces (common with MATLAB character matrices). To
70  retain trailing spaces, pass a string scalar instead.
71- Environment updates apply to the RunMat process and any child processes it spawns. They do not
72  modify the parent shell.
73
74## `setenv` Function GPU Execution Behaviour
75`setenv` always runs on the CPU. If a caller stores the arguments on the GPU—for instance via an
76accelerated string builtin—RunMat gathers them to host memory automatically before mutating the
77environment. Acceleration providers do not implement hooks for this builtin.
78
79## GPU residency in RunMat (Do I need `gpuArray`?)
80No. `setenv` is a host-side operation. GPU residency offers no benefit, and RunMat gathers
81GPU-backed values automatically if they appear as inputs.
82
83## Examples of using the `setenv` function in MATLAB / RunMat
84
85### Set a new environment variable for the current session
86```matlab
87status = setenv("RUNMAT_MODE", "development")
88```
89Expected output:
90```matlab
91status =
92     0
93```
94
95### Update an existing environment variable
96```matlab
97status = setenv("PATH", string(getenv("PATH")) + ":~/runmat/bin")
98```
99Expected output:
100```matlab
101status =
102     0
103```
104
105### Remove an environment variable with an empty value
106```matlab
107[status, message] = setenv("OLD_SETTING", "")
108```
109Expected output:
110```matlab
111status =
112     0
113
114message =
115
116```
117
118### Capture diagnostic messages when a name is invalid
119```matlab
120[status, message] = setenv("INVALID=NAME", "value")
121```
122Expected output:
123```matlab
124status =
125     1
126
127message =
128Environment variable names must not contain '='.
129```
130
131### Use character vectors from legacy code
132```matlab
133status = setenv('RUNMAT_LEGACY', 'enabled')
134```
135Expected output:
136```matlab
137status =
138     0
139```
140
141### Combine `setenv` with child process launches
142```matlab
143setenv("RUNMAT_DATASET", "demo");
144status = system("runmat-cli process-data")
145```
146Expected output:
147```matlab
148status =
149     0
150```
151
152## FAQ
153- **What status codes does `setenv` return?** `0` means success; `1` means the operating system
154  rejected the request (for example, due to an invalid name or an oversized value on Windows).
155- **Does `setenv` throw errors?** Only when the inputs are the wrong type (non-text). Platform
156  failures are reported through the status and message outputs so scripts can handle them
157  programmatically.
158- **How do I remove a variable?** Pass an empty string or empty character vector as the value.
159- **Are names case-sensitive?** RunMat defers to the operating system: case-sensitive on
160  Unix-like systems and case-insensitive on Windows.
161- **Can I include trailing spaces in the value?** Use string scalars to preserve trailing spaces.
162  Character vector inputs trim trailing padding spaces by design.
163- **Does `setenv` affect the parent shell?** No. Changes are limited to the current RunMat process
164  and any child processes launched afterwards.
165- **What characters are disallowed in names?** `setenv` rejects names containing `=` or null
166  characters. Additional platform-specific restrictions are enforced by the operating system and
167  reported through the status/message outputs.
168- **Can I call `setenv` from GPU-enabled code?** Yes. Arguments are gathered from the GPU before
169  updating the environment; the operation itself always runs on the CPU.
170- **How can I check whether the update succeeded?** Inspect the returned `status`. When it is `1`,
171  read the accompanying message to determine why the operation failed.
172- **Will the variable persist after I exit RunMat?** No. Environment modifications are scoped to the
173  current process.
174
175## See Also
176[getenv](./getenv), [mkdir](./mkdir), [pwd](./pwd)
177
178## Source & Feedback
179- Source: [`crates/runmat-runtime/src/builtins/io/repl_fs/setenv.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/repl_fs/setenv.rs)
180- Issues: [Open a GitHub ticket](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
181"#;
182
183pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
184    name: "setenv",
185    op_kind: GpuOpKind::Custom("io"),
186    supported_precisions: &[],
187    broadcast: BroadcastSemantics::None,
188    provider_hooks: &[],
189    constant_strategy: ConstantStrategy::InlineLiteral,
190    residency: ResidencyPolicy::GatherImmediately,
191    nan_mode: ReductionNaN::Include,
192    two_pass_threshold: None,
193    workgroup_size: None,
194    accepts_nan_mode: false,
195    notes:
196        "Host-only environment mutation. GPU-resident arguments are gathered automatically before invoking the OS APIs.",
197};
198
199register_builtin_gpu_spec!(GPU_SPEC);
200
201pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
202    name: "setenv",
203    shape: ShapeRequirements::Any,
204    constant_strategy: ConstantStrategy::InlineLiteral,
205    elementwise: None,
206    reduction: None,
207    emits_nan: false,
208    notes: "Environment updates terminate fusion; metadata registered for completeness.",
209};
210
211register_builtin_fusion_spec!(FUSION_SPEC);
212
213#[cfg(feature = "doc_export")]
214register_builtin_doc_text!("setenv", DOC_MD);
215
216#[runtime_builtin(
217    name = "setenv",
218    category = "io/repl_fs",
219    summary = "Set or clear environment variables with MATLAB-compatible status outputs.",
220    keywords = "setenv,environment variable,status,message,unset",
221    accel = "cpu"
222)]
223fn setenv_builtin(args: Vec<Value>) -> Result<Value, String> {
224    let eval = evaluate(&args)?;
225    Ok(eval.first_output())
226}
227
228/// Evaluate `setenv` once and expose both outputs.
229pub fn evaluate(args: &[Value]) -> Result<SetenvResult, String> {
230    let gathered = gather_arguments(args)?;
231    match gathered.len() {
232        0 | 1 => Err(ERR_TOO_FEW_INPUTS.to_string()),
233        2 => apply(&gathered[0], &gathered[1]),
234        _ => Err(ERR_TOO_MANY_INPUTS.to_string()),
235    }
236}
237
238#[derive(Debug, Clone)]
239pub struct SetenvResult {
240    status: f64,
241    message: String,
242}
243
244impl SetenvResult {
245    fn success() -> Self {
246        Self {
247            status: 0.0,
248            message: String::new(),
249        }
250    }
251
252    fn failure(message: String) -> Self {
253        Self {
254            status: 1.0,
255            message,
256        }
257    }
258
259    pub fn first_output(&self) -> Value {
260        Value::Num(self.status)
261    }
262
263    pub fn outputs(&self) -> Vec<Value> {
264        vec![Value::Num(self.status), char_array_value(&self.message)]
265    }
266
267    #[cfg(test)]
268    pub(crate) fn status(&self) -> f64 {
269        self.status
270    }
271
272    #[cfg(test)]
273    pub(crate) fn message(&self) -> &str {
274        &self.message
275    }
276}
277
278fn apply(name_value: &Value, value_value: &Value) -> Result<SetenvResult, String> {
279    let name = extract_scalar_text(name_value, ERR_NAME_TYPE)?;
280    let value = extract_scalar_text(value_value, ERR_VALUE_TYPE)?;
281
282    if name.is_empty() {
283        return Ok(SetenvResult::failure(MESSAGE_EMPTY_NAME.to_string()));
284    }
285    if name.chars().any(|ch| ch == '=') {
286        return Ok(SetenvResult::failure(MESSAGE_NAME_HAS_EQUAL.to_string()));
287    }
288    if name.chars().any(|ch| ch == '\0') {
289        return Ok(SetenvResult::failure(MESSAGE_NAME_HAS_NULL.to_string()));
290    }
291    if value.chars().any(|ch| ch == '\0') {
292        return Ok(SetenvResult::failure(MESSAGE_VALUE_HAS_NULL.to_string()));
293    }
294
295    Ok(update_environment(&name, &value))
296}
297
298fn update_environment(name: &str, value: &str) -> SetenvResult {
299    if value.is_empty() {
300        match panic::catch_unwind(|| env::remove_var(name)) {
301            Ok(()) => SetenvResult::success(),
302            Err(payload) => SetenvResult::failure(format!(
303                "{}{}",
304                MESSAGE_OPERATION_FAILED,
305                panic_payload_to_string(payload)
306            )),
307        }
308    } else {
309        match panic::catch_unwind(|| env::set_var(name, value)) {
310            Ok(()) => SetenvResult::success(),
311            Err(payload) => SetenvResult::failure(format!(
312                "{}{}",
313                MESSAGE_OPERATION_FAILED,
314                panic_payload_to_string(payload)
315            )),
316        }
317    }
318}
319
320fn gather_arguments(args: &[Value]) -> Result<Vec<Value>, String> {
321    let mut out = Vec::with_capacity(args.len());
322    for value in args {
323        out.push(gather_if_needed(value).map_err(|err| format!("setenv: {err}"))?);
324    }
325    Ok(out)
326}
327
328fn extract_scalar_text(value: &Value, error_message: &str) -> Result<String, String> {
329    match value {
330        Value::String(text) => Ok(text.clone()),
331        Value::CharArray(array) => {
332            if array.rows != 1 {
333                return Err(error_message.to_string());
334            }
335            Ok(char_row_to_string(array))
336        }
337        Value::StringArray(array) => {
338            if array.data.len() == 1 {
339                Ok(array.data[0].clone())
340            } else {
341                Err(error_message.to_string())
342            }
343        }
344        _ => Err(error_message.to_string()),
345    }
346}
347
348fn char_row_to_string(array: &CharArray) -> String {
349    if array.cols == 0 {
350        return String::new();
351    }
352    let mut text = String::with_capacity(array.cols);
353    for col in 0..array.cols {
354        text.push(array.data[col]);
355    }
356    while text.ends_with(' ') {
357        text.pop();
358    }
359    text
360}
361
362fn char_array_value(text: &str) -> Value {
363    Value::CharArray(CharArray::new_row(text))
364}
365
366fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
367    match payload.downcast::<String>() {
368        Ok(msg) => *msg,
369        Err(payload) => match payload.downcast::<&'static str>() {
370            Ok(msg) => (*msg).to_string(),
371            Err(_) => "operation failed".to_string(),
372        },
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::builtins::io::repl_fs::REPL_FS_TEST_LOCK;
380    use runmat_builtins::{CharArray, StringArray, Value};
381
382    fn unique_name(suffix: &str) -> String {
383        format!("RUNMAT_TEST_SETENV_{}", suffix)
384    }
385
386    #[test]
387    fn setenv_sets_variable_and_returns_success() {
388        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
389        let name = unique_name("BASIC");
390        env::remove_var(&name);
391
392        let result = setenv_builtin(vec![
393            Value::String(name.clone()),
394            Value::String("value".to_string()),
395        ])
396        .expect("setenv");
397
398        assert_eq!(result, Value::Num(0.0));
399        assert_eq!(env::var(&name).unwrap(), "value");
400        env::remove_var(name);
401    }
402
403    #[test]
404    fn setenv_removes_variable_when_value_is_empty() {
405        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
406        let name = unique_name("REMOVE");
407        env::set_var(&name, "seed");
408
409        let result = setenv_builtin(vec![
410            Value::String(name.clone()),
411            Value::CharArray(CharArray::new_row("")),
412        ])
413        .expect("setenv");
414
415        assert_eq!(result, Value::Num(0.0));
416        assert!(env::var(&name).is_err());
417        env::remove_var(name);
418    }
419
420    #[test]
421    fn setenv_reports_failure_for_illegal_name() {
422        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
423        let eval = evaluate(&[
424            Value::String("INVALID=NAME".to_string()),
425            Value::String("value".to_string()),
426        ])
427        .expect("evaluate");
428
429        assert_eq!(eval.status(), 1.0);
430        assert_eq!(eval.message(), MESSAGE_NAME_HAS_EQUAL);
431    }
432
433    #[test]
434    fn setenv_reports_failure_for_empty_name() {
435        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
436        let eval = evaluate(&[
437            Value::String(String::new()),
438            Value::String("value".to_string()),
439        ])
440        .expect("evaluate");
441
442        assert_eq!(eval.status(), 1.0);
443        assert_eq!(eval.message(), MESSAGE_EMPTY_NAME);
444    }
445
446    #[test]
447    fn setenv_reports_failure_for_null_in_name() {
448        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
449        let eval = evaluate(&[
450            Value::String("BAD\0NAME".to_string()),
451            Value::String("value".to_string()),
452        ])
453        .expect("evaluate");
454
455        assert_eq!(eval.status(), 1.0);
456        assert_eq!(eval.message(), MESSAGE_NAME_HAS_NULL);
457    }
458
459    #[test]
460    fn setenv_reports_failure_for_null_in_value() {
461        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
462        let eval = evaluate(&[
463            Value::String("RUNMAT_NULL_VALUE".to_string()),
464            Value::String("abc\0def".to_string()),
465        ])
466        .expect("evaluate");
467
468        assert_eq!(eval.status(), 1.0);
469        assert_eq!(eval.message(), MESSAGE_VALUE_HAS_NULL);
470    }
471
472    #[test]
473    fn setenv_errors_when_name_is_not_text() {
474        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
475        let err =
476            setenv_builtin(vec![Value::Num(5.0), Value::String("value".to_string())]).unwrap_err();
477        assert_eq!(err, ERR_NAME_TYPE);
478    }
479
480    #[test]
481    fn setenv_errors_when_value_is_not_text() {
482        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
483        let err = setenv_builtin(vec![
484            Value::String("RUNMAT_INVALID_VALUE".to_string()),
485            Value::Num(1.0),
486        ])
487        .unwrap_err();
488        assert_eq!(err, ERR_VALUE_TYPE);
489    }
490
491    #[test]
492    fn setenv_accepts_scalar_string_array_arguments() {
493        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
494        let name = unique_name("STRING_ARRAY");
495        env::remove_var(&name);
496
497        let name_array =
498            StringArray::new(vec![name.clone()], vec![1]).expect("scalar string array name");
499        let value_array =
500            StringArray::new(vec!["VALUE".to_string()], vec![1]).expect("scalar string array");
501
502        let status = setenv_builtin(vec![
503            Value::StringArray(name_array),
504            Value::StringArray(value_array),
505        ])
506        .expect("setenv");
507
508        assert_eq!(status, Value::Num(0.0));
509        assert_eq!(env::var(&name).unwrap(), "VALUE");
510        env::remove_var(name);
511    }
512
513    #[test]
514    fn setenv_errors_for_string_array_with_multiple_elements() {
515        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
516        let array =
517            StringArray::new(vec!["A".to_string(), "B".to_string()], vec![2]).expect("array");
518        let err = setenv_builtin(vec![
519            Value::StringArray(array),
520            Value::String("value".to_string()),
521        ])
522        .unwrap_err();
523        assert_eq!(err, ERR_NAME_TYPE);
524    }
525
526    #[test]
527    fn setenv_errors_for_char_array_with_multiple_rows() {
528        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
529        let array = CharArray::new(vec!['R', 'M'], 2, 1).expect("two-row char array");
530        let err = setenv_builtin(vec![
531            Value::CharArray(array),
532            Value::String("value".to_string()),
533        ])
534        .unwrap_err();
535        assert_eq!(err, ERR_NAME_TYPE);
536    }
537
538    #[test]
539    fn setenv_char_array_input_trims_padding() {
540        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
541        let chars = vec!['F', 'O', 'O', ' '];
542        let array = CharArray::new(chars, 1, 4).unwrap();
543        let result = extract_scalar_text(&Value::CharArray(array), ERR_NAME_TYPE).unwrap();
544        assert_eq!(result, "FOO");
545    }
546
547    #[test]
548    fn setenv_outputs_success_message_is_empty_char_array() {
549        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
550        let name = unique_name("SUCCESS_MSG");
551        env::remove_var(&name);
552
553        let eval = evaluate(&[
554            Value::String(name.clone()),
555            Value::String("value".to_string()),
556        ])
557        .expect("evaluate");
558        let outputs = eval.outputs();
559        assert_eq!(outputs.len(), 2);
560        match &outputs[1] {
561            Value::CharArray(ca) => {
562                assert_eq!(ca.rows, 1);
563                assert_eq!(ca.cols, 0);
564            }
565            other => panic!("expected empty CharArray, got {other:?}"),
566        }
567
568        env::remove_var(name);
569    }
570
571    #[test]
572    fn setenv_outputs_return_status_and_message() {
573        let _guard = REPL_FS_TEST_LOCK.lock().unwrap();
574        let eval = evaluate(&[
575            Value::String("INVALID=NAME".to_string()),
576            Value::String("value".to_string()),
577        ])
578        .expect("evaluate");
579
580        let outputs = eval.outputs();
581        assert_eq!(outputs.len(), 2);
582        assert!(matches!(outputs[0], Value::Num(1.0)));
583        match &outputs[1] {
584            Value::CharArray(ca) => {
585                assert_eq!(ca.rows, 1);
586                let text: String = ca.data.iter().collect();
587                assert_eq!(text, MESSAGE_NAME_HAS_EQUAL);
588            }
589            other => panic!("expected CharArray message, got {other:?}"),
590        }
591    }
592
593    #[test]
594    #[cfg(feature = "doc_export")]
595    fn doc_examples_present() {
596        let blocks = crate::builtins::common::test_support::doc_examples(DOC_MD);
597        assert!(!blocks.is_empty());
598    }
599}