diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a83f5d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,42 @@ +version: 2 +updates: + # Rust dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + commit-message: + prefix: "chore(deps)" + labels: + - "dependencies" + - "rust" + groups: + rust-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + open-pull-requests-limit: 5 + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "UTC" + commit-message: + prefix: "chore(ci)" + labels: + - "dependencies" + - "ci" + groups: + github-actions: + patterns: + - "*" + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed9070b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,251 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUSTFLAGS: -D warnings + RUST_BACKTRACE: 1 + +jobs: + format: + name: Format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + + - name: Check formatting + run: cargo +nightly fmt --all --check + + lint: + name: Lint + runs-on: ubuntu-latest + needs: format + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-clippy- + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + + - name: Run tests + run: cargo test --all-features --verbose + + build: + name: Build Release + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-release- + + - name: Build release + run: cargo build --release --all-features + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: hyq-linux-x86_64 + path: target/release/hyq + retention-days: 7 + + audit: + name: Security Audit + runs-on: ubuntu-latest + needs: format + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run audit + run: cargo audit + + docs: + name: Documentation + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-docs- + + - name: Build documentation + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate coverage + run: cargo llvm-cov --all-features --lcov --output-path lcov.info + + - name: Upload to Codecov + uses: codecov/codecov-action@v4 + with: + files: lcov.info + fail_ci_if_error: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + changelog: + name: Generate Changelog + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [build, coverage] + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Generate changelog + run: | + git-cliff --output CHANGELOG.md + + - name: Commit changelog + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: update changelog" + file_pattern: CHANGELOG.md + commit_user_name: "github-actions[bot]" + commit_user_email: "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..9010c65 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,37 @@ +name: Dependabot Auto-merge + +on: + pull_request: + types: + - opened + - synchronize + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + name: Auto-merge Dependabot PRs + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for minor and patch updates + if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Approve minor and patch updates + if: steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23c475b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +Cargo.lock +*.swp +*.swo +*~ +.idea/ +.vscode/ diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..00ca44e --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,11 @@ +trailing_comma = "Never" +brace_style = "SameLineWhere" +struct_field_align_threshold = 20 +wrap_comments = true +format_code_in_doc_comments = true +struct_lit_single_line = false +max_width = 99 +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +reorder_imports = true +unstable_features = true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..00df534 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,59 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Bug Fixes + +- Make CLI more professional with proper error messages +- Strip comments from source directive paths +- Hide invalid regex from clippy static analysis +- Handle multi query + +### Documentation + +- Update ASCII art to HYDEQUERY and improve README +- Add comprehensive man page +- Add CI and coverage badges to README +- Add comprehensive module and function documentation + +### Feat + +- Added --query "query[type][regex]" - type and regex hints! +- Added --query "query[type][regex]" - type and regex hints! + +### Features + +- Add criterion benchmarks for performance testing +- Add schema fetching and caching with --fetch-schema and --schema auto +- Add colorful custom help command with detailed documentation +- Add unit tests and improve functionality +- Rewrite hyprquery in Rust using hyprlang-rs +- Dynamic lin installation + +### Miscellaneous + +- Add professional CI workflow with caching + +### Performance + +- Optimize memory usage with Box and static str + +### Refactoring + +- Extract filters and defaults modules from app +- Extract run_with_args for testability +- Modernize error handling with masterror builder API +- Move app logic to app.rs, keep only main fn in main.rs +- Split main.rs into logical modules +- Removes the var lookup work around + +### Testing + +- Add tests for main, fetch and integration +- Add tests for help module +- Improve test coverage to 74% + +--- +Generated by [git-cliff](https://git-cliff.org) diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9f4ca13 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "hyprquery" +version = "0.2.0" +edition = "2024" +authors = ["RAprogramm "] +description = "A command-line utility for querying configuration values from Hyprland configuration files" +license = "GPL-3.0" +repository = "https://github.com/HyDE-Project/hyprquery" +keywords = ["hyprland", "configuration", "query", "cli"] +categories = ["command-line-utilities", "config"] + +[[bin]] +name = "hyq" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +hyprlang = { git = "https://github.com/spinualexandru/hyprlang-rs", features = [ + "hyprland", + "mutation", +] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +masterror = "0.25" +regex = "1" +glob = "0.3" +shellexpand = "3" +dirs = "6" +ureq = "3" + +[dev-dependencies] +criterion = "0.7" + +[[bench]] +name = "benchmarks" +harness = false + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 +strip = true +panic = "abort" diff --git a/README.md b/README.md index 0c250fd..1581243 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,283 @@ +> [!WARNING] +> +> EXPERIMENTAL branch. porting to rust. -# hyprquery +![HyDE Banner](https://raw.githubusercontent.com/HyDE-Project/HyDE/master/Source/assets/hyde_banner.png) ----------- +# Hyprquery -> [!WARNING] -> EXPERIMENTAL branch. porting to rust. +[![CI](https://github.com/HyDE-Project/hyprquery/actions/workflows/ci.yml/badge.svg)](https://github.com/HyDE-Project/hyprquery/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/HyDE-Project/hyprquery/graph/badge.svg)](https://codecov.io/gh/HyDE-Project/hyprquery) +[![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](https://opensource.org/licenses/GPL-3.0) +[![Rust](https://img.shields.io/badge/Rust-2024-orange.svg)](https://www.rust-lang.org/) + +**High-performance configuration parser for Hyprland** + +A blazing-fast CLI tool written in Rust for querying values from Hyprland configuration files. Supports nested keys, dynamic variables, type filtering, regex matching, and multiple export formats. + +## Features + +- **Fast** - Optimized Rust implementation with minimal allocations (~1ms per query) +- **Flexible queries** - Support for nested keys, type filters, and regex patterns +- **Dynamic variables** - Query `$variable` values directly +- **Multiple formats** - Export as plain text, JSON, or environment variables +- **Source following** - Recursively parse `source = path` directives with cycle detection +- **Schema support** - Load default values from Hyprland schema files +- **Colorful help** - Beautiful, detailed `--help` with examples + +## Installation + +### From source + +```bash +git clone https://github.com/HyDE-Project/hyprquery +cd hyprquery +cargo build --release +sudo cp target/release/hyq /usr/local/bin/ +``` + +### Requirements + +- Rust stable (2024 edition) +- Rust nightly (for formatting only) + +## Usage + +### Basic syntax + +```bash +hyq -Q [OPTIONS] +``` + +### Query format + +``` +key # Simple lookup +key[type] # With type filter +key[type][regex] # With type and regex filter +$variable # Dynamic variable +``` + +**Types:** `INT`, `FLOAT`, `STRING`, `VEC2`, `COLOR`, `BOOL` + +## Examples + +### Basic query + +```bash +hyq ~/.config/hypr/hyprland.conf -Q 'general:border_size' +# Output: 2 +``` + +### Query variable + +```bash +hyq config.conf -Q '$terminal' +# Output: kitty +``` + +### Multiple queries + +```bash +hyq config.conf -Q 'general:gaps_in' -Q 'general:gaps_out' +# Output: +# 5 +# 10 +``` + +### With type filter + +```bash +hyq config.conf -Q 'general:border_size[INT]' +# Output: 2 +``` + +### With regex filter + +```bash +hyq config.conf -Q 'decoration:rounding[INT][^[0-9]+$]' +# Output: 8 +``` + +### JSON export + +```bash +hyq config.conf -Q 'general:border_size' --export json +``` + +```json +{ + "key": "general:border_size", + "value": "2", + "type": "INT" +} +``` + +### Environment variables export + +```bash +hyq config.conf -Q '$terminal' --export env +# Output: TERMINAL="kitty" +``` + +### Follow source directives + +```bash +hyq config.conf -Q 'colors:background' -s +``` + +### Fetch and cache schema + +```bash +hyq --fetch-schema +# Output: Schema cached at: ~/.cache/hyprquery/hyprland.json +``` + +### Use cached schema + +```bash +hyq config.conf -Q 'general:layout' --schema auto +``` + +### Get all default keys from schema + +```bash +hyq config.conf --schema auto --get-defaults +``` + +### With custom schema + +```bash +hyq config.conf -Q 'general:layout' --schema hyprland.json +``` + +### Custom delimiter + +```bash +hyq config.conf -Q 'a' -Q 'b' -D ',' +# Output: val1,val2 +``` + +## Options + +| Option | Description | +|--------|-------------| +| `-Q, --query ` | Query to execute (multiple allowed) | +| `--schema ` | Load schema file (use `auto` for cached) | +| `--fetch-schema` | Download and cache latest schema | +| `--get-defaults` | Output all keys from schema | +| `--allow-missing` | Don't fail on NULL values (exit 0) | +| `--strict` | Fail on config parse errors | +| `--export ` | Output format: `json`, `env` | +| `-s, --source` | Follow source directives recursively | +| `-D, --delimiter ` | Delimiter for plain output (default: `\n`) | +| `--debug` | Enable debug logging to stderr | +| `-h, --help` | Show colorful help with examples | +| `-V, --version` | Show version | + +## Exit codes + +| Code | Description | +|------|-------------| +| `0` | All queries resolved successfully | +| `1` | One or more queries returned NULL, or error occurred | + +## Configuration file format + +Hydequery parses standard Hyprland configuration format: + +```bash +# Variables +$terminal = kitty +$mod = SUPER + +# Nested sections +general { + border_size = 2 + gaps_in = 5 + gaps_out = 10 +} + +decoration { + rounding = 8 + blur { + enabled = true + size = 3 + } +} + +# Source directives (supports globs) +source = ~/.config/hypr/colors.conf +source = ~/.config/hypr/keybinds/*.conf +``` + +## Schema files + +Schema files define default values for configuration options: + +```json +{ + "hyprlang_schema": [ + { + "value": "general:border_size", + "type": "INT", + "data": { "default": 2 } + }, + { + "value": "general:gaps_in", + "type": "INT", + "data": { "default": 5 } + } + ] +} +``` + +## Architecture + +``` +src/ +├── main.rs # Entry point +├── app.rs # Core application logic +├── cli.rs # CLI argument definitions +├── defaults.rs # Schema defaults handling +├── error.rs # Error handling (masterror) +├── export.rs # Output formatters (JSON, env, plain) +├── fetch.rs # Schema fetching and caching +├── filters.rs # Type and regex filtering +├── help.rs # Colorful help display +├── path.rs # Path normalization and glob resolution +├── query.rs # Query parsing +├── schema.rs # Schema loading +├── source.rs # Source directive handling +└── value.rs # Config value conversion +``` + +## Performance + +- **Binary size:** ~2.5 MB (stripped, LTO enabled) +- **Query time:** ~1ms +- **Memory:** Optimized with `Box` and `&'static str` + +## Dependencies + +- [clap](https://crates.io/crates/clap) - CLI argument parsing +- [hyprlang](https://github.com/spinualexandru/hyprlang-rs) - Hyprland config parsing +- [masterror](https://crates.io/crates/masterror) - Error handling +- [serde_json](https://crates.io/crates/serde_json) - JSON serialization +- [regex](https://crates.io/crates/regex) - Pattern matching +- [glob](https://crates.io/crates/glob) - Glob pattern support +- [shellexpand](https://crates.io/crates/shellexpand) - Path expansion (~, $HOME) +- [ureq](https://crates.io/crates/ureq) - HTTP client for schema fetching +- [dirs](https://crates.io/crates/dirs) - Platform-specific directories + +## Contributing + +Contributions are welcome! Please follow [RustManifest](https://github.com/RAprogramm/RustManifest) development standards. + +## License + +GPL-3.0 - see [LICENSE](LICENSE) for details. + +## Credits + +Created for [HyDE](https://github.com/HyDE-Project) - Hyprland Desktop Environment. diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs new file mode 100644 index 0000000..e75c51e --- /dev/null +++ b/benches/benchmarks.rs @@ -0,0 +1,194 @@ +//! Benchmarks for hyprquery operations. +//! +//! Run with: `cargo bench` +//! Results are saved to `target/criterion/` + +use std::{fs, hint::black_box, io::Write}; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use hyprquery::{cli::Args, query::parse_query_inputs}; + +/// Sample HyDE-style configuration for benchmarking. +const BENCH_CONFIG: &str = r#" +$GTK_THEME = Gruvbox-Retro +$ICON_THEME = Gruvbox-Plus-Dark +$CURSOR_THEME = Bibata-Modern-Ice +$CURSOR_SIZE = 24 +$terminal = kitty +$editor = nvim +$fileManager = dolphin +$menu = rofi + +general { + border_size = 2 + no_border_on_floating = false + gaps_in = 5 + gaps_out = 10 + col.active_border = rgba(33ccffee) rgba(00ff99ee) 45deg + col.inactive_border = rgba(595959aa) + layout = dwindle +} + +decoration { + rounding = 8 + active_opacity = 1.0 + inactive_opacity = 0.9 + blur { + enabled = true + size = 3 + passes = 1 + } + shadow { + enabled = true + range = 4 + render_power = 3 + } +} + +animations { + enabled = true + bezier = myBezier, 0.05, 0.9, 0.1, 1.05 + animation = windows, 1, 7, myBezier + animation = windowsOut, 1, 7, default, popin 80% + animation = border, 1, 10, default + animation = fade, 1, 7, default + animation = workspaces, 1, 6, default +} + +input { + kb_layout = us,ru + kb_options = grp:alt_shift_toggle + follow_mouse = 1 + sensitivity = 0 + touchpad { + natural_scroll = true + } +} + +misc { + force_default_wallpaper = 0 + disable_hyprland_logo = true +} +"#; + +/// Create a temporary config file for benchmarking. +fn create_bench_config() -> String { + let temp_dir = std::env::temp_dir().join("hyprquery_bench"); + let _ = fs::create_dir_all(&temp_dir); + let path = temp_dir.join("bench.conf"); + let mut file = fs::File::create(&path).expect("Failed to create bench config"); + write!(file, "{}", BENCH_CONFIG).expect("Failed to write bench config"); + path.to_string_lossy().to_string() +} + +/// Create test Args for benchmarking. +fn make_bench_args(config_file: &str, queries: Vec<&str>) -> Args { + Args { + help: false, + config_file: Some(config_file.to_string()), + queries: queries.into_iter().map(String::from).collect(), + schema: None, + fetch_schema: false, + allow_missing: false, + get_defaults: false, + strict: false, + export: None, + source: false, + debug: false, + delimiter: "\n".to_string() + } +} + +/// Benchmark query parsing operations. +fn bench_query_parsing(c: &mut Criterion) { + let mut group = c.benchmark_group("query_parsing"); + + group.bench_function("simple_key", |b| { + b.iter(|| parse_query_inputs(&[black_box("general:border_size".to_string())])) + }); + + group.bench_function("with_type_filter", |b| { + b.iter(|| parse_query_inputs(&[black_box("general:border_size[INT]".to_string())])) + }); + + group.bench_function("with_type_and_regex", |b| { + b.iter(|| { + parse_query_inputs(&[black_box("decoration:rounding[INT][^[0-9]+$]".to_string())]) + }) + }); + + group.bench_function("dynamic_variable", |b| { + b.iter(|| parse_query_inputs(&[black_box("$GTK_THEME".to_string())])) + }); + + // Benchmark with varying number of queries + for count in [1, 5, 10] { + let queries: Vec = (0..count).map(|i| format!("general:key_{}", i)).collect(); + group.bench_with_input( + BenchmarkId::new("multiple_queries", count), + &queries, + |b, q| b.iter(|| parse_query_inputs(black_box(q))) + ); + } + + group.finish(); +} + +/// Benchmark file I/O operations. +fn bench_file_io(c: &mut Criterion) { + let config_path = create_bench_config(); + let mut group = c.benchmark_group("file_io"); + + group.bench_function("read_config", |b| { + b.iter(|| { + let content = fs::read_to_string(black_box(&config_path)).unwrap(); + black_box(content) + }) + }); + + group.finish(); +} + +/// Benchmark full query execution. +fn bench_full_execution(c: &mut Criterion) { + let config_path = create_bench_config(); + let mut group = c.benchmark_group("full_execution"); + + group.bench_function("single_static_key", |b| { + let args = make_bench_args(&config_path, vec!["general:border_size"]); + b.iter(|| { + let _ = hyprquery::app::run_with_args(black_box(args.clone())); + }) + }); + + group.bench_function("single_with_type", |b| { + let args = make_bench_args(&config_path, vec!["general:border_size[INT]"]); + b.iter(|| { + let _ = hyprquery::app::run_with_args(black_box(args.clone())); + }) + }); + + group.bench_function("multiple_queries", |b| { + let args = make_bench_args( + &config_path, + vec![ + "general:border_size", + "general:gaps_in", + "decoration:rounding", + ] + ); + b.iter(|| { + let _ = hyprquery::app::run_with_args(black_box(args.clone())); + }) + }); + + group.finish(); +} + +criterion_group!( + benches, + bench_query_parsing, + bench_file_io, + bench_full_execution +); +criterion_main!(benches); diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..93bc70e --- /dev/null +++ b/cliff.toml @@ -0,0 +1,56 @@ +# git-cliff configuration for hydequery +# https://git-cliff.org + +[changelog] +header = """ +# Changelog + +All notable changes to this project will be documented in this file. + +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [Unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\ + {{ commit.message | upper_first }}\ + {% if commit.breaking %} [**BREAKING**]{% endif %}\ + {% endfor %} +{% endfor %}\n +""" +footer = """ +--- +Generated by [git-cliff](https://git-cliff.org) +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_preprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/HyDE-Project/hyprquery/issues/${2}))" }, + { pattern = '#2\s*', replace = "" }, +] +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^doc", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^style", group = "Style" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\)", skip = true }, + { message = "^chore\\(deps\\)", skip = true }, + { message = "^chore|^ci", group = "Miscellaneous" }, + { body = ".*security", group = "Security" }, +] +protect_breaking_commits = false +filter_commits = false +topo_order = false +sort_commits = "newest" diff --git a/man/hyq.1 b/man/hyq.1 new file mode 100644 index 0000000..addd771 --- /dev/null +++ b/man/hyq.1 @@ -0,0 +1,239 @@ +.TH HYQ 1 "November 2025" "hyprquery 0.2.0" "User Commands" +.SH NAME +hyq \- High-performance configuration parser for Hyprland +.SH SYNOPSIS +.B hyq +.RI [ OPTIONS ] +.I CONFIG_FILE +.B \-Q +.I QUERY +.RI [ \-Q +.IR QUERY .\|.\|.] +.br +.B hyq +.B \-\-fetch\-schema +.br +.B hyq +.B \-\-help +| +.B \-\-version +.SH DESCRIPTION +.B hyq +is a blazing-fast CLI tool written in Rust for querying values from Hyprland +configuration files. It supports nested keys, dynamic variables, type filtering, +regex matching, and multiple export formats. +.PP +The tool parses standard Hyprland configuration format including nested sections, +variable definitions, and source directives. +.SH OPTIONS +.SS "Required Arguments" +.TP +.I CONFIG_FILE +Path to the Hyprland configuration file. +Supports tilde (~), environment variables, and glob patterns. +.TP +.BI \-Q ", " \-\-query " " \fIQUERY\fR +Query to execute. Can be specified multiple times for multiple queries. +.br +Format: \fBkey[type][regex]\fR +.SS "Schema Options" +.TP +.BI \-\-schema " " \fIPATH\fR +Load schema file for default values. +Use \fBauto\fR to load from cache (~/.cache/hyprquery/hyprland.json). +.TP +.B \-\-fetch\-schema +Download and cache the latest schema from repository. +Schema is saved to ~/.cache/hyprquery/hyprland.json. +.TP +.B \-\-get\-defaults +Output all configuration keys defined in the schema file. +Requires \fB\-\-schema\fR to be specified. +.SS "Output Options" +.TP +.BI \-\-export " " \fIFORMAT\fR +Output format. Supported values: +.RS +.IP \fBjson\fR +JSON object or array with key, value, and type fields. +.IP \fBenv\fR +Environment variable assignments (KEY="value"). +.RE +.TP +.BI \-D ", " \-\-delimiter " " \fISTR\fR +Delimiter for plain text output when multiple queries are specified. +Default: newline (\\n). +.SS "Behavior Options" +.TP +.B \-s ", " \-\-source +Follow source directives recursively. +Parses all included configuration files with cycle detection. +.TP +.B \-\-allow\-missing +Don't exit with code 1 when queries return NULL values. +.TP +.B \-\-strict +Exit with error on configuration parse failures. +By default, parse errors are silently ignored. +.TP +.B \-\-debug +Enable debug logging to stderr. +Shows internal operations like key lookups and injected lines. +.SS "Information" +.TP +.B \-h ", " \-\-help +Display colorful help message with examples and exit. +.TP +.B \-V ", " \-\-version +Display version information and exit. +.SH QUERY FORMAT +Queries follow the format: \fBkey[type][regex]\fR +.TP +.B key +Simple configuration key lookup. +Nested keys use colon (:) separator, e.g., \fBgeneral:border_size\fR. +.TP +.B $variable +Dynamic variable lookup, e.g., \fB$terminal\fR. +.TP +.B [type] +Optional type filter. Value must match the specified type. +.br +Types: \fBINT\fR, \fBFLOAT\fR, \fBSTRING\fR, \fBVEC2\fR, \fBCOLOR\fR, \fBBOOL\fR +.TP +.B [regex] +Optional regex pattern. Value must match the pattern. +.SH EXIT STATUS +.TP +.B 0 +All queries resolved successfully. +.TP +.B 1 +One or more queries returned NULL, or an error occurred. +With \fB\-\-allow\-missing\fR, NULL values don't cause exit code 1. +.SH EXAMPLES +.SS "Basic Queries" +Query a simple configuration value: +.PP +.RS +.nf +hyq ~/.config/hypr/hyprland.conf -Q 'general:border_size' +.fi +.RE +.PP +Query a dynamic variable: +.PP +.RS +.nf +hyq config.conf -Q '$terminal' +.fi +.RE +.PP +Multiple queries: +.PP +.RS +.nf +hyq config.conf -Q 'general:gaps_in' -Q 'general:gaps_out' +.fi +.RE +.SS "Type and Regex Filtering" +Query with type filter: +.PP +.RS +.nf +hyq config.conf -Q 'general:border_size[INT]' +.fi +.RE +.PP +Query with type and regex filter: +.PP +.RS +.nf +hyq config.conf -Q 'decoration:rounding[INT][^[0-9]+$]' +.fi +.RE +.SS "Export Formats" +JSON export: +.PP +.RS +.nf +hyq config.conf -Q 'general:border_size' --export json +.fi +.RE +.PP +Environment variable export: +.PP +.RS +.nf +hyq config.conf -Q '$terminal' --export env +.fi +.RE +.SS "Schema Operations" +Fetch and cache schema: +.PP +.RS +.nf +hyq --fetch-schema +.fi +.RE +.PP +Use cached schema: +.PP +.RS +.nf +hyq config.conf -Q 'general:layout' --schema auto +.fi +.RE +.PP +Get all default keys: +.PP +.RS +.nf +hyq config.conf --schema auto --get-defaults +.fi +.RE +.SS "Advanced Usage" +Follow source directives: +.PP +.RS +.nf +hyq config.conf -Q 'colors:background' -s +.fi +.RE +.PP +Custom delimiter for scripting: +.PP +.RS +.nf +hyq config.conf -Q 'a' -Q 'b' -D ',' +.fi +.RE +.SH FILES +.TP +.I ~/.cache/hyprquery/hyprland.json +Cached schema file downloaded by \fB\-\-fetch\-schema\fR. +.TP +.I ~/.config/hypr/hyprland.conf +Default Hyprland configuration file location. +.SH ENVIRONMENT +.TP +.B HOME +Used for tilde (~) expansion in paths. +.TP +.B XDG_CACHE_HOME +Used to determine cache directory location. +Defaults to ~/.cache if not set. +.SH SEE ALSO +.BR hyprland (1), +.BR hyprctl (1) +.PP +Hyprland Wiki: https://wiki.hypr.land/ +.SH BUGS +Report bugs at: https://github.com/HyDE-Project/hyprquery/issues +.SH AUTHORS +Written by RAprogramm . +.PP +Part of the HyDE (Hyprland Desktop Environment) project. +.SH COPYRIGHT +Copyright \(co 2025 HyDE Project. +Licensed under GPL-3.0. diff --git a/schema/hyprland.json b/schema/hyprland.json new file mode 100644 index 0000000..6744867 --- /dev/null +++ b/schema/hyprland.json @@ -0,0 +1,2232 @@ +{ + "hyprlang_schema": [ + { + "value": "general:border_size", + "description": "size of the border around windows", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 1 + } + }, + { + "value": "general:no_border_on_floating", + "description": "disable borders for floating windows", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "general:gaps_in", + "description": "gaps between windows\n\nsupports css style gaps (top, right, bottom, left -> 5 10 15 20)", + "type": "STRING_SHORT", + "data": { + "default": "5" + } + }, + { + "value": "general:gaps_out", + "description": "gaps between windows and monitor edges\n\nsupports css style gaps (top, right, bottom, left -> 5 10 15 20)", + "type": "STRING_SHORT", + "data": { + "default": "20" + } + }, + { + "value": "general:gaps_workspaces", + "description": "gaps between workspaces. Stacks with gaps_out.", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 0 + } + }, + { + "value": "general:col.inactive_border", + "description": "border color for inactive windows", + "type": "GRADIENT", + "data": { + "default": "0xff444444" + } + }, + { + "value": "general:col.active_border", + "description": "border color for the active window", + "type": "GRADIENT", + "data": { + "default": "0xffffffff" + } + }, + { + "value": "general:col.nogroup_border", + "description": "inactive border color for window that cannot be added to a group (see denywindowfromgroup dispatcher)", + "type": "GRADIENT", + "data": { + "default": "0xffffaaff" + } + }, + { + "value": "general:col.nogroup_border_active", + "description": "active border color for window that cannot be added to a group", + "type": "GRADIENT", + "data": { + "default": "0xffff00ff" + } + }, + { + "value": "general:layout", + "description": "which layout to use. [dwindle/master]", + "type": "STRING_SHORT", + "data": { + "default": "dwindle" + } + }, + { + "value": "general:no_focus_fallback", + "description": "if true, will not fall back to the next available window when moving focus in a direction where no window was found", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "general:resize_on_border", + "description": "enables resizing windows by clicking and dragging on borders and gaps", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "general:extend_border_grab_area", + "description": "extends the area around the border where you can click and drag on, only used when general:resize_on_border is on.", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 15 + } + }, + { + "value": "general:hover_icon_on_border", + "description": "show a cursor icon when hovering over borders, only used when general:resize_on_border is on.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "general:allow_tearing", + "description": "master switch for allowing tearing to occur. See the Tearing page.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "general:resize_corner", + "description": "force floating windows to use a specific corner when being resized (1-4 going clockwise from top left, 0 to disable)", + "type": "INT", + "data": { + "min": 0, + "max": 4, + "default": 0 + } + }, + { + "value": "general:snap:enabled", + "description": "enable snapping for floating windows", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "general:snap:window_gap", + "description": "minimum gap in pixels between windows before snapping", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 10 + } + }, + { + "value": "general:snap:monitor_gap", + "description": "minimum gap in pixels between window and monitor edges before snapping", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 10 + } + }, + { + "value": "general:snap:border_overlap", + "description": "if true, windows snap such that only one border's worth of space is between them", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:rounding", + "description": "rounded corners' radius (in layout px)", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 0 + } + }, + { + "value": "decoration:rounding_power", + "description": "rouding power of corners (2 is a circle)", + "type": "FLOAT", + "data": { + "min": 2, + "max": 10, + "default": 2 + } + }, + { + "value": "decoration:active_opacity", + "description": "opacity of active windows. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "decoration:inactive_opacity", + "description": "opacity of inactive windows. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "decoration:fullscreen_opacity", + "description": "opacity of fullscreen windows. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "decoration:shadow:enabled", + "description": "enable drop shadows on windows", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "decoration:shadow:range", + "description": "Shadow range (size) in layout px", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 4 + } + }, + { + "value": "decoration:shadow:render_power", + "description": "in what power to render the falloff (more power, the faster the falloff) [1 - 4]", + "type": "INT", + "data": { + "min": 1, + "max": 4, + "default": 3 + } + }, + { + "value": "decoration:shadow:sharp", + "description": "whether the shadow should be sharp or not. Akin to an infinitely high render power.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:shadow:ignore_window", + "description": "if true, the shadow will not be rendered behind the window itself, only around it.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "decoration:shadow:color", + "description": "shadow's color. Alpha dictates shadow's opacity.", + "type": "COLOR", + "data": { + "default": "0xee1a1a1a" + } + }, + { + "value": "decoration:shadow:color_inactive", + "description": "inactive shadow color. (if not set, will fall back to col.shadow)", + "type": "COLOR", + "data": {} + }, + { + "value": "decoration:shadow:offset", + "description": "shadow's rendering offset.", + "type": "VECTOR", + "data": { + "default": {}, + "min": [-250, -250], + "max": [250, 250] + } + }, + { + "value": "decoration:shadow:scale", + "description": "shadow's scale. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "decoration:dim_inactive", + "description": "enables dimming of inactive windows", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:dim_strength", + "description": "how much inactive windows should be dimmed [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.5 + } + }, + { + "value": "decoration:dim_special", + "description": "how much to dim the rest of the screen by when a special workspace is open. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.2 + } + }, + { + "value": "decoration:dim_around", + "description": "how much the dimaround window rule should dim by. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.4 + } + }, + { + "value": "decoration:screen_shader", + "description": "screen_shader a path to a custom shader to be applied at the end of rendering. See examples/screenShader.frag for an example.", + "type": "STRING_LONG", + "data": { + "default": "" + } + }, + { + "value": "decoration:blur:enabled", + "description": "enable kawase window background blur", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "decoration:blur:size", + "description": "blur size (distance)", + "type": "INT", + "data": { + "min": 0, + "max": 100, + "default": 8 + } + }, + { + "value": "decoration:blur:passes", + "description": "the amount of passes to perform", + "type": "INT", + "data": { + "min": 0, + "max": 10, + "default": 1 + } + }, + { + "value": "decoration:blur:ignore_opacity", + "description": "make the blur layer ignore the opacity of the window", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "decoration:blur:new_optimizations", + "description": "whether to enable further optimizations to the blur. Recommended to leave on, as it will massively improve performance.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "decoration:blur:xray", + "description": "if enabled, floating windows will ignore tiled windows in their blur. Only available if blur_new_optimizations is true. Will reduce overhead on floating blur significantly.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:blur:noise", + "description": "how much noise to apply. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.0117 + } + }, + { + "value": "decoration:blur:contrast", + "description": "contrast modulation for blur. [0.0 - 2.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 2, + "default": 0.8916 + } + }, + { + "value": "decoration:blur:brightness", + "description": "brightness modulation for blur. [0.0 - 2.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 2, + "default": 0.8172 + } + }, + { + "value": "decoration:blur:vibrancy", + "description": "Increase saturation of blurred colors. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.1696 + } + }, + { + "value": "decoration:blur:vibrancy_darkness", + "description": "How strong the effect of vibrancy is on dark areas . [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0 + } + }, + { + "value": "decoration:blur:special", + "description": "whether to blur behind the special workspace (note: expensive)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:blur:popups", + "description": "whether to blur popups (e.g. right-click menus)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:blur:popups_ignorealpha", + "description": "works like ignorealpha in layer rules. If pixel opacity is below set value, will not blur. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.2 + } + }, + { + "value": "decoration:blur:input_methods", + "description": "whether to blur input methods (e.g. fcitx5)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "decoration:blur:input_methods_ignorealpha", + "description": "works like ignorealpha in layer rules. If pixel opacity is below set value, will not blur. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.2 + } + }, + { + "value": "animations:enabled", + "description": "enable animations", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "animations:first_launch_animation", + "description": "enable first launch animation", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "input:kb_model", + "description": "Appropriate XKB keymap parameter. See the note below.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:kb_layout", + "description": "Appropriate XKB keymap parameter", + "type": "STRING_SHORT", + "data": { + "default": "us" + } + }, + { + "value": "input:kb_variant", + "description": "Appropriate XKB keymap parameter", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:kb_options", + "description": "Appropriate XKB keymap parameter", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:kb_rules", + "description": "Appropriate XKB keymap parameter", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:kb_file", + "description": "Appropriate XKB keymap parameter", + "type": "STRING_LONG", + "data": { + "default": "" + } + }, + { + "value": "input:numlock_by_default", + "description": "Engage numlock by default.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:resolve_binds_by_sym", + "description": "Determines how keybinds act when multiple layouts are used. If false, keybinds will always act as if the first specified layout is active. If true, keybinds specified by symbols are activated when you type the respective symbol with the current layout.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:repeat_rate", + "description": "The repeat rate for held-down keys, in repeats per second.", + "type": "INT", + "data": { + "min": 0, + "max": 200, + "default": 25 + } + }, + { + "value": "input:repeat_delay", + "description": "Delay before a held-down key is repeated, in milliseconds.", + "type": "INT", + "data": { + "min": 0, + "max": 2000, + "default": 600 + } + }, + { + "value": "input:sensitivity", + "description": "Sets the mouse input sensitivity. Value is clamped to the range -1.0 to 1.0.", + "type": "FLOAT", + "data": { + "min": -1, + "max": 1, + "default": 0 + } + }, + { + "value": "input:accel_profile", + "description": "Sets the cursor acceleration profile. Can be one of adaptive, flat. Can also be custom, see below. Leave empty to use libinput's default mode for your input device. [adaptive/flat/custom]", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:force_no_accel", + "description": "Force no cursor acceleration. This bypasses most of your pointer settings to get as raw of a signal as possible. Enabling this is not recommended due to potential cursor desynchronization.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:left_handed", + "description": "Switches RMB and LMB", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:scroll_points", + "description": "Sets the scroll acceleration profile, when accel_profile is set to custom. Has to be in the form . Leave empty to have a flat scroll curve.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:scroll_method", + "description": "Sets the scroll method. Can be one of 2fg (2 fingers), edge, on_button_down, no_scroll. [2fg/edge/on_button_down/no_scroll]", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:scroll_button", + "description": "Sets the scroll button. Has to be an int, cannot be a string. Check wev if you have any doubts regarding the ID. 0 means default.", + "type": "INT", + "data": { + "min": 0, + "max": 300, + "default": 0 + } + }, + { + "value": "input:scroll_button_lock", + "description": "If the scroll button lock is enabled, the button does not need to be held down. Pressing and releasing the button toggles the button lock, which logically holds the button down or releases it. While the button is logically held down, motion events are converted to scroll events.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:scroll_factor", + "description": "Multiplier added to scroll movement for external mice. Note that there is a separate setting for touchpad scroll_factor.", + "type": "FLOAT", + "data": { + "min": 0, + "max": 2, + "default": 1 + } + }, + { + "value": "input:natural_scroll", + "description": "Inverts scrolling direction. When enabled, scrolling moves content directly, rather than manipulating a scrollbar.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:follow_mouse", + "description": "Specify if and how cursor movement should affect window focus. See the note below. [0/1/2/3]", + "type": "INT", + "data": { + "min": 0, + "max": 3, + "default": 1 + } + }, + { + "value": "input:focus_on_close", + "description": "Controls the window focus behavior when a window is closed. When set to 0, focus will shift to the next window candidate. When set to 1, focus will shift to the window under the cursor.", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["next", "cursor"] + } + }, + { + "value": "input:mouse_refocus", + "description": "if disabled, mouse focus won't switch to the hovered window unless the mouse crosses a window boundary when follow_mouse=1.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "input:float_switch_override_focus", + "description": "If enabled (1 or 2), focus will change to the window under the cursor when changing from tiled-to-floating and vice versa. If 2, focus will also follow mouse on float-to-float switches.", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 1 + } + }, + { + "value": "input:special_fallthrough", + "description": "if enabled, having only floating windows in the special workspace will not block focusing windows in the regular workspace.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:off_window_axis_events", + "description": "Handles axis events around (gaps/border for tiled, dragarea/border for floated) a focused window. 0 ignores axis events 1 sends out-of-bound coordinates 2 fakes pointer coordinates to the closest point inside the window 3 warps the cursor to the closest point inside the window", + "type": "INT", + "data": { + "min": 0, + "max": 3, + "default": 1 + } + }, + { + "value": "input:emulate_discrete_scroll", + "description": "Emulates discrete scrolling from high resolution scrolling events. 0 disables it, 1 enables handling of non-standard events only, and 2 force enables all scroll wheel events to be handled", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 1 + } + }, + { + "value": "input:touchpad:disable_while_typing", + "description": "Disable the touchpad while typing.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "input:touchpad:natural_scroll", + "description": "Inverts scrolling direction. When enabled, scrolling moves content directly, rather than manipulating a scrollbar.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:touchpad:scroll_factor", + "description": "Multiplier applied to the amount of scroll movement.", + "type": "FLOAT", + "data": { + "min": 0, + "max": 2, + "default": 1 + } + }, + { + "value": "input:touchpad:middle_button_emulation", + "description": "Sending LMB and RMB simultaneously will be interpreted as a middle click. This disables any touchpad area that would normally send a middle click based on location.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:touchpad:tap_button_map", + "description": "Sets the tap button mapping for touchpad button emulation. Can be one of lrm (default) or lmr (Left, Middle, Right Buttons). [lrm/lmr]", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:touchpad:clickfinger_behavior", + "description": "Button presses with 1, 2, or 3 fingers will be mapped to LMB, RMB, and MMB respectively. This disables interpretation of clicks based on location on the touchpad.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:touchpad:tap-to-click", + "description": "Tapping on the touchpad with 1, 2, or 3 fingers will send LMB, RMB, and MMB respectively.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "input:touchpad:drag_lock", + "description": "When enabled, lifting the finger off for a short time while dragging will not drop the dragged item.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:touchpad:tap-and-drag", + "description": "Sets the tap and drag mode for the touchpad", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:touchdevice:transform", + "description": "Transform the input from touchdevices. The possible transformations are the same as those of the monitors", + "type": "INT", + "data": { + "min": 0, + "max": 6, + "default": 0 + } + }, + { + "value": "input:touchdevice:output", + "description": "The monitor to bind touch devices. The default is auto-detection. To stop auto-detection, use an empty string or the [[Empty]] value.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:touchdevice:enabled", + "description": "Whether input is enabled for touch devices.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "input:tablet:transform", + "description": "transform the input from tablets. The possible transformations are the same as those of the monitors", + "type": "INT", + "data": { + "min": 0, + "max": 6, + "default": 0 + } + }, + { + "value": "input:tablet:output", + "description": "the monitor to bind tablets. Can be current or a monitor name. Leave empty to map across all monitors.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "input:tablet:region_position", + "description": "position of the mapped region in monitor layout relative to the top left corner of the bound monitor or all monitors.", + "type": "VECTOR", + "data": { + "default": {}, + "min": [-20000, -20000], + "max": [20000, 20000] + } + }, + { + "value": "input:tablet:absolute_region_position", + "description": "whether to treat the region_position as an absolute position in monitor layout. Only applies when output is empty.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:tablet:region_size", + "description": "size of the mapped region. When this variable is set, tablet input will be mapped to the region. [0, 0] or invalid size means unset.", + "type": "VECTOR", + "data": { + "default": {}, + "min": [-100, -100], + "max": [4000, 4000] + } + }, + { + "value": "input:tablet:relative_input", + "description": "whether the input should be relative", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:tablet:left_handed", + "description": "if enabled, the tablet will be rotated 180 degrees", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "input:tablet:active_area_size", + "description": "size of tablet's active area in mm", + "type": "VECTOR", + "data": { + "default": {}, + "min": [], + "max": [500, 500] + } + }, + { + "value": "input:tablet:active_area_position", + "description": "position of the active area in mm", + "type": "VECTOR", + "data": { + "default": {}, + "min": [], + "max": [500, 500] + } + }, + { + "value": "gestures:workspace_swipe", + "description": "enable workspace swipe gesture on touchpad", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "gestures:workspace_swipe_fingers", + "description": "how many fingers for the touchpad gesture", + "type": "INT", + "data": { + "min": 0, + "max": 5, + "default": 3 + } + }, + { + "value": "gestures:workspace_swipe_min_fingers", + "description": "if enabled, workspace_swipe_fingers is considered the minimum number of fingers to swipe", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "gestures:workspace_swipe_distance", + "description": "in px, the distance of the touchpad gesture", + "type": "INT", + "data": { + "min": 0, + "max": 2000, + "default": 300 + } + }, + { + "value": "gestures:workspace_swipe_touch", + "description": "enable workspace swiping from the edge of a touchscreen", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "gestures:workspace_swipe_invert", + "description": "invert the direction (touchpad only)", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "gestures:workspace_swipe_touch_invert", + "description": "invert the direction (touchscreen only)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "gestures:workspace_swipe_min_speed_to_force", + "description": "minimum speed in px per timepoint to force the change ignoring cancel_ratio. Setting to 0 will disable this mechanic.", + "type": "INT", + "data": { + "min": 0, + "max": 200, + "default": 30 + } + }, + { + "value": "gestures:workspace_swipe_cancel_ratio", + "description": "how much the swipe has to proceed in order to commence it. (0.7 -> if > 0.7 * distance, switch, if less, revert) [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.5 + } + }, + { + "value": "gestures:workspace_swipe_create_new", + "description": "whether a swipe right on the last workspace should create a new one.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "gestures:workspace_swipe_direction_lock", + "description": "if enabled, switching direction will be locked when you swipe past the direction_lock_threshold (touchpad only).", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "gestures:workspace_swipe_direction_lock_threshold", + "description": "in px, the distance to swipe before direction lock activates (touchpad only).", + "type": "INT", + "data": { + "min": 0, + "max": 200, + "default": 10 + } + }, + { + "value": "gestures:workspace_swipe_forever", + "description": "if enabled, swiping will not clamp at the neighboring workspaces but continue to the further ones.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "gestures:workspace_swipe_use_r", + "description": "if enabled, swiping will use the r prefix instead of the m prefix for finding workspaces.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "group:insert_after_current", + "description": "whether new windows in a group spawn after current or at group tail", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:focus_removed_window", + "description": "whether Hyprland should focus on the window that has just been moved out of the group", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:merge_groups_on_drag", + "description": "whether window groups can be dragged into other groups", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:merge_groups_on_groupbar", + "description": "whether one group will be merged with another when dragged into its groupbar", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "general:col.border_active", + "description": "border color for inactive windows", + "type": "GRADIENT", + "data": { + "default": "0x66ffff00" + } + }, + { + "value": "general:col.border_inactive", + "description": "border color for the active window", + "type": "GRADIENT", + "data": { + "default": "0x66777700" + } + }, + { + "value": "general:col.border_locked_active", + "description": "inactive border color for window that cannot be added to a group (see denywindowfromgroup dispatcher)", + "type": "GRADIENT", + "data": { + "default": "0x66ff5500" + } + }, + { + "value": "general:col.border_locked_inactive", + "description": "active border color for window that cannot be added to a group", + "type": "GRADIENT", + "data": { + "default": "0x66775500" + } + }, + { + "value": "group:auto_group", + "description": "automatically group new windows", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:drag_into_group", + "description": "whether dragging a window into a unlocked group will merge them. Options: 0 (disabled), 1 (enabled), 2 (only when dragging into the groupbar)", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["disabled", "enabled", "only when dragging into the groupbar"] + } + }, + { + "value": "group:merge_floated_into_tiled_on_groupbar", + "description": "whether dragging a floating window into a tiled window groupbar will merge them", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "group:group_on_movetoworkspace", + "description": "whether using movetoworkspace[silent] will merge the window into the workspace's solitary unlocked group", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "group:groupbar:enabled", + "description": "enables groupbars", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:groupbar:font_family", + "description": "font used to display groupbar titles, use misc:font_family if not specified", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "group:groupbar:font_size", + "description": "font size of groupbar title", + "type": "INT", + "data": { + "min": 2, + "max": 64, + "default": 8 + } + }, + { + "value": "group:groupbar:gradients", + "description": "enables gradients", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:groupbar:height", + "description": "height of the groupbar", + "type": "INT", + "data": { + "min": 1, + "max": 64, + "default": 14 + } + }, + { + "value": "group:groupbar:stacked", + "description": "render the groupbar as a vertical stack", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "group:groupbar:priority", + "description": "sets the decoration priority for groupbars", + "type": "INT", + "data": { + "min": 0, + "max": 6, + "default": 3 + } + }, + { + "value": "group:groupbar:render_titles", + "description": "whether to render titles in the group bar decoration", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:groupbar:scrolling", + "description": "whether scrolling in the groupbar changes group active window", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "group:groupbar:text_color", + "description": "controls the group bar text color", + "type": "COLOR", + "data": { + "default": "0xffffffff" + } + }, + { + "value": "group:groupbar:col.active", + "description": "active group border color", + "type": "COLOR", + "data": { + "default": "0x66ffff00" + } + }, + { + "value": "group:groupbar:col.inactive", + "description": "inactive (out of focus) group border color", + "type": "COLOR", + "data": { + "default": "0x66777700" + } + }, + { + "value": "group:groupbar:col.locked_active", + "description": "active locked group border color", + "type": "COLOR", + "data": { + "default": "0x66ff5500" + } + }, + { + "value": "group:groupbar:col.locked_inactive", + "description": "controls the group bar text color", + "type": "COLOR", + "data": { + "default": "0x66775500" + } + }, + { + "value": "misc:disable_hyprland_logo", + "description": "disables the random Hyprland logo / anime girl background. :(", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:disable_splash_rendering", + "description": "disables the Hyprland splash rendering. (requires a monitor reload to take effect)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:col.splash", + "description": "Changes the color of the splash text (requires a monitor reload to take effect).", + "type": "COLOR", + "data": { + "default": "0xffffffff" + } + }, + { + "value": "misc:font_family", + "description": "Set the global default font to render the text including debug fps/notification, config error messages and etc., selected from system fonts.", + "type": "STRING_SHORT", + "data": { + "default": "Sans" + } + }, + { + "value": "misc:splash_font_family", + "description": "Changes the font used to render the splash text, selected from system fonts (requires a monitor reload to take effect).", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "misc:force_default_wallpaper", + "description": "Enforce any of the 3 default wallpapers. Setting this to 0 or 1 disables the anime background. -1 means “random”. [-1/0/1/2]", + "type": "INT", + "data": { + "min": -1, + "max": 2, + "default": -1 + } + }, + { + "value": "misc:vfr", + "description": "controls the VFR status of Hyprland. Heavily recommended to leave enabled to conserve resources.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:vrr", + "description": "controls the VRR (Adaptive Sync) of your monitors. 0 - off, 1 - on, 2 - fullscreen only [0/1/2]", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 0 + } + }, + { + "value": "misc:mouse_move_enables_dpms", + "description": "If DPMS is set to off, wake up the monitors if the mouse move", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:key_press_enables_dpms", + "description": "If DPMS is set to off, wake up the monitors if a key is pressed.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:always_follow_on_dnd", + "description": "Will make mouse focus follow the mouse when drag and dropping. Recommended to leave it enabled, especially for people using focus follows mouse at 0.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:layers_hog_keyboard_focus", + "description": "If true, will make keyboard-interactive layers keep their focus on mouse move (e.g. wofi, bemenu)", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:animate_manual_resizes", + "description": "If true, will animate manual window resizes/moves", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:animate_mouse_windowdragging", + "description": "If true, will animate windows being dragged by mouse, note that this can cause weird behavior on some curves", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:disable_autoreload", + "description": "If true, the config will not reload automatically on save, and instead needs to be reloaded with hyprctl reload. Might save on battery.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:enable_swallow", + "description": "Enable window swallowing", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:swallow_regex", + "description": "The class regex to be used for windows that should be swallowed (usually, a terminal). To know more about the list of regex which can be used use this cheatsheet.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "misc:swallow_exception_regex", + "description": "The title regex to be used for windows that should not be swallowed by the windows specified in swallow_regex (e.g. wev). The regex is matched against the parent (e.g. Kitty) window’s title on the assumption that it changes to whatever process it’s running.", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "misc:focus_on_activate", + "description": "Whether Hyprland should focus an app that requests to be focused (an activate request)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:mouse_move_focuses_monitor", + "description": "Whether mouse moving into a different monitor should focus it", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:render_ahead_of_time", + "description": "[Warning: buggy] starts rendering before your monitor displays a frame in order to lower latency", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:render_ahead_safezone", + "description": "how many ms of safezone to add to rendering ahead of time. Recommended 1-2.", + "type": "INT", + "data": { + "min": 1, + "max": 10, + "default": 1 + } + }, + { + "value": "misc:allow_session_lock_restore", + "description": "if true, will allow you to restart a lockscreen app in case it crashes (red screen of death)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:background_color", + "description": "change the background color. (requires enabled disable_hyprland_logo)", + "type": "COLOR", + "data": { + "default": "0x111111" + } + }, + { + "value": "misc:close_special_on_empty", + "description": "close the special workspace if the last window is removed", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:new_window_takes_over_fullscreen", + "description": "if there is a fullscreen or maximized window, decide whether a new tiled window opened should replace it, stay behind or disable the fullscreen/maximized state. 0 - behind, 1 - takes over, 2 - unfullscreen/unmaxize [0/1/2]", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 0 + } + }, + { + "value": "misc:exit_window_retains_fullscreen", + "description": "if true, closing a fullscreen window makes the next focused window fullscreen", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:initial_workspace_tracking", + "description": "if enabled, windows will open on the workspace they were invoked on. 0 - disabled, 1 - single-shot, 2 - persistent (all children too)", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 1 + } + }, + { + "value": "misc:middle_click_paste", + "description": "whether to enable middle-click-paste (aka primary selection)", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "misc:render_unfocused_fps", + "description": "the maximum limit for renderunfocused windows' fps in the background", + "type": "INT", + "data": { + "min": 1, + "max": 120, + "default": 15 + } + }, + { + "value": "misc:disable_xdg_env_checks", + "description": "disable the warning if XDG environment is externally managed", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:disable_hyprland_qtutils_check", + "description": "disable the warning if hyprland-qtutils is missing", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "misc:lockdead_screen_delay", + "description": "the delay in ms after the lockdead screen appears if the lock screen did not appear after a lock event occurred.", + "type": "INT", + "data": { + "min": 0, + "max": 5000, + "default": 1000 + } + }, + { + "value": "binds:pass_mouse_when_bound", + "description": "if disabled, will not pass the mouse events to apps / dragging windows around if a keybind has been triggered.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:scroll_event_delay", + "description": "in ms, how many ms to wait after a scroll event to allow passing another one for the binds.", + "type": "INT", + "data": { + "min": 0, + "max": 2000, + "default": 300 + } + }, + { + "value": "binds:workspace_back_and_forth", + "description": "If enabled, an attempt to switch to the currently focused workspace will instead switch to the previous workspace. Akin to i3’s auto_back_and_forth.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:allow_workspace_cycles", + "description": "If enabled, workspaces don’t forget their previous workspace, so cycles can be created by switching to the first workspace in a sequence, then endlessly going to the previous workspace.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:workspace_center_on", + "description": "Whether switching workspaces should center the cursor on the workspace (0) or on the last active window for that workspace (1)", + "type": "INT", + "data": { + "min": 0, + "max": 1, + "default": 0 + } + }, + { + "value": "binds:focus_preferred_method", + "description": "sets the preferred focus finding method when using focuswindow/movewindow/etc with a direction. 0 - history (recent have priority), 1 - length (longer shared edges have priority)", + "type": "INT", + "data": { + "min": 0, + "max": 1, + "default": 0 + } + }, + { + "value": "binds:ignore_group_lock", + "description": "If enabled, dispatchers like moveintogroup, moveoutofgroup and movewindoworgroup will ignore lock per group.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:movefocus_cycles_fullscreen", + "description": "If enabled, when on a fullscreen window, movefocus will cycle fullscreen, if not, it will move the focus in a direction.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "binds:movefocus_cycles_groupfirst", + "description": "If enabled, when in a grouped window, movefocus will cycle windows in the groups first, then at each ends of tabs, it'll move on to other windows/groups", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:disable_keybind_grabbing", + "description": "If enabled, apps that request keybinds to be disabled (e.g. VMs) will not be able to do so.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "binds:window_direction_monitor_fallback", + "description": "If enabled, moving a window or focus over the edge of a monitor with a direction will move it to the next monitor in that direction.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "binds:allow_pin_fullscreen", + "description": "Allows fullscreen to pinned windows, and restore their pinned status afterwards", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "xwayland:enabled", + "description": "allow running applications using X11", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "xwayland:use_nearest_neighbor", + "description": "uses the nearest neighbor filtering for xwayland apps, making them pixelated rather than blurry", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "xwayland:force_zero_scaling", + "description": "forces a scale of 1 on xwayland windows on scaled displays.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "opengl:nvidia_anti_flicker", + "description": "reduces flickering on nvidia at the cost of possible frame drops on lower-end GPUs. On non-nvidia, this is ignored.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "opengl:force_introspection", + "description": "forces introspection at all times. Introspection is aimed at reducing GPU usage in certain cases, but might cause graphical glitches on nvidia. 0 - nothing, 1 - force always on, 2 - force always on if nvidia", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 2 + } + }, + { + "value": "render:explicit_sync", + "description": "Whether to enable explicit sync support. Requires a hyprland restart. 0 - no, 1 - yes, 2 - auto based on the gpu driver", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 2 + } + }, + { + "value": "render:explicit_sync_kms", + "description": "Whether to enable explicit sync support for the KMS layer. Requires explicit_sync to be enabled. 0 - no, 1 - yes, 2 - auto based on the gpu driver", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 2 + } + }, + { + "value": "render:direct_scanout", + "description": "Enables direct scanout. Direct scanout attempts to reduce lag when there is only one fullscreen application on a screen (e.g. game). It is also recommended to set this to false if the fullscreen application shows graphical glitches.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "render:expand_undersized_textures", + "description": "Whether to expand textures that have not yet resized to be larger, or to just stretch them instead.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "render:xp_mode", + "description": "Disable back buffer and bottom layer rendering.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "render:ctm_animation", + "description": "Whether to enable a fade animation for CTM changes (hyprsunset). 2 means 'auto' (Yes on everything but Nvidia).", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 2 + } + }, + { + "value": "render:allow_early_buffer_release", + "description": "Allow early buffer release event. Fixes stuttering and missing frames for some apps. May cause graphical glitches and memory leaks in others", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "cursor:use_nearest_neighbor", + "description": "sync xcursor theme with gsettings, it applies cursor-theme and cursor-size on theme load to gsettings making most CSD gtk based clients use same xcursor theme and size.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "cursor:no_hardware_cursors", + "description": "disables hardware cursors", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["Disabled", "Enabled", "Auto"] + } + }, + { + "value": "cursor:no_break_fs_vrr", + "description": "disables scheduling new frames on cursor movement for fullscreen apps with VRR enabled to avoid framerate spikes (requires no_hardware_cursors = true)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "cursor:min_refresh_rate", + "description": "minimum refresh rate for cursor movement when no_break_fs_vrr is active. Set to minimum supported refresh rate or higher", + "type": "INT", + "data": { + "min": 10, + "max": 500, + "default": 24 + } + }, + { + "value": "cursor:hotspot_padding", + "description": "the padding, in logical px, between screen edges and the cursor", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 1 + } + }, + { + "value": "cursor:inactive_timeout", + "description": "in seconds, after how many seconds of cursor’s inactivity to hide it. Set to 0 for never.", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 0 + } + }, + { + "value": "cursor:no_warps", + "description": "if true, will not warp the cursor in many cases (focusing, keybinds, etc)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "cursor:persistent_warps", + "description": "When a window is refocused, the cursor returns to its last position relative to that window, rather than to the centre.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "cursor:warp_on_change_workspace", + "description": "Move the cursor to the last focused window after changing the workspace. Options: 0 (Disabled), 1 (Enabled), 2 (Force - ignores cursor:no_warps option)", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["Disabled", "Enabled", "Force"] + } + }, + { + "value": "cursor:default_monitor", + "description": "the name of a default monitor for the cursor to be set to on startup (see hyprctl monitors for names)", + "type": "STRING_SHORT", + "data": { + "default": "" + } + }, + { + "value": "cursor:zoom_factor", + "description": "the factor to zoom by around the cursor. Like a magnifying glass. Minimum 1.0 (meaning no zoom)", + "type": "FLOAT", + "data": { + "min": 1, + "max": 10, + "default": 1 + } + }, + { + "value": "cursor:zoom_rigid", + "description": "whether the zoom should follow the cursor rigidly (cursor is always centered if it can be) or loosely", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "cursor:enable_hyprcursor", + "description": "whether to enable hyprcursor support", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "cursor:hide_on_key_press", + "description": "Hides the cursor when you press any key until the mouse is moved.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "cursor:hide_on_touch", + "description": "Hides the cursor when the last input was a touch input until a mouse input is done.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "cursor:use_cpu_buffer", + "description": "Makes HW cursors use a CPU buffer. Required on Nvidia to have HW cursors. Experimental", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:overlay", + "description": "print the debug performance overlay. Disable VFR for accurate results.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:damage_blink", + "description": "disable logging to a file", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:disable_logs", + "description": "disable logging to a file", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "debug:disable_time", + "description": "disables time logging", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "debug:damage_tracking", + "description": "redraw only the needed bits of the display. Do not change. (default: full - 2) monitor - 1, none - 0", + "type": "INT", + "data": { + "min": 0, + "max": 2, + "default": 2 + } + }, + { + "value": "debug:enable_stdout_logs", + "description": "enables logging to stdout", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:manual_crash", + "description": "set to 1 and then back to 0 to crash Hyprland.", + "type": "INT", + "data": { + "min": 0, + "max": 1, + "default": 0 + } + }, + { + "value": "debug:suppress_errors", + "description": "if true, do not display config file parsing errors.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:watchdog_timeout", + "description": "sets the timeout in seconds for watchdog to abort processing of a signal of the main thread. Set to 0 to disable.", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 5 + } + }, + { + "value": "debug:disable_scale_checks", + "description": "disables verification of the scale factors. Will result in pixel alignment and rounding errors.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "debug:error_limit", + "description": "limits the number of displayed config file parsing errors.", + "type": "INT", + "data": { + "min": 0, + "max": 20, + "default": 5 + } + }, + { + "value": "debug:error_position", + "description": "sets the position of the error bar. top - 0, bottom - 1", + "type": "INT", + "data": { + "min": 0, + "max": 1, + "default": 0 + } + }, + { + "value": "debug:colored_stdout_logs", + "description": "enables colors in the stdout logs.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "dwindle:pseudotile", + "description": "enable pseudotiling. Pseudotiled windows retain their floating size when tiled.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "dwindle:force_split", + "description": "0 -> split follows mouse, 1 -> always split to the left (new = left or top) 2 -> always split to the right (new = right or bottom)", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["follow mouse", "left or top", "right or bottom"] + } + }, + { + "value": "dwindle:preserve_split", + "description": "if enabled, the split (side/top) will not change regardless of what happens to the container.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "dwindle:smart_split", + "description": "if enabled, allows a more precise control over the window split direction based on the cursor's position. The window is conceptually divided into four triangles, and cursor's triangle determines the split direction. This feature also turns on preserve_split.", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "dwindle:smart_resizing", + "description": "if enabled, resizing direction will be determined by the mouse's position on the window (nearest to which corner). Else, it is based on the window's tiling position.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "dwindle:permanent_direction_override", + "description": "if enabled, makes the preselect direction persist until either this mode is turned off, another direction is specified, or a non-direction is specified (anything other than l,r,u/t,d/b)", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "dwindle:special_scale_factor", + "description": "specifies the scale factor of windows on the special workspace [0 - 1]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "dwindle:split_width_multiplier", + "description": "specifies the auto-split width multiplier", + "type": "FLOAT", + "data": { + "min": 0.1, + "max": 3, + "default": 1 + } + }, + { + "value": "dwindle:use_active_for_splits", + "description": "whether to prefer the active window or the mouse position for splits", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "dwindle:default_split_ratio", + "description": "the default split ratio on window open. 1 means even 50/50 split. [0.1 - 1.9]", + "type": "FLOAT", + "data": { + "min": 0.1, + "max": 1.9, + "default": 1 + } + }, + { + "value": "dwindle:split_bias", + "description": "specifies which window will receive the larger half of a split. positional - 0, current window - 1, opening window - 2 [0/1/2]", + "type": "CHOICE", + "data": { + "default": 0, + "choices": ["positional", "current", "opening"] + } + }, + { + "value": "master:allow_small_split", + "description": "enable adding additional master windows in a horizontal split style", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "master:special_scale_factor", + "description": "the scale of the special workspace windows. [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 1 + } + }, + { + "value": "master:mfact", + "description": "the size as a percentage of the master window, for example `mfact = 0.70` would mean 70% of the screen will be the master window, and 30% the slave [0.0 - 1.0]", + "type": "FLOAT", + "data": { + "min": 0, + "max": 1, + "default": 0.55 + } + }, + { + "value": "master:new_status", + "description": "`master`: new window becomes master; `slave`: new windows are added to slave stack; `inherit`: inherit from focused window", + "type": "STRING_SHORT", + "data": { + "default": "slave" + } + }, + { + "value": "master:new_on_top", + "description": "whether a newly open window should be on the top of the stack", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "master:new_on_active", + "description": "`before`, `after`: place new window relative to the focused window; `none`: place new window according to the value of `new_on_top`. ", + "type": "STRING_SHORT", + "data": { + "default": "none" + } + }, + { + "value": "master:orientation", + "description": "default placement of the master area, can be left, right, top, bottom or center", + "type": "STRING_SHORT", + "data": { + "default": "left" + } + }, + { + "value": "master:inherit_fullscreen", + "description": "inherit fullscreen status when cycling/swapping to another window (e.g. monocle layout)", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "master:slave_count_for_center_master", + "description": "when using orientation=center, make the master window centered only when at least this many slave windows are open. (Set 0 to always_center_master)", + "type": "INT", + "data": { + "min": 0, + "max": 10, + "default": 2 + } + }, + { + "value": "master:center_master_slaves_on_right", + "description": "set if the slaves should appear on right of master when slave_count_for_center_master > 2", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "master:center_ignores_reserved", + "description": "centers the master window on monitor ignoring reserved areas", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "master:smart_resizing", + "description": "if enabled, resizing direction will be determined by the mouse's position on the window (nearest to which corner). Else, it is based on the window's tiling position.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "master:drop_at_cursor", + "description": "when enabled, dragging and dropping windows will put them at the cursor position. Otherwise, when dropped at the stack side, they will go to the top/bottom of the stack depending on new_on_top.", + "type": "BOOL", + "data": { + "default": true + } + }, + { + "value": "experimental:wide_color_gamut", + "description": "force wide color gamut for all supported outputs", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "experimental:hdr", + "description": "force static hdr for all supported outputs", + "type": "BOOL", + "data": { + "default": false + } + }, + { + "value": "experimental:xx_color_management_v4", + "description": "enable color management protocol", + "type": "BOOL", + "data": { + "default": false + } + } + ] +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..b8c0799 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,786 @@ +//! Core application logic for hyprquery. +//! +//! This module contains the main execution flow, including: +//! - Configuration file parsing and validation +//! - Query processing and value resolution +//! - Type and regex filtering +//! - Result formatting and output +//! +//! The [`run`] function serves as the primary entry point for the application. + +use std::{collections::HashSet, fs::read_to_string, path::PathBuf}; + +use clap::Parser; +use hyprlang::{Config, ConfigOptions}; +use masterror::AppResult; + +use crate::{ + cli::Args, + defaults::handle_get_defaults, + error::{config_not_found, config_parse, from_io, schema_not_found, validation_error}, + export::{export_env, export_json, export_plain}, + fetch::{fetch_schema, resolve_schema_path}, + filters::apply_filters, + path::normalize_path, + query::{QueryResult, parse_query_inputs}, + schema::load_schema, + source::parse_sources_recursive, + value::{config_value_to_string, config_value_type_name, hash_string} +}; + +/// Execute the main application logic. +/// +/// Parses command-line arguments, loads configuration files, processes queries, +/// and outputs results in the requested format. +/// +/// # Returns +/// +/// - `Ok(0)` - All queries resolved successfully +/// - `Ok(1)` - One or more queries returned NULL (unless `--allow-missing` is +/// set) +/// - `Err(AppError)` - Fatal error occurred during execution +/// +/// # Errors +/// +/// Returns an error if: +/// - Configuration file not found or cannot be parsed +/// - Schema file not found or invalid +/// - Invalid regex pattern in query +pub fn run() -> AppResult { + let args = Args::parse(); + run_with_args(args) +} + +/// Execute application logic with provided arguments. +/// +/// This is the core implementation that can be tested directly. +/// +/// # Arguments +/// +/// * `args` - Parsed command-line arguments +/// +/// # Returns +/// +/// - `Ok(0)` - All queries resolved successfully +/// - `Ok(1)` - One or more queries returned NULL (unless `--allow-missing` is +/// set) +/// - `Err(AppError)` - Fatal error occurred during execution +pub fn run_with_args(args: Args) -> AppResult { + if args.fetch_schema { + let path = fetch_schema()?; + println!("Schema cached at: {}", path.display()); + return Ok(0); + } + + let config_file = match &args.config_file { + Some(path) => path, + None => return Err(validation_error("Configuration file is required")) + }; + + let config_path = normalize_path(config_file)?; + if !config_path.exists() { + return Err(config_not_found(&config_path.display().to_string())); + } + + let config_dir = match config_path.parent() { + Some(p) => p.to_path_buf(), + None => PathBuf::from(".") + }; + + if args.get_defaults { + return handle_get_defaults(&args); + } + + if args.queries.is_empty() { + return Err(validation_error( + "No queries specified. Use -Q to specify queries" + )); + } + + let queries = parse_query_inputs(&args.queries); + let has_dynamic = queries.iter().any(|q| q.is_dynamic_variable); + + let mut options = ConfigOptions::default(); + if args.source { + options.base_dir = Some(config_dir.clone()); + } + + let mut config = Config::with_options(options); + + if has_dynamic { + let mut content = read_to_string(&config_path).map_err(from_io)?; + + for query in &queries { + if query.is_dynamic_variable { + let dyn_key = format!("Dynamic_{}", hash_string(&query.query)); + let dyn_line = format!("\n{dyn_key}={}\n", query.query); + content.push_str(&dyn_line); + + if args.debug { + eprintln!("[debug] Injecting line: {}", dyn_line.trim()); + } + } + } + + let parse_result = config.parse(&content); + if let Err(e) = parse_result { + if args.debug { + eprintln!("[debug] Parse error: {e}"); + } + if args.strict { + return Err(config_parse(&e.to_string())); + } + } + } else { + let parse_result = config.parse_file(&config_path); + if let Err(e) = parse_result { + if args.debug { + eprintln!("[debug] Parse error: {e}"); + } + if args.strict { + return Err(config_parse(&e.to_string())); + } + } + } + + if let Some(ref schema_path_str) = args.schema { + let schema_path = if schema_path_str == "auto" { + resolve_schema_path(schema_path_str)? + } else { + normalize_path(schema_path_str)? + }; + if !schema_path.exists() { + return Err(schema_not_found(&schema_path.display().to_string())); + } + load_schema(&mut config, &schema_path)?; + } + + if args.source { + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + parse_sources_recursive( + &mut config, + &config_path, + &config_dir, + &mut visited, + args.debug + )?; + } + + let mut results = Vec::with_capacity(queries.len()); + + for query in &queries { + let lookup_key = if query.is_dynamic_variable { + format!("Dynamic_{}", hash_string(&query.query)) + } else { + query.query.clone() + }; + + if args.debug { + eprintln!("[debug] Looking up key: {lookup_key}"); + } + + let (value_str, type_str) = match config.get(&lookup_key) { + Ok(value) => { + let v = config_value_to_string(value); + let t = config_value_type_name(value); + + if query.is_dynamic_variable && v == query.query { + (String::new(), "NULL") + } else { + (v, t) + } + } + Err(_) => { + if query.is_dynamic_variable { + match config.get_variable(&query.query[1..]) { + Some(var_value) => (var_value.to_string(), "STRING"), + None => (String::new(), "NULL") + } + } else { + (String::new(), "NULL") + } + } + }; + + let (final_value, final_type) = apply_filters( + value_str, + type_str, + &query.expected_type, + &query.expected_regex + )?; + + results.push(QueryResult { + key: query.query.clone().into_boxed_str(), + value: final_value.into_boxed_str(), + value_type: final_type + }); + } + + let null_count = results.iter().filter(|r| r.value_type == "NULL").count(); + + match args.export.as_deref() { + Some("json") => export_json(&results), + Some("env") => export_env(&results, &queries), + _ => export_plain(&results, &args.delimiter) + } + + if null_count > 0 && !args.allow_missing { + Ok(1) + } else { + Ok(0) + } +} + +#[cfg(test)] +mod tests { + use std::{fs, io::Write}; + + use super::*; + + fn create_test_config(name: &str, content: &str) -> PathBuf { + let temp_dir = std::env::temp_dir().join("hydequery_app_test"); + let _ = fs::create_dir_all(&temp_dir); + let path = temp_dir.join(name); + let mut file = fs::File::create(&path).unwrap(); + write!(file, "{}", content).unwrap(); + path + } + + fn make_args(config_file: &str, queries: Vec<&str>) -> Args { + Args { + help: false, + config_file: Some(config_file.to_string()), + queries: queries.into_iter().map(String::from).collect(), + schema: None, + fetch_schema: false, + allow_missing: false, + get_defaults: false, + strict: false, + export: None, + source: false, + debug: false, + delimiter: "\n".to_string() + } + } + + #[test] + fn test_run_with_args_theme_variables() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$ICON_THEME = Gruvbox-Plus-Dark +$CURSOR_SIZE = 20 +"#; + let path = create_test_config("theme_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["$GTK_THEME", "$ICON_THEME", "$CURSOR_SIZE"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_general_settings() { + let content = r#" +general { + gaps_in = 3 + gaps_out = 8 + border_size = 2 +} +"#; + let path = create_test_config("general_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["general:gaps_in", "general:border_size"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_missing_variable() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("missing_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$FONT"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_allow_missing() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("allow_missing_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$FONT"]); + args.allow_missing = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_json_export() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("json_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.export = Some("json".to_string()); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_env_export() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$CURSOR_SIZE = 20 +"#; + let path = create_test_config("env_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME", "$CURSOR_SIZE"]); + args.export = Some("env".to_string()); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_config_not_found() { + let args = make_args("/nonexistent/path.conf", vec!["$GTK_THEME"]); + let result = run_with_args(args); + assert!(result.is_err()); + } + + #[test] + fn test_run_with_args_source_directive() { + let temp_dir = std::env::temp_dir().join("hydequery_source_run"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$GTK_THEME = Wallbash-Gtk").unwrap(); + + let main_path = temp_dir.join("main.conf"); + let mut main_file = fs::File::create(&main_path).unwrap(); + writeln!(main_file, "source = {}", theme_path.display()).unwrap(); + + let mut args = make_args(main_path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.source = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(main_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_run_with_args_custom_delimiter() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$ICON_THEME = Gruvbox-Plus-Dark +"#; + let path = create_test_config("delim_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME", "$ICON_THEME"]); + args.delimiter = ",".to_string(); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_strict_mode() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("strict_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.strict = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_debug_mode() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("debug_run.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.debug = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_decoration_settings() { + let content = r#" +decoration { + rounding = 3 + blur { + enabled = yes + size = 4 + } +} +"#; + let path = create_test_config("decoration_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["decoration:rounding"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_color_scheme() { + let content = "$COLOR_SCHEME = prefer-dark"; + let path = create_test_config("color_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$COLOR_SCHEME"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_cursor_theme() { + let content = r#" +$CURSOR_THEME = Bibata-Modern-Ice +$CURSOR_SIZE = 24 +"#; + let path = create_test_config("cursor_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["$CURSOR_THEME", "$CURSOR_SIZE"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_multiple_missing() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("multi_missing_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$FONT", "$SDDM_THEME"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_with_type_filter() { + let content = r#" +general { + border_size = 2 + gaps_in = 3 +} +"#; + let path = create_test_config("type_filter_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["general:border_size[INT]"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_with_regex_filter() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("regex_filter_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["$GTK_THEME[STRING][^Gruvbox.*$]"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_type_mismatch_returns_null() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("type_mismatch_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME[INT]"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_regex_no_match_returns_null() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("regex_nomatch_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["$GTK_THEME[STRING][^Adwaita$]"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_mixed_found_and_missing() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$CURSOR_SIZE = 20 +"#; + let path = create_test_config("mixed_run.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["$GTK_THEME", "$FONT", "$CURSOR_SIZE"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_nested_key_not_found() { + let content = r#" +general { + gaps_in = 3 +} +"#; + let path = create_test_config("nested_missing_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["general:nonexistent"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_empty_config() { + let content = ""; + let path = create_test_config("empty_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_source_with_comment() { + let temp_dir = std::env::temp_dir().join("hydequery_source_comment"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$GTK_THEME = Wallbash-Gtk").unwrap(); + + let main_path = temp_dir.join("main.conf"); + let mut main_file = fs::File::create(&main_path).unwrap(); + writeln!( + main_file, + "source = {} # theme settings", + theme_path.display() + ) + .unwrap(); + + let mut args = make_args(main_path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.source = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(main_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_run_with_args_parent_dir_fallback() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("parent_run.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_get_defaults_no_schema() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("defaults_no_schema.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.get_defaults = true; + + let result = run_with_args(args); + assert!(result.is_err()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_schema_not_found() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("schema_missing.conf", content); + let mut args = make_args(path.to_str().unwrap(), vec!["$GTK_THEME"]); + args.schema = Some("/nonexistent/schema.json".to_string()); + + let result = run_with_args(args); + assert!(result.is_err()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_dynamic_variable_not_resolved() { + let content = ""; + let path = create_test_config("dynamic_unresolved.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$NONEXISTENT_VAR"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_static_key_with_source() { + let temp_dir = std::env::temp_dir().join("hydequery_static_source"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "general {{").unwrap(); + writeln!(theme_file, " border_size = 2").unwrap(); + writeln!(theme_file, "}}").unwrap(); + + let main_path = temp_dir.join("main.conf"); + let mut main_file = fs::File::create(&main_path).unwrap(); + writeln!(main_file, "source = {}", theme_path.display()).unwrap(); + + let mut args = make_args(main_path.to_str().unwrap(), vec!["general:border_size"]); + args.source = true; + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(main_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_run_with_args_multiple_queries_all_found() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$ICON_THEME = Gruvbox-Plus-Dark +$CURSOR_THEME = Bibata-Modern-Ice +$CURSOR_SIZE = 24 +$COLOR_SCHEME = prefer-dark +"#; + let path = create_test_config("all_found.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec![ + "$GTK_THEME", + "$ICON_THEME", + "$CURSOR_THEME", + "$CURSOR_SIZE", + "$COLOR_SCHEME", + ] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_type_filter_float() { + let content = r#" +decoration { + active_opacity = 0.95 +} +"#; + let path = create_test_config("float_type.conf", content); + let args = make_args( + path.to_str().unwrap(), + vec!["decoration:active_opacity[FLOAT]"] + ); + + let result = run_with_args(args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_run_with_args_variable_value_equals_query() { + let content = "$TEST = $TEST"; + let path = create_test_config("var_equals_query.conf", content); + let args = make_args(path.to_str().unwrap(), vec!["$TEST"]); + + let result = run_with_args(args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..a0586d3 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,64 @@ +//! Command-line interface definitions for hyprquery. +//! +//! This module defines the CLI argument structure using the `clap` derive API. +//! All command-line options and flags are documented and validated by clap. + +use clap::Parser; + +/// Command-line arguments for hydequery. +/// +/// Defines all available options, flags, and positional arguments. +/// Uses clap's derive API for automatic parsing and help generation. +#[derive(Parser, Debug, Clone)] +#[command(name = "hyq")] +#[command(version)] +#[command(about = "A configuration parser for hypr* config files")] +#[command(disable_help_flag = true)] +pub struct Args { + /// Show help information + #[arg(short = 'h', long = "help")] + pub help: bool, + /// Configuration file path + #[arg(required = false)] + pub config_file: Option, + + /// Query to execute (format: `query[expectedType][expectedRegex]`) + #[arg(short = 'Q', long = "query", num_args = 1..)] + pub queries: Vec, + + /// Schema file path (use "auto" for cached schema) + #[arg(long)] + pub schema: Option, + + /// Fetch latest schema from repository + #[arg(long)] + pub fetch_schema: bool, + + /// Allow missing values (don't fail with exit code 1) + #[arg(long)] + pub allow_missing: bool, + + /// Get default keys from schema + #[arg(long)] + pub get_defaults: bool, + + /// Enable strict mode validation + #[arg(long)] + pub strict: bool, + + /// Export format: json or env + #[arg(long)] + pub export: Option, + + /// Follow source directives in config files + #[arg(short = 's', long)] + pub source: bool, + + /// Enable debug logging + #[arg(long)] + pub debug: bool, + + /// Delimiter for plain output + #[arg(short = 'D', long, default_value = "\n")] + pub delimiter: String +} diff --git a/src/defaults.rs b/src/defaults.rs new file mode 100644 index 0000000..3d3acd6 --- /dev/null +++ b/src/defaults.rs @@ -0,0 +1,192 @@ +//! Schema defaults output functionality. +//! +//! This module handles the `--get-defaults` flag to output all schema keys. + +use masterror::AppResult; +use serde_json::{Value, to_string_pretty}; + +use crate::{ + cli::Args, error::schema_not_found, fetch::resolve_schema_path, path::normalize_path, + schema::get_schema_keys +}; + +/// Handle the `--get-defaults` flag to output all schema keys. +/// +/// Reads the schema file and outputs all defined configuration keys, +/// either as plain text (one per line) or as JSON array. +/// +/// # Arguments +/// +/// * `args` - Parsed command-line arguments +/// +/// # Returns +/// +/// Always returns `Ok(0)` on success. +/// +/// # Errors +/// +/// Returns an error if schema file is not specified or cannot be read. +pub fn handle_get_defaults(args: &Args) -> AppResult { + let schema_path = match &args.schema { + Some(path) => { + if path == "auto" { + resolve_schema_path(path)? + } else { + normalize_path(path)? + } + } + None => { + return Err(schema_not_found("Schema file required for --get-defaults")); + } + }; + + if !schema_path.exists() { + return Err(schema_not_found(&schema_path.display().to_string())); + } + + let keys = get_schema_keys(&schema_path)?; + + match args.export.as_deref() { + Some("json") => { + let json_keys: Vec = keys.iter().map(|k| Value::String(k.clone())).collect(); + println!("{}", to_string_pretty(&json_keys).unwrap_or_default()); + } + _ => { + for key in keys { + println!("{key}"); + } + } + } + + Ok(0) +} + +#[cfg(test)] +mod tests { + use std::{fs, io::Write, path::PathBuf}; + + use super::*; + + fn create_test_schema(name: &str, content: &str) -> PathBuf { + let temp_dir = std::env::temp_dir().join("hydequery_defaults_test"); + let _ = fs::create_dir_all(&temp_dir); + let path = temp_dir.join(name); + let mut file = fs::File::create(&path).unwrap(); + write!(file, "{}", content).unwrap(); + path + } + + fn make_args_with_schema(schema_path: &str) -> Args { + Args { + help: false, + config_file: Some("/tmp/dummy.conf".to_string()), + queries: vec![], + schema: Some(schema_path.to_string()), + fetch_schema: false, + allow_missing: false, + get_defaults: true, + strict: false, + export: None, + source: false, + debug: false, + delimiter: "\n".to_string() + } + } + + #[test] + fn test_handle_get_defaults_no_schema() { + let args = Args { + help: false, + config_file: Some("/tmp/dummy.conf".to_string()), + queries: vec![], + schema: None, + fetch_schema: false, + allow_missing: false, + get_defaults: true, + strict: false, + export: None, + source: false, + debug: false, + delimiter: "\n".to_string() + }; + + let result = handle_get_defaults(&args); + assert!(result.is_err()); + } + + #[test] + fn test_handle_get_defaults_schema_not_found() { + let args = make_args_with_schema("/nonexistent/schema.json"); + let result = handle_get_defaults(&args); + assert!(result.is_err()); + } + + #[test] + fn test_handle_get_defaults_valid_schema() { + let schema_content = r#"{ + "hyprlang_schema": [ + { "value": "general:border_size", "type": "INT", "data": { "default": 2 } }, + { "value": "general:gaps_in", "type": "INT", "data": { "default": 3 } }, + { "value": "decoration:rounding", "type": "INT", "data": { "default": 3 } } + ] + }"#; + let path = create_test_schema("defaults.json", schema_content); + + let args = make_args_with_schema(path.to_str().unwrap()); + let result = handle_get_defaults(&args); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_handle_get_defaults_json_export() { + let schema_content = r#"{ + "hyprlang_schema": [ + { "value": "general:border_size", "type": "INT", "data": { "default": 2 } } + ] + }"#; + let path = create_test_schema("defaults_json.json", schema_content); + + let mut args = make_args_with_schema(path.to_str().unwrap()); + args.export = Some("json".to_string()); + + let result = handle_get_defaults(&args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_handle_get_defaults_multiple_keys() { + let schema_content = r#"{ + "hyprlang_schema": [ + { "value": "general:border_size", "type": "INT", "data": { "default": 2 } }, + { "value": "general:gaps_in", "type": "INT", "data": { "default": 3 } }, + { "value": "general:gaps_out", "type": "INT", "data": { "default": 8 } }, + { "value": "decoration:rounding", "type": "INT", "data": { "default": 3 } }, + { "value": "decoration:active_opacity", "type": "FLOAT", "data": { "default": 1.0 } } + ] + }"#; + let path = create_test_schema("defaults_multi.json", schema_content); + + let args = make_args_with_schema(path.to_str().unwrap()); + let result = handle_get_defaults(&args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } + + #[test] + fn test_handle_get_defaults_empty_schema() { + let schema_content = r#"{ "hyprlang_schema": [] }"#; + let path = create_test_schema("defaults_empty.json", schema_content); + + let args = make_args_with_schema(path.to_str().unwrap()); + let result = handle_get_defaults(&args); + assert!(result.is_ok()); + + let _ = fs::remove_file(path); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..93cd851 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,176 @@ +//! Error types and handling for hyprquery. +//! +//! This module provides error handling using the `masterror` crate's +//! builder pattern API. All errors are categorized by kind and include +//! descriptive messages for debugging. +//! +//! # Error Kinds +//! +//! - `NotFound` - Configuration or schema file not found +//! - `BadRequest` - Invalid input (parse errors, invalid queries) +//! - `Internal` - IO and other internal errors + +use masterror::prelude::*; + +/// Create a "configuration file not found" error. +/// +/// # Arguments +/// +/// * `path` - Path to the missing configuration file +pub fn config_not_found(path: &str) -> AppError { + AppError::not_found(format!("Configuration file not found: {path}")) +} + +/// Create a "schema file not found" error. +/// +/// # Arguments +/// +/// * `path` - Path to the missing schema file +pub fn schema_not_found(path: &str) -> AppError { + AppError::not_found(format!("Schema file not found: {path}")) +} + +/// Create a configuration parse error. +/// +/// # Arguments +/// +/// * `msg` - Parse error message +pub fn config_parse(msg: &str) -> AppError { + AppError::bad_request(format!("Failed to parse configuration: {msg}")) +} + +/// Create a schema parse error. +/// +/// # Arguments +/// +/// * `msg` - Parse error message +pub fn schema_parse(msg: &str) -> AppError { + AppError::bad_request(format!("Failed to parse schema: {msg}")) +} + +/// Create an invalid query error. +/// +/// # Arguments +/// +/// * `msg` - Error message describing the invalid query +pub fn invalid_query(msg: &str) -> AppError { + AppError::bad_request(format!("Invalid query format: {msg}")) +} + +/// Create an IO error. +/// +/// # Arguments +/// +/// * `msg` - IO error message +pub fn io_error(msg: &str) -> AppError { + AppError::internal(format!("IO error: {msg}")) +} + +/// Create a path resolution error. +/// +/// # Arguments +/// +/// * `msg` - Path resolution error message +pub fn path_resolution(msg: &str) -> AppError { + AppError::bad_request(format!("Path resolution error: {msg}")) +} + +/// Convert std::io::Error to AppError. +pub fn from_io(err: std::io::Error) -> AppError { + io_error(&err.to_string()) +} + +/// Convert serde_json::Error to AppError. +pub fn from_json(err: serde_json::Error) -> AppError { + schema_parse(&err.to_string()) +} + +/// Convert glob::PatternError to AppError. +pub fn from_glob(err: glob::PatternError) -> AppError { + path_resolution(&err.to_string()) +} + +/// Convert regex::Error to AppError. +pub fn from_regex(err: regex::Error) -> AppError { + invalid_query(&err.to_string()) +} + +/// Create a validation error for missing or invalid arguments. +/// +/// # Arguments +/// +/// * `msg` - Validation error message +pub fn validation_error(msg: &str) -> AppError { + AppError::bad_request(msg.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_not_found() { + let err = config_not_found("/test/path"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_schema_not_found() { + let err = schema_not_found("/schema/path"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_config_parse() { + let err = config_parse("syntax error"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_invalid_query() { + let err = invalid_query("bad format"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_io_error() { + let err = io_error("read failed"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_path_resolution() { + let err = path_resolution("invalid path"); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn test_from_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let app_err = from_io(io_err); + assert!(!app_err.to_string().is_empty()); + } + + #[test] + fn test_from_glob_error() { + let glob_err = glob::Pattern::new("[").unwrap_err(); + let app_err = from_glob(glob_err); + assert!(!app_err.to_string().is_empty()); + } + + #[test] + fn test_from_regex_error() { + let pattern = String::from("["); + let regex_err = regex::Regex::new(&pattern).unwrap_err(); + let app_err = from_regex(regex_err); + assert!(!app_err.to_string().is_empty()); + } + + #[test] + fn test_validation_error() { + let err = validation_error("missing argument"); + assert!(!err.to_string().is_empty()); + assert!(err.message.is_some()); + assert_eq!(err.message.as_deref(), Some("missing argument")); + } +} diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..8a94260 --- /dev/null +++ b/src/export.rs @@ -0,0 +1,239 @@ +//! Output formatting and export functions for hyprquery. +//! +//! This module provides functions to format query results in various formats: +//! - Plain text with configurable delimiter +//! - JSON (single object or array) +//! - Environment variable assignments +//! +//! All output is written to stdout. + +use serde_json::{Value, json}; + +use crate::query::{QueryInput, QueryResult}; + +/// Export results as JSON to stdout. +/// +/// Outputs a single JSON object for single results, or an array for multiple. +/// NULL values are represented as JSON `null`. +/// +/// # Arguments +/// +/// * `results` - Query results to export +pub fn export_json(results: &[QueryResult]) { + let json_results: Vec = results + .iter() + .map(|r| { + json!({ + "key": &*r.key, + "value": if r.value_type == "NULL" { Value::Null } else { Value::String(r.value.to_string()) }, + "type": r.value_type + }) + }) + .collect(); + + if results.len() == 1 { + println!( + "{}", + serde_json::to_string_pretty(&json_results[0]).unwrap_or_default() + ); + } else { + println!( + "{}", + serde_json::to_string_pretty(&json_results).unwrap_or_default() + ); + } +} + +/// Export results as shell environment variable assignments. +/// +/// Converts query keys to valid variable names by: +/// - Removing leading `$` from dynamic variables +/// - Replacing `:` with `_` +/// - Converting to uppercase +/// +/// NULL values are skipped (no output). +/// +/// # Arguments +/// +/// * `results` - Query results to export +/// * `queries` - Original query inputs for variable naming +pub fn export_env(results: &[QueryResult], queries: &[QueryInput]) { + for (i, result) in results.iter().enumerate() { + let var_name = if i < queries.len() { + let name = &queries[i].query; + let name = name.strip_prefix('$').unwrap_or(name); + name.replace(':', "_").to_uppercase() + } else { + result.key.replace(':', "_").to_uppercase() + }; + + if result.value_type != "NULL" { + println!("{}=\"{}\"", var_name, result.value); + } + } +} + +/// Export results as plain text with configurable delimiter. +/// +/// Each value is output separated by the specified delimiter. +/// NULL values are represented as empty strings. +/// +/// # Arguments +/// +/// * `results` - Query results to export +/// * `delimiter` - String to insert between values (default: newline) +pub fn export_plain(results: &[QueryResult], delimiter: &str) { + let output: Vec<&str> = results + .iter() + .map(|r| { + if r.value_type == "NULL" { + "" + } else { + &*r.value + } + }) + .collect(); + + println!("{}", output.join(delimiter)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_result(key: &str, value: &str, value_type: &'static str) -> QueryResult { + QueryResult { + key: key.to_string().into_boxed_str(), + value: value.to_string().into_boxed_str(), + value_type + } + } + + fn make_query(query: &str, is_dynamic: bool) -> QueryInput { + QueryInput { + query: query.to_string(), + expected_type: None, + expected_regex: None, + is_dynamic_variable: is_dynamic + } + } + + #[test] + fn test_export_json_single_border_size() { + let results = vec![make_result("general:border_size", "2", "INT")]; + export_json(&results); + } + + #[test] + fn test_export_json_multiple_theme_vars() { + let results = vec![ + make_result("$GTK_THEME", "Gruvbox-Retro", "STRING"), + make_result("$ICON_THEME", "Gruvbox-Plus-Dark", "STRING"), + make_result("$CURSOR_SIZE", "20", "INT"), + ]; + export_json(&results); + } + + #[test] + fn test_export_json_null_missing_var() { + let results = vec![make_result("$FONT", "", "NULL")]; + export_json(&results); + } + + #[test] + fn test_export_json_hyprland_settings() { + let results = vec![ + make_result("general:gaps_in", "3", "INT"), + make_result("general:gaps_out", "8", "INT"), + make_result("decoration:rounding", "3", "INT"), + ]; + export_json(&results); + } + + #[test] + fn test_export_env_gtk_theme() { + let results = vec![make_result("$GTK_THEME", "Gruvbox-Retro", "STRING")]; + let queries = vec![make_query("$GTK_THEME", true)]; + export_env(&results, &queries); + } + + #[test] + fn test_export_env_cursor_settings() { + let results = vec![ + make_result("$CURSOR_THEME", "Gruvbox-Retro", "STRING"), + make_result("$CURSOR_SIZE", "20", "INT"), + ]; + let queries = vec![ + make_query("$CURSOR_THEME", true), + make_query("$CURSOR_SIZE", true), + ]; + export_env(&results, &queries); + } + + #[test] + fn test_export_env_nested_general_settings() { + let results = vec![make_result("general:border_size", "2", "INT")]; + let queries = vec![make_query("general:border_size", false)]; + export_env(&results, &queries); + } + + #[test] + fn test_export_env_null_font_skipped() { + let results = vec![make_result("$FONT", "", "NULL")]; + let queries = vec![make_query("$FONT", true)]; + export_env(&results, &queries); + } + + #[test] + fn test_export_env_color_scheme() { + let results = vec![make_result("$COLOR_SCHEME", "prefer-dark", "STRING")]; + let queries = vec![make_query("$COLOR_SCHEME", true)]; + export_env(&results, &queries); + } + + #[test] + fn test_export_env_fallback_to_result_key() { + let results = vec![ + make_result("$GTK_THEME", "Gruvbox-Retro", "STRING"), + make_result("general:layout", "dwindle", "STRING"), + ]; + let queries = vec![make_query("$GTK_THEME", true)]; + export_env(&results, &queries); + } + + #[test] + fn test_export_plain_single_theme() { + let results = vec![make_result("$GTK_THEME", "Gruvbox-Retro", "STRING")]; + export_plain(&results, "\n"); + } + + #[test] + fn test_export_plain_multiple_gaps() { + let results = vec![ + make_result("general:gaps_in", "3", "INT"), + make_result("general:gaps_out", "8", "INT"), + ]; + export_plain(&results, ","); + } + + #[test] + fn test_export_plain_with_missing_sddm() { + let results = vec![ + make_result("$GTK_THEME", "Gruvbox-Retro", "STRING"), + make_result("$SDDM_THEME", "", "NULL"), + ]; + export_plain(&results, "\n"); + } + + #[test] + fn test_export_plain_all_theme_vars() { + let results = vec![ + make_result("$GTK_THEME", "Gruvbox-Retro", "STRING"), + make_result("$ICON_THEME", "Gruvbox-Plus-Dark", "STRING"), + make_result("$CURSOR_THEME", "Gruvbox-Retro", "STRING"), + make_result("$CURSOR_SIZE", "20", "INT"), + make_result("$COLOR_SCHEME", "prefer-dark", "STRING"), + ]; + export_plain(&results, "\n"); + } +} diff --git a/src/fetch.rs b/src/fetch.rs new file mode 100644 index 0000000..2d5f622 --- /dev/null +++ b/src/fetch.rs @@ -0,0 +1,262 @@ +//! Schema fetching and caching functionality. +//! +//! This module handles downloading the Hyprland schema from the repository +//! and caching it locally for offline use. +//! +//! # Cache Location +//! +//! The schema is cached in `~/.cache/hyprquery/hyprland.json`. +//! +//! # Example +//! +//! ```no_run +//! use hyprquery::fetch; +//! +//! fn main() -> Result<(), Box> { +//! // Fetch and cache the schema +//! fetch::fetch_schema()?; +//! +//! // Get the cached schema path +//! let path = fetch::get_cached_schema_path()?; +//! Ok(()) +//! } +//! ``` + +use std::{fs, io::Write, path::PathBuf}; + +use masterror::prelude::*; + +/// URL for the Hyprland schema in the HyDE-Project repository. +const SCHEMA_URL: &str = + "https://raw.githubusercontent.com/HyDE-Project/hyprquery/main/schema/hyprland.json"; + +/// Name of the cached schema file. +const SCHEMA_FILENAME: &str = "hyprland.json"; + +/// Application cache directory name. +const CACHE_DIR_NAME: &str = "hyprquery"; + +/// Returns the path to the cache directory. +/// +/// Creates the directory if it doesn't exist. +/// +/// # Returns +/// +/// Path to `~/.cache/hyprquery/` +/// +/// # Errors +/// +/// Returns an error if the cache directory cannot be determined or created. +pub fn get_cache_dir() -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| AppError::internal("Failed to determine cache directory"))? + .join(CACHE_DIR_NAME); + + if !cache_dir.exists() { + fs::create_dir_all(&cache_dir) + .map_err(|e| AppError::internal(format!("Failed to create cache directory: {e}")))?; + } + + Ok(cache_dir) +} + +/// Returns the path to the cached schema file. +/// +/// # Returns +/// +/// Path to `~/.cache/hyprquery/hyprland.json` +/// +/// # Errors +/// +/// Returns an error if the cache directory cannot be determined. +pub fn get_cached_schema_path() -> Result { + Ok(get_cache_dir()?.join(SCHEMA_FILENAME)) +} + +/// Checks if a cached schema exists. +/// +/// # Returns +/// +/// `true` if the cached schema file exists, `false` otherwise. +pub fn has_cached_schema() -> bool { + get_cached_schema_path() + .map(|p| p.exists()) + .unwrap_or(false) +} + +/// Fetches the schema from the repository and caches it locally. +/// +/// Downloads the Hyprland schema from the HyDE-Project repository +/// and saves it to the cache directory. +/// +/// # Returns +/// +/// Path to the cached schema file. +/// +/// # Errors +/// +/// Returns an error if: +/// - The network request fails +/// - The response cannot be read +/// - The file cannot be written +/// +/// # Example +/// +/// ```no_run +/// use hyprquery::fetch; +/// +/// fn main() -> Result<(), Box> { +/// let path = fetch::fetch_schema()?; +/// println!("Schema cached at: {}", path.display()); +/// Ok(()) +/// } +/// ``` +pub fn fetch_schema() -> Result { + let body = ureq::get(SCHEMA_URL) + .call() + .map_err(|e| AppError::internal(format!("Failed to fetch schema: {e}")))? + .body_mut() + .read_to_string() + .map_err(|e| AppError::internal(format!("Failed to read schema response: {e}")))?; + + let cache_path = get_cached_schema_path()?; + + let mut file = fs::File::create(&cache_path) + .map_err(|e| AppError::internal(format!("Failed to create schema file: {e}")))?; + + file.write_all(body.as_bytes()) + .map_err(|e| AppError::internal(format!("Failed to write schema file: {e}")))?; + + Ok(cache_path) +} + +/// Resolves the schema path from user input. +/// +/// Handles the special "auto" value by returning the cached schema path. +/// For any other value, returns the path as-is after normalization. +/// +/// # Arguments +/// +/// * `schema` - The schema path from CLI ("auto" or a file path) +/// +/// # Returns +/// +/// The resolved schema file path. +/// +/// # Errors +/// +/// Returns an error if: +/// - "auto" is specified but no cached schema exists +/// - The cache directory cannot be determined +pub fn resolve_schema_path(schema: &str) -> Result { + if schema == "auto" { + if !has_cached_schema() { + return Err(AppError::not_found( + "No cached schema found. Run with --fetch-schema first" + )); + } + get_cached_schema_path() + } else { + Ok(PathBuf::from(schema)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_cache_dir() { + let result = get_cache_dir(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().contains("hyprquery")); + } + + #[test] + fn test_get_cached_schema_path() { + let result = get_cached_schema_path(); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().ends_with("hyprland.json")); + } + + #[test] + fn test_resolve_schema_path_custom() { + let result = resolve_schema_path("/custom/path/schema.json"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("/custom/path/schema.json")); + } + + #[test] + fn test_resolve_schema_path_auto_no_cache() { + if !has_cached_schema() { + let result = resolve_schema_path("auto"); + assert!(result.is_err()); + } + } + + #[test] + fn test_has_cached_schema() { + let _result = has_cached_schema(); + } + + #[test] + fn test_resolve_schema_path_auto_with_cache() { + if has_cached_schema() { + let result = resolve_schema_path("auto"); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.to_string_lossy().ends_with("hyprland.json")); + } + } + + #[test] + fn test_resolve_schema_path_relative() { + let result = resolve_schema_path("./schema.json"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("./schema.json")); + } + + #[test] + fn test_resolve_schema_path_home() { + let result = resolve_schema_path("~/schema.json"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("~/schema.json")); + } + + #[test] + fn test_cache_dir_contains_correct_name() { + let cache_dir = get_cache_dir().unwrap(); + assert!(cache_dir.ends_with(CACHE_DIR_NAME)); + } + + #[test] + fn test_schema_filename_constant() { + assert_eq!(SCHEMA_FILENAME, "hyprland.json"); + } + + #[test] + fn test_cache_dir_name_constant() { + assert_eq!(CACHE_DIR_NAME, "hyprquery"); + } + + #[test] + fn test_schema_url_constant() { + assert!(SCHEMA_URL.starts_with("https://")); + assert!(SCHEMA_URL.contains("hyprland.json")); + } + + #[test] + fn test_get_cache_dir_creates_directory() { + let cache_dir = get_cache_dir().unwrap(); + assert!(cache_dir.exists() || !cache_dir.exists()); + } + + #[test] + fn test_cached_schema_path_is_file_path() { + let path = get_cached_schema_path().unwrap(); + assert!(path.file_name().is_some()); + assert_eq!(path.file_name().unwrap(), SCHEMA_FILENAME); + } +} diff --git a/src/filters.rs b/src/filters.rs new file mode 100644 index 0000000..ace032b --- /dev/null +++ b/src/filters.rs @@ -0,0 +1,186 @@ +//! Value filtering for query results. +//! +//! This module provides type and regex filtering for configuration values. + +use masterror::AppError; +use regex::Regex; + +use crate::{error::from_regex, query::normalize_type}; + +/// Apply type and regex filters to a configuration value. +/// +/// Validates that the value matches the expected type and regex pattern. +/// If validation fails, returns an empty string with NULL type. +/// +/// # Arguments +/// +/// * `value` - The resolved configuration value +/// * `type_str` - The actual type of the value (INT, FLOAT, STRING, etc.) +/// * `expected_type` - Optional expected type to match against +/// * `expected_regex` - Optional regex pattern the value must match +/// +/// # Returns +/// +/// A tuple of (filtered_value, type_str). If filters don't match, +/// returns ("", "NULL"). +/// +/// # Errors +/// +/// Returns an error if the regex pattern is invalid. +pub fn apply_filters( + value: String, + type_str: &'static str, + expected_type: &Option, + expected_regex: &Option +) -> Result<(String, &'static str), AppError> { + if let Some(exp_type) = expected_type + && normalize_type(type_str) != normalize_type(exp_type) + { + return Ok((String::new(), "NULL")); + } + + if let Some(pattern) = expected_regex { + let rx = Regex::new(pattern).map_err(from_regex)?; + if !rx.is_match(&value) { + return Ok((String::new(), "NULL")); + } + } + + Ok((value, type_str)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_filters_no_filters() { + let result = apply_filters("Gruvbox-Retro".to_string(), "STRING", &None, &None); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, "Gruvbox-Retro"); + assert_eq!(type_str, "STRING"); + } + + #[test] + fn test_apply_filters_type_match_int() { + let result = apply_filters("2".to_string(), "INT", &Some("INT".to_string()), &None); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, "2"); + assert_eq!(type_str, "INT"); + } + + #[test] + fn test_apply_filters_type_mismatch() { + let result = apply_filters( + "Gruvbox-Retro".to_string(), + "STRING", + &Some("INT".to_string()), + &None + ); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, ""); + assert_eq!(type_str, "NULL"); + } + + #[test] + fn test_apply_filters_regex_match_cursor_size() { + let result = apply_filters("20".to_string(), "INT", &None, &Some(r"^\d+$".to_string())); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, "20"); + assert_eq!(type_str, "INT"); + } + + #[test] + fn test_apply_filters_regex_no_match() { + let result = apply_filters( + "prefer-dark".to_string(), + "STRING", + &None, + &Some(r"^prefer-light$".to_string()) + ); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, ""); + assert_eq!(type_str, "NULL"); + } + + #[test] + fn test_apply_filters_type_and_regex_match() { + let result = apply_filters( + "3".to_string(), + "INT", + &Some("INT".to_string()), + &Some(r"^[0-9]+$".to_string()) + ); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, "3"); + assert_eq!(type_str, "INT"); + } + + #[test] + fn test_apply_filters_invalid_regex() { + let result = apply_filters("value".to_string(), "STRING", &None, &Some("[".to_string())); + assert!(result.is_err()); + } + + #[test] + fn test_apply_filters_border_size() { + let result = apply_filters( + "2".to_string(), + "INT", + &Some("INT".to_string()), + &Some(r"^[1-5]$".to_string()) + ); + assert!(result.is_ok()); + let (value, _) = result.unwrap(); + assert_eq!(value, "2"); + } + + #[test] + fn test_apply_filters_gaps_value() { + let result = apply_filters("8".to_string(), "INT", &Some("int".to_string()), &None); + assert!(result.is_ok()); + let (value, type_str) = result.unwrap(); + assert_eq!(value, "8"); + assert_eq!(type_str, "INT"); + } + + #[test] + fn test_apply_filters_theme_name_regex() { + let result = apply_filters( + "Gruvbox-Plus-Dark".to_string(), + "STRING", + &None, + &Some(r"^Gruvbox.*$".to_string()) + ); + assert!(result.is_ok()); + let (value, _) = result.unwrap(); + assert_eq!(value, "Gruvbox-Plus-Dark"); + } + + #[test] + fn test_apply_filters_color_scheme() { + let result = apply_filters( + "prefer-dark".to_string(), + "STRING", + &Some("STRING".to_string()), + &Some(r"^prefer-(dark|light)$".to_string()) + ); + assert!(result.is_ok()); + let (value, _) = result.unwrap(); + assert_eq!(value, "prefer-dark"); + } + + #[test] + fn test_apply_filters_rounding_value() { + let result = apply_filters("3".to_string(), "INT", &None, &Some(r"^\d$".to_string())); + assert!(result.is_ok()); + let (value, _) = result.unwrap(); + assert_eq!(value, "3"); + } +} diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..44740d8 --- /dev/null +++ b/src/help.rs @@ -0,0 +1,260 @@ +//! Custom colorful help output for hyprquery. +//! +//! This module provides a beautifully formatted, colored help display +//! with clear explanations, examples, and usage patterns. + +/// ANSI color codes for terminal output. +mod colors { + pub const RESET: &str = "\x1b[0m"; + pub const BOLD: &str = "\x1b[1m"; + pub const DIM: &str = "\x1b[2m"; + + pub const RED: &str = "\x1b[31m"; + pub const GREEN: &str = "\x1b[32m"; + pub const YELLOW: &str = "\x1b[33m"; + pub const BLUE: &str = "\x1b[34m"; + pub const MAGENTA: &str = "\x1b[35m"; + pub const CYAN: &str = "\x1b[36m"; + pub const WHITE: &str = "\x1b[37m"; +} + +use colors::*; + +/// Print the complete help message with colors and formatting. +pub fn print_help() { + print_header(); + print_usage(); + print_arguments(); + print_options(); + print_query_format(); + print_examples(); + print_exit_codes(); + print_footer(); +} + +/// Print the application header with logo. +fn print_header() { + println!( + r#" +{CYAN}{BOLD}╔════════════════════════════════════════════════════════════════════════════════╗ +║ ║ +║ {MAGENTA}██╗ ██╗██╗ ██╗██████╗ ██████╗ ██████╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗{CYAN} ║ +║ {MAGENTA}██║ ██║╚██╗ ██╔╝██╔══██╗██╔══██╗██╔═══██╗██║ ██║██╔════╝██╔══██╗╚██╗ ██╔╝{CYAN} ║ +║ {MAGENTA}███████║ ╚████╔╝ ██████╔╝██████╔╝██║ ██║██║ ██║█████╗ ██████╔╝ ╚████╔╝ {CYAN} ║ +║ {MAGENTA}██╔══██║ ╚██╔╝ ██╔═══╝ ██╔══██╗██║▄▄ ██║██║ ██║██╔══╝ ██╔══██╗ ╚██╔╝ {CYAN} ║ +║ {MAGENTA}██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝╚██████╔╝███████╗██║ ██║ ██║ {CYAN} ║ +║ {MAGENTA}╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══▀▀═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ {CYAN} ║ +║ ║ +╚════════════════════════════════════════════════════════════════════════════════╝{RESET} + +{WHITE}{BOLD}Hyprquery{RESET} - {DIM}High-performance configuration parser for Hyprland{RESET} +"# + ); +} + +/// Print usage section. +fn print_usage() { + println!( + "{YELLOW}{BOLD}USAGE:{RESET} + {GREEN}hyq{RESET} {CYAN}{RESET} {MAGENTA}-Q{RESET} {BLUE}{RESET} [{DIM}OPTIONS{RESET}] + {GREEN}hyq{RESET} {CYAN}{RESET} {MAGENTA}-Q{RESET} {BLUE}{RESET} {MAGENTA}-Q{RESET} {BLUE}{RESET} ... + {GREEN}hyq{RESET} {CYAN}{RESET} {MAGENTA}--get-defaults{RESET} {MAGENTA}--schema{RESET} {BLUE}{RESET} + {GREEN}hyq{RESET} {MAGENTA}--fetch-schema{RESET} +" + ); +} + +/// Print arguments section. +fn print_arguments() { + println!( + "{YELLOW}{BOLD}ARGUMENTS:{RESET} + {CYAN}{RESET} Path to Hyprland configuration file + {DIM}Supports: ~, $HOME, environment variables{RESET} +" + ); +} + +/// Print options section. +fn print_options() { + println!( + "{YELLOW}{BOLD}OPTIONS:{RESET} + {GREEN}-Q, --query{RESET} {BLUE}{RESET} Query to execute {DIM}(required, multiple allowed){RESET} + Format: {CYAN}key[type][regex]{RESET} + + {GREEN}--schema{RESET} {BLUE}{RESET} Load schema file {DIM}(use \"{CYAN}auto{DIM}\" for cached){RESET} + {GREEN}--fetch-schema{RESET} Download and cache latest schema + {GREEN}--get-defaults{RESET} Output all keys from schema + {GREEN}--allow-missing{RESET} Don't fail on NULL values {DIM}(exit 0){RESET} + {GREEN}--strict{RESET} Fail on config parse errors + {GREEN}--export{RESET} {BLUE}{RESET} Output format: {CYAN}json{RESET}, {CYAN}env{RESET} + {GREEN}-s, --source{RESET} Follow source directives recursively + {GREEN}-D, --delimiter{RESET} {BLUE}{RESET} Delimiter for plain output {DIM}(default: \\n){RESET} + {GREEN}--debug{RESET} Enable debug logging to stderr + + {GREEN}-h, --help{RESET} Show this help message + {GREEN}-V, --version{RESET} Show version information +" + ); +} + +/// Print query format explanation. +fn print_query_format() { + println!( + "{YELLOW}{BOLD}QUERY FORMAT:{RESET} + {CYAN}key{RESET} Simple key lookup + {CYAN}key{RESET}{DIM}[type]{RESET} With type filter + {CYAN}key{RESET}{DIM}[type][regex]{RESET} With type and regex filter + {CYAN}$variable{RESET} Dynamic variable lookup + + {WHITE}{BOLD}Types:{RESET} {BLUE}INT{RESET}, {BLUE}FLOAT{RESET}, {BLUE}STRING{RESET}, {BLUE}VEC2{RESET}, {BLUE}COLOR{RESET}, {BLUE}BOOL{RESET} + + {WHITE}{BOLD}Key Syntax:{RESET} + {DIM}•{RESET} Nested keys use {CYAN}:{RESET} separator: {GREEN}general:border_size{RESET} + {DIM}•{RESET} Categories: {GREEN}general{RESET}, {GREEN}decoration{RESET}, {GREEN}input{RESET}, {GREEN}animations{RESET}, etc. +" + ); +} + +/// Print examples section. +fn print_examples() { + println!( + "{YELLOW}{BOLD}EXAMPLES:{RESET} + + {WHITE}Basic query:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} ~/.config/hypr/hyprland.conf {MAGENTA}-Q{RESET} {CYAN}'general:border_size'{RESET} + {BLUE}2{RESET} + + {WHITE}Query variable:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'$terminal'{RESET} + {BLUE}kitty{RESET} + + {WHITE}Multiple queries:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'general:gaps_in'{RESET} {MAGENTA}-Q{RESET} {CYAN}'general:gaps_out'{RESET} + {BLUE}5{RESET} + {BLUE}10{RESET} + + {WHITE}With type filter:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'general:border_size[INT]'{RESET} + {BLUE}2{RESET} + + {WHITE}With regex filter:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'decoration:rounding[INT][^[0-9]+$]'{RESET} + {BLUE}8{RESET} + + {WHITE}JSON export:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'general:border_size'{RESET} {MAGENTA}--export{RESET} {CYAN}json{RESET} + {BLUE}{{ + \"key\": \"general:border_size\", + \"value\": \"2\", + \"type\": \"INT\" + }}{RESET} + + {WHITE}Environment variables:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'$terminal'{RESET} {MAGENTA}--export{RESET} {CYAN}env{RESET} + {BLUE}TERMINAL=\"kitty\"{RESET} + + {WHITE}Fetch and cache schema:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} {MAGENTA}--fetch-schema{RESET} + {BLUE}Schema cached at: ~/.cache/hyprquery/hyprland.json{RESET} + + {WHITE}Use cached schema:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'general:layout'{RESET} {MAGENTA}--schema{RESET} {CYAN}auto{RESET} + + {WHITE}With custom schema:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'general:layout'{RESET} {MAGENTA}--schema{RESET} {CYAN}hyprland.json{RESET} + + {WHITE}Follow source directives:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'colors:background'{RESET} {MAGENTA}-s{RESET} + + {WHITE}Custom delimiter:{RESET} + {DIM}${RESET} {GREEN}hyq{RESET} config.conf {MAGENTA}-Q{RESET} {CYAN}'a'{RESET} {MAGENTA}-Q{RESET} {CYAN}'b'{RESET} {MAGENTA}-D{RESET} {CYAN}','{RESET} + {BLUE}val1,val2{RESET} +" + ); +} + +/// Print exit codes section. +fn print_exit_codes() { + println!( + "{YELLOW}{BOLD}EXIT CODES:{RESET} + {GREEN}0{RESET} All queries resolved successfully + {YELLOW}1{RESET} One or more queries returned NULL + {RED}1{RESET} Error occurred (config not found, parse error, etc.) +" + ); +} + +/// Print footer with additional info. +fn print_footer() { + println!( + "{DIM}───────────────────────────────────────────────────────────────{RESET} +{WHITE}Repository:{RESET} {CYAN}https://github.com/HyDE-Project/hyprquery{RESET} +{WHITE}License:{RESET} {CYAN}GPL-3.0{RESET} +{DIM}───────────────────────────────────────────────────────────────{RESET} +" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_print_help_no_panic() { + print_help(); + } + + #[test] + fn test_print_header_no_panic() { + print_header(); + } + + #[test] + fn test_print_usage_no_panic() { + print_usage(); + } + + #[test] + fn test_print_arguments_no_panic() { + print_arguments(); + } + + #[test] + fn test_print_options_no_panic() { + print_options(); + } + + #[test] + fn test_print_query_format_no_panic() { + print_query_format(); + } + + #[test] + fn test_print_examples_no_panic() { + print_examples(); + } + + #[test] + fn test_print_exit_codes_no_panic() { + print_exit_codes(); + } + + #[test] + fn test_print_footer_no_panic() { + print_footer(); + } + + #[test] + fn test_color_constants() { + assert_eq!(colors::RESET, "\x1b[0m"); + assert_eq!(colors::BOLD, "\x1b[1m"); + assert_eq!(colors::DIM, "\x1b[2m"); + assert_eq!(colors::RED, "\x1b[31m"); + assert_eq!(colors::GREEN, "\x1b[32m"); + assert_eq!(colors::YELLOW, "\x1b[33m"); + assert_eq!(colors::BLUE, "\x1b[34m"); + assert_eq!(colors::MAGENTA, "\x1b[35m"); + assert_eq!(colors::CYAN, "\x1b[36m"); + assert_eq!(colors::WHITE, "\x1b[37m"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..73f55a7 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +//! Hydequery library crate for benchmarking and testing. + +pub mod app; +pub mod cli; +pub mod defaults; +pub mod error; +pub mod export; +pub mod fetch; +pub mod filters; +pub mod path; +pub mod query; +pub mod schema; +pub mod source; +pub mod value; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a62e485 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,123 @@ +//! # Hyprquery +//! +//! A high-performance command-line utility for querying configuration values +//! from Hyprland configuration files. +//! +//! ## Features +//! +//! - Query nested configuration values using dot notation +//! - Support for dynamic variables (`$var`) +//! - Type and regex filtering with `query[type][regex]` syntax +//! - Multiple export formats: plain text, JSON, environment variables +//! - Recursive source directive following +//! - Schema-based default value support +//! +//! ## Usage +//! +//! ```bash +//! hyq /path/to/config.conf -Q 'general:border_size' +//! hyq /path/to/config.conf -Q '$terminal' --export json +//! hyq /path/to/config.conf -Q 'gaps[INT][^\d+$]' --strict +//! ``` + +mod app; +mod cli; +mod defaults; +mod error; +mod export; +mod fetch; +mod filters; +mod help; +mod path; +mod query; +mod schema; +mod source; +mod value; + +use std::{env::args, process::exit}; + +use crate::{app::run, help::print_help}; + +/// Check if help flag is present in arguments. +fn has_help_flag(args: &[String]) -> bool { + args.iter().any(|a| a == "-h" || a == "--help") +} + +/// Main entry point logic without process::exit. +/// +/// Returns exit code: 0 for success, 1 for error. +fn run_main(args: &[String]) -> i32 { + if has_help_flag(args) { + print_help(); + return 0; + } + + match run() { + Ok(code) => code, + Err(e) => { + if let Some(msg) = &e.message { + eprintln!("Error: {msg}"); + } else { + eprintln!("Error: {e}"); + } + 1 + } + } +} + +fn main() { + let args: Vec = args().collect(); + exit(run_main(&args)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_help_flag_short() { + let args = vec!["hyq".to_string(), "-h".to_string()]; + assert!(has_help_flag(&args)); + } + + #[test] + fn test_has_help_flag_long() { + let args = vec!["hyq".to_string(), "--help".to_string()]; + assert!(has_help_flag(&args)); + } + + #[test] + fn test_has_help_flag_none() { + let args = vec![ + "hyq".to_string(), + "config.conf".to_string(), + "-Q".to_string(), + "$GTK_THEME".to_string(), + ]; + assert!(!has_help_flag(&args)); + } + + #[test] + fn test_has_help_flag_among_args() { + let args = vec![ + "hyq".to_string(), + "config.conf".to_string(), + "-h".to_string(), + ]; + assert!(has_help_flag(&args)); + } + + #[test] + fn test_run_main_with_help() { + let args = vec!["hyq".to_string(), "-h".to_string()]; + let code = run_main(&args); + assert_eq!(code, 0); + } + + #[test] + fn test_run_main_with_long_help() { + let args = vec!["hyq".to_string(), "--help".to_string()]; + let code = run_main(&args); + assert_eq!(code, 0); + } +} diff --git a/src/path.rs b/src/path.rs new file mode 100644 index 0000000..8458d02 --- /dev/null +++ b/src/path.rs @@ -0,0 +1,174 @@ +//! Path normalization and glob pattern resolution. +//! +//! This module handles path manipulation for configuration files: +//! - Tilde (`~`) expansion to home directory +//! - Environment variable expansion +//! - Relative to absolute path conversion +//! - Glob pattern matching for source directives + +use std::path::{Path, PathBuf}; + +use masterror::AppError; + +use crate::error; + +/// Normalize and expand a file path +/// +/// Handles: +/// - Environment variable expansion +/// - Tilde expansion for home directory +/// - Relative to absolute path conversion +/// - Path canonicalization +/// +/// # Arguments +/// +/// * `path` - Path string to normalize +/// +/// # Returns +/// +/// Normalized path as PathBuf +/// +/// # Errors +/// +/// Returns error if path cannot be resolved +pub fn normalize_path(path: &str) -> Result { + let mut processed = path.to_string(); + + if (processed.starts_with('"') && processed.ends_with('"')) + || (processed.starts_with('\'') && processed.ends_with('\'')) + { + processed = processed[1..processed.len() - 1].to_string(); + } + + let expanded = shellexpand::full(&processed) + .map_err(|e| error::path_resolution(&e.to_string()))? + .to_string(); + + let path_buf = PathBuf::from(&expanded); + + let absolute = if path_buf.is_relative() { + std::env::current_dir() + .map_err(error::from_io)? + .join(&path_buf) + } else { + path_buf + }; + + if absolute.exists() { + absolute + .canonicalize() + .map_err(|e| error::path_resolution(&e.to_string())) + } else { + Ok(absolute) + } +} + +/// Resolve paths with glob pattern support +/// +/// # Arguments +/// +/// * `pattern` - Path pattern possibly containing glob wildcards +/// * `base_dir` - Base directory for relative paths +/// +/// # Returns +/// +/// Vector of resolved paths +/// +/// # Errors +/// +/// Returns error if glob pattern is invalid +pub fn resolve_glob(pattern: &str, base_dir: &Path) -> Result, AppError> { + let expanded = shellexpand::full(pattern) + .map_err(|e| error::path_resolution(&e.to_string()))? + .to_string(); + + let full_pattern = if expanded.starts_with('/') || expanded.starts_with('~') { + expanded + } else { + base_dir.join(&expanded).display().to_string() + }; + + let paths: Vec = glob::glob(&full_pattern) + .map_err(error::from_glob)? + .filter_map(Result::ok) + .collect(); + + if paths.is_empty() { + let fallback = PathBuf::from(&full_pattern); + if fallback.parent().map(|p| p.exists()).unwrap_or(false) { + return Ok(vec![fallback]); + } + } + + Ok(paths) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn test_normalize_absolute_path() { + let result = normalize_path("/tmp"); + assert!(result.is_ok()); + assert!(result.unwrap().is_absolute()); + } + + #[test] + fn test_normalize_relative_path() { + let result = normalize_path("."); + assert!(result.is_ok()); + assert!(result.unwrap().is_absolute()); + } + + #[test] + fn test_normalize_quoted_path() { + let result = normalize_path("\"/tmp\""); + assert!(result.is_ok()); + } + + #[test] + fn test_normalize_single_quoted_path() { + let result = normalize_path("'/tmp'"); + assert!(result.is_ok()); + } + + #[test] + fn test_normalize_tilde_path() { + let result = normalize_path("~"); + assert!(result.is_ok()); + let path = result.unwrap(); + assert!(path.is_absolute()); + assert!(!path.to_string_lossy().contains('~')); + } + + #[test] + fn test_resolve_glob_no_match() { + let result = resolve_glob("/nonexistent/*.conf", Path::new("/tmp")); + assert!(result.is_ok()); + } + + #[test] + fn test_resolve_glob_with_files() { + let temp_dir = std::env::temp_dir().join("hydequery_test"); + let _ = fs::create_dir_all(&temp_dir); + let test_file = temp_dir.join("test.conf"); + let _ = fs::write(&test_file, "test"); + + let result = resolve_glob("*.conf", &temp_dir); + assert!(result.is_ok()); + let paths = result.unwrap(); + assert!(!paths.is_empty()); + + let _ = fs::remove_file(test_file); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_resolve_glob_absolute_pattern() { + let result = resolve_glob("/tmp/*.nonexistent", Path::new("/")); + assert!(result.is_ok()); + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..a012ed2 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,191 @@ +//! Query parsing and result types for hyprquery. +//! +//! This module handles the parsing of query strings in the format: +//! `key[expectedType][expectedRegex]` +//! +//! Query components: +//! - `key` - The configuration key to look up (e.g., `general:border_size`) +//! - `expectedType` - Optional type filter (INT, FLOAT, STRING, etc.) +//! - `expectedRegex` - Optional regex pattern the value must match + +/// Parsed query input with optional type and regex hints. +/// +/// Represents a single query after parsing the `key[type][regex]` format. +#[derive(Debug, Clone)] +pub struct QueryInput { + /// The query key to look up + pub query: String, + /// Expected type hint (INT, FLOAT, STRING, etc.) + pub expected_type: Option, + /// Expected value regex pattern + pub expected_regex: Option, + /// Whether this query is for a dynamic variable ($var) + pub is_dynamic_variable: bool +} + +/// Result of a configuration query. +/// +/// Contains the resolved value and its type information. +/// Uses `Box` for memory efficiency since values are not modified after +/// creation. +#[derive(Debug, Clone)] +pub struct QueryResult { + /// The original query key + pub key: Box, + /// The resolved value as string (empty for NULL) + pub value: Box, + /// The type of the value (INT, FLOAT, STRING, VEC2, COLOR, CUSTOM, NULL) + pub value_type: &'static str +} + +/// Parse raw query strings into QueryInput structs +/// +/// Query format: `query[expectedType][expectedRegex]` +/// +/// # Arguments +/// +/// * `raw_queries` - Vector of raw query strings +/// +/// # Returns +/// +/// Vector of parsed QueryInput structs +/// +/// # Examples +/// +/// ``` +/// use hyprquery::query::parse_query_inputs; +/// +/// let queries = parse_query_inputs(&["general:border_size".to_string()]); +/// assert_eq!(queries[0].query, "general:border_size"); +/// ``` +pub fn parse_query_inputs(raw_queries: &[String]) -> Vec { + raw_queries + .iter() + .map(|raw| { + let first_bracket = raw.find('['); + + if first_bracket.is_none() { + return QueryInput { + is_dynamic_variable: raw.starts_with('$'), + query: raw.clone(), + expected_type: None, + expected_regex: None + }; + } + + let first_bracket = first_bracket.unwrap_or(raw.len()); + let query = raw[..first_bracket].to_string(); + let is_dynamic_variable = query.starts_with('$'); + + let second_bracket = raw[first_bracket..].find(']').map(|i| i + first_bracket); + + let expected_type = second_bracket.and_then(|end| { + let type_str = raw[first_bracket + 1..end].to_string(); + if type_str.is_empty() { + None + } else { + Some(type_str) + } + }); + + let expected_regex = if let Some(second_end) = second_bracket { + let remaining = &raw[second_end + 1..]; + if let Some(third_start) = remaining.find('[') { + if let Some(third_end) = remaining[third_start..].find(']') { + let regex_str = + remaining[third_start + 1..third_start + third_end].to_string(); + if regex_str.is_empty() { + None + } else { + Some(regex_str) + } + } else { + None + } + } else { + None + } + } else { + None + }; + + QueryInput { + query, + expected_type, + expected_regex, + is_dynamic_variable + } + }) + .collect() +} + +/// Normalize type string to uppercase for comparison +pub fn normalize_type(type_str: &str) -> String { + type_str.to_uppercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_query() { + let queries = parse_query_inputs(&["general:border_size".to_string()]); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].query, "general:border_size"); + assert!(queries[0].expected_type.is_none()); + assert!(queries[0].expected_regex.is_none()); + assert!(!queries[0].is_dynamic_variable); + } + + #[test] + fn test_parse_query_with_type() { + let queries = parse_query_inputs(&["general:border_size[INT]".to_string()]); + assert_eq!(queries[0].query, "general:border_size"); + assert_eq!(queries[0].expected_type.as_deref(), Some("INT")); + assert!(queries[0].expected_regex.is_none()); + } + + #[test] + fn test_parse_query_with_type_and_regex() { + let queries = parse_query_inputs(&["general:border_size[INT][^\\d+$]".to_string()]); + assert_eq!(queries[0].query, "general:border_size"); + assert_eq!(queries[0].expected_type.as_deref(), Some("INT")); + assert_eq!(queries[0].expected_regex.as_deref(), Some("^\\d+$")); + } + + #[test] + fn test_parse_dynamic_variable() { + let queries = parse_query_inputs(&["$terminal".to_string()]); + assert!(queries[0].is_dynamic_variable); + assert_eq!(queries[0].query, "$terminal"); + } + + #[test] + fn test_parse_empty_type_bracket() { + let queries = parse_query_inputs(&["key[][regex]".to_string()]); + assert_eq!(queries[0].query, "key"); + assert!(queries[0].expected_type.is_none()); + assert_eq!(queries[0].expected_regex.as_deref(), Some("regex")); + } + + #[test] + fn test_parse_multiple_queries() { + let queries = parse_query_inputs(&[ + "key1".to_string(), + "key2[STRING]".to_string(), + "$var".to_string() + ]); + assert_eq!(queries.len(), 3); + assert_eq!(queries[0].query, "key1"); + assert_eq!(queries[1].expected_type.as_deref(), Some("STRING")); + assert!(queries[2].is_dynamic_variable); + } + + #[test] + fn test_normalize_type() { + assert_eq!(normalize_type("int"), "INT"); + assert_eq!(normalize_type("STRING"), "STRING"); + assert_eq!(normalize_type("Float"), "FLOAT"); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..69b043d --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,274 @@ +//! Schema loading and default value registration. +//! +//! This module handles JSON schema files that define configuration defaults. +//! Schema format follows the Hyprland schema specification with: +//! - Configuration key paths +//! - Value types (INT, FLOAT, STRING, VECTOR, etc.) +//! - Default values + +use std::{fs::File, io::BufReader, path::Path}; + +use hyprlang::{Config, ConfigValue, Vec2}; +use masterror::AppError; +use serde::Deserialize; +use serde_json::Value; + +use crate::error; + +/// Schema option data containing the default value. +#[derive(Debug, Deserialize)] +pub struct SchemaData { + /// Default value for the option (JSON value) + pub default: Option +} + +/// Single schema option definition. +/// +/// Represents one configuration key with its type and default value. +#[derive(Debug, Deserialize)] +pub struct SchemaOption { + /// Configuration key path (e.g., `general:border_size`) + pub value: String, + /// Type of the value (INT, FLOAT, STRING, VECTOR, BOOL, etc.) + #[serde(rename = "type")] + pub option_type: String, + /// Data containing default value + pub data: SchemaData +} + +/// Root schema structure. +/// +/// Top-level container for all schema options, matching the +/// Hyprland schema JSON format. +#[derive(Debug, Deserialize)] +pub struct Schema { + /// List of all schema options + pub hyprlang_schema: Vec +} + +/// Load schema from JSON file and register config values +/// +/// # Arguments +/// +/// * `config` - Configuration instance to add values to +/// * `schema_path` - Path to the schema JSON file +/// +/// # Returns +/// +/// Result indicating success or error +/// +/// # Errors +/// +/// Returns error if file cannot be read or parsed +pub fn load_schema(config: &mut Config, schema_path: &Path) -> Result<(), AppError> { + let file = File::open(schema_path) + .map_err(|_| error::schema_not_found(&schema_path.display().to_string()))?; + + let reader = BufReader::new(file); + let schema: Schema = serde_json::from_reader(reader).map_err(error::from_json)?; + + for option in schema.hyprlang_schema { + if let Some(default) = option.data.default { + let config_value = match option.option_type.as_str() { + "INT" => default.as_i64().map(ConfigValue::Int), + "BOOL" => default + .as_bool() + .map(|v| ConfigValue::Int(if v { 1 } else { 0 })), + "FLOAT" => default.as_f64().map(ConfigValue::Float), + "STRING_SHORT" | "STRING_LONG" | "GRADIENT" | "COLOR" => { + default.as_str().map(|s| ConfigValue::String(s.to_string())) + } + "VECTOR" => { + if let Some(arr) = default.as_array() { + if arr.len() == 2 { + match (arr[0].as_f64(), arr[1].as_f64()) { + (Some(x), Some(y)) => Some(ConfigValue::Vec2(Vec2 { + x, + y + })), + _ => None + } + } else { + None + } + } else { + None + } + } + _ => None + }; + + if let Some(value) = config_value { + config.set(&option.value, value); + } + } + } + + Ok(()) +} + +/// Get all schema keys from JSON file +/// +/// # Arguments +/// +/// * `schema_path` - Path to the schema JSON file +/// +/// # Returns +/// +/// Vector of all configuration keys in the schema +/// +/// # Errors +/// +/// Returns error if file cannot be read or parsed +pub fn get_schema_keys(schema_path: &Path) -> Result, AppError> { + let file = File::open(schema_path) + .map_err(|_| error::schema_not_found(&schema_path.display().to_string()))?; + + let reader = BufReader::new(file); + let schema: Schema = serde_json::from_reader(reader).map_err(error::from_json)?; + + let keys = schema + .hyprlang_schema + .into_iter() + .map(|option| option.value) + .collect(); + + Ok(keys) +} + +#[cfg(test)] +mod tests { + use std::{fs, io::Write}; + + use super::*; + + fn create_test_schema() -> (std::path::PathBuf, String) { + let temp_dir = std::env::temp_dir(); + let schema_path = temp_dir.join("test_schema.json"); + let schema_content = r#"{ + "hyprlang_schema": [ + { + "value": "general:border_size", + "type": "INT", + "data": { "default": 2 } + }, + { + "value": "decoration:rounding", + "type": "FLOAT", + "data": { "default": 8.0 } + }, + { + "value": "general:layout", + "type": "STRING_SHORT", + "data": { "default": "dwindle" } + } + ] + }"#; + (schema_path, schema_content.to_string()) + } + + #[test] + fn test_load_schema() { + let (schema_path, content) = create_test_schema(); + let mut file = fs::File::create(&schema_path).unwrap(); + file.write_all(content.as_bytes()).unwrap(); + + let mut config = Config::default(); + let result = load_schema(&mut config, &schema_path); + assert!(result.is_ok()); + + let _ = fs::remove_file(schema_path); + } + + #[test] + fn test_get_schema_keys() { + let temp_dir = std::env::temp_dir(); + let schema_path = temp_dir.join("test_get_keys_schema.json"); + let schema_content = r#"{ + "hyprlang_schema": [ + { + "value": "general:border_size", + "type": "INT", + "data": { "default": 2 } + }, + { + "value": "decoration:rounding", + "type": "FLOAT", + "data": { "default": 8.0 } + }, + { + "value": "general:layout", + "type": "STRING_SHORT", + "data": { "default": "dwindle" } + } + ] + }"#; + + let mut file = fs::File::create(&schema_path).unwrap(); + file.write_all(schema_content.as_bytes()).unwrap(); + + let result = get_schema_keys(&schema_path); + assert!(result.is_ok()); + let keys = result.unwrap(); + assert_eq!(keys.len(), 3); + assert!(keys.contains(&"general:border_size".to_string())); + assert!(keys.contains(&"decoration:rounding".to_string())); + assert!(keys.contains(&"general:layout".to_string())); + + let _ = fs::remove_file(schema_path); + } + + #[test] + fn test_schema_not_found() { + let result = get_schema_keys(Path::new("/nonexistent/schema.json")); + assert!(result.is_err()); + } + + #[test] + fn test_schema_parse_vector() { + let temp_dir = std::env::temp_dir(); + let schema_path = temp_dir.join("test_vector_schema.json"); + let schema_content = r#"{ + "hyprlang_schema": [ + { + "value": "general:gaps", + "type": "VECTOR", + "data": { "default": [5.0, 10.0] } + } + ] + }"#; + + let mut file = fs::File::create(&schema_path).unwrap(); + file.write_all(schema_content.as_bytes()).unwrap(); + + let mut config = Config::default(); + let result = load_schema(&mut config, &schema_path); + assert!(result.is_ok()); + + let _ = fs::remove_file(schema_path); + } + + #[test] + fn test_schema_parse_bool() { + let temp_dir = std::env::temp_dir(); + let schema_path = temp_dir.join("test_bool_schema.json"); + let schema_content = r#"{ + "hyprlang_schema": [ + { + "value": "decoration:blur:enabled", + "type": "BOOL", + "data": { "default": true } + } + ] + }"#; + + let mut file = fs::File::create(&schema_path).unwrap(); + file.write_all(schema_content.as_bytes()).unwrap(); + + let mut config = Config::default(); + let result = load_schema(&mut config, &schema_path); + assert!(result.is_ok()); + + let _ = fs::remove_file(schema_path); + } +} diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..dd69303 --- /dev/null +++ b/src/source.rs @@ -0,0 +1,336 @@ +//! Source directive parsing for configuration file includes. +//! +//! This module handles the recursive parsing of `source = path` directives +//! in Hyprland configuration files. Features include: +//! - Glob pattern support for source paths +//! - Cycle detection to prevent infinite loops +//! - Recursive directory traversal + +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf} +}; + +use hyprlang::Config; +use masterror::AppError; + +use crate::path::resolve_glob; + +/// Recursively parse source directives from configuration files. +/// +/// Scans the configuration file for `source = path` directives and +/// parses referenced files into the configuration. Supports glob patterns +/// and prevents infinite loops through cycle detection. +/// +/// # Arguments +/// +/// * `config` - Configuration instance to populate +/// * `config_path` - Path to the current configuration file +/// * `base_dir` - Base directory for resolving relative paths +/// * `visited` - Set of already-visited paths for cycle detection +/// * `debug` - Enable debug output to stderr +/// +/// # Errors +/// +/// Returns an error if a source file cannot be read or path resolution fails. +pub fn parse_sources_recursive( + config: &mut Config, + config_path: &Path, + base_dir: &Path, + visited: &mut HashSet, + debug: bool +) -> Result<(), AppError> { + let content = fs::read_to_string(config_path).map_err(crate::error::from_io)?; + + for line in content.lines() { + let trimmed = line.trim(); + + if !trimmed.starts_with("source") { + continue; + } + + let Some(eq_pos) = trimmed.find('=') else { + continue; + }; + + let path_part = trimmed[eq_pos + 1..].trim(); + let path_part = path_part.split('#').next().unwrap_or("").trim(); + + if path_part.is_empty() { + continue; + } + + let paths = match resolve_glob(path_part, base_dir) { + Ok(p) => p, + Err(e) => { + if debug { + eprintln!("[debug] Failed to resolve: {} - {}", path_part, e); + } + continue; + } + }; + + for source_path in paths { + if !source_path.exists() || !source_path.is_file() { + continue; + } + + let canonical = match source_path.canonicalize() { + Ok(p) => p, + Err(_) => source_path.clone() + }; + + if visited.contains(&canonical) { + if debug { + eprintln!("[debug] Skipping already visited: {}", canonical.display()); + } + continue; + } + + visited.insert(canonical.clone()); + + if debug { + eprintln!("[debug] Parsing source: {}", source_path.display()); + } + + let _ = config.parse_file(&source_path); + + let source_dir = match source_path.parent() { + Some(p) => p.to_path_buf(), + None => base_dir.to_path_buf() + }; + + parse_sources_recursive(config, &source_path, &source_dir, visited, debug)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use super::*; + + #[test] + fn test_parse_sources_theme_conf_style() { + let temp_dir = std::env::temp_dir().join("hydequery_theme_test"); + let _ = fs::create_dir_all(&temp_dir); + let config_path = temp_dir.join("theme.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "$GTK_THEME = Gruvbox-Retro").unwrap(); + writeln!(file, "$ICON_THEME = Gruvbox-Plus-Dark").unwrap(); + writeln!(file, "$CURSOR_SIZE = 20").unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_with_inline_comments() { + let temp_dir = std::env::temp_dir().join("hydequery_comment_test"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$GTK_THEME = Gruvbox-Retro").unwrap(); + + let config_path = temp_dir.join("hyprland.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!( + file, + "source = {} # theme specific settings", + theme_path.display() + ) + .unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_cycle_detection() { + let temp_dir = std::env::temp_dir().join("hydequery_cycle_test"); + let _ = fs::create_dir_all(&temp_dir); + let config_path = temp_dir.join("cycle.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = {}", config_path.display()).unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + let canonical = config_path.canonicalize().unwrap_or(config_path.clone()); + visited.insert(canonical); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_recursive_chain() { + let temp_dir = std::env::temp_dir().join("hydequery_chain_test"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$GTK_THEME = Gruvbox-Retro").unwrap(); + writeln!(theme_file, "general {{").unwrap(); + writeln!(theme_file, " border_size = 2").unwrap(); + writeln!(theme_file, "}}").unwrap(); + + let dynamic_path = temp_dir.join("dynamic.conf"); + let mut dynamic_file = fs::File::create(&dynamic_path).unwrap(); + writeln!(dynamic_file, "source = {}", theme_path.display()).unwrap(); + + let config_path = temp_dir.join("hyprland.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = {}", dynamic_path.display()).unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(dynamic_path); + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_nonexistent_file() { + let temp_dir = std::env::temp_dir().join("hydequery_nonexistent_test"); + let _ = fs::create_dir_all(&temp_dir); + let config_path = temp_dir.join("main.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = /nonexistent/path/theme.conf").unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_empty_source_after_comment_strip() { + let temp_dir = std::env::temp_dir().join("hydequery_empty_source_test"); + let _ = fs::create_dir_all(&temp_dir); + let config_path = temp_dir.join("test.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = # only comment").unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_relative_path() { + let temp_dir = std::env::temp_dir().join("hydequery_relative_test"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$CURSOR_THEME = Bibata-Modern-Ice").unwrap(); + + let config_path = temp_dir.join("hyprland.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = ./theme.conf").unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_debug_output() { + let temp_dir = std::env::temp_dir().join("hydequery_debug_test"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$COLOR_SCHEME = prefer-dark").unwrap(); + + let config_path = temp_dir.join("main.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source = {}", theme_path.display()).unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, true); + assert!(result.is_ok()); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } + + #[test] + fn test_parse_sources_no_equals_sign() { + let temp_dir = std::env::temp_dir().join("hydequery_no_equals_test"); + let _ = fs::create_dir_all(&temp_dir); + let config_path = temp_dir.join("test.conf"); + let mut file = fs::File::create(&config_path).unwrap(); + writeln!(file, "source theme.conf").unwrap(); + + let mut config = Config::default(); + let mut visited = HashSet::new(); + visited.insert(config_path.clone()); + + let result = + parse_sources_recursive(&mut config, &config_path, &temp_dir, &mut visited, false); + assert!(result.is_ok()); + + let _ = fs::remove_file(config_path); + let _ = fs::remove_dir(temp_dir); + } +} diff --git a/src/value.rs b/src/value.rs new file mode 100644 index 0000000..a2d3479 --- /dev/null +++ b/src/value.rs @@ -0,0 +1,230 @@ +//! Configuration value conversion and utility functions. +//! +//! This module provides helper functions for working with hyprlang ConfigValue: +//! - String conversion for all value types +//! - Type name extraction +//! - Hashing for dynamic variable key generation + +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher} +}; + +/// Hash a string to create a unique identifier. +/// +/// Used for generating unique keys for dynamic variable lookups. +/// Uses the default hasher for consistent results within a single run. +/// +/// # Arguments +/// +/// * `s` - String to hash +/// +/// # Returns +/// +/// A 64-bit hash value. +pub fn hash_string(s: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + +/// Convert a ConfigValue to its string representation. +/// +/// Handles all hyprlang value types: +/// - `Int` - Decimal string +/// - `Float` - Decimal string with fractional part +/// - `String` - The string value itself +/// - `Vec2` - Format: `x, y` +/// - `Color` - Format: `rgba(r, g, b, a)` +/// - `Custom` - Returns `"custom"` +/// +/// # Arguments +/// +/// * `value` - The ConfigValue to convert +/// +/// # Returns +/// +/// String representation of the value. +pub fn config_value_to_string(value: &hyprlang::ConfigValue) -> String { + match value { + hyprlang::ConfigValue::Int(i) => i.to_string(), + hyprlang::ConfigValue::Float(f) => f.to_string(), + hyprlang::ConfigValue::String(s) => s.clone(), + hyprlang::ConfigValue::Vec2(v) => format!("{}, {}", v.x, v.y), + hyprlang::ConfigValue::Color(c) => format!("rgba({}, {}, {}, {})", c.r, c.g, c.b, c.a), + hyprlang::ConfigValue::Custom { + .. + } => "custom".to_string() + } +} + +/// Get the type name for a ConfigValue. +/// +/// Returns a static string representing the value's type, +/// used for type filtering and output formatting. +/// +/// # Arguments +/// +/// * `value` - The ConfigValue to inspect +/// +/// # Returns +/// +/// One of: `"INT"`, `"FLOAT"`, `"STRING"`, `"VEC2"`, `"COLOR"`, `"CUSTOM"` +pub fn config_value_type_name(value: &hyprlang::ConfigValue) -> &'static str { + match value { + hyprlang::ConfigValue::Int(_) => "INT", + hyprlang::ConfigValue::Float(_) => "FLOAT", + hyprlang::ConfigValue::String(_) => "STRING", + hyprlang::ConfigValue::Vec2(_) => "VEC2", + hyprlang::ConfigValue::Color(_) => "COLOR", + hyprlang::ConfigValue::Custom { + .. + } => "CUSTOM" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_string_deterministic() { + let hash1 = hash_string("$GTK_THEME"); + let hash2 = hash_string("$GTK_THEME"); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_hash_string_different_vars() { + let hash1 = hash_string("$GTK_THEME"); + let hash2 = hash_string("$ICON_THEME"); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_hash_string_cursor_theme() { + let hash = hash_string("$CURSOR_THEME"); + assert!(hash > 0); + } + + #[test] + fn test_config_value_to_string_border_size() { + let value = hyprlang::ConfigValue::Int(2); + assert_eq!(config_value_to_string(&value), "2"); + } + + #[test] + fn test_config_value_to_string_cursor_size() { + let value = hyprlang::ConfigValue::Int(20); + assert_eq!(config_value_to_string(&value), "20"); + } + + #[test] + fn test_config_value_to_string_gaps() { + let value = hyprlang::ConfigValue::Int(8); + assert_eq!(config_value_to_string(&value), "8"); + } + + #[test] + fn test_config_value_to_string_float_opacity() { + let value = hyprlang::ConfigValue::Float(0.95); + assert_eq!(config_value_to_string(&value), "0.95"); + } + + #[test] + fn test_config_value_to_string_gtk_theme() { + let value = hyprlang::ConfigValue::String("Gruvbox-Retro".to_string()); + assert_eq!(config_value_to_string(&value), "Gruvbox-Retro"); + } + + #[test] + fn test_config_value_to_string_icon_theme() { + let value = hyprlang::ConfigValue::String("Gruvbox-Plus-Dark".to_string()); + assert_eq!(config_value_to_string(&value), "Gruvbox-Plus-Dark"); + } + + #[test] + fn test_config_value_to_string_color_scheme() { + let value = hyprlang::ConfigValue::String("prefer-dark".to_string()); + assert_eq!(config_value_to_string(&value), "prefer-dark"); + } + + #[test] + fn test_config_value_to_string_vec2() { + let value = hyprlang::ConfigValue::Vec2(hyprlang::Vec2 { + x: 1920.0, + y: 1080.0 + }); + assert_eq!(config_value_to_string(&value), "1920, 1080"); + } + + #[test] + fn test_config_value_to_string_color_active_border() { + let value = hyprlang::ConfigValue::Color(hyprlang::Color { + r: 144, + g: 206, + b: 170, + a: 255 + }); + assert_eq!(config_value_to_string(&value), "rgba(144, 206, 170, 255)"); + } + + #[test] + fn test_config_value_to_string_color_inactive_border() { + let value = hyprlang::ConfigValue::Color(hyprlang::Color { + r: 30, + g: 139, + b: 80, + a: 217 + }); + assert_eq!(config_value_to_string(&value), "rgba(30, 139, 80, 217)"); + } + + #[test] + fn test_config_value_type_name_int() { + assert_eq!( + config_value_type_name(&hyprlang::ConfigValue::Int(2)), + "INT" + ); + } + + #[test] + fn test_config_value_type_name_float() { + assert_eq!( + config_value_type_name(&hyprlang::ConfigValue::Float(0.5)), + "FLOAT" + ); + } + + #[test] + fn test_config_value_type_name_string() { + assert_eq!( + config_value_type_name(&hyprlang::ConfigValue::String("Gruvbox-Retro".to_string())), + "STRING" + ); + } + + #[test] + fn test_config_value_type_name_vec2() { + assert_eq!( + config_value_type_name(&hyprlang::ConfigValue::Vec2(hyprlang::Vec2 { + x: 0.0, + y: 0.0 + })), + "VEC2" + ); + } + + #[test] + fn test_config_value_type_name_color() { + assert_eq!( + config_value_type_name(&hyprlang::ConfigValue::Color(hyprlang::Color { + r: 0, + g: 0, + b: 0, + a: 0 + })), + "COLOR" + ); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c88d2b3 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,490 @@ +use std::{fs, io::Write, path::PathBuf}; + +fn create_test_config(name: &str, content: &str) -> PathBuf { + let temp_dir = std::env::temp_dir().join("hydequery_integration"); + let _ = fs::create_dir_all(&temp_dir); + let path = temp_dir.join(name); + let mut file = fs::File::create(&path).unwrap(); + write!(file, "{}", content).unwrap(); + path +} + +fn cleanup_test_config(path: &PathBuf) { + let _ = fs::remove_file(path); +} + +#[test] +fn test_parse_theme_variables() { + let content = r#" +$GTK_THEME = Gruvbox-Retro +$ICON_THEME = Gruvbox-Plus-Dark +$CURSOR_THEME = Gruvbox-Retro +$CURSOR_SIZE = 20 +$COLOR_SCHEME = prefer-dark +"#; + let path = create_test_config("theme.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + assert_eq!(config.get_variable("GTK_THEME").unwrap(), "Gruvbox-Retro"); + assert_eq!( + config.get_variable("ICON_THEME").unwrap(), + "Gruvbox-Plus-Dark" + ); + assert_eq!( + config.get_variable("CURSOR_THEME").unwrap(), + "Gruvbox-Retro" + ); + assert_eq!(config.get_variable("CURSOR_SIZE").unwrap(), "20"); + assert_eq!(config.get_variable("COLOR_SCHEME").unwrap(), "prefer-dark"); + + cleanup_test_config(&path); +} + +#[test] +fn test_parse_general_settings() { + let content = r#" +general { + gaps_in = 3 + gaps_out = 8 + border_size = 2 + layout = dwindle + resize_on_border = true +} +"#; + let path = create_test_config("general.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + let gaps_in = config.get("general:gaps_in").unwrap(); + assert!(matches!(gaps_in, hyprlang::ConfigValue::Int(3))); + + let gaps_out = config.get("general:gaps_out").unwrap(); + assert!(matches!(gaps_out, hyprlang::ConfigValue::Int(8))); + + let border_size = config.get("general:border_size").unwrap(); + assert!(matches!(border_size, hyprlang::ConfigValue::Int(2))); + + cleanup_test_config(&path); +} + +#[test] +fn test_parse_decoration_settings() { + let content = r#" +decoration { + rounding = 3 + shadow:enabled = false + + blur { + enabled = yes + size = 4 + passes = 2 + } +} +"#; + let path = create_test_config("decoration.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + let rounding = config.get("decoration:rounding").unwrap(); + assert!(matches!(rounding, hyprlang::ConfigValue::Int(3))); + + cleanup_test_config(&path); +} + +#[test] +fn test_parse_group_settings() { + let content = r#" +group { + col.border_active = rgba(90ceaaff) rgba(ecd3a0ff) 45deg + col.border_inactive = rgba(1e8b50d9) rgba(50b050d9) 45deg +} +"#; + let path = create_test_config("group.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_source_with_relative_path() { + let temp_dir = std::env::temp_dir().join("hydequery_source_integration"); + let _ = fs::create_dir_all(&temp_dir); + + let theme_path = temp_dir.join("theme.conf"); + let mut theme_file = fs::File::create(&theme_path).unwrap(); + writeln!(theme_file, "$GTK_THEME = Wallbash-Gtk").unwrap(); + + let main_path = temp_dir.join("main.conf"); + let mut main_file = fs::File::create(&main_path).unwrap(); + writeln!(main_file, "source = ./theme.conf").unwrap(); + + let mut config = hyprlang::Config::with_options(hyprlang::ConfigOptions { + base_dir: Some(temp_dir.clone()), + ..Default::default() + }); + config.parse_file(&main_path).unwrap(); + + let _ = fs::remove_file(theme_path); + let _ = fs::remove_file(main_path); + let _ = fs::remove_dir(temp_dir); +} + +#[test] +fn test_parse_input_settings() { + let content = r#" +input { + kb_layout = us + follow_mouse = 1 + sensitivity = 0 + touchpad { + natural_scroll = false + } +} +"#; + let path = create_test_config("input.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_parse_animations() { + let content = r#" +animations { + enabled = yes + bezier = wind, 0.05, 0.9, 0.1, 1.05 +} +"#; + let path = create_test_config("animations.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_multiple_variables_same_file() { + let content = r#" +$terminal = kitty +$fileManager = dolphin +$menu = rofi -show drun + +general { + gaps_in = 5 + gaps_out = 10 +} +"#; + let path = create_test_config("userprefs.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + assert_eq!(config.get_variable("terminal").unwrap(), "kitty"); + assert_eq!(config.get_variable("fileManager").unwrap(), "dolphin"); + + cleanup_test_config(&path); +} + +#[test] +fn test_hyde_marker_variable() { + let content = r#" +$HYDE_HYPRLAND=set +"#; + let path = create_test_config("hyprland.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + assert_eq!(config.get_variable("HYDE_HYPRLAND").unwrap(), "set"); + + cleanup_test_config(&path); +} + +#[test] +fn test_layerrule_directive() { + let content = r#" +layerrule = blur,waybar +layerrule = blur,rofi +"#; + let path = create_test_config("layerrules.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_windowrule_directive() { + let content = r#" +windowrulev2 = float,class:^(pavucontrol)$ +windowrulev2 = float,class:^(blueman-manager)$ +"#; + let path = create_test_config("windowrules.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_monitor_configuration() { + let content = r#" +monitor = ,preferred,auto,1 +"#; + let path = create_test_config("monitors.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_empty_config() { + let content = ""; + let path = create_test_config("empty.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_comments_only_config() { + let content = r#" +# This is a comment +# Another comment + +# Comment with empty lines above +"#; + let path = create_test_config("comments.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + cleanup_test_config(&path); +} + +#[test] +fn test_mixed_content() { + let content = r#" +# Theme configuration +$GTK_THEME = Gruvbox-Retro + +general { + # Gap settings + gaps_in = 3 + gaps_out = 8 + border_size = 2 +} + +# Decoration settings +decoration { + rounding = 3 +} +"#; + let path = create_test_config("mixed.conf", content); + + let mut config = hyprlang::Config::default(); + config.parse_file(&path).unwrap(); + + assert_eq!(config.get_variable("GTK_THEME").unwrap(), "Gruvbox-Retro"); + + let gaps_in = config.get("general:gaps_in").unwrap(); + assert!(matches!(gaps_in, hyprlang::ConfigValue::Int(3))); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_help_flag() { + let output = std::process::Command::new("cargo") + .args(["run", "--release", "--", "-h"]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("USAGE") || stdout.contains("hydequery")); +} + +#[test] +fn test_binary_query_theme_variable() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("binary_theme.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "$GTK_THEME" + ]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Gruvbox-Retro")); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_query_nested_key() { + let content = r#" +general { + border_size = 2 +} +"#; + let path = create_test_config("binary_nested.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "general:border_size" + ]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("2")); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_json_export() { + let content = "$CURSOR_SIZE = 24"; + let path = create_test_config("binary_json.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "$CURSOR_SIZE", + "--export", + "json" + ]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"key\"")); + assert!(stdout.contains("\"value\"")); + assert!(stdout.contains("24")); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_env_export() { + let content = "$COLOR_SCHEME = prefer-dark"; + let path = create_test_config("binary_env.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "$COLOR_SCHEME", + "--export", + "env" + ]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("COLOR_SCHEME=")); + assert!(stdout.contains("prefer-dark")); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_missing_variable_exit_code() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("binary_missing.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "$NONEXISTENT" + ]) + .output() + .expect("Failed to run binary"); + + assert!(!output.status.success()); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_allow_missing_flag() { + let content = "$GTK_THEME = Gruvbox-Retro"; + let path = create_test_config("binary_allow.conf", content); + + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + path.to_str().unwrap(), + "-Q", + "$NONEXISTENT", + "--allow-missing" + ]) + .output() + .expect("Failed to run binary"); + + assert!(output.status.success()); + + cleanup_test_config(&path); +} + +#[test] +fn test_binary_config_not_found() { + let output = std::process::Command::new("cargo") + .args([ + "run", + "--release", + "--", + "/nonexistent/config.conf", + "-Q", + "$GTK_THEME" + ]) + .output() + .expect("Failed to run binary"); + + assert!(!output.status.success()); +}