Skip to main content

ries_rs/
profile.rs

1//! Profile file support for RIES configuration
2//!
3//! Parse and load `.ries` profile files for custom configuration including
4//! user-defined constants, user-defined functions, symbol names, and symbol weights.
5
6use std::collections::HashMap;
7use std::fs;
8use std::io::{self, BufRead};
9use std::path::{Path, PathBuf};
10
11use crate::symbol::{NumType, Symbol};
12
13// Re-export UserFunction for convenience
14pub use crate::udf::UserFunction;
15
16/// Maximum supported user-defined constants/functions per search run.
17pub const MAX_USER_SYMBOLS: usize = 16;
18
19/// A user-defined constant
20#[derive(Clone, Debug)]
21pub struct UserConstant {
22    /// Weight (complexity) of this constant
23    ///
24    /// This field is part of the public API and is used when generating expressions
25    /// that include user-defined constants.
26    #[allow(dead_code)]
27    pub weight: u32,
28    /// Short name (single character)
29    pub name: String,
30    /// Description (for display)
31    ///
32    /// This field is part of the public API for documentation and display purposes.
33    #[allow(dead_code)]
34    pub description: String,
35    /// Numeric value
36    pub value: f64,
37    /// Numeric type classification
38    pub num_type: NumType,
39}
40
41/// Parsed profile configuration
42#[derive(Clone, Debug, Default)]
43pub struct Profile {
44    /// User-defined constants
45    pub constants: Vec<UserConstant>,
46    /// User-defined functions
47    pub functions: Vec<UserFunction>,
48    /// Custom symbol names (e.g., :p:π)
49    pub symbol_names: HashMap<Symbol, String>,
50    /// Custom symbol weights
51    pub symbol_weights: HashMap<Symbol, u32>,
52    /// Additional profile files to include
53    pub includes: Vec<PathBuf>,
54}
55
56impl Profile {
57    /// Create an empty profile
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Load a profile from a file
63    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    /// Load the default profile chain (~/.ries_profile, ./.ries)
68    pub fn load_default() -> Result<Self, ProfileError> {
69        let mut profile = Profile::new();
70
71        // Try to load from home directory
72        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        // Try to load from current directory
78        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    /// Add a validated user constant to this profile.
93    ///
94    /// This method centralizes validation logic to ensure consistent
95    /// handling of user constants across CLI and profile file parsing.
96    ///
97    /// # Arguments
98    ///
99    /// * `weight` - Complexity weight for this constant
100    /// * `name` - Short name (single character preferred)
101    /// * `description` - Human-readable description
102    /// * `value` - Numeric value
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if:
107    /// * `name` is empty
108    /// * `value` is not finite (NaN or infinity)
109    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        // Validate name
119        if name.is_empty() {
120            return Err(ProfileError::ValidationError(
121                "Constant name cannot be empty".to_string(),
122            ));
123        }
124
125        // Validate value is finite
126        if !value.is_finite() {
127            return Err(ProfileError::ValidationError(format!(
128                "Constant value must be finite (got {})",
129                value
130            )));
131        }
132
133        // Determine numeric type based on value characteristics
134        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    /// Add a validated user function to this profile.
154    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    /// Validate that the profile does not exceed fixed user symbol capacity.
161    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    /// Merge another profile into this one (other takes precedence)
186    pub fn merge(mut self, other: Profile) -> Result<Self, ProfileError> {
187        // Merge constants (append, later ones override by name)
188        for c in other.constants {
189            // Remove existing constant with same name
190            self.constants.retain(|existing| existing.name != c.name);
191            self.constants.push(c);
192        }
193
194        // Merge functions (append, later ones override by name)
195        for f in other.functions {
196            // Remove existing function with same name
197            self.functions.retain(|existing| existing.name != f.name);
198            self.functions.push(f);
199        }
200
201        // Merge symbol names
202        self.symbol_names.extend(other.symbol_names);
203
204        // Merge symbol weights
205        self.symbol_weights.extend(other.symbol_weights);
206
207        // Merge includes
208        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
309/// Parse a single profile line
310fn parse_profile_line(profile: &mut Profile, line: &str) -> Result<(), String> {
311    // Handle -X (user constant) lines
312    if line.starts_with("-X") {
313        return parse_user_constant(profile, line);
314    }
315
316    // Handle --define (user function) lines
317    if line.starts_with("--define") {
318        return parse_user_function(profile, line);
319    }
320
321    // Handle --symbol-names
322    if line.starts_with("--symbol-names") {
323        return parse_symbol_names(profile, line);
324    }
325
326    // Handle --symbol-weights
327    if line.starts_with("--symbol-weights") {
328        return parse_symbol_weights(profile, line);
329    }
330
331    // Unknown directive - could be a comment or unsupported option
332    // For now, just ignore silently
333    Ok(())
334}
335
336/// Parse a user constant definition
337/// Format: -X "weight:name:description:value"
338fn parse_user_constant(profile: &mut Profile, line: &str) -> Result<(), String> {
339    // Extract the quoted part
340    let rest = line[2..].trim();
341
342    // Handle both quoted and unquoted formats
343    let content = if let Some(stripped) = rest.strip_prefix('"') {
344        // Quoted format: -X "weight:name:description:value"
345        let end_quote = stripped.find('"').ok_or("Unclosed quote in -X directive")?;
346        &stripped[..end_quote]
347    } else {
348        // Unquoted format: -X weight:name:description:value
349        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    // Use Profile's centralized validation
372    profile
373        .add_constant(weight, name, description, value)
374        .map_err(|e| e.to_string())?;
375
376    Ok(())
377}
378
379/// Check if a value is likely rational (simple fraction)
380fn is_rational(v: f64) -> bool {
381    if !v.is_finite() || v == 0.0 {
382        return true;
383    }
384
385    // Check common denominators up to 100
386    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
395/// Parse a user function definition
396/// Format: --define "weight:name:description:formula"
397fn parse_user_function(profile: &mut Profile, line: &str) -> Result<(), String> {
398    // Extract the quoted part
399    let rest = line["--define".len()..].trim();
400
401    // Handle both quoted and unquoted formats
402    let content = if let Some(stripped) = rest.strip_prefix('"') {
403        // Quoted format: --define "weight:name:description:formula"
404        let end_quote = stripped
405            .find('"')
406            .ok_or("Unclosed quote in --define directive")?;
407        &stripped[..end_quote]
408    } else {
409        // Unquoted format: --define weight:name:description:formula
410        rest
411    };
412
413    // Parse the function using UserFunction::parse
414    let udf = UserFunction::parse(content)?;
415    profile.add_function(udf).map_err(|e| e.to_string())?;
416
417    Ok(())
418}
419
420/// Parse symbol names directive
421/// Format: --symbol-names :p:π :e:ℯ :f:φ
422fn 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
447/// Parse symbol weights directive
448/// Format: --symbol-weights :W:20 :p:25
449fn 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
476/// Parse include directive path.
477/// Format: --include /path/to/profile.ries
478fn 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    // Remove quotes if present
486    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/// Errors that can occur during profile loading
496#[derive(Debug)]
497pub enum ProfileError {
498    /// I/O error reading file
499    IoError(PathBuf, io::Error),
500    /// Parse error at specific line
501    ParseError(PathBuf, usize, String),
502    /// Validation error (e.g., invalid constant value)
503    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}