Skip to main content

badterm_core/
dialer.rs

1//! The dialing directory: saved BBS entries, stored as TOML, with import and
2//! export in SyncTERM `.lst`, CSV, JSON and TOML formats.
3
4use std::fmt;
5use std::fs;
6use std::path::Path;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use serde::{Deserialize, Serialize};
11
12use crate::ansi::Encoding;
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum Protocol {
17    #[default]
18    Telnet,
19    /// Telnet over TLS.
20    #[serde(rename = "telnets")]
21    TelnetS,
22    /// Plain TCP with no telnet processing.
23    Raw,
24    Ssh,
25    Rlogin,
26    /// A real modem on a serial port, dialing the entry's phone number (its
27    /// address) with the modem settings (see [`crate::modem`]).
28    Modem,
29}
30
31impl Protocol {
32    pub const ALL: [Protocol; 6] =
33        [Protocol::Telnet, Protocol::TelnetS, Protocol::Raw, Protocol::Ssh, Protocol::Rlogin, Protocol::Modem];
34
35    /// The usual port; 0 for a modem, which has none.
36    pub fn default_port(self) -> u16 {
37        match self {
38            Protocol::Telnet | Protocol::Raw => 23,
39            Protocol::TelnetS => 992,
40            Protocol::Ssh => 22,
41            Protocol::Rlogin => 513,
42            Protocol::Modem => 0,
43        }
44    }
45
46    /// SyncTERM's names for them, too.
47    pub fn name(self) -> &'static str {
48        match self {
49            Protocol::Telnet => "Telnet",
50            Protocol::TelnetS => "TelnetS",
51            Protocol::Raw => "Raw",
52            Protocol::Ssh => "SSH",
53            Protocol::Rlogin => "RLogin",
54            Protocol::Modem => "Modem",
55        }
56    }
57
58    /// Whether the connection speaks the telnet protocol.
59    pub fn is_telnet(self) -> bool {
60        matches!(self, Protocol::Telnet | Protocol::TelnetS)
61    }
62
63    fn from_name(s: &str) -> Option<Protocol> {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "telnet" => Some(Protocol::Telnet),
66            "telnets" => Some(Protocol::TelnetS),
67            "raw" => Some(Protocol::Raw),
68            "ssh" | "sshna" => Some(Protocol::Ssh),
69            "rlogin" | "rloginreversed" => Some(Protocol::Rlogin),
70            "modem" | "dialup" | "serial" => Some(Protocol::Modem),
71            _ => None,
72        }
73    }
74}
75
76impl fmt::Display for Protocol {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(self.name())
79    }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(default)]
84pub struct Entry {
85    pub name: String,
86    pub host: String,
87    pub port: u16,
88    pub protocol: Protocol,
89    pub encoding: Encoding,
90    pub username: String,
91    /// Stored in plain text, as in other BBS dialers.
92    pub password: String,
93    /// Answer the BBS's login prompts with `username` and `password`.
94    #[serde(skip_serializing_if = "is_true")]
95    pub auto_login: bool,
96    pub term_type: String,
97    /// Screen size: one of [`SCREEN_MODES`].
98    #[serde(skip_serializing_if = "is_80")]
99    pub cols: u16,
100    #[serde(skip_serializing_if = "is_25")]
101    pub rows: u16,
102    /// The font, as a CTerm font ID: 0 for CP437, or an Amiga font for
103    /// Amiga BBSes.
104    #[serde(skip_serializing_if = "is_zero")]
105    pub font: u8,
106    /// A font file (see [`crate::fontfile`]) in place of `font`: a name in
107    /// the fonts folder, or a path.
108    #[serde(skip_serializing_if = "String::is_empty")]
109    pub font_file: String,
110    /// iCE colors (the blink bit as a bright background) from the start:
111    /// None leaves it to the BBS (`CSI ?33h`), as before.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub ice: Option<bool>,
114    /// Baud rate emulation: show what the BBS sends at this speed, as over
115    /// a modem (0: as fast as it arrives).
116    #[serde(skip_serializing_if = "is_zero_u32")]
117    pub baud: u32,
118    /// Phosphor mode for this system; None follows the global setting.
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub crt: Option<CrtMode>,
121    /// What the bell (Ctrl-G) does on this system; None follows the global
122    /// setting.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub bell: Option<BellMode>,
125    /// Start capturing the session to a file as soon as it connects.
126    #[serde(skip_serializing_if = "is_false")]
127    pub capture: bool,
128    /// The capture file's name, with `{name}`, `{date}` and `{time}` filled
129    /// in; relative names are in the captures folder, and a `.txt` name
130    /// keeps only the text (see [`crate::session::CaptureKind`]). Empty for
131    /// the usual `name-date-time.ans`.
132    #[serde(skip_serializing_if = "String::is_empty")]
133    pub capture_file: String,
134    /// SSH private key to try first; otherwise the SSH agent and the usual
135    /// `~/.ssh/id_*` files are tried.
136    #[serde(skip_serializing_if = "String::is_empty")]
137    pub key_file: String,
138    /// Where this BBS's downloads go, and where uploads to it start
139    /// looking; empty for the folders in the settings.
140    #[serde(skip_serializing_if = "String::is_empty")]
141    pub download_dir: String,
142    #[serde(skip_serializing_if = "String::is_empty")]
143    pub upload_dir: String,
144    /// No status bar under this BBS's screen.
145    #[serde(skip_serializing_if = "is_false")]
146    pub hide_status: bool,
147    /// The group it is listed in (the directory can show one at a time).
148    #[serde(skip_serializing_if = "String::is_empty")]
149    pub group: String,
150    /// A script (see [`crate::script`]) run once the call is connected.
151    #[serde(skip_serializing_if = "String::is_empty")]
152    pub script: String,
153    /// Its own keyboard macros; an empty one leaves the settings' one.
154    #[serde(skip_serializing_if = "no_macros")]
155    pub macros: Vec<String>,
156    pub comment: String,
157    /// The password is kept in the OS keychain rather than in `password`.
158    #[serde(skip_serializing_if = "is_false")]
159    pub keychain: bool,
160    /// Pinned entries sort ahead of the rest.
161    #[serde(skip_serializing_if = "is_false")]
162    pub pinned: bool,
163    /// Unix timestamps.
164    pub added: Option<u64>,
165    pub last_connected: Option<u64>,
166    pub calls: u32,
167}
168
169impl Default for Entry {
170    fn default() -> Self {
171        Entry {
172            name: String::new(),
173            host: String::new(),
174            port: 23,
175            protocol: Protocol::Telnet,
176            encoding: Encoding::Cp437,
177            username: String::new(),
178            password: String::new(),
179            auto_login: true,
180            term_type: "ANSI".into(),
181            cols: 80,
182            rows: 25,
183            font: 0,
184            font_file: String::new(),
185            ice: None,
186            baud: 0,
187            crt: None,
188            bell: None,
189            capture: false,
190            capture_file: String::new(),
191            key_file: String::new(),
192            download_dir: String::new(),
193            upload_dir: String::new(),
194            hide_status: false,
195            group: String::new(),
196            script: String::new(),
197            macros: Vec::new(),
198            comment: String::new(),
199            keychain: false,
200            pinned: false,
201            added: None,
202            last_connected: None,
203            calls: 0,
204        }
205    }
206}
207
208impl Entry {
209    /// The screen size, (columns, rows): the entry's, if it is one BadTerm
210    /// has, or 80x25.
211    pub fn screen_mode(&self) -> (u16, u16) {
212        let mode = (self.cols, self.rows);
213        if SCREEN_MODES.contains(&mode) { mode } else { SCREEN_MODES[0] }
214    }
215
216    /// Parse `telnet://host:port`, `ssh://user@host`, `host:port` or `host`
217    /// into an entry.
218    pub fn from_address(addr: &str) -> Option<Entry> {
219        let addr = addr.trim();
220        let (protocol, rest) = match addr.split_once("://") {
221            Some((scheme, rest)) => (Protocol::from_name(scheme)?, rest),
222            None => (Protocol::Telnet, addr),
223        };
224        let rest = rest.trim_end_matches('/');
225        let (username, rest) = match rest.rsplit_once('@') {
226            Some((user, host)) => (user.to_string(), host),
227            None => (String::new(), rest),
228        };
229        let (host, port) = split_host_port(rest)?;
230        let port = port.unwrap_or(protocol.default_port());
231        Some(Entry { name: host.clone(), host, port, protocol, username, added: Some(now()), ..Entry::default() })
232    }
233
234    pub fn address(&self) -> String {
235        if self.port == self.protocol.default_port() || self.protocol == Protocol::Modem {
236            self.host.clone()
237        } else if self.host.contains(':') {
238            format!("[{}]:{}", self.host, self.port)
239        } else {
240            format!("{}:{}", self.host, self.port)
241        }
242    }
243
244    fn same_system(&self, other: &Entry) -> bool {
245        self.host.eq_ignore_ascii_case(&other.host) && self.port == other.port && self.protocol == other.protocol
246    }
247
248    /// Identifies the system (protocol, host and port), e.g. for remembering
249    /// which default entries were already offered.
250    pub fn system_id(&self) -> String {
251        format!("{}://{}:{}", self.protocol.name().to_ascii_lowercase(), self.host.to_ascii_lowercase(), self.port)
252    }
253}
254
255fn no_macros(m: &[String]) -> bool {
256    m.iter().all(String::is_empty)
257}
258
259fn is_false(b: &bool) -> bool {
260    !*b
261}
262
263fn is_true(b: &bool) -> bool {
264    *b
265}
266
267fn is_zero(n: &u8) -> bool {
268    *n == 0
269}
270
271fn is_zero_u32(n: &u32) -> bool {
272    *n == 0
273}
274
275/// The baud rates an entry can emulate; 0 is no limit.
276pub const BAUD_RATES: [u32; 11] = [0, 300, 1200, 2400, 9600, 14400, 19200, 28800, 33600, 57600, 115200];
277
278fn is_80(n: &u16) -> bool {
279    *n == 80
280}
281
282fn is_25(n: &u16) -> bool {
283    *n == 25
284}
285
286/// The screen sizes (columns, rows) an entry can ask for. Modes of 43 rows
287/// or more use the 8x8 font.
288/// The screen modes, as SyncTERM has them: 80x28 is in the VGA's 8x14
289/// font, 43 lines and more in 8x8.
290pub const SCREEN_MODES: [(u16, u16); 10] =
291    [(80, 25), (80, 28), (80, 30), (80, 43), (80, 50), (80, 60), (40, 25), (132, 25), (132, 37), (132, 52)];
292
293/// What the bell (Ctrl-G) does.
294#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
295#[serde(rename_all = "lowercase")]
296pub enum BellMode {
297    /// A beep.
298    #[default]
299    Sound,
300    /// The screen flashes.
301    Visual,
302    Both,
303    Off,
304}
305
306impl BellMode {
307    pub const ALL: [BellMode; 4] = [BellMode::Sound, BellMode::Visual, BellMode::Both, BellMode::Off];
308
309    pub fn name(self) -> &'static str {
310        match self {
311            BellMode::Sound => "Beep",
312            BellMode::Visual => "Flash",
313            BellMode::Both => "Beep and flash",
314            BellMode::Off => "Off",
315        }
316    }
317
318    pub fn sound(self) -> bool {
319        matches!(self, BellMode::Sound | BellMode::Both)
320    }
321
322    pub fn visual(self) -> bool {
323        matches!(self, BellMode::Visual | BellMode::Both)
324    }
325}
326
327/// "Phosphor mode": the look of a CRT, drawn by the front end.
328#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum CrtMode {
331    #[default]
332    Off,
333    /// A color CRT.
334    Color,
335    /// A green-phosphor monochrome monitor (P1).
336    Green,
337    /// An amber monochrome monitor (P3).
338    Amber,
339}
340
341impl CrtMode {
342    pub const ALL: [CrtMode; 4] = [CrtMode::Off, CrtMode::Color, CrtMode::Green, CrtMode::Amber];
343
344    pub fn next(self) -> CrtMode {
345        let i = CrtMode::ALL.iter().position(|&m| m == self).unwrap_or(0);
346        CrtMode::ALL[(i + 1) % CrtMode::ALL.len()]
347    }
348
349    pub fn name(self) -> &'static str {
350        match self {
351            CrtMode::Off => "Off",
352            CrtMode::Color => "Color CRT",
353            CrtMode::Green => "Green screen",
354            CrtMode::Amber => "Amber screen",
355        }
356    }
357}
358
359/// The entries a new directory starts with, first among them the project's
360/// own BBS. More can be added here over time: existing directories pick up
361/// new ones once (see [`Directory::offer_defaults`]).
362pub fn default_entries() -> Vec<Entry> {
363    vec![Entry {
364        name: "The Vortex BBS".into(),
365        host: "vortexbbs.com".into(),
366        port: 23,
367        protocol: Protocol::Telnet,
368        comment: "Home BBS of the BadTerm project".into(),
369        pinned: true,
370        ..Entry::default()
371    }]
372}
373
374fn split_host_port(s: &str) -> Option<(String, Option<u16>)> {
375    if s.is_empty() {
376        return None;
377    }
378    if let Some(rest) = s.strip_prefix('[') {
379        let (host, after) = rest.split_once(']')?;
380        let port = match after.strip_prefix(':') {
381            Some(p) => Some(p.parse().ok()?),
382            None => None,
383        };
384        return Some((host.to_string(), port));
385    }
386    match s.rsplit_once(':') {
387        // A bare IPv6 address has several colons and no port.
388        Some((h, p)) if !h.contains(':') => Some((h.to_string(), Some(p.parse().ok()?))),
389        _ => Some((s.to_string(), None)),
390    }
391}
392
393pub fn now() -> u64 {
394    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
395}
396
397#[derive(Clone, Copy, Debug, PartialEq, Eq)]
398pub enum Format {
399    Toml,
400    Json,
401    /// Ours, or a list such as the Telnet BBS Guide's `bbslist.csv`.
402    Csv,
403    SyncTerm,
404    /// MagiTerm's `magiterm.ini`: SyncTERM's layout, but every entry SSH.
405    /// Import only.
406    MagiTerm,
407    /// EtherTerm's `dialdirectory.xml`. Import only.
408    EtherTerm,
409    /// NetRunner's binary `netrunner.phn`. Import only.
410    NetRunner,
411    /// Qodem's `fonebook.txt`. Import only.
412    Qodem,
413}
414
415impl Format {
416    pub fn from_path(path: &Path) -> Option<Format> {
417        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
418        match ext.as_str() {
419            "toml" => Some(Format::Toml),
420            "json" => Some(Format::Json),
421            "csv" => Some(Format::Csv),
422            "ini" if path.file_stem().is_some_and(|s| s.eq_ignore_ascii_case("magiterm")) => Some(Format::MagiTerm),
423            "lst" | "ini" => Some(Format::SyncTerm),
424            "xml" => Some(Format::EtherTerm),
425            "phn" => Some(Format::NetRunner),
426            "txt" if path.file_stem().is_some_and(|s| s.eq_ignore_ascii_case("fonebook")) => Some(Format::Qodem),
427            _ => None,
428        }
429    }
430}
431
432/// How the dialing directory is listed. Pinned entries always come first.
433#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "snake_case")]
435pub enum SortOrder {
436    #[default]
437    Name,
438    /// Most recently called first.
439    LastCalled,
440    /// Most called first.
441    Calls,
442}
443
444impl SortOrder {
445    pub fn name(self) -> &'static str {
446        match self {
447            SortOrder::Name => "Name",
448            SortOrder::LastCalled => "Last called",
449            SortOrder::Calls => "Calls",
450        }
451    }
452
453    pub fn next(self) -> SortOrder {
454        match self {
455            SortOrder::Name => SortOrder::LastCalled,
456            SortOrder::LastCalled => SortOrder::Calls,
457            SortOrder::Calls => SortOrder::Name,
458        }
459    }
460
461    fn is_default(&self) -> bool {
462        *self == SortOrder::Name
463    }
464}
465
466#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
467pub struct Directory {
468    /// How the list is shown.
469    #[serde(default, skip_serializing_if = "SortOrder::is_default")]
470    pub sort: SortOrder,
471    /// Default entries already offered to this directory (by
472    /// [`Entry::system_id`]), so one the user deleted does not come back.
473    #[serde(default, skip_serializing_if = "Vec::is_empty")]
474    pub defaults_offered: Vec<String>,
475    #[serde(default, rename = "entry")]
476    pub entries: Vec<Entry>,
477}
478
479impl Directory {
480    /// The groups entries are in, in order, without repeats (ignoring case).
481    pub fn groups(&self) -> Vec<String> {
482        let mut groups: Vec<String> = Vec::new();
483        for e in &self.entries {
484            let g = e.group.trim();
485            if !g.is_empty() && !groups.iter().any(|h| h.eq_ignore_ascii_case(g)) {
486                groups.push(g.to_string());
487            }
488        }
489        groups.sort_by_key(|g| g.to_lowercase());
490        groups
491    }
492
493    /// Load the directory, returning an empty one if the file does not exist.
494    pub fn load(path: &Path) -> Result<Directory> {
495        match fs::read_to_string(path) {
496            Ok(s) => toml::from_str(&s).with_context(|| format!("parsing {}", path.display())),
497            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Directory::default()),
498            Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
499        }
500    }
501
502    /// Save atomically (write to a temporary file, then rename).
503    pub fn save(&self, path: &Path) -> Result<()> {
504        if let Some(dir) = path.parent() {
505            fs::create_dir_all(dir)?;
506        }
507        let tmp = path.with_extension("toml.tmp");
508        fs::write(&tmp, toml::to_string_pretty(self)?)?;
509        fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))
510    }
511
512    /// Pinned entries first, then by name.
513    pub fn sort(&mut self) {
514        self.entries.sort_by_key(|e| (!e.pinned, e.name.to_lowercase()));
515    }
516
517    /// Indices of the entries to list, in `self.sort` order, keeping those
518    /// whose name, address or comment contains `filter` (ignoring case).
519    pub fn view(&self, filter: &str) -> Vec<usize> {
520        self.view_group(filter, None)
521    }
522
523    /// [`Directory::view`], of one group's entries.
524    pub fn view_group(&self, filter: &str, group: Option<&str>) -> Vec<usize> {
525        let filter = filter.trim().to_lowercase();
526        let mut v: Vec<usize> = (0..self.entries.len())
527            .filter(|&i| {
528                let e = &self.entries[i];
529                group.is_none_or(|g| e.group.trim().eq_ignore_ascii_case(g))
530                    && (filter.is_empty()
531                        || [&e.name, &e.host, &e.comment, &e.group].iter().any(|f| f.to_lowercase().contains(&filter))
532                        || e.address().to_lowercase().contains(&filter))
533            })
534            .collect();
535        let name = |i: &usize| self.entries[*i].name.to_lowercase();
536        match self.sort {
537            SortOrder::Name => v.sort_by_key(|i| (!self.entries[*i].pinned, name(i))),
538            SortOrder::LastCalled => v.sort_by_key(|i| {
539                let e = &self.entries[*i];
540                (!e.pinned, std::cmp::Reverse(e.last_connected.unwrap_or(0)), name(i))
541            }),
542            SortOrder::Calls => v.sort_by_key(|i| {
543                let e = &self.entries[*i];
544                (!e.pinned, std::cmp::Reverse(e.calls), name(i))
545            }),
546        }
547        v
548    }
549
550    /// Add any default entries this directory has not been offered before.
551    /// Returns true if the directory changed and should be saved.
552    pub fn offer_defaults(&mut self) -> bool {
553        let mut changed = false;
554        for e in default_entries() {
555            let id = e.system_id();
556            if self.defaults_offered.contains(&id) {
557                continue;
558            }
559            if !self.entries.iter().any(|x| x.same_system(&e)) {
560                self.entries.push(Entry { added: Some(now()), ..e });
561            }
562            self.defaults_offered.push(id);
563            changed = true;
564        }
565        if changed {
566            self.sort();
567        }
568        changed
569    }
570
571    /// Add entries that are not already present (same host, port and
572    /// protocol). Returns how many were added.
573    pub fn merge(&mut self, new: Vec<Entry>) -> usize {
574        let mut added = 0;
575        for e in new {
576            if !self.entries.iter().any(|x| x.same_system(&e)) {
577                self.entries.push(e);
578                added += 1;
579            }
580        }
581        self.sort();
582        added
583    }
584}
585
586/// Read entries from a file, choosing the format by extension (falling back
587/// to sniffing the content).
588pub fn import(path: &Path) -> Result<Vec<Entry>> {
589    let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
590    // NetRunner's phone book is binary.
591    if bytes.starts_with(NETRUNNER_MAGIC) || Format::from_path(path) == Some(Format::NetRunner) {
592        return Ok(finish_import(from_netrunner(&bytes)?));
593    }
594    // SyncTERM lists from Windows may be in a legacy code page.
595    let text = match String::from_utf8(bytes) {
596        Ok(s) => s,
597        Err(e) => crate::cp437::decode(e.as_bytes()),
598    };
599    let text = text.trim_start_matches('\u{feff}');
600    let format = Format::from_path(path).or_else(|| sniff(text));
601    let entries = match format {
602        Some(Format::Toml) => toml::from_str::<Directory>(text)?.entries,
603        Some(Format::Json) => from_json(text)?,
604        Some(Format::Csv) => from_csv(text)?,
605        Some(Format::SyncTerm) => from_syncterm(text),
606        Some(Format::MagiTerm) => from_syncterm(text)
607            .into_iter()
608            .map(|mut e| {
609                if e.protocol == Protocol::Telnet {
610                    e.protocol = Protocol::Ssh;
611                    if e.port == Protocol::Telnet.default_port() {
612                        e.port = Protocol::Ssh.default_port();
613                    }
614                }
615                e
616            })
617            .collect(),
618        Some(Format::EtherTerm) => from_etherterm(text)?,
619        Some(Format::Qodem) => from_qodem(text),
620        // Read above, as bytes.
621        Some(Format::NetRunner) => unreachable!("NetRunner phone books are read before decoding"),
622        None => bail!("unrecognized dialing directory format: {}", path.display()),
623    };
624    Ok(finish_import(entries))
625}
626
627/// Write entries to a file in the format implied by its extension.
628pub fn export(path: &Path, entries: &[Entry]) -> Result<()> {
629    let Some(format) = Format::from_path(path) else {
630        bail!("use a .lst, .csv, .json or .toml file name to choose the export format");
631    };
632    let text = match format {
633        Format::Toml => toml::to_string_pretty(&Directory { entries: entries.to_vec(), ..Directory::default() })?,
634        Format::Json => serde_json::to_string_pretty(entries)?,
635        Format::Csv => to_csv(entries)?,
636        Format::SyncTerm => to_syncterm(entries),
637        Format::MagiTerm | Format::EtherTerm | Format::NetRunner | Format::Qodem => {
638            bail!("that format can be imported, not exported")
639        }
640    };
641    fs::write(path, text).with_context(|| format!("writing {}", path.display()))
642}
643
644fn sniff(text: &str) -> Option<Format> {
645    let t = text.trim_start();
646    if is_qodem(t) {
647        Some(Format::Qodem)
648    } else if t.starts_with('{') || t.starts_with('[') && t[1..].trim_start().starts_with('{') {
649        Some(Format::Json)
650    } else if t.starts_with("<?xml") || t.starts_with("<EtherTerm") {
651        Some(Format::EtherTerm)
652    } else if t.starts_with("[[entry]]") {
653        Some(Format::Toml)
654    } else if t.starts_with('[') {
655        Some(Format::SyncTerm)
656    } else if t.lines().next().is_some_and(|l| l.contains(',')) {
657        Some(Format::Csv)
658    } else {
659        None
660    }
661}
662
663fn from_json(text: &str) -> Result<Vec<Entry>> {
664    // Accept either a bare array or our directory object.
665    match serde_json::from_str::<Vec<Entry>>(text) {
666        Ok(v) => Ok(v),
667        Err(_) => Ok(serde_json::from_str::<Directory>(text)?.entries),
668    }
669}
670
671const CSV_HEADER: [&str; 8] = ["name", "host", "port", "protocol", "encoding", "username", "term_type", "comment"];
672
673fn to_csv(entries: &[Entry]) -> Result<String> {
674    let mut w = csv::Writer::from_writer(Vec::new());
675    w.write_record(CSV_HEADER)?;
676    for e in entries {
677        w.write_record([
678            e.name.as_str(),
679            &e.host,
680            &e.port.to_string(),
681            e.protocol.name(),
682            e.encoding.name(),
683            &e.username,
684            &e.term_type,
685            &e.comment,
686        ])?;
687    }
688    Ok(String::from_utf8(w.into_inner()?)?)
689}
690
691/// Parse CSV with a header row. Columns are matched by name, so files from
692/// spreadsheets or BBS lists with extra or reordered columns still import.
693fn from_csv(text: &str) -> Result<Vec<Entry>> {
694    let mut r = csv::ReaderBuilder::new().flexible(true).trim(csv::Trim::All).from_reader(text.as_bytes());
695    let headers: Vec<String> = r.headers()?.iter().map(|h| h.to_ascii_lowercase()).collect();
696    let col = |names: &[&str]| headers.iter().position(|h| names.contains(&h.as_str()));
697    let c_name = col(&["name", "bbs", "bbs name", "system", "bbsname"]);
698    let c_host = col(&["host", "address", "hostname", "telnet", "telnetaddress"]);
699    let c_port = col(&["port", "bbsport"]);
700    // The Telnet BBS Guide's list: an SSH port, the new-user login, and
701    // details worth keeping as the comment.
702    let c_ssh_port = col(&["sshport"]);
703    let c_new_login = col(&["newlogin"]);
704    let c_details: Vec<usize> = ["software", "location", "bbssysop"].iter().filter_map(|n| col(&[n])).collect();
705    let c_proto = col(&["protocol", "type", "connectiontype"]);
706    let c_enc = col(&["encoding", "charset"]);
707    let c_user = col(&["username", "user"]);
708    let c_tt = col(&["term_type", "terminal"]);
709    let c_comment = col(&["comment", "comments", "description", "notes"]);
710    let Some(c_host) = c_host else { bail!("CSV needs a 'host' or 'address' column") };
711
712    let mut out = Vec::new();
713    for rec in r.records() {
714        let rec = rec?;
715        let get = |c: Option<usize>| c.and_then(|c| rec.get(c)).unwrap_or("").to_string();
716        let mut e = match Entry::from_address(&get(Some(c_host))) {
717            Some(e) => e,
718            None => continue,
719        };
720        if let Some(p) = c_proto.and_then(|c| rec.get(c)).and_then(Protocol::from_name) {
721            if e.port == e.protocol.default_port() {
722                e.port = p.default_port();
723            }
724            e.protocol = p;
725        }
726        if let Ok(port) = get(c_port).parse() {
727            e.port = port;
728        } else if let Ok(port) = get(c_ssh_port).parse() {
729            // Only SSH: log in as the BBS's new-user name, as it says.
730            e.protocol = Protocol::Ssh;
731            e.port = port;
732            e.username = get(c_new_login);
733        }
734        let name = get(c_name);
735        if !name.is_empty() {
736            e.name = name;
737        }
738        if get(c_enc).to_ascii_lowercase().replace('-', "") == "utf8" {
739            e.encoding = Encoding::Utf8;
740        }
741        if c_user.is_some() {
742            e.username = get(c_user);
743        }
744        let tt = get(c_tt);
745        if !tt.is_empty() {
746            e.term_type = tt;
747        }
748        e.comment = get(c_comment);
749        if e.comment.is_empty() {
750            let details: Vec<String> = c_details.iter().map(|&c| get(Some(c))).filter(|d| !d.is_empty()).collect();
751            e.comment = details.join(", ");
752        }
753        out.push(e);
754    }
755    Ok(out)
756}
757
758/// Name imported entries that have none, and date them.
759fn finish_import(entries: Vec<Entry>) -> Vec<Entry> {
760    let stamp = now();
761    entries
762        .into_iter()
763        .filter(|e| !e.host.is_empty())
764        .map(|mut e| {
765            if e.name.is_empty() {
766                e.name = e.host.clone();
767            }
768            e.added.get_or_insert(stamp);
769            e
770        })
771        .collect()
772}
773
774/// The start of a NetRunner phone book: "NetRunner Phone Book" as a Pascal
775/// string.
776const NETRUNNER_MAGIC: &[u8] = b"\x14NetRunner Phone Book";
777
778/// Parse NetRunner's `netrunner.phn`: a header, then 200 fixed records of
779/// 362 bytes, made of Pascal strings (a length byte, then up to 40
780/// characters): the name, the address (`host` or `host:port`), user name
781/// and password, then the sysop and BBS software, kept as the comment.
782/// Unused records have no name or address.
783fn from_netrunner(data: &[u8]) -> Result<Vec<Entry>> {
784    const START: usize = 48;
785    const RECORD: usize = 362;
786    if !data.starts_with(NETRUNNER_MAGIC) {
787        bail!("not a NetRunner phone book");
788    }
789    let text = |r: &[u8], at: usize| -> String {
790        let len = (r[at] as usize).min(40);
791        crate::cp437::decode(&r[at + 1..at + 1 + len]).trim().to_string()
792    };
793    let mut out = Vec::new();
794    for r in data.get(START..).unwrap_or(&[]).as_chunks::<RECORD>().0 {
795        let (name, address) = (text(r, 0), text(r, 41));
796        let Some(mut e) = Entry::from_address(&address) else { continue };
797        if !name.is_empty() {
798            e.name = name;
799        }
800        e.username = text(r, 82);
801        e.password = text(r, 123);
802        let details: Vec<String> = [text(r, 320), text(r, 279)].into_iter().filter(|d| !d.is_empty()).collect();
803        e.comment = details.join(", ");
804        out.push(e);
805    }
806    Ok(out)
807}
808
809/// Parse EtherTerm's `dialdirectory.xml`: `<BBS name=".." ip=".."
810/// port=".." protocol="TELNET|ssh" login=".." password=".." font=".." />`.
811fn from_etherterm(text: &str) -> Result<Vec<Entry>> {
812    if !text.contains("<Phonebook") && !text.contains("<BBS") {
813        bail!("not an EtherTerm dialing directory");
814    }
815    let mut out = Vec::new();
816    for tag in text.split("<BBS").skip(1) {
817        let Some(end) = tag.find('>') else { continue };
818        let attrs = xml_attributes(&tag[..end]);
819        let get =
820            |k: &str| attrs.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).map(|(_, v)| v.as_str()).unwrap_or("");
821        let Some(mut e) = Entry::from_address(get("ip")) else { continue };
822        if let Some(p) = Protocol::from_name(get("protocol")) {
823            e.protocol = p;
824            e.port = p.default_port();
825        }
826        if let Ok(port) = get("port").parse() {
827            e.port = port;
828        }
829        e.name = get("name").trim().to_string();
830        e.username = get("login").to_string();
831        e.password = get("password").to_string();
832        let font = get("font").to_ascii_lowercase();
833        e.font = match () {
834            _ if font.contains("topaz") && font.contains("plus") => 40,
835            _ if font.contains("topaz") => 42,
836            _ if font.contains("microknight") && font.contains("plus") => 39,
837            _ if font.contains("microknight") => 41,
838            _ if font.contains("noodle") => 37,
839            _ if font.contains("sooul") || font.contains("mosoul") => 38,
840            _ => 0,
841        };
842        out.push(e);
843    }
844    Ok(out)
845}
846
847/// The `name="value"` pairs of an XML tag, with entities decoded.
848fn xml_attributes(tag: &str) -> Vec<(String, String)> {
849    let mut out = Vec::new();
850    let mut rest = tag;
851    while let Some(eq) = rest.find('=') {
852        let name = rest[..eq].trim().rsplit(char::is_whitespace).next().unwrap_or("").to_string();
853        let after = rest[eq + 1..].trim_start();
854        let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { break };
855        let Some(close) = after[1..].find(quote) else { break };
856        out.push((name, xml_unescape(&after[1..1 + close])));
857        rest = &after[close + 2..];
858    }
859    out
860}
861
862fn xml_unescape(s: &str) -> String {
863    let mut out = String::with_capacity(s.len());
864    let mut rest = s;
865    while let Some(amp) = rest.find('&') {
866        out.push_str(&rest[..amp]);
867        let Some(semi) = rest[amp..].find(';') else { break };
868        let entity = &rest[amp + 1..amp + semi];
869        let decoded = match entity {
870            "amp" => Some('&'),
871            "lt" => Some('<'),
872            "gt" => Some('>'),
873            "quot" => Some('"'),
874            "apos" => Some('\''),
875            _ => entity
876                .strip_prefix("#x")
877                .and_then(|h| u32::from_str_radix(h, 16).ok())
878                .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok()))
879                .and_then(char::from_u32),
880        };
881        match decoded {
882            Some(c) => out.push(c),
883            None => out.push_str(&rest[amp..amp + semi + 1]),
884        }
885        rest = &rest[amp + semi + 1..];
886    }
887    out.push_str(rest);
888    out
889}
890
891/// Parse a SyncTERM dialing directory (`syncterm.lst`): an INI file with one
892/// section per BBS.
893/// Qodem's phone book: `[entry]` sections of `key=value` lines, after a
894/// "# Qodem Phonebook" comment.
895fn is_qodem(text: &str) -> bool {
896    text.starts_with("# Qodem") || text.lines().take(20).any(|l| l.trim() == "[entry]") && text.contains("\nmethod=")
897}
898
899/// Parse Qodem's `fonebook.txt`. Its methods are TELNET, SSH, RLOGIN,
900/// SOCKET (raw TCP) and MODEM (the address is a phone number); LOCAL and
901/// CMDLINE entries run programs and are left out. Notes span lines, from
902/// `notes=<<<END` to `END`, and become the comment.
903fn from_qodem(text: &str) -> Vec<Entry> {
904    let mut out = Vec::new();
905    let mut cur: Option<Entry> = None;
906    let mut skip = false;
907    let mut notes: Option<Vec<String>> = None;
908    let finish = |e: Option<Entry>, skip: bool, out: &mut Vec<Entry>| {
909        if let Some(mut e) = e.filter(|_| !skip) {
910            if e.port == 0 {
911                e.port = e.protocol.default_port();
912            }
913            out.push(e);
914        }
915    };
916    for line in text.lines() {
917        if let Some(n) = notes.as_mut() {
918            if line == "END" {
919                let text = notes.take().unwrap_or_default().join(" ");
920                if let Some(e) = cur.as_mut() {
921                    e.comment = text.trim().to_string();
922                }
923            } else {
924                n.push(line.trim().to_string());
925            }
926            continue;
927        }
928        let line = line.trim();
929        if line == "[entry]" {
930            finish(cur.take(), skip, &mut out);
931            cur = Some(Entry { port: 0, ..Entry::default() });
932            skip = false;
933            continue;
934        }
935        let (Some(e), Some((key, value))) = (cur.as_mut(), line.split_once('=')) else { continue };
936        let value = value.trim();
937        match key {
938            "name" => e.name = value.to_string(),
939            "address" => e.host = value.to_string(),
940            "port" => e.port = value.parse().unwrap_or(0),
941            "username" => e.username = value.to_string(),
942            "password" => e.password = value.to_string(),
943            "method" => match value {
944                "TELNET" => e.protocol = Protocol::Telnet,
945                "SSH" => e.protocol = Protocol::Ssh,
946                "RLOGIN" => e.protocol = Protocol::Rlogin,
947                "SOCKET" => e.protocol = Protocol::Raw,
948                "MODEM" => e.protocol = Protocol::Modem,
949                _ => skip = true,
950            },
951            "emulation" => match value {
952                "AVATAR" => e.encoding = Encoding::Avatar,
953                "PETSCII" => e.encoding = Encoding::Petscii,
954                "ATASCII" => e.encoding = Encoding::Atascii,
955                "PRESTEL" => e.encoding = Encoding::Viewdata,
956                "BEEB" => e.encoding = Encoding::Mode7,
957                "L_UTF8" | "X_UTF8" => e.encoding = Encoding::Utf8,
958                _ => {}
959            },
960            "codepage" if value.eq_ignore_ascii_case("utf-8") => e.encoding = Encoding::Utf8,
961            "times_on" => e.calls = value.parse().unwrap_or(0),
962            "last_call" => e.last_connected = value.parse().ok().filter(|&t| t != 0),
963            "capture_filename" if !value.is_empty() => e.capture_file = value.to_string(),
964            "notes" if value == "<<<END" => notes = Some(Vec::new()),
965            _ => {}
966        }
967    }
968    finish(cur.take(), skip, &mut out);
969    out
970}
971
972/// SyncTERM's screen modes: a size, or a system with its own screen.
973fn syncterm_screen_mode(e: &mut Entry, value: &str) {
974    let v = value.to_ascii_lowercase();
975    if v.starts_with("c128") && v.contains("80") {
976        e.encoding = Encoding::C128;
977    } else if v.starts_with("c64") || v.starts_with("c128") {
978        e.encoding = Encoding::Petscii;
979    } else if v.starts_with("atari st") {
980        e.encoding = Encoding::Igs;
981    } else if v.starts_with("atari") {
982        e.encoding = Encoding::Atascii;
983    } else if v.starts_with("prestel") {
984        e.encoding = Encoding::Viewdata;
985    } else if v.contains("mode 7") {
986        e.encoding = Encoding::Mode7;
987    } else if let Some((c, r)) = v.split_whitespace().next().and_then(|s| s.split_once('x')) {
988        let mode = (c.parse().unwrap_or(0), r.trim_end_matches(|ch: char| !ch.is_ascii_digit()).parse().unwrap_or(0));
989        if SCREEN_MODES.contains(&mode) {
990            (e.cols, e.rows) = mode;
991        }
992    }
993}
994
995fn from_syncterm(text: &str) -> Vec<Entry> {
996    let mut out = Vec::new();
997    let mut cur: Option<Entry> = None;
998    for line in text.lines() {
999        let line = line.trim();
1000        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
1001            continue;
1002        }
1003        if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
1004            out.extend(cur.take());
1005            cur = Some(Entry { name: name.to_string(), port: 0, ..Entry::default() });
1006            continue;
1007        }
1008        let (Some(e), Some((key, value))) = (cur.as_mut(), line.split_once('=')) else { continue };
1009        let value = value.trim();
1010        match key.trim().to_ascii_lowercase().as_str() {
1011            "address" => e.host = value.to_string(),
1012            "port" => e.port = value.parse().unwrap_or(0),
1013            "connectiontype" => match Protocol::from_name(value) {
1014                Some(p) => e.protocol = p,
1015                None => log::warn!("{}: unsupported connection type {value}", e.name),
1016            },
1017            "username" => e.username = value.to_string(),
1018            "password" => e.password = value.to_string(),
1019            "comment" => e.comment = value.to_string(),
1020            "totalcalls" => e.calls = value.parse().unwrap_or(0),
1021            "added" => e.added = value.parse().ok(),
1022            "lastconnected" => e.last_connected = value.parse().ok().filter(|&t| t != 0),
1023            "screenmode" => syncterm_screen_mode(e, value),
1024            _ => {}
1025        }
1026    }
1027    out.extend(cur);
1028    for e in &mut out {
1029        // SyncTERM addresses may carry the port (host:port) or a URL scheme.
1030        if let Some(parsed) = Entry::from_address(&e.host) {
1031            if e.port == 0 && parsed.port != parsed.protocol.default_port() {
1032                e.port = parsed.port;
1033            }
1034            e.host = parsed.host;
1035        }
1036        if e.port == 0 {
1037            e.port = e.protocol.default_port();
1038        }
1039    }
1040    out
1041}
1042
1043fn to_syncterm(entries: &[Entry]) -> String {
1044    let mut s = String::new();
1045    for e in entries {
1046        // Section names cannot contain brackets.
1047        let name: String = e.name.chars().map(|c| if c == '[' || c == ']' { '_' } else { c }).collect();
1048        s.push_str(&format!("[{name}]\n"));
1049        s.push_str(&format!("\tAddress={}\n", e.host));
1050        s.push_str(&format!("\tPort={}\n", e.port));
1051        s.push_str(&format!("\tConnectionType={}\n", e.protocol.name()));
1052        if let Some(t) = e.added {
1053            s.push_str(&format!("\tAdded={t}\n"));
1054        }
1055        if let Some(t) = e.last_connected {
1056            s.push_str(&format!("\tLastConnected={t}\n"));
1057        }
1058        s.push_str(&format!("\tTotalCalls={}\n", e.calls));
1059        if !e.username.is_empty() {
1060            s.push_str(&format!("\tUserName={}\n", e.username));
1061        }
1062        if !e.password.is_empty() {
1063            s.push_str(&format!("\tPassword={}\n", e.password));
1064        }
1065        if !e.comment.is_empty() {
1066            s.push_str(&format!("\tComment={}\n", e.comment));
1067        }
1068        s.push('\n');
1069    }
1070    s
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076
1077    fn sample() -> Vec<Entry> {
1078        vec![
1079            Entry {
1080                name: "Alpha BBS".into(),
1081                host: "alpha.example.org".into(),
1082                comment: "Has, commas".into(),
1083                ..Entry::default()
1084            },
1085            Entry {
1086                name: "Beta".into(),
1087                host: "beta.example.org".into(),
1088                port: 2323,
1089                crt: Some(CrtMode::Amber),
1090                ..Entry::default()
1091            },
1092        ]
1093    }
1094
1095    #[test]
1096    fn parse_addresses() {
1097        let e = Entry::from_address("telnet://bbs.example.com:2323").unwrap();
1098        assert_eq!((e.host.as_str(), e.port), ("bbs.example.com", 2323));
1099        let e = Entry::from_address("bbs.example.com").unwrap();
1100        assert_eq!(e.port, 23);
1101        let e = Entry::from_address("[::1]:23").unwrap();
1102        assert_eq!(e.host, "::1");
1103        let e = Entry::from_address("ssh://bbs.example.com").unwrap();
1104        assert_eq!((e.protocol, e.port), (Protocol::Ssh, 22));
1105        let e = Entry::from_address("ssh://guest@bbs.example.com:2222").unwrap();
1106        assert_eq!((e.username.as_str(), e.host.as_str(), e.port), ("guest", "bbs.example.com", 2222));
1107        assert!(Entry::from_address("host:notaport").is_none());
1108    }
1109
1110    #[test]
1111    fn syncterm_round_trip() {
1112        let text = to_syncterm(&sample());
1113        let back = from_syncterm(&text);
1114        assert_eq!(back.len(), 2);
1115        assert_eq!(back[1].port, 2323);
1116        assert_eq!(back[0].comment, "Has, commas");
1117    }
1118
1119    #[test]
1120    fn syncterm_real_world() {
1121        let text = "[Some BBS]\n\tAddress=bbs.example.net:2300\n\tConnectionType=Telnet\n\tScreenMode=80x25\n\
1122                    [Secure]\nAddress=s.example.net\nConnectionType=SSH\nPort=2222\n";
1123        let v = from_syncterm(text);
1124        assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.net", 2300));
1125        assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 2222));
1126        // Screen modes, and systems with their own screens.
1127        let text = "[A]\nAddress=a\nScreenMode=80x50\n[B]\nAddress=b\nScreenMode=C128 (80col)\n\
1128                    [C]\nAddress=c\nScreenMode=C64\n[D]\nAddress=d\nScreenMode=132x37 (16:9)\n";
1129        let v = from_syncterm(text);
1130        assert_eq!((v[0].cols, v[0].rows), (80, 50));
1131        assert_eq!((v[1].encoding, v[2].encoding), (Encoding::C128, Encoding::Petscii));
1132        assert_eq!((v[3].cols, v[3].rows), (132, 37));
1133    }
1134
1135    #[test]
1136    fn csv_round_trip() {
1137        let text = to_csv(&sample()).unwrap();
1138        let back = from_csv(&text).unwrap();
1139        assert_eq!(back[0].name, "Alpha BBS");
1140        assert_eq!(back[0].comment, "Has, commas");
1141        assert_eq!(back[1].port, 2323);
1142    }
1143
1144    #[test]
1145    fn csv_with_foreign_columns() {
1146        let text = "BBS Name,Address,Sysop\nFoo,foo.example.com:2023,Bob\n";
1147        let v = from_csv(text).unwrap();
1148        assert_eq!((v[0].name.as_str(), v[0].port), ("Foo", 2023));
1149    }
1150
1151    #[test]
1152    fn toml_and_json_round_trip() {
1153        let d = Directory { entries: sample(), defaults_offered: vec!["telnet://x:23".into()], ..Directory::default() };
1154        let t = toml::to_string_pretty(&d).unwrap();
1155        assert_eq!(toml::from_str::<Directory>(&t).unwrap(), d);
1156        let j = serde_json::to_string(&d.entries).unwrap();
1157        assert_eq!(from_json(&j).unwrap(), d.entries);
1158    }
1159
1160    #[test]
1161    fn merge_skips_duplicates() {
1162        let mut d = Directory { entries: sample(), ..Directory::default() };
1163        let added = d.merge(sample());
1164        assert_eq!(added, 0);
1165        assert_eq!(d.entries.len(), 2);
1166    }
1167
1168    #[test]
1169    fn defaults_are_offered_once() {
1170        let mut d = Directory { entries: sample(), ..Directory::default() };
1171        assert!(d.offer_defaults());
1172        // Pinned, so first despite the name.
1173        assert_eq!(d.entries[0].host, "vortexbbs.com");
1174        assert_eq!(d.entries.len(), 3);
1175        assert!(!d.offer_defaults());
1176
1177        // Deleting it sticks, including across a save and load.
1178        d.entries.remove(0);
1179        let d: Directory = toml::from_str(&toml::to_string_pretty(&d).unwrap()).unwrap();
1180        let mut d = d;
1181        assert!(!d.offer_defaults());
1182        assert_eq!(d.entries.len(), 2);
1183    }
1184
1185    #[test]
1186    fn new_fields_are_optional_in_toml() {
1187        let d: Directory = toml::from_str("[[entry]]\nname = \"A\"\nhost = \"a.example\"\n").unwrap();
1188        assert!(!d.entries[0].pinned && !d.entries[0].keychain && d.entries[0].key_file.is_empty());
1189        let t = toml::to_string_pretty(&d).unwrap();
1190        assert!(!t.contains("pinned") && !t.contains("keychain") && !t.contains("key_file"), "{t}");
1191        assert!(d.entries[0].crt.is_none() && !t.contains("crt"), "{t}");
1192    }
1193
1194    #[test]
1195    fn crt_mode_in_toml() {
1196        let d: Directory = toml::from_str("[[entry]]\nhost = \"a.example\"\ncrt = \"green\"\n").unwrap();
1197        assert_eq!(d.entries[0].crt, Some(CrtMode::Green));
1198        assert_eq!(CrtMode::Amber.next(), CrtMode::Off);
1199    }
1200
1201    #[test]
1202    fn view_sorts_and_filters() {
1203        let mut d = Directory { entries: sample(), ..Directory::default() };
1204        d.entries[0].calls = 1;
1205        d.entries[1].calls = 5;
1206        d.entries[0].last_connected = Some(200);
1207        d.entries[1].last_connected = Some(100);
1208        assert_eq!(d.view(""), [0, 1]);
1209        d.sort = SortOrder::Calls;
1210        assert_eq!(d.view(""), [1, 0]);
1211        d.sort = SortOrder::LastCalled;
1212        assert_eq!(d.view(""), [0, 1]);
1213        // Pinned entries stay on top whatever the order.
1214        d.entries[1].pinned = true;
1215        assert_eq!(d.view(""), [1, 0]);
1216
1217        assert_eq!(d.view("ALPHA"), [0]);
1218        // Groups.
1219        d.entries[0].group = "Retro".into();
1220        d.entries[1].group = "retro ".into();
1221        assert_eq!(d.groups(), ["Retro"]);
1222        d.entries[1].group = "Art".into();
1223        assert_eq!(d.groups(), ["Art", "Retro"]);
1224        assert_eq!(d.view_group("", Some("retro")), [0]);
1225        assert_eq!(d.view_group("", Some("Art")), [1]);
1226        assert_eq!(d.view("art"), [1]);
1227        assert_eq!(d.view("commas"), [0]);
1228        assert_eq!(d.view(":2323"), [1]);
1229        assert!(d.view("nothing like it").is_empty());
1230
1231        let t = toml::to_string_pretty(&d).unwrap();
1232        assert!(t.contains("sort = \"last_called\""), "{t}");
1233        assert_eq!(toml::from_str::<Directory>(&t).unwrap().sort, SortOrder::LastCalled);
1234    }
1235
1236    #[test]
1237    fn telnet_bbs_guide_csv() {
1238        let text = "bbsName, bbsSysop, newLogin, TelnetAddress, bbsPort, sshPort, WebAddress, location, Modem, software\n\
1239                    13th Floor BBS,Hoy Brothers,new,13th.hoyvision.com,6400,,,\"Oak Ridge, TN, USA\",,\"Image BBS\"\n\
1240                    3D Realms,,NEW,172.0.79.226,,,,\"Cotati, CA, USA\",,\"Synchronet\"\n\
1241                    4 Wheel Ham BBS,w7jpj,NEW,bbs.4wheelham.com,,2222,,\"Littleton, CO, USA\",,\"Synchronet\"\n";
1242        let v = from_csv(text).unwrap();
1243        assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("13th Floor BBS", "13th.hoyvision.com", 6400));
1244        assert_eq!(v[0].comment, "Image BBS, Oak Ridge, TN, USA, Hoy Brothers");
1245        assert_eq!((v[1].protocol, v[1].port), (Protocol::Telnet, 23));
1246        assert_eq!((v[2].protocol, v[2].port, v[2].username.as_str()), (Protocol::Ssh, 2222, "NEW"));
1247    }
1248
1249    #[test]
1250    fn etherterm_and_magiterm() {
1251        let xml = r#"<?xml version="1.0" ?>
1252<EtherTerm>
1253    <Phonebook version="1.0">
1254        <BBS name="Tom &amp; Jerry&#39;s" ip="bbs.example.org" port="6523" protocol="TELNET" login="" password="" font="vga8x16.bmp" keyMap="ANSI" />
1255        <BBS name="Amiga Land (ssh)" ip="amiga.example.org" port="2222" protocol="ssh" login="guest" password="pw" font="topaz-a1200.bmp" keyMap="ANSI" />
1256    </Phonebook>
1257</EtherTerm>"#;
1258        assert_eq!(sniff(xml), Some(Format::EtherTerm));
1259        let v = from_etherterm(xml).unwrap();
1260        assert_eq!(v[0].name, "Tom & Jerry's");
1261        assert_eq!((v[0].protocol, v[0].port), (Protocol::Telnet, 6523));
1262        assert_eq!((v[1].protocol, v[1].port, v[1].username.as_str(), v[1].font), (Protocol::Ssh, 2222, "guest", 42));
1263
1264        let dir = std::env::temp_dir().join(format!("badterm-magiterm-{}", std::process::id()));
1265        std::fs::create_dir_all(&dir).unwrap();
1266        let path = dir.join("magiterm.ini");
1267        std::fs::write(
1268            &path,
1269            "[A-Net Online]\naddress = mystic-anet.online \nport = 22 \n\n[After Hours]\naddress = ah-bbs.com\n",
1270        )
1271        .unwrap();
1272        let v = import(&path).unwrap();
1273        assert_eq!((v[0].protocol, v[0].port), (Protocol::Ssh, 22));
1274        assert_eq!((v[1].protocol, v[1].port), (Protocol::Ssh, 22));
1275        assert!(export(&dir.join("out.xml"), &v).is_err());
1276        std::fs::remove_dir_all(&dir).unwrap();
1277    }
1278
1279    #[test]
1280    fn netrunner_phone_book() {
1281        let book = include_bytes!("../tests/data/netrunner.phn");
1282        let v = from_netrunner(book).unwrap();
1283        assert_eq!(v.len(), 2);
1284        assert_eq!((v[0].name.as_str(), v[0].host.as_str(), v[0].port), ("The Vortex BBS", "vortexbbs.com", 23));
1285        assert_eq!((v[0].username.as_str(), v[0].password.as_str()), ("User Here", "Password Here"));
1286        assert_eq!(v[0].comment, "Enigma 1/2, Sysop Name Here");
1287        assert_eq!((v[1].name.as_str(), v[1].host.as_str()), ("Another System", "another.com"));
1288        assert!(from_netrunner(b"not a phone book").is_err());
1289
1290        // host:port in the address, as NetRunner takes it.
1291        let mut edited = book.to_vec();
1292        let at = 48 + 41;
1293        let addr = b"bbs.example.org:2323";
1294        edited[at] = addr.len() as u8;
1295        edited[at + 1..at + 1 + addr.len()].copy_from_slice(addr);
1296        let v = from_netrunner(&edited).unwrap();
1297        assert_eq!((v[0].host.as_str(), v[0].port), ("bbs.example.org", 2323));
1298    }
1299
1300    #[test]
1301    fn imports_qodem() {
1302        let text = "# Qodem Phonebook\n#\n\n[entry]\nname=The Vortex\naddress=vortexbbs.com\nport=\n\
1303            username=guest\npassword=pw\nmethod=TELNET\nemulation=AVATAR\ncodepage=CP437\ntimes_on=7\n\
1304            last_call=1700000000\nnotes=<<<END\nBest BBS\nin town\nEND\ncapture_filename=vortex.txt\n\n\
1305            [entry]\nname=Local shell\naddress=\nmethod=LOCAL\n\n\
1306            [entry]\nname=Dial-up\naddress=555-1234\nport=\nmethod=MODEM\nemulation=ANSI\n\n\
1307            [entry]\nname=Secure\naddress=ssh.example.org\nport=2222\nmethod=SSH\nemulation=X_UTF8\n";
1308        let dir = std::env::temp_dir().join(format!("badterm-qodem-{}", std::process::id()));
1309        fs::create_dir_all(&dir).unwrap();
1310        // Found by its name, or by its content.
1311        for name in ["fonebook.txt", "phonebook"] {
1312            fs::write(dir.join(name), text).unwrap();
1313            let v = import(&dir.join(name)).unwrap();
1314            assert_eq!(v.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["The Vortex", "Dial-up", "Secure"]);
1315            let e = &v[0];
1316            assert_eq!(
1317                (e.host.as_str(), e.port, e.protocol, e.encoding),
1318                ("vortexbbs.com", 23, Protocol::Telnet, Encoding::Avatar)
1319            );
1320            assert_eq!((e.username.as_str(), e.password.as_str(), e.calls), ("guest", "pw", 7));
1321            assert_eq!((e.comment.as_str(), e.capture_file.as_str()), ("Best BBS in town", "vortex.txt"));
1322            assert_eq!(
1323                (v[1].protocol, v[1].host.as_str(), v[1].address()),
1324                (Protocol::Modem, "555-1234", "555-1234".into())
1325            );
1326            assert_eq!((v[2].protocol, v[2].port, v[2].encoding), (Protocol::Ssh, 2222, Encoding::Utf8));
1327        }
1328        let _ = fs::remove_dir_all(&dir);
1329    }
1330}