Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a582913269 | |||
| 911aaca355 | |||
| 189ae75a01 | |||
| c39fa10c9c | |||
| 5c9a349114 |
@@ -10,8 +10,38 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
linux-amd64:
|
||||
verify:
|
||||
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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -35,7 +65,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF##*/}"
|
||||
release_url="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
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)"
|
||||
@@ -68,20 +98,21 @@ jobs:
|
||||
- name: Upload Gitea release assets
|
||||
shell: bash
|
||||
run: |
|
||||
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
|
||||
release_id="${{ steps.release.outputs.release_id }}"
|
||||
for asset in dist/*.tar.gz dist/*.sha256; do
|
||||
name="$(basename "$asset")"
|
||||
release="$(curl -fsS \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
|
||||
"${release_url}/${release_id}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")"
|
||||
asset_id="$(printf '%s' "$release" | jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' | head -n 1)"
|
||||
if [ -n "$asset_id" ]; then
|
||||
curl -fsS \
|
||||
-X DELETE "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" \
|
||||
-X DELETE "${release_url}/${release_id}/assets/${asset_id}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}"
|
||||
fi
|
||||
curl -fsS \
|
||||
-X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${name}" \
|
||||
-X POST "${release_url}/${release_id}/assets?name=${name}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${asset}"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# AGENTS.md
|
||||
|
||||
Instructions for coding agents working in this repository.
|
||||
|
||||
## Project Shape
|
||||
|
||||
`gosleep-timer` is a Rust-first, single-binary Linux terminal app.
|
||||
|
||||
- Main code: `src/main.rs`
|
||||
- Package metadata: `Cargo.toml`
|
||||
- Locked dependency graph: `Cargo.lock`
|
||||
- Version files: `Cargo.toml`, `Cargo.lock`, `VERSION`, `CHANGELOG.md`
|
||||
- CI: `.gitea/workflows/ci.yaml`
|
||||
- Release workflow: `.gitea/workflows/release.yaml`
|
||||
- Release docs: `RELEASING.md`
|
||||
|
||||
Do not reintroduce Go, Python, Node, Docker packaging, or multi-language runtime dependencies unless the user explicitly asks for that migration.
|
||||
|
||||
## Required Checks
|
||||
|
||||
Run these before committing code changes:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked -- -D warnings
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
Run YAML lint when editing workflow or YAML files:
|
||||
|
||||
```bash
|
||||
yamllint -c .yamllint .
|
||||
```
|
||||
|
||||
If `yamllint` is not installed, the CI-compatible fallback is:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/work" -w /work cytopia/yamllint:latest -c .yamllint .
|
||||
```
|
||||
|
||||
## Local Smoke Tests
|
||||
|
||||
Use a temporary config path so local tests do not mutate the user's real config:
|
||||
|
||||
```bash
|
||||
tmpdir="$(mktemp -d)"
|
||||
cargo run -- --config "$tmpdir/config.yaml" init
|
||||
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.
|
||||
|
||||
## Versioning
|
||||
|
||||
The project uses semantic versioning. Tags must be annotated and formatted as `vMAJOR.MINOR.PATCH`.
|
||||
|
||||
When bumping a version, update all of:
|
||||
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
- `VERSION`
|
||||
- `CHANGELOG.md`
|
||||
|
||||
Verify version consistency with:
|
||||
|
||||
```bash
|
||||
test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Release workflow is tag-based:
|
||||
|
||||
```bash
|
||||
git tag -a v1.1.1 -m "gosleep-timer v1.1.1"
|
||||
git push origin main
|
||||
git push origin v1.1.1
|
||||
```
|
||||
|
||||
The Gitea release workflow:
|
||||
|
||||
- builds `target/release/gosleep-timer`
|
||||
- creates `gosleep-timer-<version>-linux-amd64.tar.gz`
|
||||
- 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`.
|
||||
|
||||
## Git Remote
|
||||
|
||||
Expected upstream:
|
||||
|
||||
```text
|
||||
origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
|
||||
```
|
||||
|
||||
HTTPS remote URL:
|
||||
|
||||
```text
|
||||
https://gitea.forust.xyz/forust/gosleep.git
|
||||
```
|
||||
|
||||
Before pushing, confirm:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
git branch -vv
|
||||
git remote -v
|
||||
```
|
||||
|
||||
## Repository Hygiene
|
||||
|
||||
- Keep `target/`, `dist/`, `.env*`, logs, temp files, and local artifacts out of git.
|
||||
- Keep `Cargo.lock` committed; this is an application, not a library.
|
||||
- Prefer small, direct Rust changes in `src/main.rs` until the code genuinely needs splitting.
|
||||
- Do not add broad abstractions just to prepare for hypothetical modules.
|
||||
- Do not add shelling out during tests unless the test fully controls the command.
|
||||
- Keep docs synchronized with actual behavior and command names.
|
||||
- Keep release assets and generated archives out of the repo.
|
||||
|
||||
## TUI Notes
|
||||
|
||||
The TUI uses Ratatui and Crossterm. Be careful with terminal-size assumptions.
|
||||
|
||||
For layout changes, check both:
|
||||
|
||||
- narrow terminal behavior, where settings and preview stack vertically
|
||||
- wide terminal behavior, where settings and preview are side by side
|
||||
|
||||
Avoid truncating command previews without a visible indication. Preview wrapping is expected behavior.
|
||||
|
||||
## Safety
|
||||
|
||||
The app can run destructive or disruptive post-countdown commands:
|
||||
|
||||
- `pkill`
|
||||
- `systemctl poweroff`
|
||||
- `systemctl reboot`
|
||||
- arbitrary custom shell commands
|
||||
|
||||
Never run `gosleep-timer run` against the real user config during verification unless the user explicitly asks for it. Use an isolated config and disable actions for smoke tests.
|
||||
@@ -4,6 +4,20 @@ All notable changes to this project are documented here.
|
||||
|
||||
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- Suppressed all background `stdout`/`stderr` writes while the Ratatui screen is active, including child command output from timer actions.
|
||||
- Split `time left` out of the gauge label and reduced the gauge label to a compact percentage for narrow terminal safety.
|
||||
|
||||
## [1.1.1] - 2026-07-03
|
||||
|
||||
### Changed
|
||||
@@ -13,4 +27,6 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`
|
||||
- Added YAML configuration, CLI `init`, `preview`, and `run` commands.
|
||||
- Added Gitea CI and tag-based release workflows.
|
||||
|
||||
[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.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
|
||||
|
||||
Generated
+1
-1
@@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "gosleep-timer"
|
||||
version = "1.1.1"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gosleep-timer"
|
||||
version = "1.1.1"
|
||||
version = "1.1.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
|
||||
|
||||
+8
-6
@@ -40,7 +40,7 @@ https://gitea.forust.xyz/forust/gosleep.git
|
||||
- `sha256sum`
|
||||
- Docker, if using the YAML lint job as written
|
||||
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
|
||||
|
||||
@@ -78,13 +78,15 @@ git push origin v1.1.2
|
||||
|
||||
The `.gitea/workflows/release.yaml` workflow will:
|
||||
|
||||
1. Build `target/release/gosleep-timer`.
|
||||
2. Package `gosleep-timer-<version>-linux-amd64.tar.gz`.
|
||||
3. Generate a SHA-256 checksum.
|
||||
4. Create a Gitea release.
|
||||
5. Upload the archive and checksum as release assets.
|
||||
1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
|
||||
2. Build `target/release/gosleep-timer`.
|
||||
3. Package `gosleep-timer-<version>-linux-amd64.tar.gz`.
|
||||
4. Generate a SHA-256 checksum.
|
||||
5. Create a Gitea release.
|
||||
6. Upload the archive and checksum as release assets.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+187
-23
@@ -2,7 +2,7 @@ use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
process::Command,
|
||||
process::{Command, Stdio},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -199,7 +199,13 @@ fn main() -> Result<()> {
|
||||
if let Some(duration) = duration {
|
||||
config.duration = duration;
|
||||
}
|
||||
run_timer(&config, &Arc::new(AtomicBool::new(false)))
|
||||
let mut stdout = io::stdout();
|
||||
run_timer(
|
||||
&config,
|
||||
&Arc::new(AtomicBool::new(false)),
|
||||
RunMode::Cli,
|
||||
Some(&mut stdout),
|
||||
)
|
||||
}
|
||||
Some(Commands::Preview) => {
|
||||
for command in preview_commands(&config) {
|
||||
@@ -467,42 +473,86 @@ fn preview_commands(config: &Config) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_timer(config: &Config, stop: &Arc<AtomicBool>) -> Result<()> {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RunMode {
|
||||
Cli,
|
||||
Tui,
|
||||
}
|
||||
|
||||
fn write_timer_line(output: &mut dyn Write, message: &str) {
|
||||
writeln!(output, "{message}").ok();
|
||||
}
|
||||
|
||||
fn write_timer_progress(output: &mut dyn Write, message: &str) {
|
||||
write!(output, "\r{message}").ok();
|
||||
output.flush().ok();
|
||||
}
|
||||
|
||||
fn run_timer(
|
||||
config: &Config,
|
||||
stop: &Arc<AtomicBool>,
|
||||
mode: RunMode,
|
||||
mut output: Option<&mut dyn Write>,
|
||||
) -> Result<()> {
|
||||
validate_config(config)?;
|
||||
let duration = parse_duration(&config.duration)?;
|
||||
let started = Instant::now();
|
||||
println!("timer started: {}", config.duration);
|
||||
if let Some(output) = output.as_deref_mut() {
|
||||
write_timer_line(output, &format!("timer started: {}", config.duration));
|
||||
}
|
||||
|
||||
loop {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
println!("\nstopped");
|
||||
if let Some(output) = output.as_deref_mut() {
|
||||
write_timer_line(output, "stopped");
|
||||
}
|
||||
bail!("timer stopped");
|
||||
}
|
||||
let elapsed = started.elapsed();
|
||||
if elapsed >= duration {
|
||||
break;
|
||||
}
|
||||
print!("\rremaining: {}", format_duration(duration - elapsed));
|
||||
io::stdout().flush().ok();
|
||||
if let Some(output) = output.as_deref_mut() {
|
||||
write_timer_progress(
|
||||
output,
|
||||
&format!("remaining: {}", format_duration(duration - elapsed)),
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
|
||||
println!("\nfinished");
|
||||
if let Some(output) = output.as_deref_mut() {
|
||||
write_timer_line(output, "finished");
|
||||
}
|
||||
for command in build_action_commands(config) {
|
||||
println!("run: {}", command.display());
|
||||
run_command(&command).with_context(|| format!("{} failed", command.label))?;
|
||||
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))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command(command: &ActionCommand) -> Result<()> {
|
||||
fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
|
||||
if mode == RunMode::Tui {
|
||||
process.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
|
||||
let status = if let Some(shell) = &command.shell {
|
||||
Command::new("sh").arg("-c").arg(shell).status()?
|
||||
let mut process = Command::new("sh");
|
||||
process.arg("-c").arg(shell);
|
||||
prepare_command_stdio(&mut process, mode);
|
||||
process.status()?
|
||||
} else {
|
||||
let Some(program) = command.args.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
Command::new(program).args(&command.args[1..]).status()?
|
||||
let mut process = Command::new(program);
|
||||
process.args(&command.args[1..]);
|
||||
prepare_command_stdio(&mut process, mode);
|
||||
process.status()?
|
||||
};
|
||||
if !status.success() {
|
||||
bail!("command exited with {status}");
|
||||
@@ -601,7 +651,7 @@ impl App {
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
if area.width < 36 || area.height < 10 {
|
||||
if area.width < 36 || area.height < 11 {
|
||||
frame.render_widget(Paragraph::new("terminal too small"), area);
|
||||
return;
|
||||
}
|
||||
@@ -609,7 +659,7 @@ impl App {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
@@ -633,11 +683,6 @@ impl App {
|
||||
|
||||
fn draw_header(&self, frame: &mut Frame, area: Rect) {
|
||||
let (_, remaining, progress) = self.timer_snapshot();
|
||||
let label = if self.timer.is_some() {
|
||||
format!("time left: {}", format_duration(remaining))
|
||||
} else {
|
||||
"timer not started".to_string()
|
||||
};
|
||||
let header = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -645,6 +690,7 @@ impl App {
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
frame.render_widget(
|
||||
@@ -664,12 +710,21 @@ impl App {
|
||||
Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)),
|
||||
header[2],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format_time_left_text(
|
||||
self.timer.as_ref(),
|
||||
remaining,
|
||||
header[3].width,
|
||||
))
|
||||
.style(Style::default().fg(Color::White)),
|
||||
header[3],
|
||||
);
|
||||
frame.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(Color::Magenta))
|
||||
.label(format!("{label} {:>3}%", (progress * 100.0).round() as u16))
|
||||
.label(format_gauge_label(progress))
|
||||
.ratio(progress),
|
||||
header[3],
|
||||
header[4],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -847,7 +902,7 @@ impl App {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let thread_stop = Arc::clone(&stop);
|
||||
let config = self.config.clone();
|
||||
let handle = thread::spawn(move || run_timer(&config, &thread_stop));
|
||||
let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None));
|
||||
self.timer = Some(TimerState {
|
||||
started: Instant::now(),
|
||||
duration,
|
||||
@@ -1090,6 +1145,40 @@ fn format_duration(duration: Duration) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_compact_duration(duration: Duration) -> String {
|
||||
let total_seconds = duration.as_secs();
|
||||
let hours = total_seconds / 3600;
|
||||
let minutes = (total_seconds % 3600) / 60;
|
||||
let seconds = total_seconds % 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}:{minutes:02}:{seconds:02}")
|
||||
} else {
|
||||
format!("{minutes:02}:{seconds:02}")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time_left_text(timer: Option<&TimerState>, remaining: Duration, width: u16) -> String {
|
||||
if timer.is_none() {
|
||||
return "time left: --".to_string();
|
||||
}
|
||||
|
||||
let full = format!("time left: {}", format_duration(remaining));
|
||||
if full.len() <= width as usize {
|
||||
return full;
|
||||
}
|
||||
|
||||
let compact = format!("left: {}", format_compact_duration(remaining));
|
||||
if compact.len() <= width as usize {
|
||||
return compact;
|
||||
}
|
||||
|
||||
format_compact_duration(remaining)
|
||||
}
|
||||
|
||||
fn format_gauge_label(progress: f64) -> String {
|
||||
format!("{:>3}%", (progress * 100.0).round() as u16)
|
||||
}
|
||||
|
||||
fn wrap_command(command: &str, width: usize) -> Vec<String> {
|
||||
let width = width.max(8);
|
||||
let mut lines = Vec::new();
|
||||
@@ -1125,6 +1214,22 @@ fn split_list(value: &str, separator: char) -> Vec<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quiet_config(duration: &str) -> Config {
|
||||
let mut config = Config {
|
||||
duration: duration.to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
config.actions.workspace.enabled = false;
|
||||
config.actions.media.enabled = false;
|
||||
config.actions.brightness.enabled = false;
|
||||
config.actions.mute.enabled = false;
|
||||
config.actions.lock.enabled = false;
|
||||
config.actions.kill.enabled = false;
|
||||
config.actions.power.mode = "none".to_string();
|
||||
config.actions.custom.enabled = false;
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_accepts_go_style_values() {
|
||||
assert!(parse_duration("25m").is_ok());
|
||||
@@ -1169,4 +1274,63 @@ mod tests {
|
||||
vec!["firefox", "telegram-desktop"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_timer_quiet_mode_writes_nothing() {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let config = quiet_config("1ns");
|
||||
|
||||
run_timer(&config, &stop, RunMode::Tui, None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_timer_cli_mode_writes_status_lines() {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let config = quiet_config("10ms");
|
||||
let mut output = Vec::new();
|
||||
|
||||
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap();
|
||||
|
||||
let text = String::from_utf8(output).unwrap();
|
||||
assert!(text.contains("timer started: 10ms"));
|
||||
assert!(text.contains("remaining:"));
|
||||
assert!(text.contains("finished"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_header_text_compacts_for_narrow_widths() {
|
||||
let remaining = Duration::from_secs(65);
|
||||
|
||||
assert_eq!(format_time_left_text(None, remaining, 20), "time left: --");
|
||||
assert_eq!(
|
||||
format_time_left_text(Some(&dummy_timer_state()), remaining, 40),
|
||||
"time left: 1m5s"
|
||||
);
|
||||
assert_eq!(
|
||||
format_time_left_text(Some(&dummy_timer_state()), remaining, 12),
|
||||
"left: 01:05"
|
||||
);
|
||||
assert_eq!(
|
||||
format_time_left_text(Some(&dummy_timer_state()), remaining, 5),
|
||||
"01:05"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauge_label_is_short_percent_only() {
|
||||
assert_eq!(format_gauge_label(0.0), " 0%");
|
||||
assert_eq!(format_gauge_label(0.58), " 58%");
|
||||
assert_eq!(format_gauge_label(1.0), "100%");
|
||||
}
|
||||
|
||||
fn dummy_timer_state() -> TimerState {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let handle = thread::spawn(|| Ok(()));
|
||||
TimerState {
|
||||
started: Instant::now(),
|
||||
duration: Duration::from_secs(1),
|
||||
stop,
|
||||
handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user