1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::rc::Rc;
4
5use indexmap::IndexMap;
6use ndarray::Array2;
7use num_complex::Complex;
8
9use crate::io::IoContext;
10
11type LambdaFnInner = Rc<dyn Fn(&[Value], Option<&mut IoContext>) -> Result<Value, String>>;
17
18#[derive(Clone)]
23pub struct LambdaFn(pub LambdaFnInner, pub String);
24
25impl std::fmt::Debug for LambdaFn {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 write!(f, "{}", self.1)
28 }
29}
30
31impl PartialEq for LambdaFn {
32 fn eq(&self, other: &Self) -> bool {
33 Rc::ptr_eq(&self.0, &other.0)
34 }
35}
36
37#[derive(Debug, Clone, PartialEq)]
40pub struct FunctionData {
41 pub outputs: Vec<String>,
43 pub params: Vec<String>,
45 pub body_source: String,
47 pub locals: IndexMap<String, Value>,
50 pub doc: Option<String>,
53}
54
55impl From<FunctionData> for Value {
56 fn from(fd: FunctionData) -> Self {
57 Value::Function(Box::new(fd))
58 }
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub enum Value {
64 Void,
66 Scalar(f64),
68 Matrix(Box<Array2<f64>>),
71 ComplexMatrix(Box<Array2<Complex<f64>>>),
76 Complex(f64, f64),
78 Str(String),
80 StringObj(String),
82 Lambda(Box<LambdaFn>),
85 Function(Box<FunctionData>),
93 Tuple(Vec<Value>),
98 Cell(Box<Vec<Value>>),
103 Struct(Box<IndexMap<String, Value>>),
108 StructArray(Box<Vec<IndexMap<String, Value>>>),
113 DateTime(f64),
117 Duration(f64),
121 DateTimeArray(Vec<f64>),
125 DurationArray(Vec<f64>),
127 Map(Box<IndexMap<String, Value>>),
133}
134
135const _VALUE_SIZE: () = assert!(std::mem::size_of::<Value>() <= 32);
136
137impl Value {
138 pub fn as_scalar(&self) -> Option<f64> {
149 match self {
150 Value::Scalar(n) => Some(*n),
151 Value::Void
152 | Value::Matrix(_)
153 | Value::ComplexMatrix(_)
154 | Value::Complex(_, _)
155 | Value::Str(_)
156 | Value::StringObj(_)
157 | Value::Lambda(_)
158 | Value::Function(_)
159 | Value::Tuple(_)
160 | Value::Cell(_)
161 | Value::Struct(_)
162 | Value::StructArray(_)
163 | Value::DateTime(_)
164 | Value::Duration(_)
165 | Value::DateTimeArray(_)
166 | Value::DurationArray(_)
167 | Value::Map(_) => None,
168 }
169 }
170}
171
172pub type Env = HashMap<String, Value>;
177
178pub fn config_dir() -> PathBuf {
183 dirs::config_dir()
184 .unwrap_or_else(|| PathBuf::from("."))
185 .join("ccalc")
186}
187
188fn workspace_path() -> PathBuf {
189 config_dir().join("workspace.toml")
190}
191
192fn serialize_value(v: &Value) -> Option<String> {
196 match v {
197 Value::Scalar(n) => Some(format!("{n}")),
198 Value::Str(s) if !s.contains('\'') && !s.contains('\n') => Some(format!("'{s}'")),
200 Value::StringObj(s) if !s.contains('"') && !s.contains('\n') => Some(format!("\"{s}\"")),
202 _ => None,
203 }
204}
205
206pub fn save_workspace(env: &Env, path: &Path) -> Result<(), String> {
210 if let Some(parent) = path.parent() {
211 std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {e}"))?;
212 }
213 let mut entries: Vec<(&String, String)> = env
214 .iter()
215 .filter_map(|(k, v)| serialize_value(v).map(|s| (k, s)))
216 .collect();
217 entries.sort_by_key(|(k, _)| k.as_str());
218 let mut content = String::new();
219 for (name, val) in entries {
220 content.push_str(&format!("{name} = {val}\n"));
221 }
222 std::fs::write(path, &content).map_err(|e| format!("Cannot write {}: {e}", path.display()))
223}
224
225pub fn save_workspace_vars(env: &Env, path: &Path, vars: &[&str]) -> Result<(), String> {
228 let filtered: Env = env
229 .iter()
230 .filter(|(k, _)| vars.contains(&k.as_str()))
231 .map(|(k, v)| (k.clone(), v.clone()))
232 .collect();
233 save_workspace(&filtered, path)
234}
235
236pub fn load_workspace(path: &Path) -> Result<Env, String> {
240 let content = std::fs::read_to_string(path)
241 .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
242 let mut env = Env::new();
243 for line in content.lines() {
244 let line = line.trim();
245 if line.is_empty() || line.starts_with('%') {
246 continue;
247 }
248 if let Some((key, val)) = line.split_once('=') {
249 let key = key.trim();
250 let val = val.trim();
251 if !is_valid_ident(key) {
252 continue;
253 }
254 let value = if val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2 {
255 Value::Str(val[1..val.len() - 1].to_string())
256 } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
257 Value::StringObj(val[1..val.len() - 1].to_string())
258 } else if let Ok(n) = val.parse::<f64>() {
259 Value::Scalar(n)
260 } else {
261 continue;
262 };
263 env.insert(key.to_string(), value);
264 }
265 }
266 Ok(env)
267}
268
269pub fn save_workspace_default(env: &Env) -> Result<(), String> {
271 save_workspace(env, &workspace_path())
272}
273
274pub fn load_workspace_default() -> Result<Env, String> {
276 load_workspace(&workspace_path())
277}
278
279fn is_valid_ident(s: &str) -> bool {
280 let mut chars = s.chars();
281 match chars.next() {
282 Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
283 _ => false,
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[allow(clippy::approx_constant)]
292 #[test]
293 fn test_save_load_roundtrip() {
294 let path = std::env::temp_dir().join("ccalc_test_workspace_roundtrip.toml");
295 let mut env = Env::new();
296 env.insert("x".to_string(), Value::Scalar(42.0));
297 env.insert("y".to_string(), Value::Scalar(-3.14));
298 env.insert("ans".to_string(), Value::Scalar(10.0));
299 save_workspace(&env, &path).unwrap();
300
301 let loaded = load_workspace(&path).unwrap();
302 assert_eq!(loaded.get("x"), Some(&Value::Scalar(42.0)));
303 assert_eq!(loaded.get("y"), Some(&Value::Scalar(-3.14)));
304 assert_eq!(loaded.get("ans"), Some(&Value::Scalar(10.0)));
305 std::fs::remove_file(&path).ok();
306 }
307
308 #[test]
309 fn test_save_empty_workspace() {
310 let path = std::env::temp_dir().join("ccalc_test_workspace_empty.toml");
311 save_workspace(&Env::new(), &path).unwrap();
312 let content = std::fs::read_to_string(&path).unwrap();
313 assert!(content.is_empty());
314 std::fs::remove_file(&path).ok();
315 }
316
317 #[test]
318 fn test_load_nonexistent_returns_error() {
319 let path = std::env::temp_dir().join("ccalc_test_workspace_nonexistent_xyz.toml");
320 let _ = std::fs::remove_file(&path);
321 assert!(load_workspace(&path).is_err());
322 }
323
324 #[test]
325 fn test_load_ignores_invalid_lines() {
326 let path = std::env::temp_dir().join("ccalc_test_workspace_invalid.toml");
327 std::fs::write(&path, "# comment\n\nx = 5\n1bad = 9\ngood = abc\n").unwrap();
328 let env = load_workspace(&path).unwrap();
329 assert_eq!(env.get("x"), Some(&Value::Scalar(5.0)));
330 assert!(!env.contains_key("1bad"));
331 assert!(!env.contains_key("good")); std::fs::remove_file(&path).ok();
333 }
334
335 #[test]
336 fn test_is_valid_ident() {
337 assert!(is_valid_ident("x"));
338 assert!(is_valid_ident("my_var"));
339 assert!(is_valid_ident("_private"));
340 assert!(is_valid_ident("var1"));
341 assert!(is_valid_ident("ans"));
342 assert!(!is_valid_ident("1x"));
343 assert!(!is_valid_ident(""));
344 assert!(!is_valid_ident("a b"));
345 assert!(!is_valid_ident("a-b"));
346 }
347
348 #[test]
349 fn test_save_skips_matrices() {
350 use ndarray::array;
351 let path = std::env::temp_dir().join("ccalc_test_workspace_matrix_skip.toml");
352 let mut env = Env::new();
353 env.insert("x".to_string(), Value::Scalar(5.0));
354 env.insert(
355 "m".to_string(),
356 Value::Matrix(Box::new(array![[1.0, 2.0], [3.0, 4.0]])),
357 );
358 save_workspace(&env, &path).unwrap();
359 let content = std::fs::read_to_string(&path).unwrap();
360 assert!(content.contains("x = 5"));
361 assert!(!content.contains("m"));
362 std::fs::remove_file(&path).ok();
363 }
364
365 #[test]
366 fn test_save_load_strings() {
367 let path = std::env::temp_dir().join("ccalc_test_workspace_strings.toml");
368 let mut env = Env::new();
369 env.insert("name".to_string(), Value::Str("hello".to_string()));
370 env.insert("tag".to_string(), Value::StringObj("world".to_string()));
371 env.insert("n".to_string(), Value::Scalar(1.0));
372 save_workspace(&env, &path).unwrap();
373
374 let loaded = load_workspace(&path).unwrap();
375 assert_eq!(loaded.get("name"), Some(&Value::Str("hello".to_string())));
376 assert_eq!(
377 loaded.get("tag"),
378 Some(&Value::StringObj("world".to_string()))
379 );
380 assert_eq!(loaded.get("n"), Some(&Value::Scalar(1.0)));
381 std::fs::remove_file(&path).ok();
382 }
383
384 #[test]
385 fn test_save_skips_string_with_unsafe_chars() {
386 let path = std::env::temp_dir().join("ccalc_test_workspace_unsafe_str.toml");
387 let mut env = Env::new();
388 env.insert("s".to_string(), Value::Str("it's".to_string())); env.insert("x".to_string(), Value::Scalar(5.0));
390 save_workspace(&env, &path).unwrap();
391
392 let content = std::fs::read_to_string(&path).unwrap();
393 assert!(content.contains("x = 5"));
394 assert!(!content.contains("it's")); std::fs::remove_file(&path).ok();
396 }
397
398 #[test]
399 fn test_save_workspace_vars_selective() {
400 let path = std::env::temp_dir().join("ccalc_test_workspace_vars.toml");
401 let mut env = Env::new();
402 env.insert("x".to_string(), Value::Scalar(1.0));
403 env.insert("y".to_string(), Value::Scalar(2.0));
404 env.insert("z".to_string(), Value::Scalar(3.0));
405 save_workspace_vars(&env, &path, &["x", "z"]).unwrap();
406
407 let loaded = load_workspace(&path).unwrap();
408 assert_eq!(loaded.get("x"), Some(&Value::Scalar(1.0)));
409 assert_eq!(loaded.get("z"), Some(&Value::Scalar(3.0)));
410 assert!(!loaded.contains_key("y")); std::fs::remove_file(&path).ok();
412 }
413}