Skip to main content

attune_core/
path.rs

1use std::{
2    env, fs,
3    path::{Path, PathBuf},
4};
5
6use toml::Table;
7
8use crate::SettingsError;
9
10pub struct ConfigPathOptions<'a> {
11    pub app: &'a str,
12    pub path: Option<&'a str>,
13    pub default_file: Option<&'a str>,
14}
15
16/// Resolves the path to an optional config file.
17///
18/// Resolution order:
19///
20/// 1. If `<APP>_CONFIG` is set, its value is used as an override.
21/// 2. If `options.path` is set, that path is returned unchanged.
22/// 3. If `options.default_file` is set, it is resolved under the platform
23///    config directory.
24/// 4. Otherwise, no config path is resolved.
25///
26/// This function only resolves a path. It does not check whether the file
27/// exists or read the file.
28///
29/// ## Errors
30///
31/// Returns [`SettingsError::NoConfigDir`] when `options.default_file` is set
32/// but the platform config directory cannot be discovered.
33pub fn resolve_config_file(options: &ConfigPathOptions) -> Result<Option<PathBuf>, SettingsError> {
34    // 1. Try to resolve the config path from environment variables.
35    if let Ok(env_var) = env::var(format!("{}_CONFIG", options.app.to_uppercase())) {
36        return Ok(Some(PathBuf::from(env_var)));
37    }
38
39    // 2. Use the configured path unchanged.
40    if let Some(path) = options.path {
41        return Ok(Some(Path::new(path).to_path_buf()));
42    }
43
44    // 3. If there is no configured path or default filename, there is no
45    // optional config file to resolve.
46    let Some(default_file) = options.default_file else {
47        return Ok(None);
48    };
49
50    // 4. Try to determine the default path from project directories.
51    let Some(project_dirs) = directories::ProjectDirs::from("", "", options.app) else {
52        return Err(SettingsError::NoConfigDir);
53    };
54
55    Ok(Some(project_dirs.config_dir().join(default_file)))
56}
57
58/// Loads an optional TOML config file into a table.
59///
60/// Returns `Ok(None)` when `path` is `None` or when the file does not exist.
61/// Returns `Ok(Some(table))` when the file exists and parses successfully.
62///
63/// ## Errors
64///
65/// Returns [`SettingsError::ConfigRead`] when the file exists but cannot be
66/// read. Returns [`SettingsError::ConfigParse`] when the file contents are not
67/// valid TOML.
68pub fn load_toml_file(path: Option<&Path>) -> Result<Option<toml::Table>, SettingsError> {
69    match path {
70        Some(file) => {
71            let raw_content = match fs::read_to_string(file) {
72                Ok(content) => content,
73                Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
74                Err(e) => {
75                    return Err(SettingsError::ConfigRead {
76                        path: file.to_path_buf(),
77                        source: e,
78                    });
79                }
80            };
81
82            let parsed_content =
83                raw_content
84                    .parse::<Table>()
85                    .map_err(|e| SettingsError::ConfigParse {
86                        path: file.to_path_buf(),
87                        source: e,
88                    })?;
89
90            Ok(Some(parsed_content))
91        }
92        None => Ok(None),
93    }
94}
95
96#[cfg(test)]
97mod test {
98    use std::io::Write;
99
100    use std::assert_matches;
101    use toml::Value;
102
103    use super::*;
104
105    #[test]
106    fn test_resolve_config_file_from_configured_path() {
107        let tmp = tempfile::NamedTempFile::new().unwrap();
108        let tmp_path = tmp.into_temp_path();
109        let tmp_path_str = tmp_path.to_str().unwrap().to_string();
110
111        let options = ConfigPathOptions {
112            app: "test_app",
113            path: Some(&tmp_path_str),
114            default_file: None,
115        };
116
117        let config_file = resolve_config_file(&options).unwrap().unwrap();
118        assert_eq!(config_file, PathBuf::from(tmp_path_str))
119    }
120
121    #[test]
122    fn test_resolve_config_file_from_env_var() {
123        let config_path = "~/.config/test_app/test.toml";
124        let options = ConfigPathOptions {
125            app: "test_app",
126            path: None,
127            default_file: None,
128        };
129
130        temp_env::with_var("TEST_APP_CONFIG", Some(config_path), || {
131            let config_file = resolve_config_file(&options);
132            assert!(config_file.is_ok());
133
134            let config_file = config_file.unwrap().unwrap();
135            assert_eq!(config_file.to_str().unwrap(), config_path)
136        });
137    }
138
139    #[test]
140    fn test_resolve_config_file_returns_none_without_path_or_default() {
141        let options = ConfigPathOptions {
142            app: "test_app_without_path_or_default",
143            path: None,
144            default_file: None,
145        };
146
147        let config_file = resolve_config_file(&options).unwrap();
148        assert!(config_file.is_none())
149    }
150
151    #[test]
152    fn test_load_toml_file_returns_table() {
153        let content = "foo = 'bar'";
154        let mut tmp = tempfile::NamedTempFile::new().unwrap();
155        tmp.write_all(content.as_bytes()).unwrap();
156
157        let parsed = load_toml_file(Some(tmp.path())).unwrap().unwrap();
158        assert_eq!(parsed["foo"], Value::from("bar"))
159    }
160
161    #[test]
162    fn test_load_toml_file_returns_none_for_missing_file() {
163        let parsed = load_toml_file(Some(Path::new("not/real/path"))).unwrap();
164        assert!(parsed.is_none())
165    }
166
167    #[test]
168    fn test_load_toml_file_returns_none_when_given_none() {
169        let parsed = load_toml_file(None).unwrap();
170        assert!(parsed.is_none())
171    }
172
173    #[test]
174    fn test_load_toml_file_returns_settingserror_for_invalid_toml() {
175        let content = "aaabbbccc";
176        let mut tmp = tempfile::NamedTempFile::new().unwrap();
177        tmp.write_all(content.as_bytes()).unwrap();
178
179        let err = load_toml_file(Some(tmp.path())).unwrap_err();
180        assert_matches!(err, SettingsError::ConfigParse { .. })
181    }
182}