1use std::collections::HashMap;
7use std::fs;
8use std::io::{self, BufRead};
9use std::path::{Path, PathBuf};
10
11use crate::symbol::{NumType, Symbol};
12
13pub use crate::udf::UserFunction;
15
16pub const MAX_USER_SYMBOLS: usize = 16;
18
19#[derive(Clone, Debug)]
21pub struct UserConstant {
22 #[allow(dead_code)]
27 pub weight: u32,
28 pub name: String,
30 #[allow(dead_code)]
34 pub description: String,
35 pub value: f64,
37 pub num_type: NumType,
39}
40
41#[derive(Clone, Debug, Default)]
43pub struct Profile {
44 pub constants: Vec<UserConstant>,
46 pub functions: Vec<UserFunction>,
48 pub symbol_names: HashMap<Symbol, String>,
50 pub symbol_weights: HashMap<Symbol, u32>,
52 pub includes: Vec<PathBuf>,
54}
55
56impl Profile {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ProfileError> {
64 load_profile_recursive(path.as_ref(), &mut Vec::new(), 0)
65 }
66
67 pub fn load_default() -> Result<Self, ProfileError> {
69 let mut profile = Profile::new();
70
71 if let Some(home) = dirs::home_dir() {
73 let home_profile = home.join(".ries_profile");
74 profile = Self::merge_if_exists(profile, &home_profile)?;
75 }
76
77 let local_profile = PathBuf::from(".ries");
79 profile = Self::merge_if_exists(profile, &local_profile)?;
80
81 Ok(profile)
82 }
83
84 fn merge_if_exists(profile: Profile, path: &Path) -> Result<Profile, ProfileError> {
85 if !path.exists() {
86 return Ok(profile);
87 }
88
89 profile.merge(Self::from_file(path)?)
90 }
91
92 pub fn add_constant(
110 &mut self,
111 weight: u32,
112 name: String,
113 description: String,
114 value: f64,
115 ) -> Result<(), ProfileError> {
116 self.ensure_user_symbol_capacity(self.constants.len() + 1, self.functions.len())?;
117
118 if name.is_empty() {
120 return Err(ProfileError::ValidationError(
121 "Constant name cannot be empty".to_string(),
122 ));
123 }
124
125 if !value.is_finite() {
127 return Err(ProfileError::ValidationError(format!(
128 "Constant value must be finite (got {})",
129 value
130 )));
131 }
132
133 let num_type = if value.fract() == 0.0 && value.abs() < 1e10 {
135 NumType::Integer
136 } else if is_rational(value) {
137 NumType::Rational
138 } else {
139 NumType::Transcendental
140 };
141
142 self.constants.push(UserConstant {
143 weight,
144 name,
145 description,
146 value,
147 num_type,
148 });
149
150 Ok(())
151 }
152
153 pub fn add_function(&mut self, udf: UserFunction) -> Result<(), ProfileError> {
155 self.ensure_user_symbol_capacity(self.constants.len(), self.functions.len() + 1)?;
156 self.functions.push(udf);
157 Ok(())
158 }
159
160 pub fn validate_user_symbol_capacity(&self) -> Result<(), ProfileError> {
162 self.ensure_user_symbol_capacity(self.constants.len(), self.functions.len())
163 }
164
165 fn ensure_user_symbol_capacity(
166 &self,
167 constant_count: usize,
168 function_count: usize,
169 ) -> Result<(), ProfileError> {
170 if constant_count > MAX_USER_SYMBOLS {
171 return Err(ProfileError::ValidationError(format!(
172 "At most {} user constants are supported (got {})",
173 MAX_USER_SYMBOLS, constant_count
174 )));
175 }
176 if function_count > MAX_USER_SYMBOLS {
177 return Err(ProfileError::ValidationError(format!(
178 "At most {} user functions are supported (got {})",
179 MAX_USER_SYMBOLS, function_count
180 )));
181 }
182 Ok(())
183 }
184
185 pub fn merge(mut self, other: Profile) -> Result<Self, ProfileError> {
187 for c in other.constants {
189 self.constants.retain(|existing| existing.name != c.name);
191 self.constants.push(c);
192 }
193
194 for f in other.functions {
196 self.functions.retain(|existing| existing.name != f.name);
198 self.functions.push(f);
199 }
200
201 self.symbol_names.extend(other.symbol_names);
203
204 self.symbol_weights.extend(other.symbol_weights);
206
207 self.includes.extend(other.includes);
209
210 self.validate_user_symbol_capacity()?;
211 Ok(self)
212 }
213}
214
215const MAX_INCLUDE_DEPTH: usize = 25;
216
217fn load_profile_recursive(
218 path: &Path,
219 include_stack: &mut Vec<PathBuf>,
220 depth: usize,
221) -> Result<Profile, ProfileError> {
222 if depth > MAX_INCLUDE_DEPTH {
223 return Err(ProfileError::ParseError(
224 path.to_path_buf(),
225 0,
226 format!("Profile include depth exceeded {}", MAX_INCLUDE_DEPTH),
227 ));
228 }
229
230 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
231 if include_stack.contains(&canonical) {
232 return Err(ProfileError::ParseError(
233 path.to_path_buf(),
234 0,
235 "Recursive --include detected".to_string(),
236 ));
237 }
238 include_stack.push(canonical);
239
240 let file = fs::File::open(path).map_err(|e| ProfileError::IoError(path.to_path_buf(), e))?;
241 let mut profile = Profile::new();
242 let reader = io::BufReader::new(file);
243
244 for (line_num, line_result) in reader.lines().enumerate() {
245 let line = line_result.map_err(|e| ProfileError::IoError(path.to_path_buf(), e))?;
246 let trimmed = line.trim();
247 if trimmed.is_empty() || trimmed.starts_with('#') {
248 continue;
249 }
250
251 if trimmed.starts_with("--include") {
252 let include_raw = parse_include_path(trimmed)
253 .map_err(|e| ProfileError::ParseError(path.to_path_buf(), line_num + 1, e))?;
254
255 let include_resolved = resolve_include_path(path, &include_raw).ok_or_else(|| {
256 ProfileError::ParseError(
257 path.to_path_buf(),
258 line_num + 1,
259 format!(
260 "Could not open '{}' or '{}.ries' for reading",
261 include_raw.display(),
262 include_raw.display()
263 ),
264 )
265 })?;
266
267 profile.includes.push(include_resolved.clone());
268 let nested = load_profile_recursive(&include_resolved, include_stack, depth + 1)?;
269 profile = profile.merge(nested)?;
270 continue;
271 }
272
273 if let Err(e) = parse_profile_line(&mut profile, trimmed) {
274 return Err(ProfileError::ParseError(
275 path.to_path_buf(),
276 line_num + 1,
277 e,
278 ));
279 }
280 }
281
282 include_stack.pop();
283 profile.validate_user_symbol_capacity()?;
284 Ok(profile)
285}
286
287fn resolve_include_path(current_file: &Path, include_path: &Path) -> Option<PathBuf> {
288 let mut candidates = Vec::new();
289
290 if include_path.is_absolute() {
291 candidates.push(include_path.to_path_buf());
292 } else {
293 let base = current_file.parent().unwrap_or_else(|| Path::new("."));
294 candidates.push(base.join(include_path));
295 }
296
297 let mut with_suffix = include_path.as_os_str().to_os_string();
298 with_suffix.push(".ries");
299 if include_path.is_absolute() {
300 candidates.push(PathBuf::from(with_suffix));
301 } else {
302 let base = current_file.parent().unwrap_or_else(|| Path::new("."));
303 candidates.push(base.join(PathBuf::from(with_suffix)));
304 }
305
306 candidates.into_iter().find(|p| p.exists())
307}
308
309fn parse_profile_line(profile: &mut Profile, line: &str) -> Result<(), String> {
311 if line.starts_with("-X") {
313 return parse_user_constant(profile, line);
314 }
315
316 if line.starts_with("--define") {
318 return parse_user_function(profile, line);
319 }
320
321 if line.starts_with("--symbol-names") {
323 return parse_symbol_names(profile, line);
324 }
325
326 if line.starts_with("--symbol-weights") {
328 return parse_symbol_weights(profile, line);
329 }
330
331 Ok(())
334}
335
336fn parse_user_constant(profile: &mut Profile, line: &str) -> Result<(), String> {
339 let rest = line[2..].trim();
341
342 let content = if let Some(stripped) = rest.strip_prefix('"') {
344 let end_quote = stripped.find('"').ok_or("Unclosed quote in -X directive")?;
346 &stripped[..end_quote]
347 } else {
348 rest
350 };
351
352 let parts: Vec<&str> = content.split(':').collect();
353 if parts.len() != 4 {
354 return Err(format!(
355 "Invalid -X format: expected 4 colon-separated parts, got {}",
356 parts.len()
357 ));
358 }
359
360 let weight: u32 = parts[0]
361 .parse()
362 .map_err(|_| format!("Invalid weight: {}", parts[0]))?;
363
364 let name = parts[1].to_string();
365 let description = parts[2].to_string();
366
367 let value: f64 = parts[3]
368 .parse()
369 .map_err(|_| format!("Invalid value: {}", parts[3]))?;
370
371 profile
373 .add_constant(weight, name, description, value)
374 .map_err(|e| e.to_string())?;
375
376 Ok(())
377}
378
379fn is_rational(v: f64) -> bool {
381 if !v.is_finite() || v == 0.0 {
382 return true;
383 }
384
385 for denom in 1..=100_u32 {
387 let numer = v * denom as f64;
388 if (numer.round() - numer).abs() < 1e-10 {
389 return true;
390 }
391 }
392 false
393}
394
395fn parse_user_function(profile: &mut Profile, line: &str) -> Result<(), String> {
398 let rest = line["--define".len()..].trim();
400
401 let content = if let Some(stripped) = rest.strip_prefix('"') {
403 let end_quote = stripped
405 .find('"')
406 .ok_or("Unclosed quote in --define directive")?;
407 &stripped[..end_quote]
408 } else {
409 rest
411 };
412
413 let udf = UserFunction::parse(content)?;
415 profile.add_function(udf).map_err(|e| e.to_string())?;
416
417 Ok(())
418}
419
420fn parse_symbol_names(profile: &mut Profile, line: &str) -> Result<(), String> {
423 let rest = line["--symbol-names".len()..].trim();
424
425 for part in rest.split_whitespace() {
426 if !part.starts_with(':') {
427 continue;
428 }
429
430 let inner = &part[1..];
431 if let Some(colon_pos) = inner.find(':') {
432 let symbol_char = inner[..colon_pos]
433 .chars()
434 .next()
435 .ok_or("Empty symbol in --symbol-names")?;
436 let name = inner[colon_pos + 1..].to_string();
437
438 if let Some(symbol) = Symbol::from_byte(symbol_char as u8) {
439 profile.symbol_names.insert(symbol, name);
440 }
441 }
442 }
443
444 Ok(())
445}
446
447fn parse_symbol_weights(profile: &mut Profile, line: &str) -> Result<(), String> {
450 let rest = line["--symbol-weights".len()..].trim();
451
452 for part in rest.split_whitespace() {
453 if !part.starts_with(':') {
454 continue;
455 }
456
457 let inner = &part[1..];
458 if let Some(colon_pos) = inner.find(':') {
459 let symbol_char = inner[..colon_pos]
460 .chars()
461 .next()
462 .ok_or("Empty symbol in --symbol-weights")?;
463 let weight: u32 = inner[colon_pos + 1..]
464 .parse()
465 .map_err(|_| format!("Invalid weight in --symbol-weights: {}", inner))?;
466
467 if let Some(symbol) = Symbol::from_byte(symbol_char as u8) {
468 profile.symbol_weights.insert(symbol, weight);
469 }
470 }
471 }
472
473 Ok(())
474}
475
476fn parse_include_path(line: &str) -> Result<PathBuf, String> {
479 let rest = line["--include".len()..].trim();
480
481 if rest.is_empty() {
482 return Err("--include requires a filename".to_string());
483 }
484
485 let path_str = if rest.starts_with('"') && rest.ends_with('"') {
487 &rest[1..rest.len() - 1]
488 } else {
489 rest
490 };
491
492 Ok(PathBuf::from(path_str))
493}
494
495#[derive(Debug)]
497pub enum ProfileError {
498 IoError(PathBuf, io::Error),
500 ParseError(PathBuf, usize, String),
502 ValidationError(String),
504}
505
506impl std::fmt::Display for ProfileError {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 match self {
509 ProfileError::IoError(path, e) => {
510 write!(f, "Error reading {}: {}", path.display(), e)
511 }
512 ProfileError::ParseError(path, line, msg) => {
513 write!(
514 f,
515 "Parse error in {} at line {}: {}",
516 path.display(),
517 line,
518 msg
519 )
520 }
521 ProfileError::ValidationError(msg) => {
522 write!(f, "Validation error: {}", msg)
523 }
524 }
525 }
526}
527
528impl std::error::Error for ProfileError {}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 fn temp_profile_path(name: &str) -> PathBuf {
535 std::env::temp_dir().join(format!(
536 "ries-rs-profile-test-{}-{}-{}.ries",
537 std::process::id(),
538 name,
539 std::time::SystemTime::now()
540 .duration_since(std::time::UNIX_EPOCH)
541 .expect("system clock should follow Unix epoch")
542 .as_nanos()
543 ))
544 }
545
546 #[test]
547 fn test_default_profile_merge_propagates_parse_errors() {
548 let path = temp_profile_path("invalid");
549 fs::write(&path, r#"-X "4:broken""#).expect("write invalid profile");
550
551 let result = Profile::merge_if_exists(Profile::new(), &path);
552 let _ = fs::remove_file(&path);
553
554 let err = result.expect_err("invalid default profile should not be ignored");
555 assert!(matches!(err, ProfileError::ParseError(_, 1, _)));
556 }
557
558 #[test]
559 fn test_parse_user_constant() {
560 let mut profile = Profile::new();
561 parse_user_constant(
562 &mut profile,
563 r#"-X "4:gamma:Euler's constant:0.5772156649""#,
564 )
565 .unwrap();
566
567 assert_eq!(profile.constants.len(), 1);
568 assert_eq!(profile.constants[0].name, "gamma");
569 assert_eq!(profile.constants[0].weight, 4);
570 assert!((profile.constants[0].value - 0.5772156649).abs() < 1e-10);
571 }
572
573 #[test]
574 fn test_parse_symbol_names() {
575 let mut profile = Profile::new();
576 parse_symbol_names(&mut profile, "--symbol-names :p:π :e:ℯ").unwrap();
577
578 assert_eq!(
579 profile.symbol_names.get(&Symbol::Pi),
580 Some(&"π".to_string())
581 );
582 assert_eq!(profile.symbol_names.get(&Symbol::E), Some(&"ℯ".to_string()));
583 }
584
585 #[test]
586 fn test_parse_symbol_weights() {
587 let mut profile = Profile::new();
588 parse_symbol_weights(&mut profile, "--symbol-weights :W:20 :p:25").unwrap();
589
590 assert_eq!(profile.symbol_weights.get(&Symbol::LambertW), Some(&20));
591 assert_eq!(profile.symbol_weights.get(&Symbol::Pi), Some(&25));
592 }
593
594 #[test]
595 fn test_profile_merge() {
596 let mut p1 = Profile::new();
597 p1.constants.push(UserConstant {
598 weight: 4,
599 name: "a".to_string(),
600 description: "First".to_string(),
601 value: 1.0,
602 num_type: NumType::Integer,
603 });
604
605 let mut p2 = Profile::new();
606 p2.constants.push(UserConstant {
607 weight: 5,
608 name: "b".to_string(),
609 description: "Second".to_string(),
610 value: 2.0,
611 num_type: NumType::Integer,
612 });
613 p2.symbol_names.insert(Symbol::Pi, "π".to_string());
614
615 let merged = p1.merge(p2).unwrap();
616
617 assert_eq!(merged.constants.len(), 2);
618 assert_eq!(merged.symbol_names.len(), 1);
619 }
620
621 #[test]
622 fn test_merge_rejects_over_capacity() {
623 let mut p1 = Profile::new();
624 let mut p2 = Profile::new();
625
626 for idx in 0..MAX_USER_SYMBOLS {
627 p1.add_constant(4, format!("a{}", idx), "constant".to_string(), idx as f64)
628 .unwrap();
629 }
630 p2.add_constant(4, "overflow".to_string(), "constant".to_string(), 99.0)
631 .unwrap();
632
633 let err = p1
634 .merge(p2)
635 .expect_err("merging over-capacity profiles should fail");
636 assert!(err.to_string().contains("At most 16 user constants"));
637 }
638
639 #[test]
640 fn test_add_constant_rejects_over_capacity() {
641 let mut profile = Profile::new();
642 for idx in 0..MAX_USER_SYMBOLS {
643 profile
644 .add_constant(4, format!("c{}", idx), "constant".to_string(), idx as f64)
645 .unwrap();
646 }
647
648 let err = profile
649 .add_constant(4, "overflow".to_string(), "constant".to_string(), 17.0)
650 .expect_err("17th constant should be rejected");
651
652 assert!(err.to_string().contains("At most 16 user constants"));
653 }
654
655 #[test]
656 fn test_add_function_rejects_over_capacity() {
657 let mut profile = Profile::new();
658 for idx in 0..MAX_USER_SYMBOLS {
659 let udf = UserFunction::parse(&format!("4:f{}:desc:E", idx)).unwrap();
660 profile.add_function(udf).unwrap();
661 }
662
663 let udf = UserFunction::parse("4:overflow:desc:E").unwrap();
664 let err = profile
665 .add_function(udf)
666 .expect_err("17th function should be rejected");
667
668 assert!(err.to_string().contains("At most 16 user functions"));
669 }
670}