diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 024d3d9..9cd45e0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -14,6 +14,14 @@ jobs: - name: Checkout repository 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 shell: bash run: cargo fmt --check diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 1fc9a63..d444a37 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -3,7 +3,7 @@ name: release "on": push: tags: - - "v*.*.*" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: permissions: @@ -69,7 +69,7 @@ jobs: release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" existing="$(curl -fsS \ "${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')" if [ -z "$release_id" ]; then @@ -112,14 +112,15 @@ jobs: -X DELETE "${release_url}/${release_id}/assets/${asset_id}" \ -H "Authorization: token ${GITEA_TOKEN}" fi + encoded="$(printf '%s' "$name" | jq -sRr @uri)" 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}" \ -F "attachment=@${asset}" done - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: gosleep-timer-linux-amd64 path: dist/* diff --git a/Cargo.lock b/Cargo.lock index d19dc58..f6a00e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -807,9 +807,9 @@ dependencies = [ [[package]] name = "noyalib" -version = "0.0.12" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903f06e76b14b388b47d651b9498b008888a9475084087e51c294731acbf16c1" +checksum = "2095d0b3b6017ebbdaffedf83ffb2bff7e468ab379ac391239abea4880833290" dependencies = [ "indexmap", "itoa", diff --git a/Cargo.toml b/Cargo.toml index 6a9401d..d42903c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "gosleep-timer" version = "1.2.2" +authors = ["forust"] edition = "2024" rust-version = "1.88" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" license = "MIT" repository = "https://gitea.forust.xyz/forust/gosleep" +homepage = "https://gitea.forust.xyz/forust/gosleep" readme = "README.md" keywords = ["timer", "tui", "linux", "desktop", "sleep"] categories = ["command-line-utilities"] @@ -20,7 +22,7 @@ clap = { version = "4.5", features = ["derive"] } crossterm = "0.29" ratatui = "0.30.2" serde = { version = "1.0", features = ["derive"] } -noyalib = "0.0.12" +noyalib = "0.0.13" [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 29a8414..a529cd1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Rust TUI sleep timer for Linux desktop actions. - Single Rust binary, independent of Python. - Ratatui/Crossterm terminal UI. - 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. - Wrapped command preview for smaller terminals. - 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 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 diff --git a/RELEASING.md b/RELEASING.md index 51e47b2..6fc3a12 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -52,7 +52,7 @@ version=1.1.4 Update: - `Cargo.toml`: `package.version` -- `Cargo.lock`: package version, regenerated by Cargo +- `Cargo.lock`: run `cargo build --release` (without `--locked`) to regenerate - `VERSION` - `CHANGELOG.md` diff --git a/src/main.rs b/src/main.rs index 88d7633..73692b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use std::{ }; use anyhow::{Context, Result, bail}; + +const TIMER_STOPPED_MSG: &str = "timer stopped"; use clap::{Parser, Subcommand}; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, @@ -295,10 +297,23 @@ fn main() -> Result<()> { Some(&mut stdout), )?; let slept_seconds = prompt_wake_confirmation(&config, options)?; + let status = if report.failures.is_empty() { + "finished" + } else { + "partial" + }; append_history( &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) => { print_status(&path, &config); @@ -466,10 +481,12 @@ 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")?; + let mut parts = editor.split_whitespace(); + let program = parts.next().context("empty editor command")?; + let mut cmd = Command::new(program); + cmd.args(parts); + cmd.arg(path); + let status = cmd.status().context("open editor")?; if !status.success() { bail!("editor exited with {status}"); } @@ -716,13 +733,13 @@ enum RunMode { Tui, } -fn write_timer_line(output: &mut dyn Write, message: &str) { - writeln!(output, "{message}").ok(); +fn write_timer_line(output: &mut dyn Write, message: &str) -> io::Result<()> { + writeln!(output, "{message}") } -fn write_timer_progress(output: &mut dyn Write, message: &str) { - write!(output, "\r{message}").ok(); - output.flush().ok(); +fn write_timer_progress(output: &mut dyn Write, message: &str) -> io::Result<()> { + write!(output, "\r{message}")?; + output.flush() } fn run_timer( @@ -745,15 +762,15 @@ fn run_timer( Ordering::Relaxed, ); 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 { if stop.load(Ordering::Relaxed) { 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) { @@ -778,7 +795,7 @@ fn run_timer( write_timer_progress( output, &format!("remaining: {}", format_duration(remaining)), - ); + )?; } thread::sleep(Duration::from_millis(250)); } @@ -786,13 +803,13 @@ fn run_timer( if stop.load(Ordering::Relaxed) { if let Some(output) = output.as_deref_mut() { - write_timer_line(output, "stopped"); + write_timer_line(output, "stopped")?; } bail!("timer stopped"); } if let Some(output) = output.as_deref_mut() { - write_timer_line(output, "finished"); + write_timer_line(output, "finished")?; } let mut failures = Vec::new(); let actions = if options.no_actions { @@ -801,9 +818,15 @@ fn run_timer( build_action_commands(config) }; 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() { 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 { continue; @@ -820,12 +843,8 @@ fn run_timer( dry_run: options.dry_run, no_actions: options.no_actions, }; - if !failures.is_empty() { - bail!( - "{} action(s) failed: {}", - failures.len(), - failures.join("; ") - ); + if !failures.is_empty() && let Some(output) = &mut output { + write_timer_line(output, &format!("{} action(s) failed", failures.len()))?; } Ok(report) } @@ -1359,7 +1378,9 @@ impl App { .as_ref() .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()); } } @@ -1367,16 +1388,25 @@ impl App { fn apply_timer_result(&mut self, result: thread::Result>) { match result { Ok(Ok(report)) => { - if let Err(err) = append_history( - &history_path(&self.path), - report.to_history("finished", None), - ) { + let status = if report.failures.is_empty() { + "finished" + } else { + "partial" + }; + if let Err(err) = + append_history(&history_path(&self.path), report.to_history(status, None)) + { self.status = format!("history failed: {err}"); + } else if !report.failures.is_empty() { + self.status = format!( + "timer finished ({} action(s) failed)", + report.failures.len() + ); } else { 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() } 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()]; let mut output = Vec::new(); - let error = run_timer( + let report = run_timer( &config, &stop, &Arc::new(AtomicBool::new(false)), @@ -1960,12 +1990,13 @@ mod tests { TimerOptions::default(), Some(&mut output), ) - .unwrap_err(); + .unwrap(); 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")); + assert_eq!(report.failures.len(), 1); + assert!(report.failures[0].contains("custom failed")); } #[test]