halley-config 0.2.0

Configuration loading and parsing for the Halley Wayland compositor.
Documentation
use rune_cfg::RuneConfig;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use crate::layout::RuntimeTuning;

use super::keybinds::{
    apply_explicit_keybind_overrides_entries, parse_inline_keybinds, strip_inline_keybind_block,
};
use super::rules::load_rules_section;
use super::sections::{
    load_animations_section, load_autostart_section, load_bearings_section, load_clusters_section,
    load_cursor_section, load_decay_section, load_decorations_section, load_env_section,
    load_field_section, load_focus_ring_section, load_font_section, load_input_section,
    load_keybind_sections, load_nodes_section, load_overlays_section, load_physics_section,
    load_screenshot_section, load_stacking_section, load_tile_section, load_trail_section,
    load_viewport_section,
};

impl RuntimeTuning {
    pub fn from_rune_file(path: &str) -> Option<Self> {
        let raw = std::fs::read_to_string(path).ok()?;
        let seed = Self::builtin_defaults();
        let inline_keybinds = match parse_inline_keybinds(&raw) {
            Ok(bindings) => bindings,
            Err(err) => {
                eprintln!("halley config keybind parse error: {err}");
                return None;
            }
        };

        let cfg = parse_rune_file_with_keybind_fallback(path, &raw)?;

        Self::from_parsed_rune(raw.as_str(), &cfg, inline_keybinds, seed)
    }

    pub(crate) fn from_rune_str_with_seed(raw: &str, seed: Self) -> Option<Self> {
        let inline_keybinds = match parse_inline_keybinds(raw) {
            Ok(bindings) => bindings,
            Err(err) => {
                eprintln!("halley config keybind parse error: {err}");
                return None;
            }
        };

        let cfg = RuneConfig::from_str(raw).or_else(|_| {
            let sanitized = strip_inline_keybind_block(raw);
            RuneConfig::from_str(sanitized.as_str())
        });
        let cfg = cfg.ok()?;

        Self::from_parsed_rune(raw, &cfg, inline_keybinds, seed)
    }

    pub fn from_rune_str(raw: &str) -> Option<Self> {
        Self::from_rune_str_with_seed(raw, Self::builtin_defaults())
    }

    fn from_parsed_rune(
        raw: &str,
        cfg: &RuneConfig,
        inline_keybinds: Vec<(String, String)>,
        seed: Self,
    ) -> Option<Self> {
        let mut out = seed;

        load_autostart_section(raw, &mut out);
        if let Err(err) = load_rules_section(raw, &mut out) {
            eprintln!("halley config rules parse error: {err}");
            return None;
        }
        load_env_section(cfg, &mut out);
        load_input_section(cfg, &mut out);
        load_cursor_section(cfg, &mut out);
        load_font_section(cfg, &mut out);
        load_viewport_section(cfg, &mut out);
        load_focus_ring_section(cfg, &mut out);
        load_bearings_section(cfg, &mut out);
        load_trail_section(cfg, &mut out);
        load_nodes_section(cfg, &mut out);
        load_clusters_section(cfg, &mut out);
        load_tile_section(cfg, &mut out);
        load_stacking_section(cfg, &mut out);
        load_decay_section(cfg, &mut out);
        load_field_section(cfg, &mut out);
        load_physics_section(cfg, &mut out);
        load_decorations_section(cfg, &mut out);
        load_animations_section(cfg, &mut out);
        load_overlays_section(cfg, &mut out);
        load_screenshot_section(cfg, &mut out);
        if let Err(err) = load_keybind_sections(cfg, &mut out) {
            eprintln!("halley config keybind parse error: {err}");
            return None;
        }

        if !inline_keybinds.is_empty() {
            if let Err(err) = apply_explicit_keybind_overrides_entries(&inline_keybinds, &mut out) {
                eprintln!("halley config keybind parse error: {err}");
                return None;
            }
        }

        Some(out)
    }
}

pub fn from_rune_file(path: &str) -> Option<RuntimeTuning> {
    RuntimeTuning::from_rune_file(path)
}

fn parse_rune_file_with_keybind_fallback(path: &str, raw: &str) -> Option<RuneConfig> {
    RuneConfig::from_file(path).ok().or_else(|| {
        let sanitized = strip_inline_keybind_block(raw);
        parse_sanitized_rune_file(path, sanitized.as_str())
            .or_else(|| RuneConfig::from_str(sanitized.as_str()).ok())
    })
}

fn parse_sanitized_rune_file(original_path: &str, sanitized: &str) -> Option<RuneConfig> {
    let original_path = Path::new(original_path);
    let temp_dir = sanitized_config_temp_dir(original_path);
    std::fs::create_dir_all(&temp_dir).ok()?;

    let mut visited = HashMap::new();
    let temp_path =
        write_sanitized_config_tree(original_path, Some(sanitized), &temp_dir, &mut visited)?;
    let cfg = RuneConfig::from_file(temp_path.as_path()).ok();
    let _ = std::fs::remove_dir_all(&temp_dir);
    cfg
}

