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}