refactor: type escaped OS strings and fix the final audit findings

Escaped config strings (ghostty_os::osbytes) kept reaching OS
boundaries undecoded, and decoding was occasionally applied to plain
text. The root cause was that both were plain `String`s. Now:
- `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only
  via to_os/to_path/to_bytes/to_wide; plain text enters via
  from_plain_text. OS helpers (env, home, xdg, passwd, resources dir,
  path expansion), config::Path, WorkingDirectory, theme/file_load/edit
  paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the
  compiler forces decoding at every boundary. os::open takes &OsStr.
  Config output is unchanged.

Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md):
screen-file copy; regex/OSC 8 URLs no longer decoded (regression);
lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows
resources dir/TERMINFO; color OSC request lists and X11 color lookup no
longer allocate globally (C zero-allocation test extended); macOS Mach
handler resumes all threads before KERN_FAILURE (hang regression) and
SIGABRT minidumps go through the exception thread; plus open-config,
background images, crash dir, ssh cache, exe dir/PATH, fontconfig
filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths.
The typing also surfaced and fixed gtk-custom-css, bell audio, bash
HISTFILE, OSC 52 writes and the C config getters.

Test Plan:
- cargo test --workspace (3376 passed)
- just check-macos (both), just check-windows, just check-wasm
- diff <(ghostty-rs +show-config --default --docs) \
  <(ghostty +show-config --default --docs)
This commit is contained in:
ddidderr committed 2026-10-12 10:06:07 +02:00
1 parent 0f898d3e41
commit 8f3e206d9f
92 files changed
+2967 -992

No files matched your search

+69 -44
View File
@@ -6,6 +6,8 @@
use std::collections::HashSet;
use ghostty_os::osbytes::EncodedOs;
use crate::{
cli_args::{self, ArgError, ArgIter, CliTarget},
command::Command,
@@ -3765,7 +3767,7 @@ pub enum ReplayStep {
Arg(String),
/// A base path to expand relative paths against.
Expand(String),
Expand(EncodedOs),
/// A conditional argument. This arg is parsed only if all
/// conditions match (an "AND"). An "OR" can be achieved by
@@ -3876,12 +3878,15 @@ impl Config {
/// Load configuration from the target config file at `path`.
///
/// `path` must be resolved and absolute.
pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> {
assert!(path.to_path().is_absolute());
let file = match crate::file_load::open(path) {
Ok(f) => f,
Err(crate::file_load::OpenFileError::NotAFile) => {
log::warn!("config-file {path}: not reading because it is not a file");
log::warn!(
"config-file {}: not reading because it is not a file",
path.as_encoded()
);
return Ok(());
}
Err(crate::file_load::OpenFileError::FileNotFound) => {
@@ -3894,8 +3899,8 @@ impl Config {
}
/// Load config from the given File.
fn load_fs_file(&mut self, file: std::fs::File, path: &str) -> Result<(), Error> {
log::info!("reading configuration file path={path}");
fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> {
log::info!("reading configuration file path={}", path.as_encoded());
let mut reader = std::io::BufReader::with_capacity(2048, file);
self.load_reader(&mut reader, path)
}
@@ -3904,7 +3909,7 @@ impl Config {
fn load_reader<R: std::io::BufRead>(
&mut self,
reader: &mut R,
path: &str,
path: &EncodedOs,
) -> Result<(), Error> {
// If the file starts with a UTF-8 byte order mark, skip it.
// https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
@@ -3914,11 +3919,12 @@ impl Config {
reader.consume(3);
}
}
let mut iter = cli_args::LineIterator::with_path(reader, path);
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
self.load_iter(&mut iter)?;
let dir = ghostty_os::osbytes::config_str_to_path(path)
let dir = path
.to_path()
.parent()
.map(ghostty_os::osbytes::path_to_config_string)
.map(EncodedOs::from_path)
.unwrap_or_default();
self.expand_paths(&dir)?;
Ok(())
@@ -3927,23 +3933,26 @@ impl Config {
/// Load optional configuration file from `path`. All errors are ignored.
///
/// Returns the action that was taken.
pub fn load_optional_file(&mut self, path: &str) -> OptionalFileAction {
pub fn load_optional_file(&mut self, path: &EncodedOs) -> OptionalFileAction {
match self.load_file(path) {
Ok(()) => OptionalFileAction::Loaded,
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
OptionalFileAction::NotFound
}
Err(err) => {
log::warn!("error reading optional config file, not loading err={err} path={path}");
log::warn!(
"error reading optional config file, not loading err={err} path={}",
path.as_encoded()
);
OptionalFileAction::Error
}
}
}
/// The default template with the (decoded, byte-exact) path substituted.
fn render_config_template(path: &str) -> Vec<u8> {
fn render_config_template(path: &EncodedOs) -> Vec<u8> {
let tmpl = include_str!("config-template");
let path_bytes = ghostty_os::osbytes::config_str_to_bytes(path);
let path_bytes = path.to_bytes();
let mut out = Vec::with_capacity(tmpl.len() + path_bytes.len());
for (i, part) in tmpl.split("{[path]s}").enumerate() {
if i > 0 {
@@ -3954,10 +3963,9 @@ impl Config {
out
}
fn write_config_template(path: &str) -> std::io::Result<()> {
log::info!("creating template config file: path={path}");
// `path` carries the reversible byte encoding; decode it for the OS.
let os_path = ghostty_os::osbytes::config_str_to_path(path);
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
log::info!("creating template config file: path={}", path.as_encoded());
let os_path = path.to_path();
if let Some(dir) = os_path.parent() {
std::fs::create_dir_all(dir)?;
}
@@ -3982,7 +3990,11 @@ impl Config {
if xdg_action != OptionalFileAction::NotFound
&& legacy_xdg_action != OptionalFileAction::NotFound
{
log::warn!("both config files `{legacy_xdg_path}` and `{xdg_path}` exist.");
log::warn!(
"both config files `{}` and `{}` exist.",
legacy_xdg_path.as_encoded(),
xdg_path.as_encoded()
);
log::warn!("loading them both in that order");
true
} else {
@@ -4014,7 +4026,9 @@ impl Config {
&& legacy_app_support_action != OptionalFileAction::NotFound
{
log::warn!(
"both config files `{legacy_app_support_path}` and `{app_support_path}` exist."
"both config files `{}` and `{}` exist.",
legacy_app_support_path.as_encoded(),
app_support_path.as_encoded()
);
log::warn!("loading them both in that order");
true
@@ -4142,7 +4156,7 @@ impl Config {
// Any paths referenced from the CLI are relative to the current working
// directory.
let cwd = std::fs::canonicalize(".")?;
self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?;
self.expand_paths(&EncodedOs::from_path(&cwd))?;
Ok(())
}
@@ -4153,7 +4167,7 @@ impl Config {
}
// Keeps track of loaded files to prevent cycles.
let mut loaded: HashSet<String> = HashSet::new();
let mut loaded: HashSet<EncodedOs> = HashSet::new();
// We need to insert all of our loaded config-file values
// PRIOR to the "-e" in our replay steps, since everything
@@ -4186,12 +4200,12 @@ impl Config {
// All paths should already be absolute at this point because
// they're fixed up after each load.
assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute());
assert!(path.to_path().is_absolute());
// We must only load a unique file once
if !loaded.insert(path.clone()) {
let diag = Diagnostic {
message: format!("config-file {path}: cycle detected"),
message: format!("config-file {}: cycle detected", path.as_encoded()),
..Diagnostic::default()
};
self.diagnostics.append(diag.clone());
@@ -4199,13 +4213,14 @@ impl Config {
continue;
}
let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) {
let file = match std::fs::File::open(path.to_path()) {
Ok(f) => f,
Err(err) => {
if err.kind() != std::io::ErrorKind::NotFound || !optional {
let diag = Diagnostic {
message: format!(
"error opening config-file {path}: {}",
"error opening config-file {}: {}",
path.as_encoded(),
zig_io_error_name(&err)
),
..Diagnostic::default()
@@ -4221,7 +4236,8 @@ impl Config {
if !meta.is_file() {
let diag = Diagnostic {
message: format!(
"config-file {path}: not reading because file type is {}",
"config-file {}: not reading because file type is {}",
path.as_encoded(),
file_kind_name(&meta)
),
..Diagnostic::default()
@@ -4347,9 +4363,9 @@ impl Config {
/// Expand the relative paths in config-files to be absolute paths
/// relative to the base directory.
fn expand_paths(&mut self, base: &str) -> Result<(), Error> {
fn expand_paths(&mut self, base: &EncodedOs) -> Result<(), Error> {
// Keep track of this step for replays
self.replay_steps.push(ReplayStep::Expand(base.to_string()));
self.replay_steps.push(ReplayStep::Expand(base.clone()));
// Expand all of our paths
if let Some(path) = &mut self.background_image {
@@ -4376,7 +4392,7 @@ impl Config {
// Find our theme file and open it. See the open function for details.
let resources_dir = ghostty_os::resources_dir().ok();
let Some(themefile) = crate::theme::open(
name,
&EncodedOs::from_encoded(name),
resources_dir.as_ref().and_then(|r| r.app()),
&mut self.diagnostics,
) else {
@@ -4400,7 +4416,7 @@ impl Config {
// Load our theme
let mut reader = std::io::BufReader::with_capacity(2048, file);
let mut iter = cli_args::LineIterator::with_path(&mut reader, path);
let mut iter = cli_args::LineIterator::with_path(&mut reader, path.as_encoded());
new_config.load_iter(&mut iter)?;
// Setup our replay to be conditional.
@@ -4641,14 +4657,17 @@ impl Config {
let pw = ghostty_os::passwd::get()?;
if self.command.is_none() {
if let Some(sh) = pw.shell {
log::info!("default shell src=passwd value={sh}");
self.command = Some(Command::Shell(sh));
log::info!("default shell src=passwd value={}", sh.as_encoded());
self.command = Some(Command::Shell(sh.into_encoded()));
}
}
if *wd == WorkingDirectory::Home {
if let Some(home) = pw.home {
log::info!("default working directory src=passwd value={home}");
log::info!(
"default working directory src=passwd value={}",
home.as_encoded()
);
*wd = WorkingDirectory::Path(home);
} else {
*wd = WorkingDirectory::Inherit;
@@ -4951,7 +4970,7 @@ mod tests {
let td = ghostty_os::TempDir::init().unwrap();
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
let file = dir.join("config.ghostty");
let enc = ghostty_os::osbytes::path_to_config_string(&file);
let enc = EncodedOs::from_path(&file);
Config::write_config_template(&enc).unwrap();
let data = std::fs::read(&file).unwrap();
let raw = file.as_os_str().as_bytes();
@@ -4985,8 +5004,11 @@ mod tests {
let data = "\u{feff}abnormal-command-exit-runtime = 2500\n";
let mut reader = Cursor::new(data.as_bytes().to_vec());
let mut cfg = Config::default();
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
.unwrap();
cfg.load_reader(
&mut reader,
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
)
.unwrap();
cfg.finalize().unwrap();
assert!(cfg.diagnostics.empty());
@@ -4997,8 +5019,11 @@ mod tests {
let data = "abnormal-command-exit-runtime = 2500\n";
let mut reader = Cursor::new(data.as_bytes().to_vec());
let mut cfg = Config::default();
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
.unwrap();
cfg.load_reader(
&mut reader,
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
)
.unwrap();
cfg.finalize().unwrap();
assert!(cfg.diagnostics.empty());
@@ -5141,11 +5166,11 @@ mod tests {
cfg.load_iter(&mut i).unwrap();
cfg.finalize().unwrap();
let expected = ghostty_os::expand_home("~/projects/ghostty")
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
assert_eq!(
cfg.working_directory.as_ref().unwrap().value().unwrap(),
expected
&expected
);
}
@@ -5420,7 +5445,7 @@ mod tests {
let mut cfg = Config::default();
cfg.load_iter(&mut it(&[&format!("--config-file={}", a.display())]))
.unwrap();
cfg.expand_paths("/").unwrap();
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
cfg.load_recursive_files().unwrap();
assert!(
cfg.diagnostics
@@ -5435,7 +5460,7 @@ mod tests {
let mut cfg = Config::default();
cfg.load_iter(&mut it(&["--config-file=?/nonexistent/ghostty/file"]))
.unwrap();
cfg.expand_paths("/").unwrap();
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
cfg.load_recursive_files().unwrap();
assert!(cfg.diagnostics.empty());
}
+49 -8
View File
@@ -1,6 +1,8 @@
//! Opening the config for editing (port of `config/edit.zig`).
use std::{fs::OpenOptions, io, path::Path};
use std::{fs::OpenOptions, io};
use ghostty_os::osbytes::EncodedOs;
use crate::file_load;
@@ -13,12 +15,16 @@ use crate::file_load;
/// come first.
///
/// The config file (and its directory) is created if it doesn't exist.
pub fn open_path() -> io::Result<String> {
pub fn open_path() -> io::Result<EncodedOs> {
// Get the path we should open
let config_path = config_path()?;
// The config path is an encoded OS string: decode before touching the
// filesystem.
let real_path = config_path.to_path();
// Create config directory recursively.
if let Some(config_dir) = Path::new(&config_path).parent() {
if let Some(config_dir) = real_path.parent() {
std::fs::create_dir_all(config_dir)?;
}
@@ -26,7 +32,7 @@ pub fn open_path() -> io::Result<String> {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.open(&real_path)
{
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
@@ -41,15 +47,15 @@ fn xdg_err(e: ghostty_os::xdg::Error) -> io::Error {
}
/// Returns the config path to use for open for the current OS.
fn config_path() -> io::Result<String> {
fn config_path() -> io::Result<EncodedOs> {
let paths = config_path_candidates()?;
// Find the first path that exists and is non-empty. If no paths are
// non-empty but at least one exists, we will return the first path that
// exists.
let mut exists: Option<&String> = None;
let mut exists: Option<&EncodedOs> = None;
for path in &paths {
let meta = match std::fs::File::open(path).and_then(|f| f.metadata()) {
let meta = match std::fs::File::open(path.to_path()).and_then(|f| f.metadata()) {
Ok(m) => m,
// File doesn't exist, continue.
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
@@ -79,7 +85,7 @@ fn config_path() -> io::Result<String> {
/// Returns a list of possible paths the main config file could be
/// in for the current OS.
fn config_path_candidates() -> io::Result<Vec<String>> {
fn config_path_candidates() -> io::Result<Vec<EncodedOs>> {
let mut paths = Vec::with_capacity(4);
#[cfg(target_os = "macos")]
@@ -92,3 +98,38 @@ fn config_path_candidates() -> io::Result<Vec<String>> {
paths.push(file_load::legacy_default_xdg_path().map_err(xdg_err)?);
Ok(paths)
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
#![allow(clippy::unwrap_used)]
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use super::*;
/// "Open config" must create/return the file under the *decoded* XDG
/// directory, not a second file at the escaped pathname.
#[test]
fn open_path_uses_decoded_non_utf8_xdg_dir() {
let td = ghostty_os::TempDir::init().unwrap();
let xdg = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec());
temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || {
let p = open_path().unwrap();
let real = p.to_path();
assert_eq!(real, xdg.join("ghostty/config.ghostty"));
assert!(real.is_file());
// Nothing was created at the escaped (encoded) pathname.
assert_ne!(p.as_encoded().as_bytes(), real.as_os_str().as_bytes());
assert!(!std::path::Path::new(p.as_encoded()).exists());
});
}
#[test]
fn candidates_are_xdg_paths_in_priority_order() {
temp_env::with_var("XDG_CONFIG_HOME", Some("/x/cfg"), || {
let c = config_path_candidates().unwrap();
assert_eq!(c[0], "/x/cfg/ghostty/config.ghostty");
assert_eq!(c[1], "/x/cfg/ghostty/config");
});
}
}
+46 -39
View File
@@ -7,10 +7,10 @@
use std::fs::File;
use ghostty_os::xdg;
use ghostty_os::{osbytes::EncodedOs, xdg};
/// Default path for the XDG home configuration file.
pub fn default_xdg_path() -> Result<String, xdg::Error> {
pub fn default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config.ghostty"),
home: None,
@@ -18,7 +18,7 @@ pub fn default_xdg_path() -> Result<String, xdg::Error> {
}
/// Ghostty <1.3.0 default path for the XDG home configuration file.
pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
pub fn legacy_default_xdg_path() -> Result<EncodedOs, xdg::Error> {
xdg::config(&xdg::Options {
subdir: Some("ghostty/config"),
home: None,
@@ -26,7 +26,7 @@ pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
}
/// Preferred default path for the XDG home configuration file.
pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
pub fn preferred_xdg_path() -> Result<EncodedOs, xdg::Error> {
// If the XDG path exists, use that.
let xdg_path = default_xdg_path()?;
if open(&xdg_path).is_ok() {
@@ -45,28 +45,28 @@ pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
/// Default path for the macOS Application Support configuration file.
#[cfg(target_os = "macos")]
pub fn default_app_support_path() -> Result<String, xdg::Error> {
pub fn default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config.ghostty")
}
/// Ghostty <1.3.0 default path for the macOS Application Support
/// configuration file.
#[cfg(target_os = "macos")]
pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
pub fn legacy_default_app_support_path() -> Result<EncodedOs, xdg::Error> {
app_support_dir("config")
}
#[cfg(target_os = "macos")]
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
fn app_support_dir(sub_path: &str) -> Result<EncodedOs, xdg::Error> {
ghostty_os::macos::app_support_dir(sub_path)
.map(|p| ghostty_os::osbytes::path_to_config_string(&p))
.map(|p| EncodedOs::from_path(&p))
.map_err(|_| xdg::Error::AppleApiFailed)
}
/// Preferred default path for the macOS Application Support configuration
/// file.
#[cfg(target_os = "macos")]
pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
pub fn preferred_app_support_path() -> Result<EncodedOs, xdg::Error> {
Ok(preferred_of(
default_app_support_path()?,
legacy_default_app_support_path()?,
@@ -76,7 +76,7 @@ pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
/// Pick between a new path and its legacy path: the new one if it exists,
/// else the legacy one if it exists, else the new one.
#[cfg(any(target_os = "macos", test))]
fn preferred_of(new: String, legacy: String) -> String {
fn preferred_of(new: EncodedOs, legacy: EncodedOs) -> EncodedOs {
// If the new path exists, use that.
if open(&new).is_ok() {
return new;
@@ -95,9 +95,9 @@ fn preferred_of(new: String, legacy: String) -> String {
/// one if it exists, else Application Support.
#[cfg(any(target_os = "macos", test))]
fn preferred_macos(
app_support_path: String,
xdg_path: &dyn Fn() -> Result<String, xdg::Error>,
) -> Result<String, xdg::Error> {
app_support_path: EncodedOs,
xdg_path: &dyn Fn() -> Result<EncodedOs, xdg::Error>,
) -> Result<EncodedOs, xdg::Error> {
// macOS prefers the Application Support directory if it exists.
if open(&app_support_path).is_ok() {
return Ok(app_support_path);
@@ -119,7 +119,7 @@ fn preferred_macos(
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(target_os = "macos")]
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
preferred_macos(preferred_app_support_path()?, &preferred_xdg_path)
}
@@ -129,7 +129,7 @@ pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
/// This doesn't create or populate the file with any default
/// contents; downstream callers must handle this.
#[cfg(not(target_os = "macos"))]
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
// All other platforms use XDG only
preferred_xdg_path()
}
@@ -158,21 +158,24 @@ impl std::error::Error for OpenFileError {}
/// Opens the file at the given path and returns the file handle
/// if it exists and is non-empty. This also constrains the possible
/// errors to a smaller set that we can explicitly handle.
pub fn open(path: &str) -> Result<File, OpenFileError> {
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> {
let real = path.to_path();
assert!(real.is_absolute());
// `path` may carry the reversible byte escape of non-UTF-8 names.
let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| {
let file = File::open(&real).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
OpenFileError::FileNotFound
} else {
log::warn!("unexpected file open error path={path} err={err}");
log::warn!(
"unexpected file open error path={} err={err}",
real.display()
);
OpenFileError::FileOpenFailed
}
})?;
let meta = file.metadata().map_err(|err| {
log::warn!("error getting file stat path={path} err={err}");
log::warn!("error getting file stat path={} err={err}", real.display());
OpenFileError::FileOpenFailed
})?;
if !meta.is_file() {
@@ -190,21 +193,25 @@ pub fn open(path: &str) -> Result<File, OpenFileError> {
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn open_missing_dir_and_empty() {
assert_eq!(
open("/nonexistent/ghostty/file").unwrap_err(),
open(&e("/nonexistent/ghostty/file")).unwrap_err(),
OpenFileError::FileNotFound
);
assert_eq!(open("/tmp").unwrap_err(), OpenFileError::NotAFile);
assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile);
let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id()));
std::fs::write(&p, "").unwrap();
assert_eq!(
open(p.to_str().unwrap()).unwrap_err(),
open(&EncodedOs::from_path(&p)).unwrap_err(),
OpenFileError::FileIsEmpty
);
std::fs::write(&p, "a=b").unwrap();
assert!(open(p.to_str().unwrap()).is_ok());
assert!(open(&EncodedOs::from_path(&p)).is_ok());
std::fs::remove_file(&p).unwrap();
}
@@ -212,18 +219,18 @@ mod tests {
fn preferred_of_prefers_existing_new_then_legacy() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let new = dir.join("config.ghostty").to_str().unwrap().to_owned();
let legacy = dir.join("config").to_str().unwrap().to_owned();
let _ = std::fs::remove_file(&new);
let _ = std::fs::remove_file(&legacy);
let new = EncodedOs::from_path(&dir.join("config.ghostty"));
let legacy = EncodedOs::from_path(&dir.join("config"));
let _ = std::fs::remove_file(new.to_path());
let _ = std::fs::remove_file(legacy.to_path());
// Neither exists: the new one.
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
// Only legacy exists: the legacy one.
std::fs::write(&legacy, "a=b").unwrap();
std::fs::write(legacy.to_path(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy);
// Both exist: the new one.
std::fs::write(&new, "a=b").unwrap();
std::fs::write(new.to_path(), "a=b").unwrap();
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -232,19 +239,19 @@ mod tests {
fn preferred_macos_order() {
let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let app = dir.join("app").to_str().unwrap().to_owned();
let xdg_p = dir.join("xdg").to_str().unwrap().to_owned();
let _ = std::fs::remove_file(&app);
let _ = std::fs::remove_file(&xdg_p);
let app = EncodedOs::from_path(&dir.join("app"));
let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
let _ = std::fs::remove_file(app.to_path());
let _ = std::fs::remove_file(xdg_p.to_path());
let xdg_fn = || Ok(xdg_p.clone());
// Neither exists: app support.
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
// XDG exists only: XDG.
std::fs::write(&xdg_p, "a=b").unwrap();
std::fs::write(xdg_p.to_path(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p);
// App support exists: app support wins.
std::fs::write(&app, "a=b").unwrap();
std::fs::write(app.to_path(), "a=b").unwrap();
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -252,8 +259,8 @@ mod tests {
#[test]
fn default_paths_end_with_expected_names() {
if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) {
assert!(a.ends_with("ghostty/config.ghostty"));
assert!(b.ends_with("ghostty/config"));
assert!(a.ends_with_plain("ghostty/config.ghostty"));
assert!(b.ends_with_plain("ghostty/config"));
}
}
}
+70 -62
View File
@@ -5,7 +5,7 @@ use std::{
path::{Component, PathBuf},
};
use ghostty_os::osbytes::{config_str_to_path, path_to_config_string};
use ghostty_os::osbytes::EncodedOs;
use crate::{
c_value::CValue,
@@ -20,21 +20,21 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Path {
/// No error if the file does not exist.
Optional(String),
Optional(EncodedOs),
/// The file is required to exist.
Required(String),
Required(EncodedOs),
}
impl Default for Path {
fn default() -> Self {
Path::Required(String::new())
Path::Required(EncodedOs::new())
}
}
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
fn resolve_lexical(base: &str, path: &str) -> String {
let joined = config_str_to_path(base).join(config_str_to_path(path));
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
let joined = base.to_path().join(path.to_path());
let mut out = PathBuf::new();
for c in joined.components() {
match c {
@@ -45,29 +45,24 @@ fn resolve_lexical(base: &str, path: &str) -> String {
other => out.push(other.as_os_str()),
}
}
path_to_config_string(&out)
EncodedOs::from_path(&out)
}
impl Path {
/// The inner path string.
#[must_use]
pub fn path(&self) -> &str {
pub fn path(&self) -> &EncodedOs {
match self {
Path::Optional(p) | Path::Required(p) => p,
}
}
fn path_mut(&mut self) -> &mut String {
fn path_mut(&mut self) -> &mut EncodedOs {
match self {
Path::Optional(p) | Path::Required(p) => p,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.path().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.path().is_empty()
@@ -98,9 +93,9 @@ impl Path {
}
Ok(Some(if optional {
Path::Optional(value.to_string())
Path::Optional(EncodedOs::from_encoded(value))
} else {
Path::Required(value.to_string())
Path::Required(EncodedOs::from_encoded(value))
}))
}
@@ -110,16 +105,15 @@ impl Path {
/// `base` is the base directory to expand relative paths; it must be an
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
/// only returned if the base directory cannot be opened.
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Config strings carry non-UTF-8 bytes via a reversible escape
// encoding; filesystem calls use the decoded OS paths.
let base_path = config_str_to_path(base);
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
// Filesystem calls use the decoded OS paths.
let base_path = base.to_path();
assert!(base_path.is_absolute());
let path = self.path().to_string();
let path = self.path().clone();
// If it is already absolute we can ignore it.
if path.is_empty() || config_str_to_path(&path).is_absolute() {
if path.is_empty() || path.to_path().is_absolute() {
return Ok(());
}
@@ -127,21 +121,27 @@ impl Path {
// home directory. We explicitly look for "~/" because we don't
// support alternate users such as "~alice/"
// (Windows isn't supported yet: Zig skips this there.)
if !cfg!(windows) && path.starts_with("~/") {
if !cfg!(windows) && path.starts_with_plain("~/") {
match ghostty_os::expand_home(&path) {
Ok(expanded) => {
log::debug!("expanding file path from home directory: path={expanded}");
log::debug!(
"expanding file path from home directory: path={}",
expanded.as_encoded()
);
*self.path_mut() = expanded;
}
Err(err) => {
diags.append(Diagnostic {
message: format!("error expanding home directory for path {path}: {err}"),
message: format!(
"error expanding home directory for path {}: {err}",
path.as_encoded()
),
..Diagnostic::default()
});
// Blank this path so that we don't attempt to resolve it
// again
*self = Path::Required(String::new());
*self = Path::Required(EncodedOs::new());
}
}
return Ok(());
@@ -150,9 +150,9 @@ impl Path {
// Make sure the base directory can be opened.
std::fs::read_dir(&base_path)?;
let full = base_path.join(config_str_to_path(&path));
let full = base_path.join(path.to_path());
let abs = match std::fs::canonicalize(&full) {
Ok(p) => path_to_config_string(&p),
Ok(p) => EncodedOs::from_path(&p),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// The file doesn't exist. Try to resolve the relative path
// another way.
@@ -160,17 +160,21 @@ impl Path {
}
Err(err) => {
diags.append(Diagnostic {
message: format!("error resolving file path {path}: {err}"),
message: format!("error resolving file path {}: {err}", path.as_encoded()),
..Diagnostic::default()
});
// Blank this path so that we don't attempt to resolve it again
*self = Path::Required(String::new());
*self = Path::Required(EncodedOs::new());
return Ok(());
}
};
log::debug!("expanding file path relative={path} abs={abs}");
log::debug!(
"expanding file path relative={} abs={}",
path.as_encoded(),
abs.as_encoded()
);
*self.path_mut() = abs;
Ok(())
}
@@ -180,11 +184,11 @@ impl ConfigField for Path {
fn c_value(&self) -> CValue {
match self {
Path::Optional(p) => CValue::Path {
path: p.clone(),
path: p.as_encoded().to_owned(),
optional: true,
},
Path::Required(p) => CValue::Path {
path: p.clone(),
path: p.as_encoded().to_owned(),
optional: false,
},
}
@@ -205,8 +209,8 @@ impl ConfigField for Path {
/// Used by formatter.
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
match self {
Path::Optional(p) => writeln!(f.writer, "{} = ?{p}", f.name),
Path::Required(p) => writeln!(f.writer, "{} = {p}", f.name),
Path::Optional(p) => writeln!(f.writer, "{} = ?{}", f.name, p.as_encoded()),
Path::Required(p) => writeln!(f.writer, "{} = {}", f.name, p.as_encoded()),
}
}
}
@@ -222,7 +226,7 @@ pub struct RepeatablePath {
impl RepeatablePath {
/// Expand all the paths relative to the base directory.
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
for path in &mut self.value {
path.expand(base, diags)?;
}
@@ -267,6 +271,10 @@ mod tests {
use super::*;
use crate::formatter::format_entry;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
fn fmt<T: ConfigField>(v: &T) -> String {
let mut s = String::new();
format_entry("a", v, &mut s).unwrap();
@@ -276,13 +284,13 @@ mod tests {
#[test]
fn path_parse() {
let p = |s| Path::parse(Some(s)).unwrap().unwrap();
assert_eq!(p("config.1"), Path::Required("config.1".into()));
assert_eq!(p("?config.2"), Path::Optional("config.2".into()));
assert_eq!(p("\"?config.3\""), Path::Required("?config.3".into()));
assert_eq!(p("?\"config.4\""), Path::Optional("config.4".into()));
assert_eq!(p("?"), Path::Optional(String::new()));
assert_eq!(p("\"\""), Path::Required(String::new()));
assert_eq!(p("?\"\""), Path::Optional(String::new()));
assert_eq!(p("config.1"), Path::Required(e("config.1")));
assert_eq!(p("?config.2"), Path::Optional(e("config.2")));
assert_eq!(p("\"?config.3\""), Path::Required(e("?config.3")));
assert_eq!(p("?\"config.4\""), Path::Optional(e("config.4")));
assert_eq!(p("?"), Path::Optional(EncodedOs::new()));
assert_eq!(p("\"\""), Path::Required(EncodedOs::new()));
assert_eq!(p("?\"\""), Path::Optional(EncodedOs::new()));
assert_eq!(Path::parse(None), Err(ArgError::ValueRequired));
assert_eq!(Path::parse(Some("")), Ok(None));
}
@@ -292,21 +300,21 @@ mod tests {
let mut item = Path::default();
item.parse_cli(Some("config.1")).unwrap();
assert_eq!(item, Path::Required("config.1".into()));
assert_eq!(item, Path::Required(e("config.1")));
item.parse_cli(Some("?config.2")).unwrap();
assert_eq!(item, Path::Optional("config.2".into()));
assert_eq!(item, Path::Optional(e("config.2")));
item.parse_cli(Some("\"?config.3\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
// Zero-length values, ignored
item.parse_cli(Some("?")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
item.parse_cli(Some("\"\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
item.parse_cli(Some("?\"\"")).unwrap();
assert_eq!(item, Path::Required("?config.3".into()));
assert_eq!(item, Path::Required(e("?config.3")));
}
#[test]
@@ -339,9 +347,9 @@ mod tests {
assert_eq!(list.value.len(), 3);
assert_eq!(list.value[0], Path::Required("config.1".into()));
assert_eq!(list.value[1], Path::Optional("config.2".into()));
assert_eq!(list.value[2], Path::Required("?config.3".into()));
assert_eq!(list.value[0], Path::Required(e("config.1")));
assert_eq!(list.value[1], Path::Optional(e("config.2")));
assert_eq!(list.value[2], Path::Required(e("?config.3")));
list.parse_cli(Some("")).unwrap();
assert_eq!(list.value.len(), 0);
@@ -370,13 +378,13 @@ mod tests {
#[test]
fn path_expand_relative_nonexistent_and_absolute() {
let mut diags = DiagnosticList::default();
let mut p = Path::Required("../nope/x.conf".into());
p.expand("/tmp", &mut diags).unwrap();
assert_eq!(p, Path::Required("/nope/x.conf".into()));
let mut p = Path::Required(e("../nope/x.conf"));
p.expand(&e("/tmp"), &mut diags).unwrap();
assert_eq!(p, Path::Required(e("/nope/x.conf")));
let mut p = Path::Optional("/abs/path".into());
p.expand("/tmp", &mut diags).unwrap();
assert_eq!(p, Path::Optional("/abs/path".into()));
let mut p = Path::Optional(e("/abs/path"));
p.expand(&e("/tmp"), &mut diags).unwrap();
assert_eq!(p, Path::Optional(e("/abs/path")));
assert!(diags.is_empty());
}
@@ -390,12 +398,12 @@ mod tests {
std::fs::write(&target, b"x").unwrap();
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
let mut p = Path::Required("link".into());
let mut p = Path::Required(e("link"));
let mut diags = DiagnosticList::default();
p.expand(&path_to_config_string(&base), &mut diags).unwrap();
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
assert_eq!(diags.items().len(), 0);
// The canonical (non-UTF-8) target is preserved byte for byte.
assert_eq!(config_str_to_path(p.path()), target);
assert!(std::fs::metadata(config_str_to_path(p.path())).is_ok());
assert_eq!(p.path().to_path(), target);
assert!(std::fs::metadata(p.path().to_path()).is_ok());
}
}
+46 -39
View File
@@ -5,12 +5,7 @@
use std::fs::File;
use ghostty_os::osbytes::{
config_str_to_os,
config_str_to_path,
os_to_config_string,
path_to_config_string,
};
use ghostty_os::osbytes::EncodedOs;
use crate::diagnostics::{Diagnostic, DiagnosticList};
@@ -33,7 +28,7 @@ impl Location {
/// invalid for any reason. For example, it is perfectly valid to
/// install and run Ghostty without the resources directory.
#[must_use]
pub fn dir(self, resources_dir: Option<&str>) -> Option<String> {
pub fn dir(self, resources_dir: Option<&EncodedOs>) -> Option<EncodedOs> {
match self {
Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options {
subdir: Some("ghostty/themes"),
@@ -42,9 +37,7 @@ impl Location {
.ok(),
Location::Resources => {
let base = resources_dir?;
Some(path_to_config_string(
&config_str_to_path(base).join("themes"),
))
Some(EncodedOs::from_path(&base.to_path().join("themes")))
}
}
}
@@ -54,24 +47,24 @@ impl Location {
/// order of priority.
#[derive(Debug, Clone)]
pub struct LocationIterator {
pub resources_dir: Option<String>,
pub resources_dir: Option<EncodedOs>,
pub i: usize,
}
impl LocationIterator {
#[must_use]
pub fn new(resources_dir: Option<&str>) -> Self {
pub fn new(resources_dir: Option<&EncodedOs>) -> Self {
Self {
resources_dir: resources_dir.map(str::to_string),
resources_dir: resources_dir.cloned(),
i: 0,
}
}
pub fn next_location(&mut self) -> Option<(Location, String)> {
pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> {
while self.i < Location::ALL.len() {
let location = Location::ALL[self.i];
self.i += 1;
if let Some(dir) = location.dir(self.resources_dir.as_deref()) {
if let Some(dir) = location.dir(self.resources_dir.as_ref()) {
return Some((location, dir));
}
}
@@ -86,7 +79,7 @@ impl LocationIterator {
/// An opened theme.
#[derive(Debug)]
pub struct OpenedTheme {
pub path: String,
pub path: EncodedOs,
pub file: File,
}
@@ -160,31 +153,34 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
/// the theme resolves to something other than a regular file a diagnostic entry
/// will be added to the list and None will be returned.
pub fn open(
theme: &str,
resources_dir: Option<&str>,
theme: &EncodedOs,
resources_dir: Option<&EncodedOs>,
diags: &mut DiagnosticList,
) -> Option<OpenedTheme> {
let theme_path = theme.to_path();
let theme_text = theme.as_encoded();
// Absolute themes are loaded a different path.
if config_str_to_path(theme).is_absolute() {
if theme_path.is_absolute() {
let file = open_absolute(theme, diags)?;
if !check_regular(&file, theme, diags) {
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme {
path: theme.to_string(),
path: theme.clone(),
file,
});
}
let basename = config_str_to_path(theme)
let basename = theme_path
.file_name()
.map(os_to_config_string)
.map(EncodedOs::from_os)
.unwrap_or_default();
if theme != basename {
if *theme != basename {
diag(
diags,
format!(
"theme \"{theme}\" cannot include path separators unless it is an absolute path"
"theme \"{theme_text}\" cannot include path separators unless it is an absolute path"
),
);
return None;
@@ -194,10 +190,10 @@ pub fn open(
// one that exists.
let mut it = LocationIterator::new(resources_dir);
while let Some((_, dir)) = it.next_location() {
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
match File::open(config_str_to_path(&path)) {
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
match File::open(path.to_path()) {
Ok(file) => {
if !check_regular(&file, theme, diags) {
if !check_regular(&file, theme_text, diags) {
return None;
}
return Some(OpenedTheme { path, file });
@@ -208,7 +204,10 @@ pub fn open(
Err(err) => {
diag(
diags,
format!("failed to load theme \"{theme}\" from the file \"{path}\": {err}"),
format!(
"failed to load theme \"{theme_text}\" from the file \"{}\": {err}",
path.as_encoded()
),
);
return None;
}
@@ -219,10 +218,13 @@ pub fn open(
// our iterator, reiterate over in order to build a better error message.
it.reset();
while let Some((_, dir)) = it.next_location() {
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
diag(
diags,
format!("theme \"{theme}\" not found, tried path \"{path}\""),
format!(
"theme \"{theme_text}\" not found, tried path \"{}\"",
path.as_encoded()
),
);
}
@@ -232,8 +234,9 @@ pub fn open(
/// Open the given theme from an absolute path. If there are any errors
/// then messages will be appended to the given error list and None is
/// returned.
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
match File::open(config_str_to_path(theme)) {
pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
let theme = theme.as_encoded();
match File::open(EncodedOs::from_encoded(theme).to_path()) {
Ok(f) => Some(f),
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
@@ -257,10 +260,14 @@ pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
mod tests {
use super::*;
fn e(s: &str) -> EncodedOs {
EncodedOs::from_encoded(s)
}
#[test]
fn theme_path_separators_rejected() {
let mut d = DiagnosticList::default();
assert!(open("a/b", None, &mut d).is_none());
assert!(open(&e("a/b"), None, &mut d).is_none());
assert!(
d.items()[0]
.message
@@ -271,13 +278,13 @@ mod tests {
#[test]
fn theme_absolute_missing_and_dir() {
let mut d = DiagnosticList::default();
assert!(open("/nonexistent/ghostty/theme", None, &mut d).is_none());
assert!(open(&e("/nonexistent/ghostty/theme"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"failed to load theme from the path \"/nonexistent/ghostty/theme\""
);
let mut d = DiagnosticList::default();
assert!(open("/tmp", None, &mut d).is_none());
assert!(open(&e("/tmp"), None, &mut d).is_none());
assert_eq!(
d.items()[0].message,
"not reading theme from \"/tmp\": it is a directory"
@@ -290,8 +297,8 @@ mod tests {
std::fs::create_dir_all(dir.join("themes")).unwrap();
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
let mut d = DiagnosticList::default();
let t = open("mytheme-zzz", Some(dir.to_str().unwrap()), &mut d).unwrap();
assert!(t.path.ends_with("themes/mytheme-zzz"));
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
assert!(t.path.ends_with_plain("themes/mytheme-zzz"));
assert!(d.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -299,7 +306,7 @@ mod tests {
#[test]
fn theme_not_found_lists_tried_paths() {
let mut d = DiagnosticList::default();
assert!(open("nope-zzz", Some("/nonexistent-res"), &mut d).is_none());
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
assert!(d.items().iter().any(|x| x.message
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\""));
}
+25 -12
View File
@@ -147,7 +147,7 @@ pub enum WorkingDirectory {
/// Use an explicit working directory path. This may be not be
/// expanded until finalize is called.
Path(String),
Path(ghostty_os::osbytes::EncodedOs),
}
impl ConfigField for WorkingDirectory {
@@ -169,7 +169,7 @@ impl ConfigField for WorkingDirectory {
*self = match input {
"home" => WorkingDirectory::Home,
"inherit" => WorkingDirectory::Inherit,
_ => WorkingDirectory::Path(input.to_string()),
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)),
};
Ok(())
}
@@ -178,7 +178,7 @@ impl ConfigField for WorkingDirectory {
match self {
WorkingDirectory::Home => write_str(f, "home"),
WorkingDirectory::Inherit => write_str(f, "inherit"),
WorkingDirectory::Path(p) => write_str(f, p),
WorkingDirectory::Path(p) => write_str(f, p.as_encoded()),
}
}
}
@@ -190,7 +190,7 @@ impl WorkingDirectory {
return;
};
if !path.starts_with("~/") {
if !path.starts_with_plain("~/") {
return;
}
@@ -198,7 +198,8 @@ impl WorkingDirectory {
Ok(e) => e,
Err(err) => {
log::warn!(
"error expanding home directory for working-directory path={path}: {err}"
"error expanding home directory for working-directory path={}: {err}",
path.as_encoded()
);
return;
}
@@ -211,7 +212,7 @@ impl WorkingDirectory {
}
#[must_use]
pub fn value(&self) -> Option<&str> {
pub fn value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> {
match self {
WorkingDirectory::Path(p) => Some(p),
WorkingDirectory::Home | WorkingDirectory::Inherit => None,
@@ -1044,6 +1045,8 @@ impl ConfigField for RepeatableLink {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use ghostty_os::osbytes::EncodedOs;
use super::*;
use crate::cli_args::ConfigField;
@@ -1069,18 +1072,28 @@ mod tests {
parse(&mut wd, "home").unwrap();
assert_eq!(wd, WorkingDirectory::Home);
parse(&mut wd, "~/projects/ghostty").unwrap();
assert_eq!(wd, WorkingDirectory::Path("~/projects/ghostty".into()));
assert_eq!(
wd,
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
"~/projects/ghostty"
))
);
parse(&mut wd, "\"/tmp path\"").unwrap();
assert_eq!(wd, WorkingDirectory::Path("/tmp path".into()));
assert_eq!(
wd,
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp path"))
);
}
#[test]
fn working_directory_finalize() {
let mut wd = WorkingDirectory::Path("~/projects/ghostty".into());
let mut wd = WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
"~/projects/ghostty",
));
wd.finalize();
let expected = ghostty_os::expand_home("~/projects/ghostty")
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
assert_eq!(wd.value().unwrap(), expected);
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
assert_eq!(wd.value().unwrap(), &expected);
}
}