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)]
39pub enum Value {
40 Void,
42 Scalar(f64),
44 Matrix(Array2<f64>),
47 ComplexMatrix(Array2<Complex<f64>>),
52 Complex(f64, f64),
54 Str(String),
56 StringObj(String),
58 Lambda(LambdaFn),
61 Function {
67 outputs: Vec<String>,
69 params: Vec<String>,
71 body_source: String,
73 locals: IndexMap<String, Value>,
76 doc: Option<String>,
79 },
80 Tuple(Vec<Value>),
85 Cell(Vec<Value>),
90 Struct(IndexMap<String, Value>),
95 StructArray(Vec<IndexMap<String, Value>>),
100 DateTime(f64),
104 Duration(f64),
108 DateTimeArray(Vec<f64>),
112 DurationArray(Vec<f64>),
114}
115
116impl Value {
117 pub fn as_scalar(&self) -> Option<f64> {
128 match self {
129 Value::Scalar(n) => Some(*n),
130 Value::Void
131 | Value::Matrix(_)
132 | Value::ComplexMatrix(_)
133 | Value::Complex(_, _)
134 | Value::Str(_)
135 | Value::StringObj(_)
136 | Value::Lambda(_)
137 | Value::Function { .. }
138 | Value::Tuple(_)
139 | Value::Cell(_)
140 | Value::Struct(_)
141 | Value::StructArray(_)
142 | Value::DateTime(_)
143 | Value::Duration(_)
144 | Value::DateTimeArray(_)
145 | Value::DurationArray(_) => None,
146 }
147 }
148}
149
150pub type Env = HashMap<String, Value>;
155
156pub fn config_dir() -> PathBuf {
161 dirs::config_dir()
162 .unwrap_or_else(|| PathBuf::from("."))
163 .join("ccalc")
164}
165
166fn workspace_path() -> PathBuf {
167 config_dir().join("workspace.toml")
168}
169
170fn serialize_value(v: &Value) -> Option<String> {
174 match v {
175 Value::Scalar(n) => Some(format!("{n}")),
176 Value::Str(s) if !s.contains('\'') && !s.contains('\n') => Some(format!("'{s}'")),
178 Value::StringObj(s) if !s.contains('"') && !s.contains('\n') => Some(format!("\"{s}\"")),
180 _ => None,
181 }
182}
183
184pub fn save_workspace(env: &Env, path: &Path) -> Result<(), String> {
188 if let Some(parent) = path.parent() {
189 std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {e}"))?;
190 }
191 let mut entries: Vec<(&String, String)> = env
192 .iter()
193 .filter_map(|(k, v)| serialize_value(v).map(|s| (k, s)))
194 .collect();
195 entries.sort_by_key(|(k, _)| k.as_str());
196 let mut content = String::new();
197 for (name, val) in entries {
198 content.push_str(&format!("{name} = {val}\n"));
199 }
200 std::fs::write(path, &content).map_err(|e| format!("Cannot write {}: {e}", path.display()))
201}
202
203pub fn save_workspace_vars(env: &Env, path: &Path, vars: &[&str]) -> Result<(), String> {
206 let filtered: Env = env
207 .iter()
208 .filter(|(k, _)| vars.contains(&k.as_str()))
209 .map(|(k, v)| (k.clone(), v.clone()))
210 .collect();
211 save_workspace(&filtered, path)
212}
213
214pub fn load_workspace(path: &Path) -> Result<Env, String> {
218 let content = std::fs::read_to_string(path)
219 .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
220 let mut env = Env::new();
221 for line in content.lines() {
222 let line = line.trim();
223 if line.is_empty() || line.starts_with('%') {
224 continue;
225 }
226 if let Some((key, val)) = line.split_once('=') {
227 let key = key.trim();
228 let val = val.trim();
229 if !is_valid_ident(key) {
230 continue;
231 }
232 let value = if val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2 {
233 Value::Str(val[1..val.len() - 1].to_string())
234 } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
235 Value::StringObj(val[1..val.len() - 1].to_string())
236 } else if let Ok(n) = val.parse::<f64>() {
237 Value::Scalar(n)
238 } else {
239 continue;
240 };
241 env.insert(key.to_string(), value);
242 }
243 }
244 Ok(env)
245}
246
247pub fn save_workspace_default(env: &Env) -> Result<(), String> {
249 save_workspace(env, &workspace_path())
250}
251
252pub fn load_workspace_default() -> Result<Env, String> {
254 load_workspace(&workspace_path())
255}
256
257fn is_valid_ident(s: &str) -> bool {
258 let mut chars = s.chars();
259 match chars.next() {
260 Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
261 _ => false,
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[allow(clippy::approx_constant)]
270 #[test]
271 fn test_save_load_roundtrip() {
272 let path = std::env::temp_dir().join("ccalc_test_workspace_roundtrip.toml");
273 let mut env = Env::new();
274 env.insert("x".to_string(), Value::Scalar(42.0));
275 env.insert("y".to_string(), Value::Scalar(-3.14));
276 env.insert("ans".to_string(), Value::Scalar(10.0));
277 save_workspace(&env, &path).unwrap();
278
279 let loaded = load_workspace(&path).unwrap();
280 assert_eq!(loaded.get("x"), Some(&Value::Scalar(42.0)));
281 assert_eq!(loaded.get("y"), Some(&Value::Scalar(-3.14)));
282 assert_eq!(loaded.get("ans"), Some(&Value::Scalar(10.0)));
283 std::fs::remove_file(&path).ok();
284 }
285
286 #[test]
287 fn test_save_empty_workspace() {
288 let path = std::env::temp_dir().join("ccalc_test_workspace_empty.toml");
289 save_workspace(&Env::new(), &path).unwrap();
290 let content = std::fs::read_to_string(&path).unwrap();
291 assert!(content.is_empty());
292 std::fs::remove_file(&path).ok();
293 }
294
295 #[test]
296 fn test_load_nonexistent_returns_error() {
297 let path = std::env::temp_dir().join("ccalc_test_workspace_nonexistent_xyz.toml");
298 let _ = std::fs::remove_file(&path);
299 assert!(load_workspace(&path).is_err());
300 }
301
302 #[test]
303 fn test_load_ignores_invalid_lines() {
304 let path = std::env::temp_dir().join("ccalc_test_workspace_invalid.toml");
305 std::fs::write(&path, "# comment\n\nx = 5\n1bad = 9\ngood = abc\n").unwrap();
306 let env = load_workspace(&path).unwrap();
307 assert_eq!(env.get("x"), Some(&Value::Scalar(5.0)));
308 assert!(!env.contains_key("1bad"));
309 assert!(!env.contains_key("good")); std::fs::remove_file(&path).ok();
311 }
312
313 #[test]
314 fn test_is_valid_ident() {
315 assert!(is_valid_ident("x"));
316 assert!(is_valid_ident("my_var"));
317 assert!(is_valid_ident("_private"));
318 assert!(is_valid_ident("var1"));
319 assert!(is_valid_ident("ans"));
320 assert!(!is_valid_ident("1x"));
321 assert!(!is_valid_ident(""));
322 assert!(!is_valid_ident("a b"));
323 assert!(!is_valid_ident("a-b"));
324 }
325
326 #[test]
327 fn test_save_skips_matrices() {
328 use ndarray::array;
329 let path = std::env::temp_dir().join("ccalc_test_workspace_matrix_skip.toml");
330 let mut env = Env::new();
331 env.insert("x".to_string(), Value::Scalar(5.0));
332 env.insert(
333 "m".to_string(),
334 Value::Matrix(array![[1.0, 2.0], [3.0, 4.0]]),
335 );
336 save_workspace(&env, &path).unwrap();
337 let content = std::fs::read_to_string(&path).unwrap();
338 assert!(content.contains("x = 5"));
339 assert!(!content.contains("m"));
340 std::fs::remove_file(&path).ok();
341 }
342
343 #[test]
344 fn test_save_load_strings() {
345 let path = std::env::temp_dir().join("ccalc_test_workspace_strings.toml");
346 let mut env = Env::new();
347 env.insert("name".to_string(), Value::Str("hello".to_string()));
348 env.insert("tag".to_string(), Value::StringObj("world".to_string()));
349 env.insert("n".to_string(), Value::Scalar(1.0));
350 save_workspace(&env, &path).unwrap();
351
352 let loaded = load_workspace(&path).unwrap();
353 assert_eq!(loaded.get("name"), Some(&Value::Str("hello".to_string())));
354 assert_eq!(
355 loaded.get("tag"),
356 Some(&Value::StringObj("world".to_string()))
357 );
358 assert_eq!(loaded.get("n"), Some(&Value::Scalar(1.0)));
359 std::fs::remove_file(&path).ok();
360 }
361
362 #[test]
363 fn test_save_skips_string_with_unsafe_chars() {
364 let path = std::env::temp_dir().join("ccalc_test_workspace_unsafe_str.toml");
365 let mut env = Env::new();
366 env.insert("s".to_string(), Value::Str("it's".to_string())); env.insert("x".to_string(), Value::Scalar(5.0));
368 save_workspace(&env, &path).unwrap();
369
370 let content = std::fs::read_to_string(&path).unwrap();
371 assert!(content.contains("x = 5"));
372 assert!(!content.contains("it's")); std::fs::remove_file(&path).ok();
374 }
375
376 #[test]
377 fn test_save_workspace_vars_selective() {
378 let path = std::env::temp_dir().join("ccalc_test_workspace_vars.toml");
379 let mut env = Env::new();
380 env.insert("x".to_string(), Value::Scalar(1.0));
381 env.insert("y".to_string(), Value::Scalar(2.0));
382 env.insert("z".to_string(), Value::Scalar(3.0));
383 save_workspace_vars(&env, &path, &["x", "z"]).unwrap();
384
385 let loaded = load_workspace(&path).unwrap();
386 assert_eq!(loaded.get("x"), Some(&Value::Scalar(1.0)));
387 assert_eq!(loaded.get("z"), Some(&Value::Scalar(3.0)));
388 assert!(!loaded.contains_key("y")); std::fs::remove_file(&path).ok();
390 }
391}