runmat_runtime/builtins/io/
diary.rs1use 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}