fix: editor split, stop flag in actions, write errors, history on failure, expect panic, string match

- src/main.rs: split VISUAL/EDITOR into prog+args, drop expect panic in reap_timer, propagate write_timer errors, check stop during actions, record history on partial failure, use const for stop msg
- Cargo.toml: add authors/homepage, bump noyalib 0.0.12->0.0.13
- release.yaml: tighten tag glob, fix curl 404 fallback, URI-encode asset name, upgrade upload-artifact v3->v4
- ci.yaml: add cargo cache step
- README.md: sync feature list, fix push to two-step
- RELEASING.md: clarify Cargo.lock regeneration
This commit is contained in:
2026-07-07 23:13:19 +02:00
parent 69e20c89ee
commit 1a77043a71
7 changed files with 86 additions and 43 deletions
+8
View File
@@ -14,6 +14,14 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Cache Cargo registry and build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
target
key: cargo-${{ hashFiles('Cargo.lock') }}
- name: Check formatting - name: Check formatting
shell: bash shell: bash
run: cargo fmt --check run: cargo fmt --check
+5 -4
View File
@@ -3,7 +3,7 @@ name: release
"on": "on":
push: push:
tags: tags:
- "v*.*.*" - "v[0-9]*.[0-9]*.[0-9]*"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -69,7 +69,7 @@ jobs:
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
existing="$(curl -fsS \ existing="$(curl -fsS \
"${release_url}/tags/${tag}" \ "${release_url}/tags/${tag}" \
-H "Authorization: token ${GITEA_TOKEN}" || true)" -H "Authorization: token ${GITEA_TOKEN}")" || existing='{}'
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')" release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
@@ -112,14 +112,15 @@ jobs:
-X DELETE "${release_url}/${release_id}/assets/${asset_id}" \ -X DELETE "${release_url}/${release_id}/assets/${asset_id}" \
-H "Authorization: token ${GITEA_TOKEN}" -H "Authorization: token ${GITEA_TOKEN}"
fi fi
encoded="$(printf '%s' "$name" | jq -sRr @uri)"
curl -fsS \ curl -fsS \
-X POST "${release_url}/${release_id}/assets?name=${name}" \ -X POST "${release_url}/${release_id}/assets?name=${encoded}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset}" -F "attachment=@${asset}"
done done
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: gosleep-timer-linux-amd64 name: gosleep-timer-linux-amd64
path: dist/* path: dist/*
Generated
+2 -2
View File
@@ -807,9 +807,9 @@ dependencies = [
[[package]] [[package]]
name = "noyalib" name = "noyalib"
version = "0.0.12" version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "903f06e76b14b388b47d651b9498b008888a9475084087e51c294731acbf16c1" checksum = "2095d0b3b6017ebbdaffedf83ffb2bff7e468ab379ac391239abea4880833290"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"itoa", "itoa",
+3 -1
View File
@@ -1,11 +1,13 @@
[package] [package]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.2.2" version = "1.2.2"
authors = ["forust"]
edition = "2024" edition = "2024"
rust-version = "1.88" rust-version = "1.88"
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"
license = "MIT" license = "MIT"
repository = "https://gitea.forust.xyz/forust/gosleep" repository = "https://gitea.forust.xyz/forust/gosleep"
homepage = "https://gitea.forust.xyz/forust/gosleep"
readme = "README.md" readme = "README.md"
keywords = ["timer", "tui", "linux", "desktop", "sleep"] keywords = ["timer", "tui", "linux", "desktop", "sleep"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
@@ -20,7 +22,7 @@ clap = { version = "4.5", features = ["derive"] }
crossterm = "0.29" crossterm = "0.29"
ratatui = "0.30.2" ratatui = "0.30.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
noyalib = "0.0.12" noyalib = "0.0.13"
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
+3 -2
View File
@@ -15,7 +15,7 @@ Rust TUI sleep timer for Linux desktop actions.
- Single Rust binary, independent of Python. - Single Rust binary, independent of Python.
- Ratatui/Crossterm terminal UI. - Ratatui/Crossterm terminal UI.
- YAML config at `~/.config/gosleep-timer/config.yaml` or `$XDG_CONFIG_HOME/gosleep-timer/config.yaml`. - YAML config at `~/.config/gosleep-timer/config.yaml` or `$XDG_CONFIG_HOME/gosleep-timer/config.yaml`.
- CLI commands for initialization, preview, and direct timer runs. - CLI commands for init, status, validate, edit, preview, run, history, and stats.
- Time-left display and progress bar. - Time-left display and progress bar.
- Wrapped command preview for smaller terminals. - Wrapped command preview for smaller terminals.
- Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands. - Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands.
@@ -224,7 +224,8 @@ Release tags must match the package version:
```bash ```bash
git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH" git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
git push origin main --tags git push origin main
git push origin vMAJOR.MINOR.PATCH
``` ```
## Releases ## Releases
+1 -1
View File
@@ -52,7 +52,7 @@ version=1.1.4
Update: Update:
- `Cargo.toml`: `package.version` - `Cargo.toml`: `package.version`
- `Cargo.lock`: package version, regenerated by Cargo - `Cargo.lock`: run `cargo build --release` (without `--locked`) to regenerate
- `VERSION` - `VERSION`
- `CHANGELOG.md` - `CHANGELOG.md`
+64 -33
View File
@@ -12,6 +12,8 @@ use std::{
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
const TIMER_STOPPED_MSG: &str = "timer stopped";
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
@@ -295,10 +297,23 @@ fn main() -> Result<()> {
Some(&mut stdout), Some(&mut stdout),
)?; )?;
let slept_seconds = prompt_wake_confirmation(&config, options)?; let slept_seconds = prompt_wake_confirmation(&config, options)?;
let status = if report.failures.is_empty() {
"finished"
} else {
"partial"
};
append_history( append_history(
&history_path(&path), &history_path(&path),
report.to_history("finished", slept_seconds), report.to_history(status, slept_seconds),
) )?;
if !report.failures.is_empty() {
bail!(
"{} action(s) failed: {}",
report.failures.len(),
report.failures.join("; ")
);
}
Ok(())
} }
Some(Commands::Status) => { Some(Commands::Status) => {
print_status(&path, &config); print_status(&path, &config);
@@ -466,10 +481,12 @@ fn edit_config(path: &Path) -> Result<()> {
let editor = std::env::var("VISUAL") let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR")) .or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string()); .unwrap_or_else(|_| "vi".to_string());
let status = Command::new(editor) let mut parts = editor.split_whitespace();
.arg(path) let program = parts.next().context("empty editor command")?;
.status() let mut cmd = Command::new(program);
.context("open editor")?; cmd.args(parts);
cmd.arg(path);
let status = cmd.status().context("open editor")?;
if !status.success() { if !status.success() {
bail!("editor exited with {status}"); bail!("editor exited with {status}");
} }
@@ -716,13 +733,13 @@ enum RunMode {
Tui, Tui,
} }
fn write_timer_line(output: &mut dyn Write, message: &str) { fn write_timer_line(output: &mut dyn Write, message: &str) -> io::Result<()> {
writeln!(output, "{message}").ok(); writeln!(output, "{message}")
} }
fn write_timer_progress(output: &mut dyn Write, message: &str) { fn write_timer_progress(output: &mut dyn Write, message: &str) -> io::Result<()> {
write!(output, "\r{message}").ok(); write!(output, "\r{message}")?;
output.flush().ok(); output.flush()
} }
fn run_timer( fn run_timer(
@@ -745,15 +762,15 @@ fn run_timer(
Ordering::Relaxed, 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))?;
} }
loop { loop {
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped"); write_timer_line(output, "stopped")?;
} }
bail!("timer stopped"); bail!(TIMER_STOPPED_MSG);
} }
if pause.load(Ordering::Relaxed) { if pause.load(Ordering::Relaxed) {
@@ -778,7 +795,7 @@ fn run_timer(
write_timer_progress( write_timer_progress(
output, output,
&format!("remaining: {}", format_duration(remaining)), &format!("remaining: {}", format_duration(remaining)),
); )?;
} }
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
@@ -786,13 +803,13 @@ fn run_timer(
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped"); write_timer_line(output, "stopped")?;
} }
bail!("timer 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")?;
} }
let mut failures = Vec::new(); let mut failures = Vec::new();
let actions = if options.no_actions { let actions = if options.no_actions {
@@ -801,9 +818,15 @@ fn run_timer(
build_action_commands(config) build_action_commands(config)
}; };
for command in &actions { for command in &actions {
if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped")?;
}
break;
}
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
let prefix = if options.dry_run { "would run" } else { "run" }; let prefix = if options.dry_run { "would run" } else { "run" };
write_timer_line(output, &format!("{prefix}: {}", command.display())); write_timer_line(output, &format!("{prefix}: {}", command.display()))?;
} }
if options.dry_run { if options.dry_run {
continue; continue;
@@ -820,12 +843,8 @@ fn run_timer(
dry_run: options.dry_run, dry_run: options.dry_run,
no_actions: options.no_actions, no_actions: options.no_actions,
}; };
if !failures.is_empty() { if !failures.is_empty() && let Some(output) = &mut output {
bail!( write_timer_line(output, &format!("{} action(s) failed", failures.len()))?;
"{} action(s) failed: {}",
failures.len(),
failures.join("; ")
);
} }
Ok(report) Ok(report)
} }
@@ -1359,7 +1378,9 @@ impl App {
.as_ref() .as_ref()
.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 Some(timer) = self.timer.take() else {
return;
};
self.apply_timer_result(timer.handle.join()); self.apply_timer_result(timer.handle.join());
} }
} }
@@ -1367,16 +1388,25 @@ impl App {
fn apply_timer_result(&mut self, result: thread::Result<Result<TimerReport>>) { fn apply_timer_result(&mut self, result: thread::Result<Result<TimerReport>>) {
match result { match result {
Ok(Ok(report)) => { Ok(Ok(report)) => {
if let Err(err) = append_history( let status = if report.failures.is_empty() {
&history_path(&self.path), "finished"
report.to_history("finished", None), } else {
) { "partial"
};
if let Err(err) =
append_history(&history_path(&self.path), report.to_history(status, None))
{
self.status = format!("history failed: {err}"); self.status = format!("history failed: {err}");
} else if !report.failures.is_empty() {
self.status = format!(
"timer finished ({} action(s) failed)",
report.failures.len()
);
} else { } else {
self.status = "timer finished".to_string(); self.status = "timer finished".to_string();
} }
} }
Ok(Err(err)) if err.to_string() == "timer stopped" => { Ok(Err(err)) if err.to_string() == TIMER_STOPPED_MSG => {
self.status = "timer stopped".to_string() self.status = "timer stopped".to_string()
} }
Ok(Err(err)) => self.status = format!("timer failed: {err}"), Ok(Err(err)) => self.status = format!("timer failed: {err}"),
@@ -1951,7 +1981,7 @@ mod tests {
config.actions.custom.commands = vec!["false".to_string(), "true".to_string()]; config.actions.custom.commands = vec!["false".to_string(), "true".to_string()];
let mut output = Vec::new(); let mut output = Vec::new();
let error = run_timer( let report = run_timer(
&config, &config,
&stop, &stop,
&Arc::new(AtomicBool::new(false)), &Arc::new(AtomicBool::new(false)),
@@ -1960,12 +1990,13 @@ mod tests {
TimerOptions::default(), TimerOptions::default(),
Some(&mut output), Some(&mut output),
) )
.unwrap_err(); .unwrap();
let text = String::from_utf8(output).unwrap(); let text = String::from_utf8(output).unwrap();
assert!(text.contains("run: false")); assert!(text.contains("run: false"));
assert!(text.contains("run: true")); assert!(text.contains("run: true"));
assert!(error.to_string().contains("1 action(s) failed")); assert_eq!(report.failures.len(), 1);
assert!(report.failures[0].contains("custom failed"));
} }
#[test] #[test]