Skip to main content

aster/
config.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(default, deny_unknown_fields)]
10pub struct Settings {
11    pub completion: CompletionSettings,
12    pub history: HistorySettings,
13    pub ui: UiSettings,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(default, deny_unknown_fields)]
18pub struct CompletionSettings {
19    pub max_candidates: usize,
20    pub accept: AcceptMode,
21    pub key: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(default, deny_unknown_fields)]
26pub struct HistorySettings {
27    pub ignore_leading_space: bool,
28    pub successful_first: bool,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(default, deny_unknown_fields)]
33pub struct UiSettings {
34    pub menu_width: usize,
35    pub max_visible: usize,
36    pub prompt_offset: usize,
37    pub border: String,
38    pub accent: String,
39    pub text: String,
40    pub muted: String,
41    pub ghost: String,
42    pub selected_background: String,
43    pub selected_text: String,
44    pub selected_source: String,
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "kebab-case")]
49pub enum AcceptMode {
50    Segment,
51    Full,
52}
53
54impl Default for CompletionSettings {
55    fn default() -> Self {
56        Self {
57            max_candidates: 8,
58            accept: AcceptMode::Full,
59            key: "ctrl-space".to_owned(),
60        }
61    }
62}
63
64impl Default for HistorySettings {
65    fn default() -> Self {
66        Self {
67            ignore_leading_space: true,
68            successful_first: true,
69        }
70    }
71}
72
73impl Default for UiSettings {
74    fn default() -> Self {
75        Self {
76            menu_width: 64,
77            max_visible: 6,
78            prompt_offset: 2,
79            border: "4".to_owned(),
80            accent: "10".to_owned(),
81            text: "7".to_owned(),
82            muted: "8".to_owned(),
83            ghost: "8".to_owned(),
84            selected_background: "8".to_owned(),
85            selected_text: "15".to_owned(),
86            selected_source: "0".to_owned(),
87        }
88    }
89}
90
91impl Settings {
92    pub fn load(path: &Path) -> Result<Self> {
93        if !path.exists() {
94            return Ok(Self::default());
95        }
96        let content = fs::read_to_string(path)
97            .with_context(|| format!("failed to read config {}", path.display()))?;
98        let settings: Self = toml::from_str(&content)
99            .with_context(|| format!("failed to parse config {}", path.display()))?;
100        settings.validate()?;
101        Ok(settings)
102    }
103
104    pub fn write_default(path: &Path) -> Result<bool> {
105        if path.exists() {
106            return Ok(false);
107        }
108        let parent = path
109            .parent()
110            .context("config path has no parent directory")?;
111        fs::create_dir_all(parent)
112            .with_context(|| format!("failed to create directory {}", parent.display()))?;
113        fs::write(path, DEFAULT_CONFIG)
114            .with_context(|| format!("failed to write config {}", path.display()))?;
115        set_owner_only_file(path)?;
116        Ok(true)
117    }
118
119    fn validate(&self) -> Result<()> {
120        if !(1..=100).contains(&self.completion.max_candidates) {
121            bail!("completion.max_candidates must be between 1 and 100");
122        }
123        completion_key_sequence(&self.completion.key)?;
124        if !(40..=120).contains(&self.ui.menu_width) {
125            bail!("ui.menu_width must be between 40 and 120");
126        }
127        if !(1..=10).contains(&self.ui.max_visible) {
128            bail!("ui.max_visible must be between 1 and 10");
129        }
130        if self.ui.prompt_offset > 40 {
131            bail!("ui.prompt_offset must not exceed 40");
132        }
133        for (name, color) in [
134            ("border", &self.ui.border),
135            ("accent", &self.ui.accent),
136            ("text", &self.ui.text),
137            ("muted", &self.ui.muted),
138            ("ghost", &self.ui.ghost),
139            ("selected_background", &self.ui.selected_background),
140            ("selected_text", &self.ui.selected_text),
141            ("selected_source", &self.ui.selected_source),
142        ] {
143            validate_color(name, color)?;
144        }
145        Ok(())
146    }
147}
148
149fn validate_color(name: &str, color: &str) -> Result<()> {
150    let ansi = color.parse::<u8>().is_ok();
151    let rgb = color.len() == 7
152        && color.starts_with('#')
153        && color[1..].bytes().all(|byte| byte.is_ascii_hexdigit());
154    if !ansi && !rgb {
155        bail!("ui.{name} must be an ANSI color from 0 to 255 or #RRGGBB");
156    }
157    Ok(())
158}
159
160pub fn completion_key_sequence(key: &str) -> Result<String> {
161    match key {
162        "ctrl-space" => Ok("^@".to_owned()),
163        _ => {
164            let Some(letter) = key.strip_prefix("ctrl-") else {
165                bail!("completion.key must be ctrl-space or ctrl-a through ctrl-z");
166            };
167            if letter.len() != 1 || !letter.as_bytes()[0].is_ascii_lowercase() {
168                bail!("completion.key must be ctrl-space or ctrl-a through ctrl-z");
169            }
170            if matches!(letter, "i" | "j" | "k" | "m" | "n") {
171                bail!("completion.key conflicts with an Aster menu control");
172            }
173            Ok(format!("^{}", letter.to_ascii_uppercase()))
174        }
175    }
176}
177
178pub const DEFAULT_CONFIG: &str = r#"[completion]
179# Aster abstains instead of filling this list with low-confidence candidates.
180max_candidates = 8
181
182# Accept the highlighted completion with Ctrl-Space. Also accepts "ctrl-a"
183# through "ctrl-z" (excluding reserved menu controls).
184key = "ctrl-space"
185
186# Ctrl-Space accepts the whole suggestion; Tab always advances one segment.
187accept = "full"
188
189[history]
190# Match common shell history privacy behavior.
191ignore_leading_space = true
192
193# Prefer commands known to have completed successfully.
194successful_first = true
195
196[ui]
197# ANSI palette indexes adapt to the active terminal theme. #RRGGBB is also valid.
198menu_width = 64
199max_visible = 6
200prompt_offset = 2
201border = "4"
202accent = "10"
203text = "7"
204muted = "8"
205ghost = "8"
206selected_background = "8"
207selected_text = "15"
208selected_source = "0"
209"#;
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Paths {
213    pub config_file: PathBuf,
214    pub state_dir: PathBuf,
215    pub database_file: PathBuf,
216    pub command_description_cache: PathBuf,
217    pub daemon_lock_file: PathBuf,
218    pub socket_file: PathBuf,
219}
220
221impl Paths {
222    pub fn discover() -> Result<Self> {
223        let home = env::var_os("HOME")
224            .map(PathBuf::from)
225            .context("HOME is not set")?;
226
227        let config_file = env::var_os("ASTER_CONFIG")
228            .map(PathBuf::from)
229            .unwrap_or_else(|| {
230                env::var_os("XDG_CONFIG_HOME")
231                    .map(PathBuf::from)
232                    .unwrap_or_else(|| home.join(".config"))
233                    .join("aster/config.toml")
234            });
235        let config_file = absolute_path(config_file)?;
236
237        let state_dir = env::var_os("ASTER_STATE_DIR")
238            .map(PathBuf::from)
239            .unwrap_or_else(|| {
240                env::var_os("XDG_STATE_HOME")
241                    .map(PathBuf::from)
242                    .unwrap_or_else(|| home.join(".local/state"))
243                    .join("aster")
244            });
245        let state_dir = absolute_path(state_dir)?;
246
247        let socket_file = env::var_os("ASTER_SOCKET")
248            .map(PathBuf::from)
249            .unwrap_or_else(|| state_dir.join("aster.sock"));
250        let socket_file = absolute_path(socket_file)?;
251
252        Ok(Self {
253            config_file,
254            command_description_cache: state_dir.join("command-descriptions.json"),
255            database_file: state_dir.join("history.sqlite3"),
256            daemon_lock_file: state_dir.join("daemon.lock"),
257            state_dir,
258            socket_file,
259        })
260    }
261
262    pub fn ensure_directories(&self) -> Result<()> {
263        create_private_dir(&self.state_dir)?;
264        if let Some(parent) = self.socket_file.parent() {
265            create_private_dir(parent)?;
266        }
267        Ok(())
268    }
269}
270
271fn absolute_path(path: PathBuf) -> Result<PathBuf> {
272    if path.is_absolute() {
273        return Ok(path);
274    }
275    Ok(env::current_dir()
276        .context("failed to resolve current directory")?
277        .join(path))
278}
279
280fn create_private_dir(path: &Path) -> Result<()> {
281    let existed = path.exists();
282    fs::create_dir_all(path)
283        .with_context(|| format!("failed to create directory {}", path.display()))?;
284
285    #[cfg(unix)]
286    {
287        use std::os::unix::fs::{MetadataExt, PermissionsExt};
288
289        if !existed {
290            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
291                .with_context(|| format!("failed to secure directory {}", path.display()))?;
292        }
293        let metadata = fs::symlink_metadata(path)?;
294        if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } {
295            bail!(
296                "directory is not owned by the current user: {}",
297                path.display()
298            );
299        }
300        if metadata.mode() & 0o077 != 0 {
301            bail!(
302                "directory must not be accessible by group or other users: {}",
303                path.display()
304            );
305        }
306    }
307    Ok(())
308}
309
310fn set_owner_only_file(path: &Path) -> Result<()> {
311    #[cfg(unix)]
312    {
313        use std::os::unix::fs::PermissionsExt;
314        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
315            .with_context(|| format!("failed to secure file {}", path.display()))?;
316    }
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    #[cfg(unix)]
324    use std::os::unix::fs::{MetadataExt, PermissionsExt};
325    use tempfile::tempdir;
326
327    #[test]
328    fn default_config_round_trips() {
329        let parsed: Settings = toml::from_str(DEFAULT_CONFIG).unwrap();
330        assert_eq!(parsed, Settings::default());
331    }
332
333    #[test]
334    fn rejects_excessive_candidate_limit() {
335        let settings: Settings =
336            toml::from_str("[completion]\nmax_candidates = 101\naccept = \"segment\"\n").unwrap();
337        assert!(settings.validate().is_err());
338    }
339
340    #[test]
341    fn validates_completion_key() {
342        assert_eq!(completion_key_sequence("ctrl-space").unwrap(), "^@");
343        assert_eq!(completion_key_sequence("ctrl-x").unwrap(), "^X");
344        assert!(completion_key_sequence("tab").is_err());
345        assert!(completion_key_sequence("shift-tab").is_err());
346        assert!(completion_key_sequence("ctrl-i").is_err());
347        assert!(completion_key_sequence("ctrl-k").is_err());
348        assert!(completion_key_sequence("alt-space").is_err());
349    }
350
351    #[test]
352    fn validates_ui_colors() {
353        assert!(validate_color("border", "4").is_ok());
354        assert!(validate_color("border", "255").is_ok());
355        assert!(validate_color("border", "#5f87af").is_ok());
356        assert!(validate_color("border", "256").is_err());
357        assert!(validate_color("border", "blue").is_err());
358    }
359
360    #[cfg(unix)]
361    #[test]
362    fn writing_config_does_not_change_existing_parent_permissions() {
363        let directory = tempdir().unwrap();
364        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();
365        let config = directory.path().join("config.toml");
366
367        assert!(Settings::write_default(&config).unwrap());
368        assert_eq!(
369            fs::metadata(directory.path()).unwrap().mode() & 0o777,
370            0o755
371        );
372        assert_eq!(fs::metadata(config).unwrap().mode() & 0o777, 0o600);
373    }
374
375    #[cfg(unix)]
376    #[test]
377    fn refuses_insecure_existing_state_directory_without_chmod() {
378        let directory = tempdir().unwrap();
379        fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();
380
381        assert!(create_private_dir(directory.path()).is_err());
382        assert_eq!(
383            fs::metadata(directory.path()).unwrap().mode() & 0o777,
384            0o755
385        );
386        assert_eq!(fs::metadata(directory.path()).unwrap().uid(), unsafe {
387            libc::geteuid()
388        });
389    }
390}