Skip to main content

todo_tree/
config.rs

1//! `.todorc` configuration file discovery, parsing, and merging with CLI
2//! overrides.
3
4use crate::core::tags::default_tag_names;
5use color_eyre::eyre::{Result, WrapErr};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Resolves the global config directory, honoring `XDG_CONFIG_HOME` on all
10/// platforms (not just Linux, where `dirs::config_dir` already does this)
11/// before falling back to the platform default.
12fn config_home() -> Option<PathBuf> {
13    std::env::var_os("XDG_CONFIG_HOME")
14        .map(PathBuf::from)
15        .filter(|p| !p.as_os_str().is_empty())
16        .or_else(dirs::config_dir)
17}
18
19/// Reads `name` as a comma-separated list, trimming entries and dropping
20/// empty ones. Returns `None` if the variable is unset or resolves to an
21/// empty list (so it doesn't clobber the config file's value with nothing).
22fn env_list(name: &str) -> Option<Vec<String>> {
23    let value = std::env::var(name).ok()?;
24    let items: Vec<String> = value
25        .split(',')
26        .map(str::trim)
27        .filter(|s| !s.is_empty())
28        .map(String::from)
29        .collect();
30
31    if items.is_empty() { None } else { Some(items) }
32}
33
34/// Reads `name` as a boolean: unset, empty, `0`, or `false` (case-insensitive)
35/// is `false`; any other value is `true`. Returns `None` if unset, so the
36/// config file's value is left alone rather than forced off.
37fn env_bool(name: &str) -> Option<bool> {
38    let value = std::env::var(name).ok()?;
39    Some(!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false"))
40}
41
42/// CLI-provided overrides to merge into a loaded [`Config`].
43#[derive(Debug, Clone, Default)]
44pub struct CliOptions {
45    /// Tags to search for, overriding the config file's list if present.
46    pub tags: Option<Vec<String>>,
47    /// Include patterns, overriding the config file's list if present.
48    pub include: Option<Vec<String>>,
49    /// Exclude patterns, appended to the config file's list if present.
50    pub exclude: Option<Vec<String>>,
51    /// Forces JSON output on.
52    pub json: bool,
53    /// Forces flat output on.
54    pub flat: bool,
55    /// Forces colored output off.
56    pub no_color: bool,
57    /// Forces case-insensitive tag matching on.
58    pub ignore_case: bool,
59    /// Forces the trailing-colon requirement off.
60    pub no_require_colon: bool,
61}
62
63/// A loaded (or default) `.todorc` configuration.
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65#[serde(default)]
66pub struct Config {
67    /// Tags to search for.
68    pub tags: Vec<String>,
69    /// Glob patterns to include.
70    pub include: Vec<String>,
71    /// Glob patterns to exclude.
72    pub exclude: Vec<String>,
73    /// Whether to default to JSON output.
74    pub json: bool,
75    /// Whether to default to flat output.
76    pub flat: bool,
77    /// Whether to default to uncolored output.
78    pub no_color: bool,
79    /// An optional custom tag-matching regex, in place of
80    /// [`crate::parser::DEFAULT_REGEX`].
81    pub custom_pattern: Option<String>,
82    /// Whether tag matching is case-insensitive.
83    pub ignore_case: bool,
84    /// Whether a trailing colon is required after the tag.
85    pub require_colon: bool,
86}
87
88impl Config {
89    /// Builds a config with the default tag set and strict matching
90    /// (case-sensitive, colon required).
91    pub fn new() -> Self {
92        Self {
93            tags: default_tag_names(),
94            include: Vec::new(),
95            exclude: Vec::new(),
96            json: false,
97            flat: false,
98            no_color: false,
99            custom_pattern: None,
100            ignore_case: false,
101            require_colon: true,
102        }
103    }
104
105    /// Load configuration from a .todorc file
106    ///
107    /// Searches for configuration files in the following order:
108    /// 1. .todorc in the current directory
109    /// 2. .todorc.json in the current directory
110    /// 3. .todorc.toml in the current directory
111    /// 4. Parent directories (recursive)
112    /// 5. `$XDG_CONFIG_HOME/todo-tree/config.json` or `config.toml`, falling
113    ///    back to the platform config directory if `XDG_CONFIG_HOME` isn't
114    ///    set (global config)
115    pub fn load(start_path: &Path) -> Result<Option<Self>> {
116        let local_configs = [
117            start_path.join(".todorc"),
118            start_path.join(".todorc.json"),
119            start_path.join(".todorc.toml"),
120        ];
121
122        for config_path in &local_configs {
123            if config_path.exists() {
124                return Self::load_from_file(config_path).map(Some);
125            }
126        }
127
128        if let Some(parent) = start_path.parent()
129            && parent != start_path
130            && let Ok(Some(config)) = Self::load(parent)
131        {
132            return Ok(Some(config));
133        }
134
135        if let Some(config_dir) = config_home() {
136            let global_configs = [
137                config_dir.join("todo-tree").join("config.json"),
138                config_dir.join("todo-tree").join("config.toml"),
139            ];
140
141            for config_path in &global_configs {
142                if config_path.exists() {
143                    return Self::load_from_file(config_path).map(Some);
144                }
145            }
146        }
147
148        Ok(None)
149    }
150
151    /// Loads and parses a specific config file, auto-detecting JSON vs.
152    /// TOML from its extension (falling back to JSON-then-TOML for
153    /// extensionless files like `.todorc`).
154    pub fn load_from_file(path: &Path) -> Result<Self> {
155        let content = std::fs::read_to_string(path).wrap_err_with(|| {
156            format!(
157                "Failed to read config file: {}. Check that it exists and you have permission to read it.",
158                path.display()
159            )
160        })?;
161
162        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
163        let parse_result: Result<Self> = if extension == "toml" {
164            toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e))
165        } else {
166            serde_json::from_str(&content)
167                .map_err(|e| color_eyre::eyre::eyre!(e))
168                .or_else(|_| toml::from_str(&content).map_err(|e| color_eyre::eyre::eyre!(e)))
169        };
170
171        parse_result.wrap_err_with(|| {
172            format!(
173                "Failed to parse config: {}. Check that it's valid {} and that its keys match the documented .todorc options.",
174                path.display(),
175                if extension == "toml" { "TOML" } else { "JSON (or TOML)" }
176            )
177        })
178    }
179
180    /// Merges CLI-provided overrides into this config in place.
181    pub fn merge_with_cli(&mut self, cli: CliOptions) {
182        if let Some(tags) = cli.tags
183            && !tags.is_empty()
184        {
185            self.tags = tags;
186        }
187
188        if let Some(include) = cli.include
189            && !include.is_empty()
190        {
191            self.include = include;
192        }
193
194        if let Some(exclude) = cli.exclude
195            && !exclude.is_empty()
196        {
197            self.exclude.extend(exclude);
198        }
199
200        if cli.json {
201            self.json = true;
202        }
203
204        if cli.flat {
205            self.flat = true;
206        }
207
208        if cli.no_color {
209            self.no_color = true;
210        }
211
212        if cli.ignore_case {
213            self.ignore_case = true;
214        }
215
216        if cli.no_require_colon {
217            self.require_colon = false;
218        }
219    }
220
221    /// Loads a config, honoring an explicit `config_path` override before
222    /// falling back to [`Config::load`]'s discovery starting at `path`, and
223    /// finally to [`Config::new`] if nothing is found anywhere. `TODO_TREE_*`
224    /// environment variables (see [`Config::apply_env_overrides`]) are then
225    /// layered on top, so the full precedence is: CLI flags (applied by the
226    /// caller via [`Config::merge_with_cli`]) > environment > project/user
227    /// config file > built-in defaults.
228    pub fn load_or_default(path: &Path, config_path: Option<&Path>) -> Result<Self> {
229        let mut config = if let Some(config_path) = config_path {
230            Self::load_from_file(config_path)?
231        } else {
232            match Self::load(path)? {
233                Some(config) => config,
234                None => Self::new(),
235            }
236        };
237
238        config.apply_env_overrides();
239        Ok(config)
240    }
241
242    /// Applies `TODO_TREE_*` environment variable overrides in place, sitting
243    /// between the config file and CLI flags in precedence. Recognizes
244    /// `TODO_TREE_TAGS`/`_INCLUDE`/`_EXCLUDE` (comma-separated lists) and
245    /// `TODO_TREE_JSON`/`_FLAT`/`_NO_COLOR`/`_IGNORE_CASE`/`_REQUIRE_COLON`
246    /// (booleans: unset/empty/`0`/`false` is off, anything else is on).
247    fn apply_env_overrides(&mut self) {
248        if let Some(tags) = env_list("TODO_TREE_TAGS") {
249            self.tags = tags;
250        }
251        if let Some(include) = env_list("TODO_TREE_INCLUDE") {
252            self.include = include;
253        }
254        if let Some(exclude) = env_list("TODO_TREE_EXCLUDE") {
255            self.exclude.extend(exclude);
256        }
257        if let Some(value) = env_bool("TODO_TREE_JSON") {
258            self.json = value;
259        }
260        if let Some(value) = env_bool("TODO_TREE_FLAT") {
261            self.flat = value;
262        }
263        if let Some(value) = env_bool("TODO_TREE_NO_COLOR") {
264            self.no_color = value;
265        }
266        if let Some(value) = env_bool("TODO_TREE_IGNORE_CASE") {
267            self.ignore_case = value;
268        }
269        if let Some(value) = env_bool("TODO_TREE_REQUIRE_COLON") {
270            self.require_colon = value;
271        }
272    }
273
274    /// Saves this config to whichever `.todorc`/`.todorc.json`/`.todorc.toml`
275    /// already exists in the current directory, or `.todorc.json` if none
276    /// do.
277    pub fn save_in_cwd(&self) -> Result<()> {
278        let current_dir = std::env::current_dir()?;
279        let config_files = [
280            current_dir.join(".todorc"),
281            current_dir.join(".todorc.json"),
282            current_dir.join(".todorc.toml"),
283        ];
284
285        for path in &config_files {
286            if path.exists() {
287                return self.save(path);
288            }
289        }
290
291        let path = current_dir.join(".todorc.json");
292        self.save(&path)
293    }
294
295    /// Writes this config to `path`, choosing TOML or JSON based on its
296    /// extension (JSON if unrecognized).
297    pub fn save(&self, path: &Path) -> Result<()> {
298        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
299        let content = if extension == "toml" {
300            toml::to_string_pretty(self)?
301        } else {
302            serde_json::to_string_pretty(self)?
303        };
304
305        std::fs::write(path, content)
306            .wrap_err_with(|| format!("Failed to write config file: {}", path.display()))?;
307
308        Ok(())
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::fs;
316    use std::time::{SystemTime, UNIX_EPOCH};
317
318    // `XDG_CONFIG_HOME` is process-global; serialize every test that touches
319    // it so they don't race each other under parallel test execution.
320    static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
321
322    fn temp_path(name: &str) -> std::path::PathBuf {
323        let unique = SystemTime::now()
324            .duration_since(UNIX_EPOCH)
325            .unwrap()
326            .as_nanos();
327        std::env::temp_dir().join(format!("todo_tree_config_test_{name}_{unique}"))
328    }
329
330    #[test]
331    fn load_from_file_parses_json() {
332        let path = temp_path("json").with_extension("json");
333        fs::write(&path, r#"{"tags": ["TODO", "FIXME"], "ignore_case": true}"#).unwrap();
334
335        let config = Config::load_from_file(&path).unwrap();
336        let _ = fs::remove_file(&path);
337
338        assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
339        assert!(config.ignore_case);
340    }
341
342    #[test]
343    fn config_home_prefers_xdg_config_home_when_set() {
344        let _lock = XDG_ENV_LOCK.lock().unwrap();
345        let dir = temp_path("xdg");
346
347        // SAFETY: mutating process env is inherently racy under parallel
348        // test execution; the window is kept as narrow as possible and the
349        // var is always removed before returning.
350        unsafe {
351            std::env::set_var("XDG_CONFIG_HOME", &dir);
352        }
353        let resolved = config_home();
354        unsafe {
355            std::env::remove_var("XDG_CONFIG_HOME");
356        }
357
358        assert_eq!(resolved, Some(dir));
359    }
360
361    #[test]
362    fn load_from_file_parses_toml() {
363        let path = temp_path("toml").with_extension("toml");
364        fs::write(&path, "tags = [\"TODO\", \"FIXME\"]\nignore_case = true\n").unwrap();
365
366        let config = Config::load_from_file(&path).unwrap();
367        let _ = fs::remove_file(&path);
368
369        assert_eq!(config.tags, vec!["TODO".to_string(), "FIXME".to_string()]);
370        assert!(config.ignore_case);
371    }
372
373    #[test]
374    fn save_then_load_round_trips_toml() {
375        let path = temp_path("roundtrip").with_extension("toml");
376        let mut config = Config::new();
377        config.tags = vec!["NOTE".to_string()];
378
379        config.save(&path).unwrap();
380        let loaded = Config::load_from_file(&path).unwrap();
381        let _ = fs::remove_file(&path);
382
383        assert_eq!(loaded.tags, vec!["NOTE".to_string()]);
384    }
385
386    #[test]
387    fn load_does_not_recognize_yaml_files() {
388        let dir = temp_path("yaml_dir");
389        fs::create_dir_all(&dir).unwrap();
390        fs::write(dir.join(".todorc.yaml"), "tags:\n  - TODO\n").unwrap();
391
392        let result = Config::load(&dir).unwrap();
393        let _ = fs::remove_dir_all(&dir);
394
395        assert!(
396            result.is_none() || result.unwrap().tags != vec!["TODO".to_string()],
397            ".todorc.yaml must no longer be picked up as a config file"
398        );
399    }
400
401    #[test]
402    fn config_home_falls_back_to_platform_dir_when_xdg_unset() {
403        let _lock = XDG_ENV_LOCK.lock().unwrap();
404        let previous = std::env::var_os("XDG_CONFIG_HOME");
405        unsafe {
406            std::env::remove_var("XDG_CONFIG_HOME");
407        }
408        let resolved = config_home();
409        unsafe {
410            match &previous {
411                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
412                None => std::env::remove_var("XDG_CONFIG_HOME"),
413            }
414        }
415
416        assert_eq!(resolved, dirs::config_dir());
417    }
418
419    #[test]
420    fn config_home_falls_back_when_xdg_is_empty() {
421        let _lock = XDG_ENV_LOCK.lock().unwrap();
422        let previous = std::env::var_os("XDG_CONFIG_HOME");
423        unsafe {
424            std::env::set_var("XDG_CONFIG_HOME", "");
425        }
426        let resolved = config_home();
427        unsafe {
428            match &previous {
429                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
430                None => std::env::remove_var("XDG_CONFIG_HOME"),
431            }
432        }
433
434        assert_eq!(resolved, dirs::config_dir());
435    }
436
437    #[test]
438    fn new_returns_strict_defaults() {
439        let config = Config::new();
440        assert_eq!(config.tags, default_tag_names());
441        assert!(config.include.is_empty());
442        assert!(config.exclude.is_empty());
443        assert!(!config.json);
444        assert!(!config.flat);
445        assert!(!config.no_color);
446        assert!(config.custom_pattern.is_none());
447        assert!(!config.ignore_case);
448        assert!(config.require_colon);
449    }
450
451    #[test]
452    fn load_returns_none_when_nothing_found_anywhere() {
453        let _lock = XDG_ENV_LOCK.lock().unwrap();
454        // Force `config_home()` to resolve to a guaranteed-empty directory so
455        // this test doesn't depend on whether the machine running it happens
456        // to have a real global todo-tree config.
457        let empty_xdg = temp_path("empty_xdg");
458        fs::create_dir_all(&empty_xdg).unwrap();
459        let scan_dir = temp_path("no_config_anywhere");
460        fs::create_dir_all(&scan_dir).unwrap();
461
462        let previous = std::env::var_os("XDG_CONFIG_HOME");
463        unsafe {
464            std::env::set_var("XDG_CONFIG_HOME", &empty_xdg);
465        }
466        let result = Config::load(&scan_dir);
467        unsafe {
468            match &previous {
469                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
470                None => std::env::remove_var("XDG_CONFIG_HOME"),
471            }
472        }
473        let _ = fs::remove_dir_all(&empty_xdg);
474        let _ = fs::remove_dir_all(&scan_dir);
475
476        assert!(result.unwrap().is_none());
477    }
478
479    #[test]
480    fn load_falls_back_to_global_config_dir() {
481        let _lock = XDG_ENV_LOCK.lock().unwrap();
482        let xdg_dir = temp_path("global_xdg");
483        let todo_tree_dir = xdg_dir.join("todo-tree");
484        fs::create_dir_all(&todo_tree_dir).unwrap();
485        fs::write(todo_tree_dir.join("config.json"), r#"{"tags": ["GLOBAL"]}"#).unwrap();
486
487        let scan_dir = temp_path("global_scan_target");
488        fs::create_dir_all(&scan_dir).unwrap();
489
490        let previous = std::env::var_os("XDG_CONFIG_HOME");
491        unsafe {
492            std::env::set_var("XDG_CONFIG_HOME", &xdg_dir);
493        }
494        let result = Config::load(&scan_dir);
495        unsafe {
496            match &previous {
497                Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
498                None => std::env::remove_var("XDG_CONFIG_HOME"),
499            }
500        }
501        let _ = fs::remove_dir_all(&xdg_dir);
502        let _ = fs::remove_dir_all(&scan_dir);
503
504        let config = result
505            .unwrap()
506            .expect("expected the global config to be found");
507        assert_eq!(config.tags, vec!["GLOBAL".to_string()]);
508    }
509
510    #[test]
511    fn load_finds_exact_todorc_filename() {
512        let dir = temp_path("exact_todorc");
513        fs::create_dir_all(&dir).unwrap();
514        fs::write(dir.join(".todorc"), r#"{"tags": ["NOTE"]}"#).unwrap();
515
516        let config = Config::load(&dir).unwrap().expect("expected a config");
517        let _ = fs::remove_dir_all(&dir);
518
519        assert_eq!(config.tags, vec!["NOTE".to_string()]);
520    }
521
522    #[test]
523    fn load_recurses_into_parent_directories() {
524        let dir = temp_path("parent_recursion");
525        let child = dir.join("child");
526        fs::create_dir_all(&child).unwrap();
527        fs::write(dir.join(".todorc.json"), r#"{"tags": ["PARENT"]}"#).unwrap();
528
529        let config = Config::load(&child).unwrap().expect("expected a config");
530        let _ = fs::remove_dir_all(&dir);
531
532        assert_eq!(config.tags, vec!["PARENT".to_string()]);
533    }
534
535    #[test]
536    fn load_from_file_errors_on_missing_file() {
537        let path = temp_path("missing").with_extension("json");
538        assert!(Config::load_from_file(&path).is_err());
539    }
540
541    #[test]
542    fn load_from_file_errors_on_unparseable_content() {
543        let path = temp_path("garbage").with_extension("toml");
544        fs::write(&path, "not: valid { toml or json").unwrap();
545
546        let result = Config::load_from_file(&path);
547        let _ = fs::remove_file(&path);
548
549        assert!(result.is_err());
550    }
551
552    #[test]
553    fn save_writes_json_for_non_toml_extension() {
554        let path = temp_path("save_json").with_extension("json");
555        let config = Config::new();
556
557        config.save(&path).unwrap();
558        let content = fs::read_to_string(&path).unwrap();
559        let _ = fs::remove_file(&path);
560
561        assert!(content.trim_start().starts_with('{'));
562    }
563
564    #[test]
565    fn merge_with_cli_applies_every_override() {
566        let mut config = Config::new();
567        config.exclude = vec!["existing/**".to_string()];
568
569        config.merge_with_cli(CliOptions {
570            tags: Some(vec!["CUSTOM".to_string()]),
571            include: Some(vec!["*.rs".to_string()]),
572            exclude: Some(vec!["extra/**".to_string()]),
573            json: true,
574            flat: true,
575            no_color: true,
576            ignore_case: true,
577            no_require_colon: true,
578        });
579
580        assert_eq!(config.tags, vec!["CUSTOM".to_string()]);
581        assert_eq!(config.include, vec!["*.rs".to_string()]);
582        assert_eq!(
583            config.exclude,
584            vec!["existing/**".to_string(), "extra/**".to_string()]
585        );
586        assert!(config.json);
587        assert!(config.flat);
588        assert!(config.no_color);
589        assert!(config.ignore_case);
590        assert!(!config.require_colon);
591    }
592
593    #[test]
594    fn merge_with_cli_is_a_no_op_with_default_options() {
595        let config_before = Config::new();
596        let mut config = Config::new();
597
598        config.merge_with_cli(CliOptions::default());
599
600        assert_eq!(config.tags, config_before.tags);
601        assert_eq!(config.include, config_before.include);
602        assert_eq!(config.exclude, config_before.exclude);
603        assert_eq!(config.json, config_before.json);
604        assert_eq!(config.flat, config_before.flat);
605        assert_eq!(config.no_color, config_before.no_color);
606        assert_eq!(config.ignore_case, config_before.ignore_case);
607        assert_eq!(config.require_colon, config_before.require_colon);
608    }
609
610    // `TODO_TREE_*` vars are process-global; serialize tests that touch them.
611    static TODO_TREE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
612
613    /// Clears every `TODO_TREE_*` override var this module tests, so each
614    /// test starts from a known-empty environment regardless of test order.
615    fn clear_todo_tree_env() {
616        for var in [
617            "TODO_TREE_TAGS",
618            "TODO_TREE_INCLUDE",
619            "TODO_TREE_EXCLUDE",
620            "TODO_TREE_JSON",
621            "TODO_TREE_FLAT",
622            "TODO_TREE_NO_COLOR",
623            "TODO_TREE_IGNORE_CASE",
624            "TODO_TREE_REQUIRE_COLON",
625        ] {
626            // SAFETY: see XDG_ENV_LOCK above; same narrow-window rationale.
627            unsafe {
628                std::env::remove_var(var);
629            }
630        }
631    }
632
633    #[test]
634    fn apply_env_overrides_applies_every_recognized_var() {
635        let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
636        clear_todo_tree_env();
637
638        let mut config = Config::new();
639        config.exclude = vec!["existing/**".to_string()];
640
641        // SAFETY: see XDG_ENV_LOCK above; same narrow-window rationale.
642        unsafe {
643            std::env::set_var("TODO_TREE_TAGS", "CUSTOM, OTHER");
644            std::env::set_var("TODO_TREE_INCLUDE", "*.rs");
645            std::env::set_var("TODO_TREE_EXCLUDE", "extra/**");
646            std::env::set_var("TODO_TREE_JSON", "true");
647            std::env::set_var("TODO_TREE_FLAT", "1");
648            std::env::set_var("TODO_TREE_NO_COLOR", "true");
649            std::env::set_var("TODO_TREE_IGNORE_CASE", "true");
650            std::env::set_var("TODO_TREE_REQUIRE_COLON", "false");
651        }
652        config.apply_env_overrides();
653        clear_todo_tree_env();
654
655        assert_eq!(config.tags, vec!["CUSTOM".to_string(), "OTHER".to_string()]);
656        assert_eq!(config.include, vec!["*.rs".to_string()]);
657        assert_eq!(
658            config.exclude,
659            vec!["existing/**".to_string(), "extra/**".to_string()]
660        );
661        assert!(config.json);
662        assert!(config.flat);
663        assert!(config.no_color);
664        assert!(config.ignore_case);
665        assert!(!config.require_colon);
666    }
667
668    #[test]
669    fn apply_env_overrides_is_a_no_op_when_unset() {
670        let _lock = TODO_TREE_ENV_LOCK.lock().unwrap();
671        clear_todo_tree_env();
672
673        let before = Config::new();
674        let mut config = Config::new();
675        config.apply_env_overrides();
676
677        assert_eq!(config.tags, before.tags);
678        assert_eq!(config.include, before.include);
679        assert_eq!(config.exclude, before.exclude);
680        assert_eq!(config.json, before.json);
681        assert_eq!(config.flat, before.flat);
682        assert_eq!(config.no_color, before.no_color);
683        assert_eq!(config.ignore_case, before.ignore_case);
684        assert_eq!(config.require_colon, before.require_colon);
685    }
686
687    #[test]
688    fn merge_with_cli_ignores_empty_tag_and_include_overrides() {
689        let mut config = Config::new();
690        let original_tags = config.tags.clone();
691
692        config.merge_with_cli(CliOptions {
693            tags: Some(vec![]),
694            include: Some(vec![]),
695            exclude: Some(vec![]),
696            ..Default::default()
697        });
698
699        assert_eq!(config.tags, original_tags);
700        assert!(config.include.is_empty());
701        assert!(config.exclude.is_empty());
702    }
703}