Skip to main content

hypermail/
config.rs

1use crate::error::{HypermailError, Result};
2use std::path::{Path, PathBuf};
3
4pub const ANTISPAM_AT: &str = "@";
5pub const LANGUAGE: &str = "en";
6pub const HTMLSUFFIX: &str = "html";
7pub const DEFAULTINDEX: &str = "date";
8pub const INLINE_TYPES: &str = "image/gif image/jpeg image/png";
9pub const PROGRESS: i32 = 0;
10pub const MAILCOMMAND: &str = "mailto:$TO?subject=$SUBJECT&in-reply-to=$ID";
11pub const DOMAINADDR: &str = "";
12
13pub const DELETE_REMOVES_FILES: i32 = 0;
14pub const DELETE_LEAVES_STUBS: i32 = 1;
15pub const DELETE_LEAVES_EXPIRED_TEXT: i32 = 2;
16pub const DELETE_LEAVES_TEXT: i32 = 3;
17
18/// Supported configuration value types for the hypermail config parser.
19#[derive(Debug, Clone)]
20pub enum ConfigType {
21    String,
22    Switch,
23    Integer,
24    List,
25    StringList,
26    Octal,
27}
28
29/// A single configuration entry definition with metadata for parsing.
30#[derive(Debug, Clone)]
31pub struct ConfigEntry {
32    pub label: &'static str,
33    pub flags: ConfigType,
34    pub default_str: Option<&'static str>,
35    pub default_int: i64,
36    pub verbose: &'static str,
37}
38
39/// A whitespace-separated list of values used for multi-value config options.
40#[derive(Debug, Clone)]
41pub struct HmList {
42    pub values: Vec<String>,
43}
44
45impl Default for HmList {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl HmList {
52    /// Creates an empty list.
53    pub fn new() -> Self {
54        HmList { values: Vec::new() }
55    }
56
57    /// Creates a list by splitting a whitespace-delimited string.
58    pub fn from_whitespace_str(s: &str) -> Self {
59        let values: Vec<String> = s.split_whitespace().map(|s| s.to_string()).collect();
60        HmList { values }
61    }
62
63    /// Returns true if the list contains the given value.
64    pub fn contains(&self, val: &str) -> bool {
65        self.values.iter().any(|v| v == val)
66    }
67
68    /// Adds a value to the list if not already present.
69    pub fn add(&mut self, val: &str) {
70        if !self.contains(val) {
71            self.values.push(val.to_string());
72        }
73    }
74
75    /// Splits a whitespace-delimited string and adds each token to the list.
76    pub fn add_list(&mut self, val: &str) {
77        for v in val.split_whitespace() {
78            self.add(v);
79        }
80    }
81}
82
83/// Complete runtime configuration for a hypermail archive run.
84///
85/// Controls input/output paths, HTML generation options, index types,
86/// spam protection, i18n, MIME handling, and template customization.
87#[derive(Debug, Clone)]
88pub struct Config {
89    // --- String configs ---
90    pub fragment_prefix: String,
91    pub htmlmessage_deleted: Option<String>,
92    pub antispam_at: String,
93    pub antispamdomain: Option<String>,
94    pub language: String,
95    pub htmlsuffix: String,
96    pub mbox: Option<String>,
97    pub archives: Option<String>,
98    pub custom_archives: Option<String>,
99    pub about: Option<String>,
100    pub label: Option<String>,
101    pub dir: Option<String>,
102    pub defaultindex: String,
103    pub default_top_index: String,
104    pub mailcommand: String,
105    pub newmsg_command: String,
106    pub replymsg_command: String,
107    pub inreplyto_command: Option<String>,
108    pub mailto: Option<String>,
109    pub hmail: Option<String>,
110    pub domainaddr: Option<String>,
111    pub css: Option<String>,
112    pub icss_url: Option<String>,
113    pub mcss_url: Option<String>,
114    pub dateformat: Option<String>,
115    pub indexdateformat: Option<String>,
116    pub stripsubject: Option<String>,
117    pub link_to_replies: Option<String>,
118    pub quote_link_string: Option<String>,
119    pub ihtmlheader: Option<String>,
120    pub ihtmlfooter: Option<String>,
121    pub ihtmlhead: Option<String>,
122    pub ihtmlhelpup: Option<String>,
123    pub ihtmlhelplow: Option<String>,
124    pub ihtmlnavbar2up: Option<String>,
125    pub mhtmlheader: Option<String>,
126    pub mhtmlfooter: Option<String>,
127    pub attachmentlink: Option<String>,
128    pub bodyheader: Option<String>,
129    pub bodyheaderend: Option<String>,
130    pub bodyfooter: Option<String>,
131    pub unsafe_chars: Option<String>,
132    pub filename_base: Option<String>,
133    pub folder_by_date: Option<String>,
134    pub latest_folder: Option<String>,
135    pub base_url: Option<String>,
136    pub describe_folder: Option<String>,
137    pub delete_older: Option<String>,
138    pub delete_newer: Option<String>,
139    pub alts_text: Option<String>,
140    pub description: Option<String>,
141    pub theme: Option<String>,
142    pub append_filename: Option<String>,
143    pub txtsuffix: Option<String>,
144
145    // --- Switch (bool) configs ---
146    pub email_address_obfuscation: bool,
147    pub i18n: bool,
148    pub i18n_body: bool,
149    pub overwrite: bool,
150    pub inlinehtml: bool,
151    pub readone: bool,
152    pub reverse: bool,
153    pub reverse_folders: bool,
154    pub showheaders: bool,
155    pub showbr: bool,
156    pub showreplies: bool,
157    pub indextable: bool,
158    pub iquotes: bool,
159    pub eurodate: bool,
160    pub gmtime: bool,
161    pub isodate: bool,
162    pub require_msgids: bool,
163    pub discard_dup_msgids: bool,
164    pub usemeta: bool,
165    pub uselock: bool,
166    pub ietf_mbox: bool,
167    pub linkquotes: bool,
168    pub monthly_index: bool,
169    pub yearly_index: bool,
170    pub spamprotect: bool,
171    pub spamprotect_id: bool,
172    pub attachmentsindex: bool,
173    pub usegdbm: bool,
174    pub writehaof: bool,
175    pub append: bool,
176    pub nonsequential: bool,
177    pub warn_surpressions: bool,
178    pub files_by_thread: bool,
179    pub href_detection: bool,
180    pub mbox_shortened: bool,
181    pub report_new_file: bool,
182    pub report_new_folder: bool,
183    pub use_sender_date: bool,
184    pub inline_addlink: bool,
185    pub iso2022jp: bool,
186    pub delete_incremental: bool,
187    pub showgenerator: bool,
188    pub show_warnings: bool,
189
190    // --- Integer configs ---
191    pub increment: i32,
192    pub showhtml: i32,
193    pub show_msg_links: i32,
194    pub show_index_links: i32,
195    pub thrdlevels: i32,
196    pub dirmode: i32,
197    pub filemode: i32,
198    pub locktime: i32,
199    pub searchbackmsgnum: i32,
200    pub quote_hide_threshold: i32,
201    pub thread_file_depth: i32,
202    pub startmsgnum: i32,
203    pub msgsperfolder: i32,
204    pub save_alts: i32,
205    pub delete_level: i32,
206    pub progress: i32,
207    pub max_message_size: usize,
208
209    // --- List configs ---
210    pub show_headers: HmList,
211    pub avoid_indices: HmList,
212    pub avoid_top_indices: HmList,
213    pub skip_headers: HmList,
214    pub text_types: HmList,
215    pub inline_types: HmList,
216    pub prefered_types: HmList,
217    pub ignore_types: HmList,
218    pub filter_out: HmList,
219    pub filter_require: HmList,
220    pub filter_out_full_body: HmList,
221    pub filter_require_full_body: HmList,
222    pub deleted: HmList,
223    pub expires: HmList,
224    pub delete_msgnum: HmList,
225}
226
227impl Default for Config {
228    fn default() -> Self {
229        Config {
230            fragment_prefix: "msg".to_string(),
231            htmlmessage_deleted: None,
232            antispam_at: ANTISPAM_AT.to_string(),
233            antispamdomain: None,
234            language: LANGUAGE.to_string(),
235            htmlsuffix: HTMLSUFFIX.to_string(),
236            mbox: None,
237            archives: None,
238            custom_archives: None,
239            about: None,
240            label: None,
241            dir: None,
242            defaultindex: DEFAULTINDEX.to_string(),
243            default_top_index: "folders".to_string(),
244            mailcommand: MAILCOMMAND.to_string(),
245            newmsg_command: "mailto:$TO".to_string(),
246            replymsg_command: MAILCOMMAND.to_string(),
247            inreplyto_command: None,
248            mailto: None,
249            hmail: None,
250            domainaddr: None,
251            css: None,
252            icss_url: None,
253            mcss_url: None,
254            dateformat: None,
255            indexdateformat: None,
256            stripsubject: None,
257            link_to_replies: None,
258            quote_link_string: None,
259            ihtmlheader: None,
260            ihtmlfooter: None,
261            ihtmlhead: None,
262            ihtmlhelpup: None,
263            ihtmlhelplow: None,
264            ihtmlnavbar2up: None,
265            mhtmlheader: None,
266            mhtmlfooter: None,
267            attachmentlink: None,
268            unsafe_chars: None,
269            filename_base: None,
270            folder_by_date: None,
271            latest_folder: None,
272            base_url: None,
273            describe_folder: None,
274            delete_older: None,
275            delete_newer: None,
276            alts_text: None,
277            append_filename: None,
278            txtsuffix: None,
279            description: None,
280            theme: None,
281            bodyheader: None,
282            bodyheaderend: None,
283            bodyfooter: None,
284            email_address_obfuscation: false,
285            i18n: false,
286            i18n_body: false,
287            overwrite: false,
288            inlinehtml: true,
289            readone: false,
290            reverse: false,
291            reverse_folders: false,
292            showheaders: true,
293            showbr: true,
294            showreplies: true,
295            indextable: false,
296            iquotes: true,
297            eurodate: true,
298            gmtime: false,
299            isodate: false,
300            require_msgids: true,
301            discard_dup_msgids: true,
302            usemeta: false,
303            uselock: true,
304            ietf_mbox: false,
305            linkquotes: false,
306            monthly_index: false,
307            yearly_index: false,
308            spamprotect: true,
309            spamprotect_id: true,
310            attachmentsindex: true,
311            usegdbm: false,
312            writehaof: false,
313            append: false,
314            nonsequential: false,
315            warn_surpressions: true,
316            files_by_thread: false,
317            href_detection: true,
318            mbox_shortened: false,
319            report_new_file: false,
320            report_new_folder: false,
321            use_sender_date: false,
322            inline_addlink: true,
323            iso2022jp: false,
324            delete_incremental: true,
325            showgenerator: true,
326            show_warnings: false,
327            increment: 0,
328            showhtml: 1,
329            show_msg_links: 1,
330            show_index_links: 1,
331            thrdlevels: 50, // High default to show full tree structure
332            dirmode: 0o755,
333            filemode: 0o644,
334            locktime: 3600,
335            searchbackmsgnum: 500,
336            quote_hide_threshold: 100,
337            thread_file_depth: 0,
338            startmsgnum: 0,
339            msgsperfolder: 0,
340            save_alts: 0,
341            delete_level: DELETE_LEAVES_TEXT,
342            progress: PROGRESS,
343            max_message_size: 100 * 1024 * 1024,
344            show_headers: HmList::new(),
345            avoid_indices: HmList::new(),
346            avoid_top_indices: HmList::new(),
347            skip_headers: HmList::new(),
348            text_types: HmList::new(),
349            inline_types: HmList::from_whitespace_str(INLINE_TYPES),
350            deleted: HmList::from_whitespace_str("X-Hypermail-Deleted X-No-Archive"),
351            expires: HmList::from_whitespace_str("Expires"),
352            delete_msgnum: HmList::new(),
353            filter_out: HmList::new(),
354            filter_require: HmList::new(),
355            filter_out_full_body: HmList::new(),
356            filter_require_full_body: HmList::new(),
357            prefered_types: HmList::new(),
358            ignore_types: HmList::new(),
359        }
360    }
361}
362
363impl Config {
364    /// Sets a string configuration value by key name.
365    pub fn set_string(&mut self, key: &str, val: &str) -> Result<()> {
366        match key {
367            "fragment_prefix" => self.fragment_prefix = val.to_string(),
368            "htmlmessage_deleted" => self.htmlmessage_deleted = Some(val.to_string()),
369            "antispam_at" => self.antispam_at = val.to_string(),
370            "antispamdomain" => {
371                if val == "NONE" || val.is_empty() {
372                    self.antispamdomain = None;
373                } else {
374                    self.antispamdomain = Some(val.to_string());
375                }
376            },
377            "language" => self.language = val.to_string(),
378            "htmlsuffix" => self.htmlsuffix = val.to_string(),
379            "mbox" => {
380                if val == "NONE" {
381                    self.mbox = None;
382                } else {
383                    self.mbox = Some(val.to_string());
384                }
385            },
386            "archives" => {
387                if val == "NONE" {
388                    self.archives = None;
389                } else {
390                    self.archives = Some(val.to_string());
391                }
392            },
393            "custom_archives" => {
394                if val == "NONE" {
395                    self.custom_archives = None;
396                } else {
397                    self.custom_archives = Some(val.to_string());
398                }
399            },
400            "about" => {
401                if val == "NONE" {
402                    self.about = None;
403                } else {
404                    self.about = Some(val.to_string());
405                }
406            },
407            "label" => {
408                if val == "NONE" {
409                    self.label = None;
410                } else {
411                    self.label = Some(val.to_string());
412                }
413            },
414            "dir" => {
415                if val == "NONE" {
416                    self.dir = None;
417                } else {
418                    self.dir = Some(val.to_string());
419                }
420            },
421            "defaultindex" => self.defaultindex = val.to_string(),
422            "default_top_index" => self.default_top_index = val.to_string(),
423            "mailcommand" => self.mailcommand = val.to_string(),
424            "newmsg_command" => self.newmsg_command = val.to_string(),
425            "replymsg_command" => self.replymsg_command = val.to_string(),
426            "inreplyto_command" => self.inreplyto_command = Some(val.to_string()),
427            "mailto" => {
428                if val == "NONE" {
429                    self.mailto = None;
430                } else {
431                    self.mailto = Some(val.to_string());
432                }
433            },
434            "hmail" => {
435                if val == "NONE" {
436                    self.hmail = None;
437                } else {
438                    self.hmail = Some(val.to_string());
439                }
440            },
441            "domainaddr" => {
442                if val == "NONE" {
443                    self.domainaddr = None;
444                } else {
445                    self.domainaddr = Some(val.to_string());
446                }
447            },
448            "css" => self.css = Some(val.to_string()),
449            "icss_url" => self.icss_url = Some(val.to_string()),
450            "mcss_url" => self.mcss_url = Some(val.to_string()),
451            "dateformat" => self.dateformat = Some(val.to_string()),
452            "indexdateformat" => self.indexdateformat = Some(val.to_string()),
453            "stripsubject" => self.stripsubject = Some(val.to_string()),
454            "link_to_replies" => self.link_to_replies = Some(val.to_string()),
455            "quote_link_string" => self.quote_link_string = Some(val.to_string()),
456            "ihtmlheaderfile" => self.ihtmlheader = Some(val.to_string()),
457            "ihtmlfooterfile" => self.ihtmlfooter = Some(val.to_string()),
458            "ihtmlheadfile" => self.ihtmlhead = Some(val.to_string()),
459            "ihtmlhelpupfile" => self.ihtmlhelpup = Some(val.to_string()),
460            "ihtmlhelplowfile" => self.ihtmlhelplow = Some(val.to_string()),
461            "ihtmlnavbar2upfile" => self.ihtmlnavbar2up = Some(val.to_string()),
462            "mhtmlheaderfile" => self.mhtmlheader = Some(val.to_string()),
463            "mhtmlfooterfile" => self.mhtmlfooter = Some(val.to_string()),
464            "attachmentlink" => self.attachmentlink = Some(val.to_string()),
465            "unsafe_chars" => self.unsafe_chars = Some(val.to_string()),
466            "description" => self.description = Some(val.to_string()),
467            "theme" => self.theme = Some(val.to_string()),
468            "bodyheader" => self.bodyheader = Some(val.to_string()),
469            "bodyheaderend" => self.bodyheaderend = Some(val.to_string()),
470            "bodyfooter" => self.bodyfooter = Some(val.to_string()),
471            "filename_base" => self.filename_base = Some(val.to_string()),
472            "folder_by_date" => {
473                if val.is_empty() || val == "NONE" {
474                    self.folder_by_date = None;
475                } else {
476                    self.folder_by_date = Some(val.to_string());
477                }
478            },
479            "latest_folder" => self.latest_folder = Some(val.to_string()),
480            "base_url" => self.base_url = Some(val.to_string()),
481            "describe_folder" => self.describe_folder = Some(val.to_string()),
482            "delete_older" => self.delete_older = Some(val.to_string()),
483            "delete_newer" => self.delete_newer = Some(val.to_string()),
484            "alts_text" => self.alts_text = Some(val.to_string()),
485            "append_filename" => self.append_filename = Some(val.to_string()),
486            "txtsuffix" => self.txtsuffix = Some(val.to_string()),
487            _ => {
488                return Err(HypermailError::InvalidConfigValue {
489                    key: key.to_string(),
490                    message: format!("unknown string config key: {}", key),
491                })
492            },
493        }
494        Ok(())
495    }
496
497    /// Sets a boolean switch configuration value by key name.
498    pub fn set_switch(&mut self, key: &str, val: bool) -> Result<()> {
499        match key {
500            "email_address_obfuscation" => self.email_address_obfuscation = val,
501            "i18n" => self.i18n = val,
502            "i18n_body" => self.i18n_body = val,
503            "overwrite" => self.overwrite = val,
504            "inlinehtml" => self.inlinehtml = val,
505            "readone" => self.readone = val,
506            "reverse" => self.reverse = val,
507            "reverse_folders" => self.reverse_folders = val,
508            "showheaders" => self.showheaders = val,
509            "showbr" => self.showbr = val,
510            "showreplies" => self.showreplies = val,
511            "indextable" => self.indextable = val,
512            "iquotes" => self.iquotes = val,
513            "eurodate" => self.eurodate = val,
514            "gmtime" => self.gmtime = val,
515            "isodate" => self.isodate = val,
516            "require_msgids" => self.require_msgids = val,
517            "discard_dup_msgids" => self.discard_dup_msgids = val,
518            "usemeta" => self.usemeta = val,
519            "uselock" => self.uselock = val,
520            "ietf_mbox" => self.ietf_mbox = val,
521            "linkquotes" => self.linkquotes = val,
522            "monthly_index" => self.monthly_index = val,
523            "yearly_index" => self.yearly_index = val,
524            "spamprotect" => self.spamprotect = val,
525            "spamprotect_id" => self.spamprotect_id = val,
526            "attachmentsindex" => self.attachmentsindex = val,
527            "usegdbm" => self.usegdbm = val,
528            "writehaof" => self.writehaof = val,
529            "append" => self.append = val,
530            "nonsequential" => self.nonsequential = val,
531            "warn_surpressions" => self.warn_surpressions = val,
532            "files_by_thread" => self.files_by_thread = val,
533            "href_detection" => self.href_detection = val,
534            "mbox_shortened" => self.mbox_shortened = val,
535            "report_new_file" => self.report_new_file = val,
536            "report_new_folder" => self.report_new_folder = val,
537            "use_sender_date" => self.use_sender_date = val,
538            "inline_addlink" => self.inline_addlink = val,
539            "iso2022jp" => self.iso2022jp = val,
540            "delete_incremental" => self.delete_incremental = val,
541            "showgenerator" => self.showgenerator = val,
542            "show_warnings" => self.show_warnings = val,
543            _ => {
544                return Err(HypermailError::InvalidConfigValue {
545                    key: key.to_string(),
546                    message: format!("unknown switch config key: {}", key),
547                })
548            },
549        }
550        Ok(())
551    }
552
553    /// Sets an integer configuration value by key name.
554    pub fn set_integer(&mut self, key: &str, val: i64) -> Result<()> {
555        match key {
556            "increment" => self.increment = val as i32,
557            "showhtml" => self.showhtml = val as i32,
558            "show_msg_links" => self.show_msg_links = val as i32,
559            "show_index_links" => self.show_index_links = val as i32,
560            "thrdlevels" => self.thrdlevels = val as i32,
561            "dirmode" => self.dirmode = val as i32,
562            "filemode" => self.filemode = val as i32,
563            "locktime" => self.locktime = val as i32,
564            "searchbackmsgnum" => self.searchbackmsgnum = val as i32,
565            "quote_hide_threshold" => self.quote_hide_threshold = val as i32,
566            "thread_file_depth" => self.thread_file_depth = val as i32,
567            "startmsgnum" => self.startmsgnum = val as i32,
568            "msgsperfolder" => self.msgsperfolder = val as i32,
569            "save_alts" => self.save_alts = val as i32,
570            "delete_level" => self.delete_level = val as i32,
571            "progress" => self.progress = val as i32,
572            "max_message_size" => self.max_message_size = val as usize,
573            _ => {
574                return Err(HypermailError::InvalidConfigValue {
575                    key: key.to_string(),
576                    message: format!("unknown integer config key: {}", key),
577                })
578            },
579        }
580        Ok(())
581    }
582
583    /// Appends whitespace-separated values to a list configuration by key name.
584    pub fn set_list(&mut self, key: &str, val: &str) -> Result<()> {
585        let list = match key {
586            "show_headers" => &mut self.show_headers,
587            "avoid_indices" => &mut self.avoid_indices,
588            "avoid_top_indices" => &mut self.avoid_top_indices,
589            "text_types" => &mut self.text_types,
590            "inline_types" => &mut self.inline_types,
591            "prefered_types" => &mut self.prefered_types,
592            "ignore_types" => &mut self.ignore_types,
593            "filter_out" => &mut self.filter_out,
594            "filter_require" => &mut self.filter_require,
595            "filter_out_full_body" => &mut self.filter_out_full_body,
596            "filter_require_full_body" => &mut self.filter_require_full_body,
597            "deleted" => &mut self.deleted,
598            "expires" => &mut self.expires,
599            "delete_msgnum" => &mut self.delete_msgnum,
600            _ => {
601                return Err(HypermailError::InvalidConfigValue {
602                    key: key.to_string(),
603                    message: format!("unknown list config key: {}", key),
604                })
605            },
606        };
607        list.add_list(val);
608        Ok(())
609    }
610
611    /// Applies a CLI argument, auto-detecting the value type (bool/int/string/list).
612    ///
613    /// Supports `key=value` syntax, `hm_` and `set_` prefixes, and ON/OFF/YES/NO values.
614    pub fn apply_cli_arg(&mut self, key: &str, val: &str) -> Result<()> {
615        let (actual_key, actual_val) = if let Some(eq_pos) = key.find('=') {
616            let k = &key[..eq_pos];
617            let v = &key[eq_pos + 1..];
618            (k, v)
619        } else {
620            (key, val)
621        };
622
623        let actual_key = actual_key.strip_prefix("hm_").unwrap_or(actual_key);
624        let actual_key = actual_key.strip_prefix("set_").unwrap_or(actual_key);
625        let actual_val = actual_val.trim();
626
627        if actual_val == "ON"
628            || actual_val == "YES"
629            || actual_val == "On"
630            || actual_val == "Yes"
631            || actual_val == "on"
632            || actual_val == "yes"
633        {
634            if let Ok(()) = self.set_switch(actual_key, true) {
635                return Ok(());
636            }
637            return self
638                .set_integer(actual_key, 1)
639                .or_else(|_| self.set_string(actual_key, actual_val));
640        }
641        if (actual_val == "OFF"
642            || actual_val == "NO"
643            || actual_val == "Off"
644            || actual_val == "No"
645            || actual_val == "off"
646            || actual_val == "no")
647            && self.set_switch(actual_key, false).is_ok()
648        {
649            return Ok(());
650        }
651
652        // Octal parsing for permission modes (dirmode, filemode)
653        // Always parse as octal — "755" means 0o755, with or without leading zero
654        if actual_key == "dirmode" || actual_key == "filemode" {
655            let octal_str = actual_val.strip_prefix('0').unwrap_or(actual_val);
656            if let Ok(i) = i64::from_str_radix(octal_str, 8) {
657                if self.set_integer(actual_key, i).is_ok() {
658                    return Ok(());
659                }
660            }
661        }
662
663        let int_val = actual_val.parse::<i64>();
664        if let Ok(i) = int_val {
665            if self.set_integer(actual_key, i).is_ok() {
666                return Ok(());
667            }
668        }
669        if self.set_string(actual_key, actual_val).is_ok() {
670            return Ok(());
671        }
672        if self.set_list(actual_key, actual_val).is_ok() {
673            return Ok(());
674        }
675
676        Err(HypermailError::InvalidConfigValue {
677            key: actual_key.to_string(),
678            message: format!("unrecognized config key or invalid value: {}", actual_val),
679        })
680    }
681
682    /// Loads configuration from environment variables prefixed with `HM_`.
683    pub fn load_env(&mut self) {
684        for (key, val) in std::env::vars() {
685            if let Some(stripped) = key.strip_prefix("HM_") {
686                let config_key = stripped.to_lowercase();
687                let _ = self.apply_cli_arg(&config_key, &val);
688            }
689        }
690    }
691
692    /// Applies post-processing defaults (e.g., always-skipped headers).
693    pub fn post_process(&mut self) {
694        self.skip_headers.add("from");
695        self.skip_headers.add("date");
696        self.skip_headers.add("subject");
697    }
698
699    /// Returns the resolved CSS path, joining with `dir` if relative.
700    pub fn css_path(&self) -> String {
701        if let Some(ref css) = self.css {
702            if css.starts_with("http") || Path::new(css).is_absolute() {
703                css.clone()
704            } else if let Some(ref dir) = self.dir {
705                PathBuf::from(dir).join(css).to_string_lossy().into_owned()
706            } else {
707                css.clone()
708            }
709        } else {
710            String::new()
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn test_default_config() {
721        let cfg = Config::default();
722        assert_eq!(cfg.language, "en");
723        assert_eq!(cfg.htmlsuffix, "html");
724        assert_eq!(cfg.defaultindex, "date");
725        assert!(cfg.inlinehtml);
726        assert!(!cfg.overwrite);
727        assert_eq!(cfg.showhtml, 1);
728        assert_eq!(cfg.thrdlevels, 50); // Changed from 4 to 50 for deeper thread display
729        assert_eq!(cfg.dirmode, 0o755);
730        assert_eq!(cfg.filemode, 0o644);
731        assert_eq!(cfg.locktime, 3600);
732        assert_eq!(cfg.searchbackmsgnum, 500);
733        assert_eq!(cfg.quote_hide_threshold, 100);
734        assert_eq!(cfg.delete_level, DELETE_LEAVES_TEXT);
735        assert!(cfg.discard_dup_msgids);
736        assert!(cfg.require_msgids);
737        assert!(cfg.uselock);
738        assert!(cfg.href_detection);
739        assert!(cfg.warn_surpressions);
740        assert!(cfg.attachmentsindex);
741        assert!(cfg.spamprotect);
742        assert!(cfg.spamprotect_id);
743        assert!(cfg.showbr);
744        assert!(cfg.showreplies);
745        assert!(cfg.inline_addlink);
746        assert!(cfg.delete_incremental);
747        assert_eq!(cfg.fragment_prefix, "msg");
748        assert_eq!(cfg.antispam_at, "@");
749        assert_eq!(cfg.progress, 0);
750        assert!(cfg.inline_types.contains("image/gif"));
751        assert!(cfg.deleted.contains("X-Hypermail-Deleted"));
752        assert!(cfg.expires.contains("Expires"));
753    }
754
755    #[test]
756    fn test_set_string() {
757        let mut cfg = Config::default();
758        cfg.set_string("language", "de").unwrap();
759        assert_eq!(cfg.language, "de");
760        cfg.set_string("mbox", "NONE").unwrap();
761        assert!(cfg.mbox.is_none());
762        cfg.set_string("label", "test list").unwrap();
763        assert_eq!(cfg.label.as_deref(), Some("test list"));
764    }
765
766    #[test]
767    fn test_set_switch() {
768        let mut cfg = Config::default();
769        assert!(!cfg.overwrite);
770        cfg.set_switch("overwrite", true).unwrap();
771        assert!(cfg.overwrite);
772    }
773
774    #[test]
775    fn test_set_integer() {
776        let mut cfg = Config::default();
777        cfg.set_integer("showhtml", 2).unwrap();
778        assert_eq!(cfg.showhtml, 2);
779        cfg.set_integer("thrdlevels", 8).unwrap();
780        assert_eq!(cfg.thrdlevels, 8);
781    }
782
783    #[test]
784    fn test_set_list() {
785        let mut cfg = Config::default();
786        cfg.set_list("text_types", "text/html text/plain").unwrap();
787        assert!(cfg.text_types.contains("text/html"));
788        assert!(cfg.text_types.contains("text/plain"));
789    }
790
791    #[test]
792    fn test_apply_cli_arg() {
793        let mut cfg = Config::default();
794        cfg.apply_cli_arg("overwrite", "On").unwrap();
795        assert!(cfg.overwrite);
796        cfg.apply_cli_arg("showhtml", "2").unwrap();
797        assert_eq!(cfg.showhtml, 2);
798        cfg.apply_cli_arg("language=de", "").unwrap();
799        assert_eq!(cfg.language, "de");
800    }
801
802    #[test]
803    fn test_post_process() {
804        let mut cfg = Config::default();
805        cfg.post_process();
806        assert!(cfg.skip_headers.contains("from"));
807        assert!(cfg.skip_headers.contains("date"));
808        assert!(cfg.skip_headers.contains("subject"));
809    }
810
811    #[test]
812    fn test_unknown_key() {
813        let mut cfg = Config::default();
814        assert!(cfg.set_string("nonexistent", "value").is_err());
815        assert!(cfg.set_switch("nonexistent", true).is_err());
816        assert!(cfg.set_integer("nonexistent", 42).is_err());
817    }
818
819    #[test]
820    fn test_none_strings() {
821        let mut cfg = Config::default();
822        cfg.set_string("archives", "NONE").unwrap();
823        assert!(cfg.archives.is_none());
824        cfg.set_string("about", "NONE").unwrap();
825        assert!(cfg.about.is_none());
826        cfg.set_string("custom_archives", "NONE").unwrap();
827        assert!(cfg.custom_archives.is_none());
828    }
829
830    #[test]
831    fn test_apply_cli_hm_prefix() {
832        let mut cfg = Config::default();
833        cfg.apply_cli_arg("hm_overwrite", "On").unwrap();
834        assert!(cfg.overwrite);
835    }
836
837    #[test]
838    fn test_apply_cli_set_prefix() {
839        let mut cfg = Config::default();
840        cfg.apply_cli_arg("set_overwrite", "On").unwrap();
841        assert!(cfg.overwrite);
842    }
843
844    #[test]
845    fn test_apply_cli_bool_yes() {
846        let mut cfg = Config::default();
847        cfg.apply_cli_arg("overwrite", "YES").unwrap();
848        assert!(cfg.overwrite);
849    }
850
851    #[test]
852    fn test_apply_cli_bool_no() {
853        let mut cfg = Config::default();
854        assert!(cfg.inlinehtml);
855        cfg.apply_cli_arg("inlinehtml", "OFF").unwrap();
856        assert!(!cfg.inlinehtml);
857    }
858
859    #[test]
860    fn test_apply_cli_octal_dirmode() {
861        let mut cfg = Config::default();
862        cfg.apply_cli_arg("dirmode", "0755").unwrap();
863        assert_eq!(cfg.dirmode, 0o755);
864    }
865
866    #[test]
867    fn test_apply_cli_octal_filemode() {
868        let mut cfg = Config::default();
869        cfg.apply_cli_arg("filemode", "0644").unwrap();
870        assert_eq!(cfg.filemode, 0o644);
871    }
872
873    #[test]
874    fn test_apply_cli_decimal_dirmode() {
875        let mut cfg = Config::default();
876        // "755" without leading zero is still parsed as octal for dirmode/filemode
877        cfg.apply_cli_arg("dirmode", "755").unwrap();
878        assert_eq!(cfg.dirmode, 0o755);
879    }
880
881    #[test]
882    fn test_apply_cli_inline_eq() {
883        let mut cfg = Config::default();
884        cfg.apply_cli_arg("language=fr", "").unwrap();
885        assert_eq!(cfg.language, "fr");
886    }
887
888    #[test]
889    fn test_apply_cli_list() {
890        let mut cfg = Config::default();
891        cfg.apply_cli_arg("filter_out", "spam").unwrap();
892        assert!(cfg.filter_out.contains("spam"));
893    }
894
895    #[test]
896    fn test_apply_cli_with_quoted_value() {
897        let mut cfg = Config::default();
898        cfg.apply_cli_arg("label", "\"My Archive\"").unwrap();
899        assert_eq!(cfg.label.as_deref(), Some("\"My Archive\""));
900    }
901
902    #[test]
903    fn test_apply_cli_unknown_key() {
904        let mut cfg = Config::default();
905        assert!(cfg.apply_cli_arg("nonexistent", "value").is_err());
906    }
907
908    #[test]
909    fn test_env_loading() {
910        let mut cfg = Config::default();
911        std::env::set_var("HM_LANGUAGE", "de");
912        cfg.load_env();
913        std::env::remove_var("HM_LANGUAGE");
914        assert_eq!(cfg.language, "de");
915    }
916
917    #[test]
918    fn test_set_all_switches() {
919        let mut cfg = Config::default();
920        for (key, initial) in &[
921            ("email_address_obfuscation", false),
922            ("i18n", true),
923            ("i18n_body", false),
924            ("overwrite", false),
925            ("inlinehtml", true),
926            ("readone", false),
927            ("reverse", false),
928            ("reverse_folders", false),
929            ("showheaders", true),
930            ("showbr", true),
931            ("showreplies", true),
932            ("indextable", false),
933            ("iquotes", true),
934            ("eurodate", true),
935            ("gmtime", false),
936            ("isodate", false),
937            ("require_msgids", true),
938            ("discard_dup_msgids", true),
939            ("usemeta", false),
940            ("uselock", true),
941            ("ietf_mbox", false),
942            ("linkquotes", false),
943            ("monthly_index", false),
944            ("yearly_index", false),
945            ("spamprotect", true),
946            ("spamprotect_id", true),
947            ("attachmentsindex", true),
948            ("usegdbm", false),
949            ("writehaof", false),
950            ("append", false),
951            ("nonsequential", false),
952            ("warn_surpressions", true),
953            ("files_by_thread", false),
954            ("href_detection", true),
955            ("mbox_shortened", false),
956            ("report_new_file", false),
957            ("report_new_folder", false),
958            ("use_sender_date", false),
959            ("inline_addlink", true),
960            ("iso2022jp", false),
961            ("delete_incremental", true),
962            ("showgenerator", true),
963            ("show_warnings", false),
964        ] {
965            assert!(cfg.set_switch(key, *initial).is_ok(), "switch {} should exist", key);
966        }
967    }
968
969    #[test]
970    fn test_set_all_integers() {
971        let mut cfg = Config::default();
972        for key in &[
973            "increment",
974            "showhtml",
975            "show_msg_links",
976            "show_index_links",
977            "thrdlevels",
978            "dirmode",
979            "filemode",
980            "locktime",
981            "searchbackmsgnum",
982            "quote_hide_threshold",
983            "thread_file_depth",
984            "startmsgnum",
985            "msgsperfolder",
986            "save_alts",
987            "delete_level",
988            "progress",
989            "max_message_size",
990        ] {
991            assert!(cfg.set_integer(key, 1).is_ok(), "integer {} should exist", key);
992        }
993    }
994
995    #[test]
996    fn test_set_all_strings() {
997        let mut cfg = Config::default();
998        for key in &[
999            "fragment_prefix",
1000            "htmlmessage_deleted",
1001            "antispam_at",
1002            "antispamdomain",
1003            "language",
1004            "htmlsuffix",
1005            "mbox",
1006            "archives",
1007            "custom_archives",
1008            "about",
1009            "label",
1010            "dir",
1011            "defaultindex",
1012            "default_top_index",
1013            "mailcommand",
1014            "newmsg_command",
1015            "replymsg_command",
1016            "inreplyto_command",
1017            "mailto",
1018            "hmail",
1019            "domainaddr",
1020            "css",
1021            "icss_url",
1022            "mcss_url",
1023            "dateformat",
1024            "indexdateformat",
1025            "stripsubject",
1026            "link_to_replies",
1027            "quote_link_string",
1028            "ihtmlheaderfile",
1029            "ihtmlfooterfile",
1030            "ihtmlheadfile",
1031            "ihtmlhelpupfile",
1032            "ihtmlhelplowfile",
1033            "ihtmlnavbar2upfile",
1034            "mhtmlheaderfile",
1035            "mhtmlfooterfile",
1036            "attachmentlink",
1037            "bodyheader",
1038            "bodyheaderend",
1039            "bodyfooter",
1040            "unsafe_chars",
1041            "filename_base",
1042            "folder_by_date",
1043            "latest_folder",
1044            "base_url",
1045            "describe_folder",
1046            "delete_older",
1047            "delete_newer",
1048            "alts_text",
1049            "description",
1050            "theme",
1051            "append_filename",
1052            "txtsuffix",
1053        ] {
1054            assert!(cfg.set_string(key, "test").is_ok(), "string {} should exist", key);
1055        }
1056    }
1057
1058    #[test]
1059    fn test_set_all_lists() {
1060        let mut cfg = Config::default();
1061        for key in &[
1062            "show_headers",
1063            "avoid_indices",
1064            "avoid_top_indices",
1065            "text_types",
1066            "inline_types",
1067            "prefered_types",
1068            "ignore_types",
1069            "filter_out",
1070            "filter_require",
1071            "filter_out_full_body",
1072            "filter_require_full_body",
1073            "deleted",
1074            "expires",
1075            "delete_msgnum",
1076        ] {
1077            assert!(cfg.set_list(key, "test").is_ok(), "list {} should exist", key);
1078        }
1079    }
1080
1081    #[test]
1082    fn test_config_file_content_can_parse() {
1083        let config_content = "\
1084# comment
1085set language=de
1086hm_overwrite=On
1087nonsequential On
1088dirmode 0755
1089mbox mailbox/test
1090label \"My Archive\"
1091";
1092        // Verify each line can be parsed via apply_cli_arg
1093        let mut cfg = Config::default();
1094        for line in config_content.lines() {
1095            let line = line.trim();
1096            if line.is_empty() || line.starts_with('#') {
1097                continue;
1098            }
1099            let line = line.strip_prefix("set ").unwrap_or(line);
1100            let eq_pos = line.find('=').or_else(|| line.find(':'));
1101            if let Some(eq_pos) = eq_pos {
1102                let key = line[..eq_pos].trim();
1103                let val = line[eq_pos + 1..].trim();
1104                let val = val.trim_matches('"');
1105                cfg.apply_cli_arg(key, val).unwrap_or_else(|e| {
1106                    panic!("Failed to parse line '{}': {e}", line);
1107                });
1108            }
1109        }
1110        assert_eq!(cfg.language, "de");
1111        assert!(cfg.overwrite);
1112    }
1113
1114    #[test]
1115    fn test_antispamdomain_roundtrip() {
1116        let mut cfg = Config::default();
1117        cfg.set_string("antispamdomain", "nospam.invalid").unwrap();
1118        assert_eq!(cfg.antispamdomain.as_deref(), Some("nospam.invalid"));
1119    }
1120
1121    #[test]
1122    fn test_antispamdomain_none_on_empty() {
1123        let mut cfg = Config::default();
1124        cfg.set_string("antispamdomain", "").unwrap();
1125        assert!(cfg.antispamdomain.is_none());
1126    }
1127
1128    #[test]
1129    fn test_antispamdomain_none_on_keyword() {
1130        let mut cfg = Config::default();
1131        cfg.set_string("antispamdomain", "NONE").unwrap();
1132        assert!(cfg.antispamdomain.is_none());
1133    }
1134}