1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::rc::Rc;
4
5use indexmap::IndexMap;
6use ndarray::Array2;
7
8use crate::io::IoContext;
9
10type LambdaFnInner = Rc<dyn Fn(&[Value], Option<&mut IoContext>) -> Result<Value, String>>;
16
17#[derive(Clone)]
22pub struct LambdaFn(pub LambdaFnInner, pub String);
23
24impl std::fmt::Debug for LambdaFn {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 write!(f, "{}", self.1)
27 }
28}
29
30impl PartialEq for LambdaFn {
31 fn eq(&self, other: &Self) -> bool {
32 Rc::ptr_eq(&self.0, &other.0)
33 }
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub enum Value {
39 Void,
41 Scalar(f64),
43 Matrix(Array2<f64>),
46 Complex(f64, f64),
48 Str(String),
50 StringObj(String),
52 Lambda(LambdaFn),
55 Function {
61 outputs: Vec<String>,
63 params: Vec<String>,
65 body_source: String,
67 locals: IndexMap<String, Value>,
70 doc: Option<String>,
73 },
74 Tuple(Vec<Value>),
79 Cell(Vec<Value>),
84 Struct(IndexMap<String, Value>),
89 StructArray(Vec<IndexMap<String, Value>>),
94}
95
96impl Value {
97 pub fn as_scalar(&self) -> Option<f64> {
108 match self {
109 Value::Scalar(n) => Some(*n),
110 Value::Void
111 | Value::Matrix(_)
112 | Value::Complex(_, _)
113 | Value::Str(_)
114 | Value::StringObj(_)
115 | Value::Lambda(_)
116 | Value::Function { .. }
117 | Value::Tuple(_)
118 | Value::Cell(_)
119 | Value::Struct(_)
120 | Value::StructArray(_) => None,
121 }
122 }
123}
124
125pub type Env = HashMap<String, Value>;
130
131pub fn config_dir() -> PathBuf {
136 dirs::config_dir()
137 .unwrap_or_else(|| PathBuf::from("."))
138 .join("ccalc")
139}
140
141fn workspace_path() -> PathBuf {
142 config_dir().join("workspace.toml")
143}
144
145fn serialize_value(v: &Value) -> Option<String> {
149 match v {
150 Value::Scalar(n) => Some(format!("{n}")),
151 Value::Str(s) if !s.contains('\'') && !s.contains('\n') => Some(format!("'{s}'")),
153 Value::StringObj(s) if !s.contains('"') && !s.contains('\n') => Some(format!("\"{s}\"")),
155 _ => None,
156 }
157}
158
159pub fn save_workspace(env: &Env, path: &Path) -> Result<(), String> {
163 if let Some(parent) = path.parent() {
164 std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {e}"))?;
165 }
166 let mut entries: Vec<(&String, String)> = env
167 .iter()
168 .filter_map(|(k, v)| serialize_value(v).map(|s| (k, s)))
169 .collect();
170 entries.sort_by_key(|(k, _)| k.as_str());
171 let mut content = String::new();
172 for (name, val) in entries {
173 content.push_str(&format!("{name} = {val}\n"));
174 }
175 std::fs::write(path, &content).map_err(|e| format!("Cannot write {}: {e}", path.display()))
176}
177
178pub fn save_workspace_vars(env: &Env, path: &Path, vars: &[&str]) -> Result<(), String> {
181 let filtered: Env = env
182 .iter()
183 .filter(|(k, _)| vars.contains(&k.as_str()))
184 .map(|(k, v)| (k.clone(), v.clone()))
185 .collect();
186 save_workspace(&filtered, path)
187}
188
189pub fn load_workspace(path: &Path) -> Result<Env, String> {
193 let content = std::fs::read_to_string(path)
194 .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
195 let mut env = Env::new();
196 for line in content.lines() {
197 let line = line.trim();
198 if line.is_empty() || line.starts_with('%') {
199 continue;
200 }
201 if let Some((key, val)) = line.split_once('=') {
202 let key = key.trim();
203 let val = val.trim();
204 if !is_valid_ident(key) {
205 continue;
206 }
207 let value = if val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2 {
208 Value::Str(val[1..val.len() - 1].to_string())
209 } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
210 Value::StringObj(val[1..val.len() - 1].to_string())
211 } else if let Ok(n) = val.parse::<f64>() {
212 Value::Scalar(n)
213 } else {
214 continue;
215 };
216 env.insert(key.to_string(), value);
217 }
218 }
219 Ok(env)
220}
221
222pub fn save_workspace_default(env: &Env) -> Result<(), String> {
224 save_workspace(env, &workspace_path())
225}
226
227pub fn load_workspace_default() -> Result<Env, String> {
229 load_workspace(&workspace_path())
230}
231
232fn is_valid_ident(s: &str) -> bool {
233 let mut chars = s.chars();
234 match chars.next() {
235 Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
236 _ => false,
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[allow(clippy::approx_constant)]
245 #[test]
246 fn test_save_load_roundtrip() {
247 let path = std::env::temp_dir().join("ccalc_test_workspace_roundtrip.toml");
248 let mut env = Env::new();
249 env.insert("x".to_string(), Value::Scalar(42.0));
250 env.insert("y".to_string(), Value::Scalar(-3.14));
251 env.insert("ans".to_string(), Value::Scalar(10.0));
252 save_workspace(&env, &path).unwrap();
253
254 let loaded = load_workspace(&path).unwrap();
255 assert_eq!(loaded.get("x"), Some(&Value::Scalar(42.0)));
256 assert_eq!(loaded.get("y"), Some(&Value::Scalar(-3.14)));
257 assert_eq!(loaded.get("ans"), Some(&Value::Scalar(10.0)));
258 std::fs::remove_file(&path).ok();
259 }
260
261 #[test]
262 fn test_save_empty_workspace() {
263 let path = std::env::temp_dir().join("ccalc_test_workspace_empty.toml");
264 save_workspace(&Env::new(), &path).unwrap();
265 let content = std::fs::read_to_string(&path).unwrap();
266 assert!(content.is_empty());
267 std::fs::remove_file(&path).ok();
268 }
269
270 #[test]
271 fn test_load_nonexistent_returns_error() {
272 let path = std::env::temp_dir().join("ccalc_test_workspace_nonexistent_xyz.toml");
273 let _ = std::fs::remove_file(&path);
274 assert!(load_workspace(&path).is_err());
275 }
276
277 #[test]
278 fn test_load_ignores_invalid_lines() {
279 let path = std::env::temp_dir().join("ccalc_test_workspace_invalid.toml");
280 std::fs::write(&path, "# comment\n\nx = 5\n1bad = 9\ngood = abc\n").unwrap();
281 let env = load_workspace(&path).unwrap();
282 assert_eq!(env.get("x"), Some(&Value::Scalar(5.0)));
283 assert!(!env.contains_key("1bad"));
284 assert!(!env.contains_key("good")); std::fs::remove_file(&path).ok();
286 }
287
288 #[test]
289 fn test_is_valid_ident() {
290 assert!(is_valid_ident("x"));
291 assert!(is_valid_ident("my_var"));
292 assert!(is_valid_ident("_private"));
293 assert!(is_valid_ident("var1"));
294 assert!(is_valid_ident("ans"));
295 assert!(!is_valid_ident("1x"));
296 assert!(!is_valid_ident(""));
297 assert!(!is_valid_ident("a b"));
298 assert!(!is_valid_ident("a-b"));
299 }
300
301 #[test]
302 fn test_save_skips_matrices() {
303 use ndarray::array;
304 let path = std::env::temp_dir().join("ccalc_test_workspace_matrix_skip.toml");
305 let mut env = Env::new();
306 env.insert("x".to_string(), Value::Scalar(5.0));
307 env.insert(
308 "m".to_string(),
309 Value::Matrix(array![[1.0, 2.0], [3.0, 4.0]]),
310 );
311 save_workspace(&env, &path).unwrap();
312 let content = std::fs::read_to_string(&path).unwrap();
313 assert!(content.contains("x = 5"));
314 assert!(!content.contains("m"));
315 std::fs::remove_file(&path).ok();
316 }
317
318 #[test]
319 fn test_save_load_strings() {
320 let path = std::env::temp_dir().join("ccalc_test_workspace_strings.toml");
321 let mut env = Env::new();
322 env.insert("name".to_string(), Value::Str("hello".to_string()));
323 env.insert("tag".to_string(), Value::StringObj("world".to_string()));
324 env.insert("n".to_string(), Value::Scalar(1.0));
325 save_workspace(&env, &path).unwrap();
326
327 let loaded = load_workspace(&path).unwrap();
328 assert_eq!(loaded.get("name"), Some(&Value::Str("hello".to_string())));
329 assert_eq!(
330 loaded.get("tag"),
331 Some(&Value::StringObj("world".to_string()))
332 );
333 assert_eq!(loaded.get("n"), Some(&Value::Scalar(1.0)));
334 std::fs::remove_file(&path).ok();
335 }
336
337 #[test]
338 fn test_save_skips_string_with_unsafe_chars() {
339 let path = std::env::temp_dir().join("ccalc_test_workspace_unsafe_str.toml");
340 let mut env = Env::new();
341 env.insert("s".to_string(), Value::Str("it's".to_string())); env.insert("x".to_string(), Value::Scalar(5.0));
343 save_workspace(&env, &path).unwrap();
344
345 let content = std::fs::read_to_string(&path).unwrap();
346 assert!(content.contains("x = 5"));
347 assert!(!content.contains("it's")); std::fs::remove_file(&path).ok();
349 }
350
351 #[test]
352 fn test_save_workspace_vars_selective() {
353 let path = std::env::temp_dir().join("ccalc_test_workspace_vars.toml");
354 let mut env = Env::new();
355 env.insert("x".to_string(), Value::Scalar(1.0));
356 env.insert("y".to_string(), Value::Scalar(2.0));
357 env.insert("z".to_string(), Value::Scalar(3.0));
358 save_workspace_vars(&env, &path, &["x", "z"]).unwrap();
359
360 let loaded = load_workspace(&path).unwrap();
361 assert_eq!(loaded.get("x"), Some(&Value::Scalar(1.0)));
362 assert_eq!(loaded.get("z"), Some(&Value::Scalar(3.0)));
363 assert!(!loaded.contains_key("y")); std::fs::remove_file(&path).ok();
365 }
366}