11 Commits

Author SHA1 Message Date
forust 2c32f5856b chore: release v1.2.0
release / verify (push) Successful in 26s
ci / rust (push) Successful in 45s
ci / lint-yaml (push) Successful in 5s
release / linux-amd64 (push) Successful in 29s
2026-07-05 02:37:18 +02:00
forust 1babd404f9 docs: document timer utilities
ci / rust (push) Successful in 45s
ci / lint-yaml (push) Successful in 5s
2026-07-05 02:33:21 +02:00
forust ce35617bbe feat: show fullscreen timer view 2026-07-05 02:31:58 +02:00
forust f4c54837de feat: add timer cli utilities 2026-07-05 02:30:27 +02:00
forust 483ef2fecf fix: publish valid release assets
ci / rust (push) Successful in 41s
ci / lint-yaml (push) Successful in 4s
2026-07-05 02:20:20 +02:00
forust ce236bc49f feat: hide disabled action settings 2026-07-05 02:20:04 +02:00
forust b067cf572e fix: harden timer lifecycle 2026-07-05 02:19:46 +02:00
forust af95742f69 build: publish raw release binary 2026-07-05 01:49:17 +02:00
forust 0b0f0af82d docs: remove version-specific README text
ci / rust (push) Successful in 43s
ci / lint-yaml (push) Successful in 5s
2026-07-03 23:54:23 +02:00
forust a582913269 chore: release v1.1.3
release / verify (push) Successful in 25s
ci / rust (push) Successful in 47s
ci / lint-yaml (push) Successful in 5s
release / linux-amd64 (push) Successful in 36s
2026-07-03 23:49:01 +02:00
forust 911aaca355 fix: expose gitea token in release workflow
ci / rust (push) Successful in 46s
ci / lint-yaml (push) Successful in 5s
2026-07-03 23:44:22 +02:00
9 changed files with 819 additions and 89 deletions
+36 -5
View File
@@ -10,8 +10,38 @@ permissions:
contents: write contents: write
jobs: jobs:
linux-amd64: verify:
runs-on: [self-hosted, linux, arch, homelab] runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check formatting
shell: bash
run: cargo fmt --check
- name: Run tests
shell: bash
run: cargo test --locked
- name: Run clippy
shell: bash
run: cargo clippy --locked -- -D warnings
- name: Lint YAML
shell: bash
run: |
docker run --rm \
-v "$PWD:/work" \
-w /work \
cytopia/yamllint:latest \
-c .yamllint .
linux-amd64:
needs: verify
runs-on: [self-hosted, linux, arch, homelab]
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -26,9 +56,10 @@ jobs:
tag="${GITHUB_REF##*/}" tag="${GITHUB_REF##*/}"
version="${tag#v}" version="${tag#v}"
mkdir -p dist mkdir -p dist
cp target/release/gosleep-timer dist/gosleep-timer binary="gosleep-timer-${version}-linux-amd64"
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer cp target/release/gosleep-timer "dist/${binary}"
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" cd dist
sha256sum "${binary}" > "${binary}.sha256"
- name: Create Gitea release - name: Create Gitea release
id: release id: release
@@ -70,7 +101,7 @@ jobs:
run: | run: |
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
release_id="${{ steps.release.outputs.release_id }}" release_id="${{ steps.release.outputs.release_id }}"
for asset in dist/*.tar.gz dist/*.sha256; do for asset in dist/gosleep-timer-*; do
name="$(basename "$asset")" name="$(basename "$asset")"
release="$(curl -fsS \ release="$(curl -fsS \
"${release_url}/${release_id}" \ "${release_url}/${release_id}" \
+8 -5
View File
@@ -46,10 +46,12 @@ Use a temporary config path so local tests do not mutate the user's real config:
```bash ```bash
tmpdir="$(mktemp -d)" tmpdir="$(mktemp -d)"
cargo run -- --config "$tmpdir/config.yaml" init cargo run -- --config "$tmpdir/config.yaml" init
cargo run -- --config "$tmpdir/config.yaml" validate
cargo run -- --config "$tmpdir/config.yaml" status
cargo run -- --config "$tmpdir/config.yaml" preview cargo run -- --config "$tmpdir/config.yaml" preview
``` ```
For `run` smoke tests, disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands. For `run` smoke tests, pass `--no-actions` or disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands.
## Versioning ## Versioning
@@ -73,20 +75,21 @@ test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r '
Release workflow is tag-based: Release workflow is tag-based:
```bash ```bash
git tag -a v1.1.1 -m "gosleep-timer v1.1.1" git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
git push origin main git push origin main
git push origin v1.1.1 git push origin vMAJOR.MINOR.PATCH
``` ```
The Gitea release workflow: The Gitea release workflow:
- runs formatting, tests, clippy, and YAML lint before building
- builds `target/release/gosleep-timer` - builds `target/release/gosleep-timer`
- creates `gosleep-timer-<version>-linux-amd64.tar.gz` - copies `gosleep-timer-<version>-linux-amd64`
- writes a `.sha256` - writes a `.sha256`
- creates or reuses the matching Gitea release - creates or reuses the matching Gitea release
- replaces release assets with matching names - replaces release assets with matching names
The workflow uses the built-in `GITEA_TOKEN` and declares `permissions: contents: write`. The workflow exposes `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN` and declares `permissions: contents: write`.
## Git Remote ## Git Remote
+24
View File
@@ -4,6 +4,28 @@ All notable changes to this project are documented here.
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`. The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
## [1.2.0] - 2026-07-05
### Added
- Added `status`, `validate`, `edit`, `history`, and `stats` CLI commands.
- Added `run --dry-run` and `run --no-actions` for safer timer verification.
- Added TUI pause/resume support and a fullscreen running timer view with large ASCII time-left display.
- Added history recording for completed sessions and aggregate sleep/timer statistics.
- Added red highlighting for dangerous post-timer actions and common destructive custom commands.
### Changed
- TUI settings now hide dependent options when their parent action is disabled.
- Release assets are published as a raw Linux binary plus `.sha256` instead of a tar archive.
## [1.1.3] - 2026-07-03
### Fixed
- Made the tag-based release workflow fail closed: lint and test gates now run before build and publish.
- Exposed Gitea Actions' built-in `${{ secrets.GITEA_TOKEN }}` to the release API steps so release creation and asset upload authenticate correctly.
## [1.1.2] - 2026-07-03 ## [1.1.2] - 2026-07-03
### Fixed ### Fixed
@@ -20,5 +42,7 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`
- Added YAML configuration, CLI `init`, `preview`, and `run` commands. - Added YAML configuration, CLI `init`, `preview`, and `run` commands.
- Added Gitea CI and tag-based release workflows. - Added Gitea CI and tag-based release workflows.
[1.2.0]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.0
[1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3
[1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2 [1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1 [1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
Generated
+1 -1
View File
@@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.2" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.2" version = "1.2.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
+45 -8
View File
@@ -6,8 +6,7 @@ Rust TUI sleep timer for Linux desktop actions.
## Status ## Status
- Current version: `1.1.1` - Release tags: `vMAJOR.MINOR.PATCH`
- Release tags: `vMAJOR.MINOR.PATCH`, for example `v1.1.1`
- Primary repository: <https://gitea.forust.xyz/forust/gosleep> - Primary repository: <https://gitea.forust.xyz/forust/gosleep>
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git` - SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
@@ -34,11 +33,11 @@ install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer
### From release artifact ### From release artifact
Download `gosleep-timer-<version>-linux-amd64.tar.gz` from the Gitea release page, then: Download `gosleep-timer-<version>-linux-amd64` and its `.sha256` from the Gitea release page, then:
```bash ```bash
tar -xzf gosleep-timer-1.1.1-linux-amd64.tar.gz sha256sum -c gosleep-timer-<version>-linux-amd64.sha256
install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer install -Dm755 gosleep-timer-<version>-linux-amd64 ~/.local/bin/gosleep-timer
``` ```
## Usage ## Usage
@@ -61,6 +60,24 @@ Preview commands that will run after the countdown:
gosleep-timer preview gosleep-timer preview
``` ```
Show the current config, enabled actions, and dangerous-action markers:
```bash
gosleep-timer status
```
Validate the config without starting the timer:
```bash
gosleep-timer validate
```
Open the config in `$VISUAL`, `$EDITOR`, or `vi`:
```bash
gosleep-timer edit
```
Run the saved timer config: Run the saved timer config:
```bash ```bash
@@ -74,6 +91,20 @@ gosleep-timer run 45m
gosleep-timer run 1h30m gosleep-timer run 1h30m
``` ```
Run the countdown without executing post-timer actions:
```bash
gosleep-timer run --dry-run 10s
gosleep-timer run --no-actions 10s
```
Inspect recorded sessions and aggregate stats:
```bash
gosleep-timer history
gosleep-timer stats
```
Use a custom config path: Use a custom config path:
```bash ```bash
@@ -90,6 +121,7 @@ gosleep-timer --config ./config.yaml preview
| `Enter` while editing | Apply edit | | `Enter` while editing | Apply edit |
| `Esc` while editing | Cancel edit | | `Esc` while editing | Cancel edit |
| `r` | Start/restart timer | | `r` | Start/restart timer |
| `p` | Pause/resume running timer |
| `x` | Stop timer | | `x` | Stop timer |
| `s` | Save config | | `s` | Save config |
| `q` / `Esc` | Quit | | `q` / `Esc` | Quit |
@@ -155,6 +187,12 @@ Other actions use common Linux desktop tools:
- `systemctl` - `systemctl`
Install only the tools needed for the actions you enable. Install only the tools needed for the actions you enable.
Dangerous actions such as `pkill`, power actions, and common destructive custom commands are highlighted in red in the TUI preview.
## History
Completed timer sessions are appended to `history.yaml` next to the active config file.
If no poweroff or reboot action is configured, CLI `run` asks for wake confirmation after the timer finishes and records the elapsed sleep time.
## Development ## Development
@@ -185,7 +223,7 @@ Version is stored in:
Release tags must match the package version: Release tags must match the package version:
```bash ```bash
git tag -a v1.1.1 -m "gosleep-timer v1.1.1" git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
git push origin main --tags git push origin main --tags
``` ```
@@ -198,12 +236,11 @@ Required runner tools:
- Rust stable toolchain with `cargo`, `rustfmt`, and `clippy` - Rust stable toolchain with `cargo`, `rustfmt`, and `clippy`
- `curl` - `curl`
- `jq` - `jq`
- `tar`
- `sha256sum` - `sha256sum`
Release authentication: Release authentication:
- The workflow uses Gitea Actions' built-in `GITEA_TOKEN`. - The workflow exposes Gitea Actions' built-in `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN`.
- The workflow declares `permissions: contents: write` so the token can create releases and upload assets. - The workflow declares `permissions: contents: write` so the token can create releases and upload assets.
See [RELEASING.md](RELEASING.md) for the full release checklist. See [RELEASING.md](RELEASING.md) for the full release checklist.
+17 -15
View File
@@ -36,18 +36,17 @@ https://gitea.forust.xyz/forust/gosleep.git
- `clippy` - `clippy`
- `curl` - `curl`
- `jq` - `jq`
- `tar`
- `sha256sum` - `sha256sum`
- Docker, if using the YAML lint job as written - Docker, if using the YAML lint job as written
4. Confirm Actions job token permissions allow `contents: write`. 4. Confirm Actions job token permissions allow `contents: write`.
The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`; no extra repository secret is required. The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`, exposed in the workflow as `${{ secrets.GITEA_TOKEN }}`; no extra repository secret is required.
## Version Bump Checklist ## Version Bump Checklist
For a patch bump, for example `1.1.1` to `1.1.2`: For a patch bump, for example `1.1.3` to `1.1.4`:
```bash ```bash
version=1.1.2 version=1.1.4
``` ```
Update: Update:
@@ -70,21 +69,23 @@ cargo build --release --locked
```bash ```bash
git status --short git status --short
git commit -am "chore: release v1.1.2" git commit -am "chore: release v${version}"
git tag -a v1.1.2 -m "gosleep-timer v1.1.2" git tag -a "v${version}" -m "gosleep-timer v${version}"
git push origin main git push origin main
git push origin v1.1.2 git push origin "v${version}"
``` ```
The `.gitea/workflows/release.yaml` workflow will: The `.gitea/workflows/release.yaml` workflow will:
1. Build `target/release/gosleep-timer`. 1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
2. Package `gosleep-timer-<version>-linux-amd64.tar.gz`. 2. Build `target/release/gosleep-timer`.
3. Generate a SHA-256 checksum. 3. Copy binary as `gosleep-timer-<version>-linux-amd64`.
4. Create a Gitea release. 4. Generate a SHA-256 checksum.
5. Upload the archive and checksum as release assets. 5. Create a Gitea release.
6. Upload the binary and checksum as release assets.
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names. The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
If any lint or test step fails, the build and release job will not run.
## Manual Asset Build ## Manual Asset Build
@@ -94,7 +95,8 @@ If Actions are unavailable:
version="$(cat VERSION)" version="$(cat VERSION)"
cargo build --release --locked cargo build --release --locked
mkdir -p dist mkdir -p dist
cp target/release/gosleep-timer dist/gosleep-timer binary="gosleep-timer-${version}-linux-amd64"
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer cp target/release/gosleep-timer "dist/${binary}"
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" cd dist
sha256sum "${binary}" > "${binary}.sha256"
``` ```
+1 -1
View File
@@ -1 +1 @@
1.1.2 1.2.0
+678 -45
View File
@@ -1,14 +1,14 @@
use std::{ use std::{
fs, fs,
io::{self, Write}, io::{self, Write},
path::PathBuf, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
sync::{ sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
@@ -42,7 +42,18 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
Run { duration: Option<String> }, Run {
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_actions: bool,
duration: Option<String>,
},
Status,
Validate,
Edit,
History,
Stats,
Preview, Preview,
Init, Init,
} }
@@ -129,6 +140,34 @@ struct ActionCommand {
shell: Option<String>, shell: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HistoryEntry {
started_at: u64,
duration_seconds: u64,
slept_seconds: Option<u64>,
status: String,
actions: Vec<String>,
failures: Vec<String>,
dry_run: bool,
no_actions: bool,
}
#[derive(Debug, Clone)]
struct TimerReport {
started_at: u64,
duration: Duration,
actions: Vec<String>,
failures: Vec<String>,
dry_run: bool,
no_actions: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct TimerOptions {
dry_run: bool,
no_actions: bool,
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -187,6 +226,44 @@ impl ActionCommand {
fn display(&self) -> String { fn display(&self) -> String {
self.shell.clone().unwrap_or_else(|| self.args.join(" ")) self.shell.clone().unwrap_or_else(|| self.args.join(" "))
} }
fn is_dangerous(&self) -> bool {
if matches!(self.label, "kill" | "power") {
return true;
}
let command = self.display().to_lowercase();
[
"rm -rf",
"mkfs",
"dd if=",
"shutdown",
"reboot",
"poweroff",
"systemctl poweroff",
"systemctl reboot",
"wipefs",
"shred",
"killall",
"pkill",
]
.iter()
.any(|needle| command.contains(needle))
}
}
impl TimerReport {
fn to_history(&self, status: &str, slept_seconds: Option<u64>) -> HistoryEntry {
HistoryEntry {
started_at: self.started_at,
duration_seconds: self.duration.as_secs(),
slept_seconds,
status: status.to_string(),
actions: self.actions.clone(),
failures: self.failures.clone(),
dry_run: self.dry_run,
no_actions: self.no_actions,
}
}
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -195,18 +272,52 @@ fn main() -> Result<()> {
let mut config = ensure_config(&path)?; let mut config = ensure_config(&path)?;
match cli.command { match cli.command {
Some(Commands::Run { duration }) => { Some(Commands::Run {
dry_run,
no_actions,
duration,
}) => {
if let Some(duration) = duration { if let Some(duration) = duration {
config.duration = duration; config.duration = duration;
} }
let mut stdout = io::stdout(); let mut stdout = io::stdout();
run_timer( let options = TimerOptions {
dry_run,
no_actions,
};
let report = run_timer(
&config, &config,
&Arc::new(AtomicBool::new(false)), &Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli, RunMode::Cli,
options,
Some(&mut stdout), Some(&mut stdout),
)?;
let slept_seconds = prompt_wake_confirmation(&config, options)?;
append_history(
&history_path(&path),
report.to_history("finished", slept_seconds),
) )
} }
Some(Commands::Status) => {
print_status(&path, &config);
Ok(())
}
Some(Commands::Validate) => {
validate_config(&config)?;
println!("valid {}", path.display());
Ok(())
}
Some(Commands::Edit) => edit_config(&path),
Some(Commands::History) => {
print_history(&history_path(&path))?;
Ok(())
}
Some(Commands::Stats) => {
print_stats(&history_path(&path))?;
Ok(())
}
Some(Commands::Preview) => { Some(Commands::Preview) => {
for command in preview_commands(&config) { for command in preview_commands(&config) {
println!("{command}"); println!("{command}");
@@ -233,7 +344,7 @@ fn default_config_path() -> PathBuf {
.join("config.yaml") .join("config.yaml")
} }
fn ensure_config(path: &PathBuf) -> Result<Config> { fn ensure_config(path: &Path) -> Result<Config> {
if !path.exists() { if !path.exists() {
let config = Config::default(); let config = Config::default();
save_config(path, &config)?; save_config(path, &config)?;
@@ -242,10 +353,11 @@ fn ensure_config(path: &PathBuf) -> Result<Config> {
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut config: Config = serde_yaml::from_str(&data).context("parse config")?; let mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
normalize_config(&mut config); normalize_config(&mut config);
validate_config(&config)?;
Ok(config) Ok(config)
} }
fn save_config(path: &PathBuf, config: &Config) -> Result<()> { fn save_config(path: &Path, config: &Config) -> Result<()> {
let mut config = config.clone(); let mut config = config.clone();
normalize_config(&mut config); normalize_config(&mut config);
validate_config(&config)?; validate_config(&config)?;
@@ -257,6 +369,131 @@ fn save_config(path: &PathBuf, config: &Config) -> Result<()> {
Ok(()) Ok(())
} }
fn history_path(config_path: &Path) -> PathBuf {
config_path.with_file_name("history.yaml")
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_history(path: &Path) -> Result<Vec<HistoryEntry>> {
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_yaml::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn append_history(path: &Path, entry: HistoryEntry) -> Result<()> {
let mut entries = read_history(path)?;
entries.push(entry);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let data = serde_yaml::to_string(&entries).context("serialize history")?;
fs::write(path, data).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn print_status(path: &Path, config: &Config) {
println!("config: {}", path.display());
println!("history: {}", history_path(path).display());
println!("duration: {}", config.duration);
println!("actions:");
let commands = build_action_commands(config);
if commands.is_empty() {
println!(" none");
} else {
for command in commands {
let danger = if command.is_dangerous() { " !" } else { "" };
println!(" - [{}{}] {}", command.label, danger, command.display());
}
}
}
fn print_history(path: &Path) -> Result<()> {
let entries = read_history(path)?;
if entries.is_empty() {
println!("no history");
return Ok(());
}
for entry in entries.iter().rev().take(20) {
let slept = entry
.slept_seconds
.map(|seconds| format!(" slept={}", format_duration(Duration::from_secs(seconds))))
.unwrap_or_default();
println!(
"{} status={} duration={} actions={} failures={}{}",
entry.started_at,
entry.status,
format_duration(Duration::from_secs(entry.duration_seconds)),
entry.actions.len(),
entry.failures.len(),
slept
);
}
Ok(())
}
fn print_stats(path: &Path) -> Result<()> {
let entries = read_history(path)?;
let finished = entries
.iter()
.filter(|entry| entry.status == "finished")
.count();
let total_duration: u64 = entries.iter().map(|entry| entry.duration_seconds).sum();
let total_slept: u64 = entries.iter().filter_map(|entry| entry.slept_seconds).sum();
let failures: usize = entries.iter().map(|entry| entry.failures.len()).sum();
println!("sessions: {}", entries.len());
println!("finished: {finished}");
println!(
"scheduled: {}",
format_duration(Duration::from_secs(total_duration))
);
println!(
"slept: {}",
format_duration(Duration::from_secs(total_slept))
);
println!("action failures: {failures}");
Ok(())
}
fn edit_config(path: &Path) -> Result<()> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let status = Command::new(editor)
.arg(path)
.status()
.context("open editor")?;
if !status.success() {
bail!("editor exited with {status}");
}
let _ = ensure_config(path)?;
Ok(())
}
fn prompt_wake_confirmation(config: &Config, options: TimerOptions) -> Result<Option<u64>> {
if options.dry_run
|| options.no_actions
|| matches!(config.actions.power.mode.as_str(), "poweroff" | "reboot")
{
return Ok(None);
}
print!("timer finished. Press Enter when awake to record sleep time...");
io::stdout().flush().ok();
let started = Instant::now();
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.context("read wake confirmation")?;
Ok(Some(started.elapsed().as_secs()))
}
fn normalize_config(config: &mut Config) { fn normalize_config(config: &mut Config) {
if config.duration.is_empty() { if config.duration.is_empty() {
config.duration = "25m".to_string(); config.duration = "25m".to_string();
@@ -491,12 +728,22 @@ fn write_timer_progress(output: &mut dyn Write, message: &str) {
fn run_timer( fn run_timer(
config: &Config, config: &Config,
stop: &Arc<AtomicBool>, stop: &Arc<AtomicBool>,
pause: &Arc<AtomicBool>,
remaining_ms: &Arc<AtomicU64>,
mode: RunMode, mode: RunMode,
options: TimerOptions,
mut output: Option<&mut dyn Write>, mut output: Option<&mut dyn Write>,
) -> Result<()> { ) -> Result<TimerReport> {
validate_config(config)?; validate_config(config)?;
let duration = parse_duration(&config.duration)?; let duration = parse_duration(&config.duration)?;
let started_at = unix_timestamp();
let started = Instant::now(); let started = Instant::now();
let mut remaining = duration;
let mut last_tick = started;
remaining_ms.store(
duration.as_millis().min(u128::from(u64::MAX)) as u64,
Ordering::Relaxed,
);
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("timer started: {}", config.duration)); write_timer_line(output, &format!("timer started: {}", config.duration));
} }
@@ -508,29 +755,79 @@ fn run_timer(
} }
bail!("timer stopped"); bail!("timer stopped");
} }
let elapsed = started.elapsed();
if elapsed >= duration { if pause.load(Ordering::Relaxed) {
last_tick = Instant::now();
thread::sleep(Duration::from_millis(250));
continue;
}
let now = Instant::now();
let elapsed = now.saturating_duration_since(last_tick);
last_tick = now;
remaining = remaining.saturating_sub(elapsed);
remaining_ms.store(
remaining.as_millis().min(u128::from(u64::MAX)) as u64,
Ordering::Relaxed,
);
if remaining.is_zero() {
break; break;
} }
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_progress( write_timer_progress(
output, output,
&format!("remaining: {}", format_duration(duration - elapsed)), &format!("remaining: {}", format_duration(remaining)),
); );
} }
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
remaining_ms.store(0, Ordering::Relaxed);
if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped");
}
bail!("timer stopped");
}
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "finished"); write_timer_line(output, "finished");
} }
for command in build_action_commands(config) { let mut failures = Vec::new();
let actions = if options.no_actions {
Vec::new()
} else {
build_action_commands(config)
};
for command in &actions {
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("run: {}", command.display())); let prefix = if options.dry_run { "would run" } else { "run" };
write_timer_line(output, &format!("{prefix}: {}", command.display()));
} }
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?; if options.dry_run {
continue;
} }
Ok(()) if let Err(err) = run_command(command, mode) {
failures.push(format!("{} failed: {err}", command.label));
}
}
let report = TimerReport {
started_at,
duration,
actions: actions.iter().map(ActionCommand::display).collect(),
failures: failures.clone(),
dry_run: options.dry_run,
no_actions: options.no_actions,
};
if !failures.is_empty() {
bail!(
"{} action(s) failed: {}",
failures.len(),
failures.join("; ")
);
}
Ok(report)
} }
fn prepare_command_stdio(process: &mut Command, mode: RunMode) { fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
@@ -562,16 +859,19 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
fn run_tui(path: PathBuf, config: Config) -> Result<()> { fn run_tui(path: PathBuf, config: Config) -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let result = (|| {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let mut app = App::new(path, config); let mut app = App::new(path, config);
let result = app.run(&mut terminal); let result = app.run(&mut terminal);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok(); terminal.show_cursor().ok();
result result
})();
disable_raw_mode().ok();
execute!(io::stdout(), LeaveAlternateScreen).ok();
result
} }
struct App { struct App {
@@ -585,10 +885,11 @@ struct App {
} }
struct TimerState { struct TimerState {
started: Instant,
duration: Duration, duration: Duration,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
handle: thread::JoinHandle<Result<()>>, pause: Arc<AtomicBool>,
remaining_ms: Arc<AtomicU64>,
handle: thread::JoinHandle<Result<TimerReport>>,
} }
struct EditState { struct EditState {
@@ -636,11 +937,13 @@ impl App {
self.focus = self.focus.saturating_sub(1) self.focus = self.focus.saturating_sub(1)
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
self.focus = (self.focus + 1).min(FIELDS.len() - 1); let last = self.visible_fields().len().saturating_sub(1);
self.focus = (self.focus + 1).min(last);
} }
KeyCode::Char(' ') | KeyCode::Enter => self.activate()?, KeyCode::Char(' ') | KeyCode::Enter => self.activate()?,
KeyCode::Char('s') => self.save(), KeyCode::Char('s') => self.save(),
KeyCode::Char('r') => self.start_timer(), KeyCode::Char('r') => self.start_timer(),
KeyCode::Char('p') => self.toggle_pause(),
KeyCode::Char('x') => self.stop_timer(), KeyCode::Char('x') => self.stop_timer(),
_ => {} _ => {}
} }
@@ -655,6 +958,10 @@ impl App {
frame.render_widget(Paragraph::new("terminal too small"), area); frame.render_widget(Paragraph::new("terminal too small"), area);
return; return;
} }
if self.timer.is_some() {
self.draw_running(frame, area);
return;
}
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -673,7 +980,7 @@ impl App {
edit.field.label, edit.value edit.field.label, edit.value
) )
} else { } else {
"space/enter edit-toggle | r run | x stop | s save | q quit".to_string() "space/enter edit-toggle | r run | p pause | x stop | s save | q quit".to_string()
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
@@ -681,6 +988,90 @@ impl App {
); );
} }
fn draw_running(&self, frame: &mut Frame, area: Rect) {
let (_, remaining, progress) = self.timer_snapshot();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(7),
Constraint::Length(2),
Constraint::Min(4),
Constraint::Length(1),
])
.split(area);
frame.render_widget(
Paragraph::new("gosleep-timer").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
chunks[0],
);
let time_lines = ascii_time(&format_compact_duration(remaining))
.into_iter()
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Cyan))))
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(time_lines), chunks[1]);
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta))
.label(format_gauge_label(progress))
.ratio(progress),
chunks[2],
);
let commands = build_action_commands(&self.config);
let lines = if commands.is_empty() {
vec![Line::from("no post-timer actions enabled")]
} else {
commands
.iter()
.flat_map(|command| {
let style = if command.is_dangerous() {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
wrap_command(
&command.display(),
chunks[3].width.saturating_sub(4) as usize,
)
.into_iter()
.map(move |line| Line::from(Span::styled(line, style)))
})
.collect::<Vec<_>>()
};
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.title(" commands after countdown ")
.borders(Borders::ALL)
.border_style(Color::Blue),
)
.wrap(Wrap { trim: false }),
chunks[3],
);
let footer = if self
.timer
.as_ref()
.is_some_and(|timer| timer.pause.load(Ordering::Relaxed))
{
"paused | p resume | x stop | q quit"
} else {
"running | p pause | x stop | q quit"
};
frame.render_widget(
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
chunks[4],
);
}
fn draw_header(&self, frame: &mut Frame, area: Rect) { fn draw_header(&self, frame: &mut Frame, area: Rect) {
let (_, remaining, progress) = self.timer_snapshot(); let (_, remaining, progress) = self.timer_snapshot();
let header = Layout::default() let header = Layout::default()
@@ -747,8 +1138,10 @@ impl App {
} }
fn draw_fields(&mut self, frame: &mut Frame, area: Rect) { fn draw_fields(&mut self, frame: &mut Frame, area: Rect) {
let fields = self.visible_fields();
let visible = area.height.saturating_sub(2) as usize; let visible = area.height.saturating_sub(2) as usize;
self.offset = self.offset.min(FIELDS.len().saturating_sub(visible)); self.focus = self.focus.min(fields.len().saturating_sub(1));
self.offset = self.offset.min(fields.len().saturating_sub(visible));
if self.focus < self.offset { if self.focus < self.offset {
self.offset = self.focus; self.offset = self.focus;
} }
@@ -756,7 +1149,7 @@ impl App {
self.offset = self.focus.saturating_sub(visible.saturating_sub(1)); self.offset = self.focus.saturating_sub(visible.saturating_sub(1));
} }
let items = FIELDS let items = fields
.iter() .iter()
.enumerate() .enumerate()
.skip(self.offset) .skip(self.offset)
@@ -785,10 +1178,19 @@ impl App {
} }
fn draw_preview(&self, frame: &mut Frame, area: Rect) { fn draw_preview(&self, frame: &mut Frame, area: Rect) {
let lines = preview_commands(&self.config) let commands = build_action_commands(&self.config);
let lines = commands
.iter()
.flat_map(|command| {
let style = if command.is_dangerous() {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
wrap_command(&command.display(), area.width.saturating_sub(4) as usize)
.into_iter() .into_iter()
.flat_map(|command| wrap_command(&command, area.width.saturating_sub(4) as usize)) .map(move |line| Line::from(Span::styled(line, style)))
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Green)))) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let lines = if lines.is_empty() { let lines = if lines.is_empty() {
vec![Line::from("no post-timer actions enabled")] vec![Line::from("no post-timer actions enabled")]
@@ -809,11 +1211,13 @@ impl App {
} }
fn activate(&mut self) -> Result<()> { fn activate(&mut self) -> Result<()> {
match FIELDS[self.focus].kind { let Some(field) = self.focused_field() else {
FieldKind::Bool => self.toggle_bool(FIELDS[self.focus].key), return Ok(());
FieldKind::Cycle(options) => self.cycle(FIELDS[self.focus].key, options), };
match field.kind {
FieldKind::Bool => self.toggle_bool(field.key),
FieldKind::Cycle(options) => self.cycle(field.key, options),
FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => { FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => {
let field = FIELDS[self.focus];
self.edit = Some(EditState { self.edit = Some(EditState {
field, field,
value: self.field_value(&field), value: self.field_value(&field),
@@ -900,24 +1304,56 @@ impl App {
} }
}; };
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let pause = Arc::new(AtomicBool::new(false));
let remaining_ms = Arc::new(AtomicU64::new(
duration.as_millis().min(u128::from(u64::MAX)) as u64,
));
let thread_stop = Arc::clone(&stop); let thread_stop = Arc::clone(&stop);
let thread_pause = Arc::clone(&pause);
let thread_remaining_ms = Arc::clone(&remaining_ms);
let config = self.config.clone(); let config = self.config.clone();
let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None)); let handle = thread::spawn(move || {
run_timer(
&config,
&thread_stop,
&thread_pause,
&thread_remaining_ms,
RunMode::Tui,
TimerOptions::default(),
None,
)
});
self.timer = Some(TimerState { self.timer = Some(TimerState {
started: Instant::now(),
duration, duration,
stop, stop,
pause,
remaining_ms,
handle, handle,
}); });
self.status = "timer running".to_string(); self.status = "timer running".to_string();
} }
fn toggle_pause(&mut self) {
let Some(timer) = &self.timer else {
return;
};
let paused = !timer.pause.load(Ordering::Relaxed);
timer.pause.store(paused, Ordering::Relaxed);
self.status = if paused {
"timer paused".to_string()
} else {
"timer running".to_string()
};
}
fn stop_timer(&mut self) { fn stop_timer(&mut self) {
if let Some(timer) = self.timer.take() { if let Some(timer) = self.timer.take() {
timer.stop.store(true, Ordering::Relaxed); timer.stop.store(true, Ordering::Relaxed);
} self.apply_timer_result(timer.handle.join());
} else {
self.status = "timer stopped".to_string(); self.status = "timer stopped".to_string();
} }
}
fn reap_timer(&mut self) { fn reap_timer(&mut self) {
if self if self
@@ -926,21 +1362,41 @@ impl App {
.is_some_and(|timer| timer.handle.is_finished()) .is_some_and(|timer| timer.handle.is_finished())
{ {
let timer = self.timer.take().expect("timer exists"); let timer = self.timer.take().expect("timer exists");
match timer.handle.join() { self.apply_timer_result(timer.handle.join());
Ok(Ok(())) => self.status = "timer finished".to_string(), }
}
fn apply_timer_result(&mut self, result: thread::Result<Result<TimerReport>>) {
match result {
Ok(Ok(report)) => {
if let Err(err) = append_history(
&history_path(&self.path),
report.to_history("finished", None),
) {
self.status = format!("history failed: {err}");
} else {
self.status = "timer finished".to_string();
}
}
Ok(Err(err)) if err.to_string() == "timer stopped" => {
self.status = "timer stopped".to_string()
}
Ok(Err(err)) => self.status = format!("timer failed: {err}"), Ok(Err(err)) => self.status = format!("timer failed: {err}"),
Err(_) => self.status = "timer failed: thread panicked".to_string(), Err(_) => self.status = "timer failed: thread panicked".to_string(),
} }
} }
}
fn timer_snapshot(&self) -> (Duration, Duration, f64) { fn timer_snapshot(&self) -> (Duration, Duration, f64) {
let Some(timer) = &self.timer else { let Some(timer) = &self.timer else {
return (Duration::ZERO, Duration::ZERO, 0.0); return (Duration::ZERO, Duration::ZERO, 0.0);
}; };
let elapsed = timer.started.elapsed().min(timer.duration); let remaining = Duration::from_millis(timer.remaining_ms.load(Ordering::Relaxed));
let remaining = timer.duration.saturating_sub(elapsed); let elapsed = timer.duration.saturating_sub(remaining).min(timer.duration);
let progress = elapsed.as_secs_f64() / timer.duration.as_secs_f64(); let progress = if timer.duration.is_zero() {
0.0
} else {
elapsed.as_secs_f64() / timer.duration.as_secs_f64()
};
(elapsed, remaining, progress.clamp(0.0, 1.0)) (elapsed, remaining, progress.clamp(0.0, 1.0))
} }
@@ -1005,6 +1461,14 @@ impl App {
.unwrap_or(0); .unwrap_or(0);
*current = options[(index + 1) % options.len()].to_string(); *current = options[(index + 1) % options.len()].to_string();
} }
fn visible_fields(&self) -> Vec<&'static Field> {
visible_fields(&self.config)
}
fn focused_field(&self) -> Option<Field> {
self.visible_fields().get(self.focus).map(|field| **field)
}
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -1024,7 +1488,7 @@ enum FieldKind {
Semi, Semi,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FieldKey { enum FieldKey {
Duration, Duration,
WorkspaceEnabled, WorkspaceEnabled,
@@ -1127,6 +1591,33 @@ const FIELDS: &[Field] = &[
}, },
]; ];
fn visible_fields(config: &Config) -> Vec<&'static Field> {
FIELDS
.iter()
.filter(|field| field_visible(config, field.key))
.collect()
}
fn field_visible(config: &Config, key: FieldKey) -> bool {
match key {
FieldKey::WorkspaceBackend | FieldKey::WorkspaceNumber => config.actions.workspace.enabled,
FieldKey::MediaAction => config.actions.media.enabled,
FieldKey::BrightnessValue => config.actions.brightness.enabled,
FieldKey::LockCommand => config.actions.lock.enabled,
FieldKey::KillProcesses => config.actions.kill.enabled,
FieldKey::CustomCommands => config.actions.custom.enabled,
FieldKey::Duration
| FieldKey::WorkspaceEnabled
| FieldKey::MediaEnabled
| FieldKey::BrightnessEnabled
| FieldKey::MuteEnabled
| FieldKey::LockEnabled
| FieldKey::KillEnabled
| FieldKey::PowerMode
| FieldKey::CustomEnabled => true,
}
}
fn on_off(value: bool) -> String { fn on_off(value: bool) -> String {
if value { "on" } else { "off" }.to_string() if value { "on" } else { "off" }.to_string()
} }
@@ -1179,6 +1670,37 @@ fn format_gauge_label(progress: f64) -> String {
format!("{:>3}%", (progress * 100.0).round() as u16) format!("{:>3}%", (progress * 100.0).round() as u16)
} }
fn ascii_time(value: &str) -> Vec<String> {
let mut rows = vec![String::new(); 5];
for ch in value.chars() {
let glyph = ascii_glyph(ch);
for (row, part) in rows.iter_mut().zip(glyph) {
if !row.is_empty() {
row.push(' ');
}
row.push_str(part);
}
}
rows
}
fn ascii_glyph(ch: char) -> [&'static str; 5] {
match ch {
'0' => [" ### ", "# #", "# #", "# #", " ### "],
'1' => [" # ", " ## ", " # ", " # ", " ### "],
'2' => [" ### ", "# #", " # ", " # ", "#####"],
'3' => ["#### ", " #", " ### ", " #", "#### "],
'4' => ["# #", "# #", "#####", " #", " #"],
'5' => ["#####", "# ", "#### ", " #", "#### "],
'6' => [" ### ", "# ", "#### ", "# #", " ### "],
'7' => ["#####", " #", " # ", " # ", " # "],
'8' => [" ### ", "# #", " ### ", "# #", " ### "],
'9' => [" ### ", "# #", " ####", " #", " ### "],
':' => [" ", " # ", " ", " # ", " "],
_ => [" ", " ", " ", " ", " "],
}
}
fn wrap_command(command: &str, width: usize) -> Vec<String> { fn wrap_command(command: &str, width: usize) -> Vec<String> {
let width = width.max(8); let width = width.max(8);
let mut lines = Vec::new(); let mut lines = Vec::new();
@@ -1280,7 +1802,16 @@ mod tests {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("1ns"); let config = quiet_config("1ns");
run_timer(&config, &stop, RunMode::Tui, None).unwrap(); run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Tui,
TimerOptions::default(),
None,
)
.unwrap();
} }
#[test] #[test]
@@ -1289,7 +1820,16 @@ mod tests {
let config = quiet_config("10ms"); let config = quiet_config("10ms");
let mut output = Vec::new(); let mut output = Vec::new();
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap(); run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli,
TimerOptions::default(),
Some(&mut output),
)
.unwrap();
let text = String::from_utf8(output).unwrap(); let text = String::from_utf8(output).unwrap();
assert!(text.contains("timer started: 10ms")); assert!(text.contains("timer started: 10ms"));
@@ -1297,6 +1837,50 @@ mod tests {
assert!(text.contains("finished")); assert!(text.contains("finished"));
} }
#[test]
fn run_timer_attempts_all_actions_before_returning_failures() {
let stop = Arc::new(AtomicBool::new(false));
let mut config = quiet_config("1ns");
config.actions.custom.enabled = true;
config.actions.custom.commands = vec!["false".to_string(), "true".to_string()];
let mut output = Vec::new();
let error = run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli,
TimerOptions::default(),
Some(&mut output),
)
.unwrap_err();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("run: false"));
assert!(text.contains("run: true"));
assert!(error.to_string().contains("1 action(s) failed"));
}
#[test]
fn ensure_config_validates_loaded_config() {
let path = std::env::temp_dir().join(format!(
"gosleep-timer-invalid-{}-{}.yaml",
std::process::id(),
"power"
));
fs::write(
&path,
"duration: 25m\nactions:\n power:\n mode: suspend\n",
)
.unwrap();
let error = ensure_config(&path).unwrap_err();
fs::remove_file(path).ok();
assert!(error.to_string().contains("invalid power mode"));
}
#[test] #[test]
fn timer_header_text_compacts_for_narrow_widths() { fn timer_header_text_compacts_for_narrow_widths() {
let remaining = Duration::from_secs(65); let remaining = Duration::from_secs(65);
@@ -1323,13 +1907,62 @@ mod tests {
assert_eq!(format_gauge_label(1.0), "100%"); assert_eq!(format_gauge_label(1.0), "100%");
} }
#[test]
fn visible_fields_hide_disabled_action_settings() {
let mut config = quiet_config("25m");
let keys = visible_field_keys(&config);
assert!(keys.contains(&FieldKey::Duration));
assert!(keys.contains(&FieldKey::MediaEnabled));
assert!(!keys.contains(&FieldKey::MediaAction));
assert!(!keys.contains(&FieldKey::WorkspaceBackend));
assert!(!keys.contains(&FieldKey::WorkspaceNumber));
assert!(!keys.contains(&FieldKey::BrightnessValue));
assert!(!keys.contains(&FieldKey::LockCommand));
assert!(!keys.contains(&FieldKey::KillProcesses));
assert!(!keys.contains(&FieldKey::CustomCommands));
config.actions.media.enabled = true;
config.actions.workspace.enabled = true;
config.actions.brightness.enabled = true;
config.actions.lock.enabled = true;
config.actions.kill.enabled = true;
config.actions.custom.enabled = true;
let keys = visible_field_keys(&config);
assert!(keys.contains(&FieldKey::MediaAction));
assert!(keys.contains(&FieldKey::WorkspaceBackend));
assert!(keys.contains(&FieldKey::WorkspaceNumber));
assert!(keys.contains(&FieldKey::BrightnessValue));
assert!(keys.contains(&FieldKey::LockCommand));
assert!(keys.contains(&FieldKey::KillProcesses));
assert!(keys.contains(&FieldKey::CustomCommands));
}
fn visible_field_keys(config: &Config) -> Vec<FieldKey> {
visible_fields(config)
.into_iter()
.map(|field| field.key)
.collect()
}
fn dummy_timer_state() -> TimerState { fn dummy_timer_state() -> TimerState {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn(|| Ok(())); let handle = thread::spawn(|| {
Ok(TimerReport {
started_at: 0,
duration: Duration::from_secs(1),
actions: Vec::new(),
failures: Vec::new(),
dry_run: false,
no_actions: false,
})
});
TimerState { TimerState {
started: Instant::now(),
duration: Duration::from_secs(1), duration: Duration::from_secs(1),
stop, stop,
pause: Arc::new(AtomicBool::new(false)),
remaining_ms: Arc::new(AtomicU64::new(1000)),
handle, handle,
} }
} }