Skip to main content

runmat_runtime/builtins/io/
diary.rs

1//! MATLAB-compatible `diary` builtin for command-window text logging.
2
3use std::path::PathBuf;
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    Tensor, Value,
9};
10use runmat_macros::runtime_builtin;
11
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::console;
17use crate::{build_runtime_error, BuiltinResult};
18
19#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::diary")]
20pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
21    name: "diary",
22    op_kind: GpuOpKind::Custom("file-io"),
23    supported_precisions: &[],
24    broadcast: BroadcastSemantics::None,
25    provider_hooks: &[],
26    constant_strategy: ConstantStrategy::InlineLiteral,
27    residency: ResidencyPolicy::GatherImmediately,
28    nan_mode: ReductionNaN::Include,
29    two_pass_threshold: None,
30    workgroup_size: None,
31    accepts_nan_mode: false,
32    notes: "Host-side command-window logging. Diary writes through the active filesystem provider and never operates on GPU buffers.",
33};
34
35#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::diary")]
36pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
37    name: "diary",
38    shape: ShapeRequirements::Any,
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    elementwise: None,
41    reduction: None,
42    emits_nan: false,
43    notes: "Side-effecting file sink; excluded from fusion planning.",
44};
45
46const BUILTIN_NAME: &str = "diary";
47
48const DIARY_OUTPUTS_EMPTY: [BuiltinParamDescriptor; 0] = [];
49const DIARY_INPUTS_EMPTY: [BuiltinParamDescriptor; 0] = [];
50const DIARY_INPUTS_ARG: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
51    name: "filenameOrState",
52    ty: BuiltinParamType::StringScalar,
53    arity: BuiltinParamArity::Required,
54    default: None,
55    description: "Log filename, `on`, or `off`.",
56}];
57const DIARY_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
58    BuiltinSignatureDescriptor {
59        label: "diary",
60        inputs: &DIARY_INPUTS_EMPTY,
61        outputs: &DIARY_OUTPUTS_EMPTY,
62    },
63    BuiltinSignatureDescriptor {
64        label: "diary(filename)",
65        inputs: &DIARY_INPUTS_ARG,
66        outputs: &DIARY_OUTPUTS_EMPTY,
67    },
68    BuiltinSignatureDescriptor {
69        label: "diary off",
70        inputs: &DIARY_INPUTS_ARG,
71        outputs: &DIARY_OUTPUTS_EMPTY,
72    },
73    BuiltinSignatureDescriptor {
74        label: "diary on",
75        inputs: &DIARY_INPUTS_ARG,
76        outputs: &DIARY_OUTPUTS_EMPTY,
77    },
78];
79
80const DIARY_ERROR_ARG_COUNT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
81    code: "RM.DIARY.ARG_COUNT",
82    identifier: None,
83    when: "More than one input argument is passed to diary.",
84    message: "diary: expected zero or one input argument",
85};
86const DIARY_ERROR_ARG_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
87    code: "RM.DIARY.ARG_TYPE",
88    identifier: None,
89    when: "The diary filename/state argument is not a string scalar or character row.",
90    message: "diary: expected filename, 'on', or 'off'",
91};
92const DIARY_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
93    code: "RM.DIARY.IO",
94    identifier: None,
95    when: "The selected diary file cannot be opened for append through the active filesystem provider.",
96    message: "diary: failed to open diary file",
97};
98const DIARY_ERROR_TOO_MANY_OUTPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
99    code: "RM.DIARY.TOO_MANY_OUTPUTS",
100    identifier: Some("RunMat:diary:TooManyOutputs"),
101    when: "One or more output arguments are requested from diary.",
102    message: "diary: expected no output arguments",
103};
104const DIARY_ERRORS: [BuiltinErrorDescriptor; 4] = [
105    DIARY_ERROR_ARG_COUNT,
106    DIARY_ERROR_ARG_TYPE,
107    DIARY_ERROR_IO,
108    DIARY_ERROR_TOO_MANY_OUTPUTS,
109];
110
111pub const DIARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
112    signatures: &DIARY_SIGNATURES,
113    output_mode: BuiltinOutputMode::Fixed,
114    completion_policy: BuiltinCompletionPolicy::Public,
115    errors: &DIARY_ERRORS,
116};
117
118fn diary_error(error: &'static BuiltinErrorDescriptor) -> crate::RuntimeError {
119    diary_error_with(error, error.message)
120}
121
122fn diary_error_with(
123    error: &'static BuiltinErrorDescriptor,
124    message: impl Into<String>,
125) -> crate::RuntimeError {
126    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
127    if let Some(identifier) = error.identifier {
128        builder = builder.with_identifier(identifier);
129    }
130    builder.build()
131}
132
133#[runtime_builtin(
134    name = "diary",
135    category = "io",
136    summary = "Log Command Window text to a file.",
137    keywords = "diary,log,command window,console,file",
138    sink = true,
139    suppress_auto_output = true,
140    descriptor(crate::builtins::io::diary::DIARY_DESCRIPTOR),
141    builtin_path = "crate::builtins::io::diary"
142)]
143async fn diary_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
144    if crate::output_count::current_output_count().unwrap_or(0) > 0 {
145        return Err(diary_error(&DIARY_ERROR_TOO_MANY_OUTPUTS));
146    }
147    match args.as_slice() {
148        [] => console::toggle_diary().map_err(|err| {
149            diary_error_with(
150                &DIARY_ERROR_IO,
151                format!("diary: failed to open diary file ({err})"),
152            )
153        })?,
154        [arg] => match parse_diary_arg(arg)? {
155            DiaryAction::On => {
156                console::set_diary_enabled_checked(true).map_err(|err| {
157                    diary_error_with(
158                        &DIARY_ERROR_IO,
159                        format!("diary: failed to open diary file ({err})"),
160                    )
161                })?;
162            }
163            DiaryAction::Off => console::set_diary_enabled(false),
164            DiaryAction::Filename(path) => {
165                console::set_diary_filename_checked(path).map_err(|err| {
166                    diary_error_with(
167                        &DIARY_ERROR_IO,
168                        format!("diary: failed to open diary file ({err})"),
169                    )
170                })?;
171            }
172        },
173        _ => return Err(diary_error(&DIARY_ERROR_ARG_COUNT)),
174    }
175    Ok(empty_return_value())
176}
177
178enum DiaryAction {
179    On,
180    Off,
181    Filename(PathBuf),
182}
183
184fn parse_diary_arg(value: &Value) -> BuiltinResult<DiaryAction> {
185    let text = scalar_text(value).ok_or_else(|| diary_error(&DIARY_ERROR_ARG_TYPE))?;
186    let trimmed = text.trim();
187    if trimmed.eq_ignore_ascii_case("on") {
188        Ok(DiaryAction::On)
189    } else if trimmed.eq_ignore_ascii_case("off") {
190        Ok(DiaryAction::Off)
191    } else if trimmed.is_empty() {
192        Err(diary_error(&DIARY_ERROR_ARG_TYPE))
193    } else {
194        Ok(DiaryAction::Filename(PathBuf::from(trimmed)))
195    }
196}
197
198fn scalar_text(value: &Value) -> Option<String> {
199    match value {
200        Value::String(text) => Some(text.clone()),
201        Value::CharArray(chars) if chars.rows == 1 => Some(chars.data.iter().collect()),
202        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
203        _ => None,
204    }
205}
206
207fn empty_return_value() -> Value {
208    Value::Tensor(Tensor::zeros(vec![0, 0]))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use futures::executor::block_on;
215    use std::sync::Arc;
216
217    #[test]
218    fn diary_descriptor_covers_core_forms() {
219        let labels: Vec<&str> = DIARY_DESCRIPTOR
220            .signatures
221            .iter()
222            .map(|sig| sig.label)
223            .collect();
224        assert!(labels.contains(&"diary"));
225        assert!(labels.contains(&"diary(filename)"));
226        assert!(labels.contains(&"diary off"));
227        assert!(labels.contains(&"diary on"));
228    }
229
230    #[test]
231    fn diary_logs_console_output_to_active_provider() {
232        let _lock = runmat_filesystem::provider_override_lock();
233        let dir = tempfile::tempdir().expect("tempdir");
234        let provider =
235            runmat_filesystem::SandboxFsProvider::new(dir.path().to_path_buf()).expect("sandbox");
236        let _guard = runmat_filesystem::replace_provider(Arc::new(provider));
237
238        block_on(diary_builtin(vec![Value::from("session.log")])).expect("diary on");
239        console::record_console_line(console::ConsoleStream::Stdout, "alpha");
240        block_on(diary_builtin(vec![Value::from("off")])).expect("diary off");
241        console::record_console_line(console::ConsoleStream::Stdout, "beta");
242
243        let text = std::fs::read_to_string(dir.path().join("session.log")).expect("read log");
244        assert!(text.contains("alpha\n"));
245        assert!(!text.contains("beta\n"));
246    }
247
248    #[test]
249    fn diary_filename_appends_existing_log() {
250        let _lock = runmat_filesystem::provider_override_lock();
251        let dir = tempfile::tempdir().expect("tempdir");
252        let provider =
253            runmat_filesystem::SandboxFsProvider::new(dir.path().to_path_buf()).expect("sandbox");
254        let _guard = runmat_filesystem::replace_provider(Arc::new(provider));
255        std::fs::write(dir.path().join("session.log"), "seed\n").expect("seed");
256
257        block_on(diary_builtin(vec![Value::from("session.log")])).expect("diary on");
258        console::record_console_line(console::ConsoleStream::Stdout, "next");
259        block_on(diary_builtin(vec![Value::from("off")])).expect("diary off");
260
261        let text = std::fs::read_to_string(dir.path().join("session.log")).expect("read log");
262        assert_eq!(text, "seed\nnext\n");
263    }
264
265    #[test]
266    fn diary_does_not_log_active_evalc_capture_scope() {
267        let _lock = runmat_filesystem::provider_override_lock();
268        let dir = tempfile::tempdir().expect("tempdir");
269        let provider =
270            runmat_filesystem::SandboxFsProvider::new(dir.path().to_path_buf()).expect("sandbox");
271        let _guard = runmat_filesystem::replace_provider(Arc::new(provider));
272
273        block_on(diary_builtin(vec![Value::from("session.log")])).expect("diary on");
274        let capture = console::begin_capture();
275        console::record_console_line(console::ConsoleStream::Stdout, "captured-only");
276        let captured = capture.finish();
277        console::record_console_line(console::ConsoleStream::Stdout, "logged");
278        block_on(diary_builtin(vec![Value::from("off")])).expect("diary off");
279
280        assert_eq!(captured, "captured-only\n");
281        let text = std::fs::read_to_string(dir.path().join("session.log")).expect("read log");
282        assert!(!text.contains("captured-only"));
283        assert!(text.contains("logged\n"));
284    }
285}