diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 5bff030..1fc9a63 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -58,7 +58,8 @@ jobs: mkdir -p dist binary="gosleep-timer-${version}-linux-amd64" cp target/release/gosleep-timer "dist/${binary}" - sha256sum "dist/${binary}" > "dist/${binary}.sha256" + cd dist + sha256sum "${binary}" > "${binary}.sha256" - name: Create Gitea release id: release @@ -100,7 +101,7 @@ jobs: run: | release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" release_id="${{ steps.release.outputs.release_id }}" - for asset in dist/gosleep-timer-* dist/gosleep-timer-*.sha256; do + for asset in dist/gosleep-timer-*; do name="$(basename "$asset")" release="$(curl -fsS \ "${release_url}/${release_id}" \ diff --git a/AGENTS.md b/AGENTS.md index 8530e72..49e31db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,20 +73,21 @@ test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r ' Release workflow is tag-based: ```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 v1.1.1 +git push origin vMAJOR.MINOR.PATCH ``` The Gitea release workflow: +- runs formatting, tests, clippy, and YAML lint before building - builds `target/release/gosleep-timer` -- creates `gosleep-timer--linux-amd64.tar.gz` +- copies `gosleep-timer--linux-amd64` - writes a `.sha256` - creates or reuses the matching Gitea release - 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 diff --git a/README.md b/README.md index d61226e..21bbc9d 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer ### From release artifact -Download `gosleep-timer--linux-amd64.tar.gz` from the Gitea release page, then: +Download `gosleep-timer--linux-amd64` and its `.sha256` from the Gitea release page, then: ```bash -tar -xzf gosleep-timer--linux-amd64.tar.gz -install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer +sha256sum -c gosleep-timer--linux-amd64.sha256 +install -Dm755 gosleep-timer--linux-amd64 ~/.local/bin/gosleep-timer ``` ## Usage @@ -197,12 +197,11 @@ Required runner tools: - Rust stable toolchain with `cargo`, `rustfmt`, and `clippy` - `curl` - `jq` -- `tar` - `sha256sum` 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. See [RELEASING.md](RELEASING.md) for the full release checklist. diff --git a/RELEASING.md b/RELEASING.md index 8cb93f0..51e47b2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -36,7 +36,6 @@ https://gitea.forust.xyz/forust/gosleep.git - `clippy` - `curl` - `jq` - - `tar` - `sha256sum` - Docker, if using the YAML lint job as written 4. Confirm Actions job token permissions allow `contents: write`. @@ -44,10 +43,10 @@ https://gitea.forust.xyz/forust/gosleep.git ## 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 -version=1.1.2 +version=1.1.4 ``` Update: @@ -70,10 +69,10 @@ cargo build --release --locked ```bash git status --short -git commit -am "chore: release v1.1.2" -git tag -a v1.1.2 -m "gosleep-timer v1.1.2" +git commit -am "chore: release v${version}" +git tag -a "v${version}" -m "gosleep-timer v${version}" git push origin main -git push origin v1.1.2 +git push origin "v${version}" ``` The `.gitea/workflows/release.yaml` workflow will: @@ -98,5 +97,6 @@ cargo build --release --locked mkdir -p dist binary="gosleep-timer-${version}-linux-amd64" cp target/release/gosleep-timer "dist/${binary}" -sha256sum "dist/${binary}" > "dist/${binary}.sha256" +cd dist +sha256sum "${binary}" > "${binary}.sha256" ``` diff --git a/src/main.rs b/src/main.rs index 78254ed..dd27f95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -242,6 +242,7 @@ fn ensure_config(path: &PathBuf) -> Result { 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")?; normalize_config(&mut config); + validate_config(&config)?; Ok(config) } @@ -521,14 +522,31 @@ fn run_timer( thread::sleep(Duration::from_millis(250)); } + 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() { write_timer_line(output, "finished"); } + let mut failures = Vec::new(); for command in build_action_commands(config) { if let Some(output) = output.as_deref_mut() { write_timer_line(output, &format!("run: {}", command.display())); } - run_command(&command, mode).with_context(|| format!("{} failed", command.label))?; + if let Err(err) = run_command(&command, mode) { + failures.push(format!("{} failed: {err}", command.label)); + } + } + if !failures.is_empty() { + bail!( + "{} action(s) failed: {}", + failures.len(), + failures.join("; ") + ); } Ok(()) } @@ -562,15 +580,18 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> { fn run_tui(path: PathBuf, config: Config) -> Result<()> { enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - let mut app = App::new(path, config); - let result = app.run(&mut terminal); + let result = (|| { + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let mut app = App::new(path, config); + let result = app.run(&mut terminal); + terminal.show_cursor().ok(); + result + })(); disable_raw_mode().ok(); - execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); - terminal.show_cursor().ok(); + execute!(io::stdout(), LeaveAlternateScreen).ok(); result } @@ -636,7 +657,8 @@ impl App { self.focus = self.focus.saturating_sub(1) } 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('s') => self.save(), @@ -747,8 +769,10 @@ impl App { } fn draw_fields(&mut self, frame: &mut Frame, area: Rect) { + let fields = self.visible_fields(); 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 { self.offset = self.focus; } @@ -756,7 +780,7 @@ impl App { self.offset = self.focus.saturating_sub(visible.saturating_sub(1)); } - let items = FIELDS + let items = fields .iter() .enumerate() .skip(self.offset) @@ -809,11 +833,13 @@ impl App { } fn activate(&mut self) -> Result<()> { - match FIELDS[self.focus].kind { - FieldKind::Bool => self.toggle_bool(FIELDS[self.focus].key), - FieldKind::Cycle(options) => self.cycle(FIELDS[self.focus].key, options), + let Some(field) = self.focused_field() else { + return Ok(()); + }; + 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 => { - let field = FIELDS[self.focus]; self.edit = Some(EditState { field, value: self.field_value(&field), @@ -915,8 +941,10 @@ impl App { fn stop_timer(&mut self) { if let Some(timer) = self.timer.take() { 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) { @@ -926,11 +954,18 @@ impl App { .is_some_and(|timer| timer.handle.is_finished()) { let timer = self.timer.take().expect("timer exists"); - match timer.handle.join() { - Ok(Ok(())) => self.status = "timer finished".to_string(), - Ok(Err(err)) => self.status = format!("timer failed: {err}"), - Err(_) => self.status = "timer failed: thread panicked".to_string(), + self.apply_timer_result(timer.handle.join()); + } + } + + fn apply_timer_result(&mut self, result: thread::Result>) { + match result { + Ok(Ok(())) => 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}"), + Err(_) => self.status = "timer failed: thread panicked".to_string(), } } @@ -1005,6 +1040,14 @@ impl App { .unwrap_or(0); *current = options[(index + 1) % options.len()].to_string(); } + + fn visible_fields(&self) -> Vec<&'static Field> { + visible_fields(&self.config) + } + + fn focused_field(&self) -> Option { + self.visible_fields().get(self.focus).map(|field| **field) + } } #[derive(Clone, Copy)] @@ -1024,7 +1067,7 @@ enum FieldKind { Semi, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FieldKey { Duration, WorkspaceEnabled, @@ -1127,6 +1170,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 { if value { "on" } else { "off" }.to_string() } @@ -1297,6 +1367,41 @@ mod tests { 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, RunMode::Cli, 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] fn timer_header_text_compacts_for_narrow_widths() { let remaining = Duration::from_secs(65); @@ -1323,6 +1428,45 @@ mod tests { 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 { + visible_fields(config) + .into_iter() + .map(|field| field.key) + .collect() + } + fn dummy_timer_state() -> TimerState { let stop = Arc::new(AtomicBool::new(false)); let handle = thread::spawn(|| Ok(()));