Skip to main content

aimcal_cli/
config.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{
6    error::Error,
7    io::{IsTerminal, stdin, stdout},
8    path::PathBuf,
9    str::FromStr,
10};
11
12use tokio::fs;
13
14use aimcal_core::{APP_NAME, Config as CoreConfig};
15
16use crate::prompt::{DevModeChoice, prompt_dev_mode_choice};
17
18const AIM_CONFIG_ENV: &str = "AIM_CONFIG";
19const AIM_DEV_ENV: &str = "AIM_DEV";
20
21const AIM_DEV_VALID_TRUE: &[&str] = &["1", "true", "yes"];
22const AIM_DEV_VALID_FALSE: &[&str] = &["0", "false", "no"];
23
24#[tracing::instrument]
25pub async fn parse_config(path: Option<PathBuf>) -> Result<(CoreConfig, Config), Box<dyn Error>> {
26    let dev_mode_strategy = resolve_dev_mode_strategy()?;
27
28    let path = if let Some(path) = path {
29        path
30    } else if should_use_aim_config_env(dev_mode_strategy) {
31        PathBuf::from(std::env::var(AIM_CONFIG_ENV)?)
32    } else if matches!(dev_mode_strategy, DevModeStrategy::ForcedNormal) {
33        let config = get_config_dir()?.join(format!("{APP_NAME}/config.toml"));
34        if !config.exists() {
35            return Err(format!("No config found at: {}", config.display()).into());
36        }
37        config
38    } else if let Ok(env_path) = std::env::var(AIM_CONFIG_ENV) {
39        PathBuf::from(env_path)
40    } else {
41        if matches!(effective_dev_mode(dev_mode_strategy), Some(true)) {
42            return Err(format!(
43                "Development environment detected ({AIM_DEV_ENV} is set): config must be explicitly specified via --config or {AIM_CONFIG_ENV} environment variable",
44            ).into());
45        }
46        // TODO: search config in multiple locations
47        let config = get_config_dir()?.join(format!("{APP_NAME}/config.toml"));
48        if !config.exists() {
49            return Err(format!("No config found at: {}", config.display()).into());
50        }
51        config
52    };
53
54    fs::read_to_string(&path)
55        .await
56        .map_err(|e| format!("Failed to read config file at {}: {}", path.display(), e))?
57        .parse::<ConfigRaw>()
58        .map(|mut a| {
59            a.core.config_dir = path.parent().map(PathBuf::from);
60            a.core.dev_mode = is_dev_mode().unwrap_or(false);
61            (a.core, Config {})
62        })
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66enum DevModeStrategy {
67    Environment,
68    ForcedNormal,
69    ForcedDev,
70}
71
72/// Configuration for the Aim application.
73#[derive(Debug, Clone, Copy, serde::Deserialize)]
74pub struct Config;
75
76#[derive(Debug, serde::Deserialize)]
77struct ConfigRaw {
78    core: CoreConfig,
79}
80
81impl FromStr for ConfigRaw {
82    type Err = Box<dyn Error>;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        Ok(toml::from_str(s)?)
86    }
87}
88
89fn get_config_dir() -> Result<PathBuf, Box<dyn Error>> {
90    #[cfg(unix)]
91    let config_dir = xdg::BaseDirectories::new().get_config_home();
92    #[cfg(windows)]
93    let config_dir = dirs::config_dir();
94    config_dir.ok_or_else(|| "User-specific home directory not found".into())
95}
96
97fn is_dev_mode() -> Option<bool> {
98    if let Ok(val) = std::env::var(AIM_DEV_ENV) {
99        let lower = val.to_lowercase();
100        if AIM_DEV_VALID_TRUE.contains(&lower.as_str()) {
101            Some(true)
102        } else if AIM_DEV_VALID_FALSE.contains(&lower.as_str()) {
103            Some(false)
104        } else {
105            tracing::warn!(
106                "Unrecognized value for {}: '{}'. Expected one of: {}. Treating as unset.",
107                AIM_DEV_ENV,
108                val,
109                format!(
110                    "true: {}, false: {}",
111                    AIM_DEV_VALID_TRUE.join(", "),
112                    AIM_DEV_VALID_FALSE.join(", ")
113                )
114            );
115            None
116        }
117    } else {
118        None
119    }
120}
121
122fn resolve_dev_mode_strategy() -> Result<DevModeStrategy, Box<dyn Error>> {
123    if cfg!(debug_assertions) || std::env::var_os(AIM_DEV_ENV).is_none() {
124        return Ok(DevModeStrategy::Environment);
125    }
126
127    if !stdin().is_terminal() || !stdout().is_terminal() {
128        return Ok(DevModeStrategy::Environment);
129    }
130
131    match prompt_dev_mode_choice()? {
132        DevModeChoice::Exit => {
133            Err("Aborted because AIM_DEV was detected in the environment".into())
134        }
135        DevModeChoice::Normal => Ok(DevModeStrategy::ForcedNormal),
136        DevModeChoice::Dev => Ok(DevModeStrategy::ForcedDev),
137    }
138}
139
140fn effective_dev_mode(strategy: DevModeStrategy) -> Option<bool> {
141    match strategy {
142        DevModeStrategy::Environment => is_dev_mode(),
143        DevModeStrategy::ForcedNormal => Some(false),
144        DevModeStrategy::ForcedDev => Some(true),
145    }
146}
147
148fn should_use_aim_config_env(strategy: DevModeStrategy) -> bool {
149    match strategy {
150        DevModeStrategy::Environment | DevModeStrategy::ForcedDev => {
151            std::env::var(AIM_CONFIG_ENV).is_ok()
152        }
153        DevModeStrategy::ForcedNormal => false,
154    }
155}
156
157#[cfg(test)]
158#[allow(unsafe_code)]
159mod tests {
160    use super::*;
161    use std::fs;
162    use std::sync::OnceLock;
163    use tempfile::TempDir;
164    use tokio::sync::Mutex;
165
166    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
167
168    fn env_lock() -> &'static Mutex<()> {
169        ENV_LOCK.get_or_init(|| Mutex::new(()))
170    }
171
172    #[test]
173    fn forced_normal_disables_aim_config_env() {
174        assert!(!should_use_aim_config_env(DevModeStrategy::ForcedNormal));
175    }
176
177    #[test]
178    fn forced_dev_enables_dev_mode() {
179        assert_eq!(effective_dev_mode(DevModeStrategy::ForcedDev), Some(true));
180    }
181
182    #[test]
183    fn forced_normal_disables_dev_mode() {
184        assert_eq!(
185            effective_dev_mode(DevModeStrategy::ForcedNormal),
186            Some(false)
187        );
188    }
189
190    #[tokio::test]
191    async fn cli_flag_overrides_env_var() {
192        let temp_dir = TempDir::new().unwrap();
193        let config_path = temp_dir.path().join("config.toml");
194        let calendar_dir = temp_dir.path().join("calendar");
195        fs::create_dir(&calendar_dir).unwrap();
196
197        let toml_content = format!(
198            r#"
199[core]
200calendar_path = "{}"
201"#,
202            calendar_dir.to_str().unwrap().replace('\\', "/")
203        );
204        fs::write(&config_path, toml_content).unwrap();
205
206        let env_path = temp_dir.path().join("env_config.toml");
207        let env_calendar_dir = temp_dir.path().join("env_calendar");
208        fs::create_dir(&env_calendar_dir).unwrap();
209        let env_toml_content = format!(
210            r#"
211[core]
212calendar_path = "{}"
213"#,
214            env_calendar_dir.to_str().unwrap().replace('\\', "/")
215        );
216        fs::write(&env_path, env_toml_content).unwrap();
217
218        {
219            let _guard = env_lock().lock().await;
220            unsafe {
221                std::env::remove_var(AIM_CONFIG_ENV);
222                std::env::remove_var(AIM_DEV_ENV);
223                std::env::set_var(AIM_CONFIG_ENV, env_path.to_str().unwrap());
224            }
225
226            let (config, _) = parse_config(Some(config_path.clone())).await.unwrap();
227
228            assert_eq!(config.calendar_path, Some(calendar_dir));
229
230            unsafe {
231                std::env::remove_var(AIM_CONFIG_ENV);
232            }
233        }
234    }
235
236    #[tokio::test]
237    async fn env_var_overrides_default_config() {
238        let temp_dir = TempDir::new().unwrap();
239        let env_config_path = temp_dir.path().join("env_config.toml");
240        let calendar_dir = temp_dir.path().join("calendar");
241        fs::create_dir(&calendar_dir).unwrap();
242
243        let toml_content = format!(
244            r#"
245[core]
246calendar_path = "{}"
247"#,
248            calendar_dir.to_str().unwrap().replace('\\', "/")
249        );
250        fs::write(&env_config_path, toml_content).unwrap();
251
252        {
253            let _guard = env_lock().lock().await;
254            unsafe {
255                std::env::remove_var(AIM_CONFIG_ENV);
256                std::env::remove_var(AIM_DEV_ENV);
257                std::env::set_var(AIM_CONFIG_ENV, env_config_path.to_str().unwrap());
258            }
259
260            let (config, _) = parse_config(None).await.unwrap();
261
262            assert_eq!(config.calendar_path, Some(calendar_dir));
263
264            unsafe {
265                std::env::remove_var(AIM_CONFIG_ENV);
266            }
267        }
268    }
269
270    #[tokio::test]
271    async fn respects_priority_order() {
272        let temp_dir = TempDir::new().unwrap();
273
274        let cli_config_path = temp_dir.path().join("cli_config.toml");
275        let cli_calendar_dir = temp_dir.path().join("cli_calendar");
276        fs::create_dir(&cli_calendar_dir).unwrap();
277        let cli_toml_content = format!(
278            r#"
279[core]
280calendar_path = "{}"
281"#,
282            cli_calendar_dir.to_str().unwrap().replace('\\', "/")
283        );
284        fs::write(&cli_config_path, cli_toml_content).unwrap();
285
286        let env_config_path = temp_dir.path().join("env_config.toml");
287        let env_calendar_dir = temp_dir.path().join("env_calendar");
288        fs::create_dir(&env_calendar_dir).unwrap();
289        let env_toml_content = format!(
290            r#"
291[core]
292calendar_path = "{}"
293"#,
294            env_calendar_dir.to_str().unwrap().replace('\\', "/")
295        );
296        fs::write(&env_config_path, env_toml_content).unwrap();
297
298        {
299            let _guard = env_lock().lock().await;
300            unsafe {
301                std::env::remove_var(AIM_CONFIG_ENV);
302                std::env::remove_var(AIM_DEV_ENV);
303                std::env::set_var(AIM_CONFIG_ENV, env_config_path.to_str().unwrap());
304            }
305
306            let (config, _) = parse_config(Some(cli_config_path)).await.unwrap();
307
308            assert_eq!(config.calendar_path, Some(cli_calendar_dir));
309
310            unsafe {
311                std::env::remove_var(AIM_CONFIG_ENV);
312            }
313        }
314    }
315
316    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
317    #[cfg(unix)]
318    #[tokio::test]
319    async fn uses_default_when_no_cli_or_env() {
320        let temp_dir = TempDir::new().unwrap();
321        let default_config_dir = temp_dir.path().join("aim");
322        fs::create_dir_all(&default_config_dir).unwrap();
323        let default_config_path = default_config_dir.join("config.toml");
324        let calendar_dir = temp_dir.path().join("calendar");
325        fs::create_dir(&calendar_dir).unwrap();
326
327        let toml_content = format!(
328            r#"
329[core]
330calendar_path = "{}"
331"#,
332            calendar_dir.to_str().unwrap().replace('\\', "/")
333        );
334        fs::write(&default_config_path, toml_content).unwrap();
335
336        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
337        {
338            let _guard = env_lock().lock().await;
339            unsafe {
340                std::env::remove_var(AIM_CONFIG_ENV);
341                std::env::remove_var(AIM_DEV_ENV);
342                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
343            }
344
345            let (config, _) = parse_config(None).await.unwrap();
346
347            assert_eq!(config.calendar_path, Some(calendar_dir));
348
349            unsafe {
350                std::env::remove_var("XDG_CONFIG_HOME");
351            }
352        }
353    }
354
355    #[tokio::test]
356    async fn returns_error_when_no_config_found() {
357        let temp_dir = TempDir::new().unwrap();
358        let empty_dir = temp_dir.path().join("empty");
359        fs::create_dir(&empty_dir).unwrap();
360
361        let xdg_config_home = empty_dir.to_str().unwrap().to_string();
362        {
363            let _guard = env_lock().lock().await;
364            unsafe {
365                std::env::remove_var(AIM_CONFIG_ENV);
366                std::env::remove_var(AIM_DEV_ENV);
367                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
368            }
369
370            let result = parse_config(None).await;
371
372            assert!(result.is_err());
373
374            unsafe {
375                std::env::remove_var("XDG_CONFIG_HOME");
376            }
377        }
378    }
379
380    #[tokio::test]
381    async fn aim_dev_1_disables_default_discovery() {
382        let temp_dir = TempDir::new().unwrap();
383        let empty_dir = temp_dir.path().join("empty");
384        fs::create_dir(&empty_dir).unwrap();
385
386        let xdg_config_home = empty_dir.to_str().unwrap().to_string();
387        {
388            let _guard = env_lock().lock().await;
389            unsafe {
390                std::env::remove_var(AIM_CONFIG_ENV);
391                std::env::remove_var(AIM_DEV_ENV);
392                std::env::remove_var("XDG_CONFIG_HOME");
393                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
394                std::env::set_var(AIM_DEV_ENV, "1");
395            }
396
397            let result = parse_config(None).await;
398
399            assert!(result.is_err());
400            let error_msg = result.unwrap_err().to_string();
401            assert!(error_msg.contains("Development environment detected"));
402            assert!(error_msg.contains(AIM_DEV_ENV));
403
404            unsafe {
405                std::env::remove_var(AIM_DEV_ENV);
406                std::env::remove_var("XDG_CONFIG_HOME");
407            }
408        }
409    }
410
411    #[tokio::test]
412    async fn aim_dev_true_disables_default_discovery() {
413        let temp_dir = TempDir::new().unwrap();
414        let empty_dir = temp_dir.path().join("empty");
415        fs::create_dir(&empty_dir).unwrap();
416
417        let xdg_config_home = empty_dir.to_str().unwrap().to_string();
418        {
419            let _guard = env_lock().lock().await;
420            unsafe {
421                std::env::remove_var(AIM_CONFIG_ENV);
422                std::env::remove_var(AIM_DEV_ENV);
423                std::env::remove_var("XDG_CONFIG_HOME");
424                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
425                std::env::set_var(AIM_DEV_ENV, "true");
426            }
427
428            let result = parse_config(None).await;
429
430            assert!(result.is_err());
431            let error_msg = result.unwrap_err().to_string();
432            assert!(error_msg.contains("Development environment detected"));
433
434            unsafe {
435                std::env::remove_var(AIM_DEV_ENV);
436                std::env::remove_var("XDG_CONFIG_HOME");
437            }
438        }
439    }
440
441    #[tokio::test]
442    async fn aim_dev_yes_disables_default_discovery() {
443        let temp_dir = TempDir::new().unwrap();
444        let empty_dir = temp_dir.path().join("empty");
445        fs::create_dir(&empty_dir).unwrap();
446
447        let xdg_config_home = empty_dir.to_str().unwrap().to_string();
448        {
449            let _guard = env_lock().lock().await;
450            unsafe {
451                std::env::remove_var(AIM_CONFIG_ENV);
452                std::env::remove_var(AIM_DEV_ENV);
453                std::env::remove_var("XDG_CONFIG_HOME");
454                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
455                std::env::set_var(AIM_DEV_ENV, "yes");
456            }
457
458            let result = parse_config(None).await;
459
460            assert!(result.is_err());
461
462            unsafe {
463                std::env::remove_var(AIM_DEV_ENV);
464                std::env::remove_var("XDG_CONFIG_HOME");
465            }
466        }
467    }
468
469    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
470    #[cfg(unix)]
471    #[tokio::test]
472    async fn aim_dev_0_allows_default_discovery() {
473        let temp_dir = TempDir::new().unwrap();
474        let default_config_dir = temp_dir.path().join("aim");
475        fs::create_dir_all(&default_config_dir).unwrap();
476        let default_config_path = default_config_dir.join("config.toml");
477        let calendar_dir = temp_dir.path().join("calendar");
478        fs::create_dir(&calendar_dir).unwrap();
479
480        let toml_content = format!(
481            r#"
482[core]
483calendar_path = "{}"
484"#,
485            calendar_dir.to_str().unwrap().replace('\\', "/")
486        );
487        fs::write(&default_config_path, toml_content).unwrap();
488
489        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
490        {
491            let _guard = env_lock().lock().await;
492            unsafe {
493                std::env::remove_var(AIM_CONFIG_ENV);
494                std::env::remove_var(AIM_DEV_ENV);
495                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
496                std::env::set_var(AIM_DEV_ENV, "0");
497            }
498
499            let (config, _) = parse_config(None).await.unwrap();
500            assert_eq!(config.calendar_path, Some(calendar_dir));
501
502            unsafe {
503                std::env::remove_var(AIM_DEV_ENV);
504                std::env::remove_var("XDG_CONFIG_HOME");
505            }
506        }
507    }
508
509    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
510    #[cfg(unix)]
511    #[tokio::test]
512    async fn aim_dev_false_allows_default_discovery() {
513        let temp_dir = TempDir::new().unwrap();
514        let default_config_dir = temp_dir.path().join("aim");
515        fs::create_dir_all(&default_config_dir).unwrap();
516        let default_config_path = default_config_dir.join("config.toml");
517        let calendar_dir = temp_dir.path().join("calendar");
518        fs::create_dir(&calendar_dir).unwrap();
519
520        let toml_content = format!(
521            r#"
522[core]
523calendar_path = "{}"
524"#,
525            calendar_dir.to_str().unwrap().replace('\\', "/")
526        );
527        fs::write(&default_config_path, toml_content).unwrap();
528
529        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
530        {
531            let _guard = env_lock().lock().await;
532            unsafe {
533                std::env::remove_var(AIM_CONFIG_ENV);
534                std::env::remove_var(AIM_DEV_ENV);
535                std::env::remove_var("XDG_CONFIG_HOME");
536                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
537                std::env::set_var(AIM_DEV_ENV, "false");
538            }
539
540            let (config, _) = parse_config(None).await.unwrap();
541            assert_eq!(config.calendar_path, Some(calendar_dir));
542
543            unsafe {
544                std::env::remove_var(AIM_DEV_ENV);
545                std::env::remove_var("XDG_CONFIG_HOME");
546            }
547        }
548    }
549
550    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
551    #[cfg(unix)]
552    #[tokio::test]
553    async fn aim_dev_no_allows_default_discovery() {
554        let temp_dir = TempDir::new().unwrap();
555        let default_config_dir = temp_dir.path().join("aim");
556        fs::create_dir_all(&default_config_dir).unwrap();
557        let default_config_path = default_config_dir.join("config.toml");
558        let calendar_dir = temp_dir.path().join("calendar");
559        fs::create_dir(&calendar_dir).unwrap();
560
561        let toml_content = format!(
562            r#"
563[core]
564calendar_path = "{}"
565"#,
566            calendar_dir.to_str().unwrap().replace('\\', "/")
567        );
568        fs::write(&default_config_path, toml_content).unwrap();
569
570        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
571        {
572            let _guard = env_lock().lock().await;
573            unsafe {
574                std::env::remove_var(AIM_CONFIG_ENV);
575                std::env::remove_var(AIM_DEV_ENV);
576                std::env::remove_var("XDG_CONFIG_HOME");
577                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
578                std::env::set_var(AIM_DEV_ENV, "no");
579            }
580
581            let (config, _) = parse_config(None).await.unwrap();
582            assert_eq!(config.calendar_path, Some(calendar_dir));
583
584            unsafe {
585                std::env::remove_var(AIM_DEV_ENV);
586                std::env::remove_var("XDG_CONFIG_HOME");
587            }
588        }
589    }
590
591    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
592    #[cfg(unix)]
593    #[tokio::test]
594    async fn aim_dev_case_insensitive() {
595        let temp_dir = TempDir::new().unwrap();
596        let empty_dir = temp_dir.path().join("empty");
597        fs::create_dir(&empty_dir).unwrap();
598
599        let xdg_config_home = empty_dir.to_str().unwrap().to_string();
600        {
601            let _guard = env_lock().lock().await;
602            unsafe {
603                std::env::remove_var(AIM_CONFIG_ENV);
604                std::env::remove_var(AIM_DEV_ENV);
605                std::env::remove_var("XDG_CONFIG_HOME");
606                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
607                std::env::set_var(AIM_DEV_ENV, "TRUE");
608            }
609
610            let result = parse_config(None).await;
611            assert!(result.is_err());
612        }
613
614        let default_config_dir = temp_dir.path().join("aim");
615        fs::create_dir_all(&default_config_dir).unwrap();
616        let default_config_path = default_config_dir.join("config.toml");
617        let calendar_dir = temp_dir.path().join("calendar");
618        fs::create_dir(&calendar_dir).unwrap();
619
620        let toml_content = format!(
621            r#"
622[core]
623calendar_path = "{}"
624"#,
625            calendar_dir.to_str().unwrap().replace('\\', "/")
626        );
627        fs::write(&default_config_path, toml_content).unwrap();
628
629        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
630        {
631            let _guard = env_lock().lock().await;
632            unsafe {
633                std::env::remove_var(AIM_CONFIG_ENV);
634                std::env::remove_var(AIM_DEV_ENV);
635                std::env::remove_var("XDG_CONFIG_HOME");
636                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
637                std::env::set_var(AIM_DEV_ENV, "False");
638            }
639
640            let (config, _) = parse_config(None).await.unwrap();
641            assert_eq!(config.calendar_path, Some(calendar_dir));
642
643            unsafe {
644                std::env::remove_var(AIM_DEV_ENV);
645                std::env::remove_var("XDG_CONFIG_HOME");
646            }
647        }
648    }
649
650    #[tokio::test]
651    async fn aim_dev_cli_flag_overrides() {
652        let temp_dir = TempDir::new().unwrap();
653        let config_path = temp_dir.path().join("config.toml");
654        let calendar_dir = temp_dir.path().join("calendar");
655        fs::create_dir(&calendar_dir).unwrap();
656
657        let toml_content = format!(
658            r#"
659[core]
660calendar_path = "{}"
661"#,
662            calendar_dir.to_str().unwrap().replace('\\', "/")
663        );
664        fs::write(&config_path, toml_content).unwrap();
665
666        {
667            let _guard = env_lock().lock().await;
668            unsafe {
669                std::env::set_var(AIM_DEV_ENV, "1");
670            }
671
672            let (config, _) = parse_config(Some(config_path)).await.unwrap();
673            assert_eq!(config.calendar_path, Some(calendar_dir));
674
675            unsafe {
676                std::env::remove_var(AIM_DEV_ENV);
677            }
678        }
679    }
680
681    #[tokio::test]
682    async fn aim_dev_aim_config_env_var_overrides() {
683        let temp_dir = TempDir::new().unwrap();
684        let env_config_path = temp_dir.path().join("env_config.toml");
685        let calendar_dir = temp_dir.path().join("calendar");
686        fs::create_dir(&calendar_dir).unwrap();
687
688        let toml_content = format!(
689            r#"
690[core]
691calendar_path = "{}"
692"#,
693            calendar_dir.to_str().unwrap().replace('\\', "/")
694        );
695        fs::write(&env_config_path, toml_content).unwrap();
696
697        {
698            let _guard = env_lock().lock().await;
699            unsafe {
700                std::env::remove_var(AIM_CONFIG_ENV);
701                std::env::remove_var(AIM_DEV_ENV);
702                std::env::set_var(AIM_CONFIG_ENV, env_config_path.to_str().unwrap());
703                std::env::set_var(AIM_DEV_ENV, "1");
704            }
705
706            let (config, _) = parse_config(None).await.unwrap();
707            assert_eq!(config.calendar_path, Some(calendar_dir));
708
709            unsafe {
710                std::env::remove_var(AIM_CONFIG_ENV);
711                std::env::remove_var(AIM_DEV_ENV);
712            }
713        }
714    }
715
716    // TODO: Re-enable on Windows once get_config_dir() supports environment variables
717    #[cfg(unix)]
718    #[tokio::test]
719    async fn aim_dev_unrecognized_value_allows_default() {
720        let temp_dir = TempDir::new().unwrap();
721        let default_config_dir = temp_dir.path().join("aim");
722        fs::create_dir_all(&default_config_dir).unwrap();
723        let default_config_path = default_config_dir.join("config.toml");
724        let calendar_dir = temp_dir.path().join("calendar");
725        fs::create_dir(&calendar_dir).unwrap();
726
727        let toml_content = format!(
728            r#"
729[core]
730calendar_path = "{}"
731"#,
732            calendar_dir.to_str().unwrap().replace('\\', "/")
733        );
734        fs::write(&default_config_path, toml_content).unwrap();
735
736        let xdg_config_home = temp_dir.path().to_str().unwrap().to_string();
737        {
738            let _guard = env_lock().lock().await;
739            unsafe {
740                std::env::remove_var(AIM_CONFIG_ENV);
741                std::env::remove_var(AIM_DEV_ENV);
742                std::env::remove_var("XDG_CONFIG_HOME");
743                std::env::set_var("XDG_CONFIG_HOME", xdg_config_home);
744                std::env::set_var(AIM_DEV_ENV, "invalid");
745            }
746
747            let (config, _) = parse_config(None).await.unwrap();
748            assert_eq!(config.calendar_path, Some(calendar_dir));
749
750            unsafe {
751                std::env::remove_var(AIM_DEV_ENV);
752                std::env::remove_var("XDG_CONFIG_HOME");
753            }
754        }
755    }
756}