fn write_sanitized_config_tree(
    source_path: &Path,
    raw_override: Option<&str>,
    temp_dir: &Path,
    visited: &mut HashMap<PathBuf, PathBuf>,
) -> Option<PathBuf> {
    let source_key = absolutize_config_path(source_path);
    if let Some(existing) = visited.get(&source_key) {
        return Some(existing.clone());
    }

    let temp_path = sanitized_config_temp_path(&source_key, temp_dir, visited.len());
    visited.insert(source_key.clone(), temp_path.clone());

    let raw = match raw_override {
        Some(raw) => raw.to_string(),
        None => std::fs::read_to_string(&source_key).ok()?,
    };
    let sanitized = strip_inline_keybind_block(&raw);
    let rewritten = rewrite_gather_paths_to_sanitized_files(
        sanitized.as_str(),
        source_key.parent().unwrap_or_else(|| Path::new(".")),
        temp_dir,
        visited,
    );

    std::fs::write(&temp_path, rewritten).ok()?;
    Some(temp_path)
}

fn rewrite_gather_paths_to_sanitized_files(
    content: &str,
    base_dir: &Path,
    temp_dir: &Path,
    visited: &mut HashMap<PathBuf, PathBuf>,
) -> String {
    let mut out = String::with_capacity(content.len());

    for line in content.lines() {
        if let Some(rewritten) = rewrite_gather_line(line, base_dir, temp_dir, visited) {
            out.push_str(rewritten.as_str());
        } else {
            out.push_str(line);
        }
        out.push('\n');
    }

    out
}

fn rewrite_gather_line(
    line: &str,
    base_dir: &Path,
    temp_dir: &Path,
    visited: &mut HashMap<PathBuf, PathBuf>,
) -> Option<String> {
    let trimmed = line.trim_start();
    if !trimmed.starts_with("gather") {
        return None;
    }

    let indent_len = line.len() - trimmed.len();
    let after_gather = trimmed.strip_prefix("gather")?.trim_start();
    let quote = after_gather.chars().next()?;
    if quote != '"' && quote != '\'' {
        return None;
    }

    let close_relative = after_gather[1..].find(quote)?;
    let raw_path = &after_gather[1..close_relative + 1];
    let after_path = &after_gather[close_relative + 2..];
    let import_path = resolve_gather_path_for_halley(raw_path, base_dir);

    if !import_path.exists() {
        return None;
    }

    let sanitized_import = write_sanitized_config_tree(&import_path, None, temp_dir, visited)?;
    Some(format!(
        "{}gather \"{}\"{}",
        &line[..indent_len],
        sanitized_import.to_string_lossy(),
        after_path
    ))
}

fn resolve_gather_path_for_halley(raw_path: &str, base_dir: &Path) -> PathBuf {
    let mut path = if let Some(rest) = raw_path.strip_prefix("~/") {
        std::env::var_os("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("~"))
            .join(rest)
    } else {
        PathBuf::from(raw_path)
    };

    if path.is_relative() {
        path = base_dir.join(path);
    }

    absolutize_config_path(&path)
}

fn absolutize_config_path(path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(path)
    }
}

fn sanitized_config_temp_dir(original_path: &Path) -> PathBuf {
    let stem = original_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("halley");
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();

    std::env::temp_dir().join(format!(
        "{stem}.sanitized.{}.{}",
        std::process::id(),
        unique
    ))
}

fn sanitized_config_temp_path(source_path: &Path, temp_dir: &Path, index: usize) -> PathBuf {
    let stem = source_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("halley");
    temp_dir.join(format!("{index}-{stem}.rune"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::layout::OverlayColorMode;

    #[test]
    fn from_rune_file_resolves_gather_when_inline_keybinds_require_sanitized_parse() {
        let dir = test_temp_dir("gather-inline-keybinds");
        let import_path = dir.join("colors.rune");
        let config_path = dir.join("halley.rune");

        std::fs::write(
            &import_path,
            r##"pywal_background "#123456"

keybinds:
  mod "super"
  "$var.mod+q" "close-focused"
end
"##,
        )
        .unwrap();
        std::fs::write(
            &config_path,
            r##"gather "colors.rune"

screenshot:
  background-colour pywal_background
end

keybinds:
  mod "super"
  "$var.mod+r" "reload"
end
"##,
        )
        .unwrap();

        let tuning = RuntimeTuning::from_rune_file(config_path.to_str().unwrap())
            .expect("config should parse with gathered colors and inline keybinds");

        assert_eq!(
            tuning.screenshot.background_color,
            OverlayColorMode::Fixed {
                r: 0x12 as f32 / 255.0,
                g: 0x34 as f32 / 255.0,
                b: 0x56 as f32 / 255.0,
            }
        );
        assert!(tuning.keybinds.modifier.super_key);

        let _ = std::fs::remove_dir_all(dir);
    }

    fn test_temp_dir(name: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or_default();
        let dir = std::env::temp_dir().join(format!(
            "halley-config-{name}-{}-{unique}",
            std::process::id()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }
}