Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Auto-detect text files and always use LF everywhere (Linux, macOS and
# Windows). CRLF is never checked out, so no autocrlf warnings on Windows.
* text=auto eol=lf

# Rust source code
*.rs linguist-language=Rust text

# Configuration files
*.toml linguist-language=TOML text
*.jsonc linguist-language=JSON text
*.json linguist-language=JSON text
Cargo.lock linguist-language=TOML text

# Scripts and CI
*.sh linguist-language=Shell text eol=lf
*.ps1 linguist-language=PowerShell text
*.yml linguist-language=YAML text
*.yaml linguist-language=YAML text

# Documentation
*.md linguist-language=Markdown text
LICENSE linguist-language=Text text
*.txt linguist-language=Text text

# Git configuration
.gitignore linguist-language=Ignore-List text
.gitattributes linguist-language=Git-Attributes text
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

## 2026-08-19

### Timeout Standard

- Both extensions wrap their work in `with_timeout` with a 2 s budget; on timeout they exit with an error instead of hanging the config load.
- An extension without a runtime limit is rejected — enforced by CI (`ci/unix.sh`, `ci/windows.ps1`, running on Linux, macOS and Windows). PRs must pass CI.
- Requires `xfetch-extension-api` with `with_timeout` (see the `api` repo).

### Extensions (as of 2026-08-19)

- `config-roulette` — picks a config from a routes list (random or daily); `~` expansion fixed on Windows (`USERPROFILE` fallback)
- `layout-override` — overrides layout and/or modules at load time

Each extension has its own CHANGELOG with its specific changes.
48 changes: 48 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<h1>Contributing Extensions</h1>

<p>
Thanks for contributing to the <strong>xfetch</strong> extension ecosystem.
This repository contains the official config-provider extensions.
</p>

<h2>Workflow</h2>

<ol>
<li>Fork the repository and create a feature branch.</li>
<li>Create or update an extension directory at <code>extensions/&lt;name&gt;</code>.</li>
<li>Run <code>cargo test --workspace</code>.</li>
<li>
Run the full CI locally before opening the PR:
<code>bash ci/unix.sh</code> (Linux/macOS) or <code>./ci/windows.ps1</code>
(Windows). The CI checks tests <strong>and</strong> the extension standard.
</li>
<li>Document the extension in its own <code>README.md</code> and in the repository <code>README.md</code>.</li>
<li>
Open a pull request with usage details and any required external
dependencies. PRs that fail CI are rejected.
</li>
</ol>

<h2>Extension Rules</h2>

<ul>
<li>Use the binary naming convention <code>xfetch-extension-&lt;name&gt;</code>.</li>
<li>Keep extensions focused on a single responsibility.</li>
<li>Write errors to stderr and exit with a non-zero status on failure.</li>
<li>Prefer stable, actively maintained dependencies and keep them minimal.</li>
<li>
<strong>Every extension MUST have a runtime limit.</strong> Wrap all work
in <code>with_timeout</code> (from <code>xfetch_extension_api</code>)
with a <code>const BUDGET</code> that fits the extension and exit with an
error when the budget elapses. An extension without a timeout is
rejected: it could hang the config load forever. This is enforced by CI.
</li>
</ul>

<h2>Protocol Guide</h2>

<p>
The full stdin/stdout JSON protocol is defined in the
<a href="https://github.com/xfetch-cli/api">xfetch-cli/api</a> repository
(<code>crates/extension-api</code>).
</p>
15 changes: 15 additions & 0 deletions ci/unix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# CI for Linux/macOS: build, test and enforce the extension standard.
set -euo pipefail
cd "$(dirname "$0")/.."

cargo test --workspace

# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md).
for f in extensions/*/src/main.rs; do
grep -q "with_timeout" "$f" || {
echo "::error::$f must use xfetch_extension_api::with_timeout" >&2
exit 1
}
done
echo "All extensions use with_timeout."
14 changes: 14 additions & 0 deletions ci/windows.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# CI for Windows: build, test and enforce the extension standard.
$ErrorActionPreference = "Stop"
Set-Location (Join-Path $PSScriptRoot "..")

cargo test --workspace

# Standard: every extension must wrap its work in with_timeout (CONTRIBUTING.md).
foreach ($f in Get-ChildItem "extensions\*\src\main.rs") {
if (-not (Select-String -Path $f.FullName -Pattern "with_timeout" -Quiet)) {
Write-Error "$($f.FullName) must use xfetch_extension_api::with_timeout"
exit 1
}
}
Write-Host "All extensions use with_timeout."
5 changes: 5 additions & 0 deletions extensions/config-roulette/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 2026-08-19
- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging.
- Fixed `~` expansion on Windows (falls back to `USERPROFILE` when `HOME` is unset).
160 changes: 89 additions & 71 deletions extensions/config-roulette/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use serde::Deserialize;
use std::io::{Read, Write};
use xfetch_extension_api::{ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER};
use std::time::Duration;
use xfetch_extension_api::{
ConfigProviderRequest, ConfigProviderResponse, KIND_CONFIG_PROVIDER, with_timeout,
};

#[derive(Debug, Deserialize)]
struct Route {
Expand All @@ -27,85 +30,98 @@ fn default_strategy() -> Strategy {
Strategy::Daily
}

/// Local file reads only; 2 s is plenty.
const BUDGET: Duration = Duration::from_secs(2);

fn main() {
let request: ConfigProviderRequest = match read_stdin() {
Ok(v) => v,
Err(err) => {
eprintln!("{}", err);
let result = with_timeout(BUDGET, || {
let request: ConfigProviderRequest = match read_stdin() {
Ok(v) => v,
Err(err) => {
eprintln!("{}", err);
std::process::exit(1);
}
};

if request.kind != KIND_CONFIG_PROVIDER {
eprintln!("Unsupported kind: {}", request.kind);
std::process::exit(1);
}
};

if request.kind != KIND_CONFIG_PROVIDER {
eprintln!("Unsupported kind: {}", request.kind);
std::process::exit(1);
}

let args: RouletteArgs = request
.args
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| RouletteArgs {
routes: "~/.config/xfetch/routes.json".to_string(),
strategy: default_strategy(),
});

let routes_path = expand_path(&args.routes);
let content = match std::fs::read_to_string(&routes_path) {
Ok(c) => c,
Err(err) => {
eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err);
let args: RouletteArgs = request
.args
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| RouletteArgs {
routes: "~/.config/xfetch/routes.json".to_string(),
strategy: default_strategy(),
});

let routes_path = expand_path(&args.routes);
let content = match std::fs::read_to_string(&routes_path) {
Ok(c) => c,
Err(err) => {
eprintln!("Failed to read routes file '{}': {}", routes_path.display(), err);
std::process::exit(1);
}
};

let all_routes: Vec<Route> = match serde_json::from_str(&content) {
Ok(r) => r,
Err(err) => {
eprintln!("Failed to parse routes file: {}", err);
std::process::exit(1);
}
};

if all_routes.is_empty() {
eprintln!("Routes file is empty");
std::process::exit(1);
}
};

let all_routes: Vec<Route> = match serde_json::from_str(&content) {
Ok(r) => r,
Err(err) => {
eprintln!("Failed to parse routes file: {}", err);
let index = match args.strategy {
Strategy::Random => {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
(nanos as usize) % all_routes.len()
}
Strategy::Daily => {
let now = chrono_day();
(now as usize) % all_routes.len()
}
};

let chosen = &all_routes[index];
let config_path = expand_path(&chosen.path);

let config_content = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(err) => {
eprintln!("Failed to read config '{}': {}", config_path.display(), err);
std::process::exit(1);
}
};

let config: serde_json::Value = match serde_json::from_str(&config_content) {
Ok(v) => v,
Err(err) => {
eprintln!("Failed to parse config '{}': {}", config_path.display(), err);
std::process::exit(1);
}
};

config
});

match result {
Ok(config) => write_stdout(&ConfigProviderResponse { config }),
Err(_) => {
eprintln!("config-roulette: timed out");
std::process::exit(1);
}
};

if all_routes.is_empty() {
eprintln!("Routes file is empty");
std::process::exit(1);
}

let index = match args.strategy {
Strategy::Random => {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
(nanos as usize) % all_routes.len()
}
Strategy::Daily => {
let now = chrono_day();
(now as usize) % all_routes.len()
}
};

let chosen = &all_routes[index];
let config_path = expand_path(&chosen.path);

let config_content = match std::fs::read_to_string(&config_path) {
Ok(c) => c,
Err(err) => {
eprintln!("Failed to read config '{}': {}", config_path.display(), err);
std::process::exit(1);
}
};

let config: serde_json::Value = match serde_json::from_str(&config_content) {
Ok(v) => v,
Err(err) => {
eprintln!("Failed to parse config '{}': {}", config_path.display(), err);
std::process::exit(1);
}
};

write_stdout(&ConfigProviderResponse { config });
}

fn chrono_day() -> u32 {
Expand All @@ -118,7 +134,9 @@ fn chrono_day() -> u32 {

fn expand_path(path: &str) -> std::path::PathBuf {
if let Some(rest) = path.strip_prefix('~') {
if let Ok(home) = std::env::var("HOME") {
// Windows has no HOME; USERPROFILE is the equivalent.
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"));
if let Ok(home) = home {
return std::path::PathBuf::from(home).join(rest.strip_prefix('/').unwrap_or(rest));
}
}
Expand Down
4 changes: 4 additions & 0 deletions extensions/layout-override/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Changelog

## 2026-08-19
- Wrapped work in `with_timeout` with a 2 s budget; on timeout it exits with an error instead of hanging.
Loading