Skip to main content

badterm_core/
ripvars.rs

1//! RIPscrip's text variables (see [`crate::rip`]): `$DATE$`, `$TIME$` and
2//! the other built-in ones, and the user's own, which a BBS defines with
3//! `RIP_DEFINE` (`1D`) and fills into what buttons send or what it asks
4//! with `RIP_QUERY` (`1 ESC`).
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use chrono::{Datelike, Local, Timelike};
10
11/// A user variable's definition (`1D`): `NAME[,width]:[?question?][default]`.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Define {
14    pub name: String,
15    pub width: usize,
16    pub question: String,
17    pub default: String,
18}
19
20impl Define {
21    pub fn parse(text: &[u8]) -> Option<Define> {
22        let text = String::from_utf8_lossy(text);
23        let (head, rest) = text.split_once(':')?;
24        let (name, width) = match head.split_once(',') {
25            Some((n, w)) => (n, w.trim().parse().unwrap_or(60)),
26            None => (head, 60),
27        };
28        let name = name.trim().to_ascii_uppercase();
29        if !is_name(&name) {
30            return None;
31        }
32        let (question, default) = match rest.strip_prefix('?').and_then(|r| r.split_once('?')) {
33            Some((q, d)) => (q.to_string(), d.to_string()),
34            None => (String::new(), rest.to_string()),
35        };
36        Some(Define { name, width: width.clamp(1, 60), question, default })
37    }
38
39    /// What to ask: the BBS's question, or RIPterm's own.
40    pub fn prompt(&self) -> String {
41        if self.question.is_empty() { format!("Enter {}", self.name) } else { self.question.clone() }
42    }
43}
44
45/// A variable's name: 1-12 letters, digits and underscores, not starting
46/// with a digit or an underscore.
47pub fn is_name(s: &str) -> bool {
48    (1..=12).contains(&s.len())
49        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
50        && s.starts_with(|c: char| c.is_ascii_alphabetic())
51}
52
53/// The built-in variables that give a value. `mouse`: where the mouse was
54/// last clicked on the graphics, if it was.
55pub fn builtin(name: &str, mouse: Option<(usize, usize)>) -> Option<String> {
56    let now = Local::now();
57    let (x, y) = mouse.unwrap_or((0, 0));
58    let hour12 = match now.hour() % 12 {
59        0 => 12,
60        h => h,
61    };
62    Some(match name {
63        "RIPVER" => String::from_utf8_lossy(crate::rip::VERSION_REPLY).into_owned(),
64        "DATE" => now.format("%m/%d/%y").to_string(),
65        "MONTH" => now.format("%B").to_string(),
66        "MONTHNUM" => format!("{:02}", now.month()),
67        "DAY" => format!("{:02}", now.day()),
68        "DOY" => format!("{:03}", now.ordinal()),
69        "YEAR" => format!("{:02}", now.year() % 100),
70        "FYEAR" => format!("{:04}", now.year()),
71        "TIME" => now.format("%H:%M:%S").to_string(),
72        "HOUR" => format!("{hour12:02}"),
73        "MHOUR" => format!("{:02}", now.hour()),
74        "MIN" => format!("{:02}", now.minute()),
75        "SEC" => format!("{:02}", now.second()),
76        "AMPM" => if now.hour() < 12 { "AM" } else { "PM" }.into(),
77        "DATETIME" => now.format("%a %b %d %H:%M:%S %Y").to_string(),
78        "TIMEZONE" => now.format("%Z").to_string(),
79        "DOW" => now.format("%A").to_string(),
80        "ADOW" => now.format("%a").to_string(),
81        "WDAY" => format!("{}", now.weekday().num_days_from_sunday()),
82        "WOY" => now.format("%U").to_string(),
83        "WOYM" => now.format("%W").to_string(),
84        "X" => format!("{x:04}"),
85        "Y" => format!("{y:04}"),
86        "XY" => format!("{x:04}:{y:04}"),
87        "XYM" => format!("{x:04}:{y:04}:000"),
88        "M" => "000".into(),
89        "MSTAT" => "YES".into(),
90        "STATBAR" | "TWIN" | "CURSOR" => "ON".into(),
91        _ => return None,
92    })
93}
94
95/// The variables saved for a BBS: `NAME=value` lines.
96pub fn load(path: &Path) -> BTreeMap<String, String> {
97    std::fs::read_to_string(path)
98        .unwrap_or_default()
99        .lines()
100        .filter_map(|l| l.split_once('='))
101        .filter(|(n, _)| is_name(n))
102        .map(|(n, v)| (n.to_string(), v.to_string()))
103        .collect()
104}
105
106pub fn save(path: &Path, vars: &BTreeMap<String, String>) -> std::io::Result<()> {
107    if let Some(dir) = path.parent() {
108        std::fs::create_dir_all(dir)?;
109    }
110    let text: String = vars.iter().map(|(n, v)| format!("{n}={}\n", v.replace(['\r', '\n'], " "))).collect();
111    std::fs::write(path, text)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn definitions_and_builtins() {
120        let d = Define::parse(b"full_name,30:?What is your full name?John Doe").unwrap();
121        assert_eq!(
122            d,
123            Define {
124                name: "FULL_NAME".into(),
125                width: 30,
126                question: "What is your full name".into(),
127                default: "John Doe".into()
128            }
129        );
130        let d = Define::parse(b"AGE:").unwrap();
131        assert_eq!((d.width, d.prompt(), d.default.as_str()), (60, "Enter AGE".into(), ""));
132        assert!(Define::parse(b"_X:").is_none() && Define::parse(b"no colon").is_none());
133        assert_eq!(builtin("RIPVER", None).as_deref(), Some("RIPSCRIP015400"));
134        assert_eq!(builtin("XY", Some((297, 321))).as_deref(), Some("0297:0321"));
135        assert_eq!(builtin("DATE", None).unwrap().len(), 8);
136        assert!(builtin("NOPE", None).is_none());
137    }
138}