Jake Goldsborough

Shelltrax Part 2: Footer, Tests, and CI

Published October 29, 2025

5 min read

Tags: rust, tui, ci

In Part 1, I built the core of shelltrax

This post covers three improvements: implementing a proper footer with playback progress, adding unit tests for the tricky bits, and setting up CI to keep code quality high.

Running With A Limp

The footer existed from early on (progress bar, time display, track info), but it had a critical bug: when autoplay advanced to the next track, the progress bar wouldn't reset. It would either keep counting from where the previous song left off, showing wrong times and eventually overflowing past 100% or it would just reset back to 0 and not progress. Whatever it did, it didn't work.

The bug was in play_next_track(). It would set playback_start and current_track, but it wouldn't reset paused_duration or paused_at. If you paused the first song for 30 seconds, that 30 seconds would carry over to every subsequent song, throwing off the footer display completely.

Consolidation: begin_playback()

The solution was extracting the timer reset logic into a dedicated method:

pub fn begin_playback(&mut self, track: &LibraryTrack) {
    self.current_track = Some(track.clone());
    self.playback_start = Some(Instant::now());
    self.paused_duration = Duration::ZERO;
    self.paused_at = None;
    self.playback_duration = track.duration.unwrap_or(0);
}

Now play_next_track() calls begin_playback() instead of manually setting fields. This ensures all timing state resets properly when advancing to the next song, whether manually or via autoplay.

Implementation: Tracking Time Correctly

The app needs to track multiple timing values:

pub struct App {
    pub playback_start: Option<Instant>,
    pub playback_duration: u64,
    pub paused_at: Option<Instant>,
    pub paused_duration: Duration,
    // ... other fields
}

When a song starts, we record playback_start. When the user pauses, we record paused_at. When they unpause, we add the pause duration to paused_duration and clear paused_at.

The footer calculation looks like this:

let elapsed = if let Some(paused_at) = app.paused_at {
    paused_at.duration_since(start)
} else {
    now.duration_since(start)
};

let adjusted = elapsed.saturating_sub(app.paused_duration);

If currently paused, elapsed time is frozen at the pause moment. Otherwise, it's the time since playback started. Then we subtract all the accumulated pause time to get the actual playback position.

The saturating_sub is important. Without it, if paused_duration somehow exceeded elapsed (race condition, clock skew, whatever), you'd get an underflow panic. saturating_sub clamps to zero instead.

The footer uses a vertical layout with three lines:

let layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
        Constraint::Length(1),  // Progress bar
        Constraint::Length(1),  // Time display
        Constraint::Length(1),  // Track info
    ])
    .split(inner);

Line 1 is a Gauge widget showing the ratio of elapsed to total time. Line 2 shows MM:SS / MM:SS. Line 3 shows Artist - Title - Album.

The progress bar ratio:

let ratio = if total.as_secs_f64() > 0.0 {
    adjusted.as_secs_f64() / total.as_secs_f64()
} else {
    0.0
};

Clamp it to 1.0 max so the gauge doesn't overflow if the elapsed time somehow exceeds the track duration (can happen with malformed metadata).

Testing: What Actually Needs Tests?

I'm not a fan of testing UI rendering code. It's tedious, fragile, and doesn't catch the bugs that matter. What I do test is the state management logic that the UI depends on.

For shelltrax, the critical logic is:

I added two test modules: one in app.rs for playback logic, one in library.rs for library state.

Testing Playback State

Four tests in app.rs:

test_begin_playback_resets_timers:

#[test]
fn test_begin_playback_resets_timers() {
    let mut app = App::new();

    app.playback_start = Some(Instant::now());
    app.paused_duration = Duration::from_secs(10);
    app.paused_at = Some(Instant::now());

    let track = create_test_track("test", 180);
    app.begin_playback(&track);

    assert!(app.playback_start.is_some());
    assert_eq!(app.paused_duration, Duration::ZERO);
    assert!(app.paused_at.is_none());
}

When starting a new track, all the timing state should reset. If it didn't, the footer would show stale pause data from the previous song.

test_toggle_pause_accumulates_paused_duration:

#[test]
fn test_toggle_pause_accumulates_paused_duration() {
    let mut app = App::new();

    let start = Instant::now();
    app.paused_at = Some(start);
    app.paused_duration = Duration::from_secs(5);

    app.toggle_pause();  // Unpause

    std::thread::sleep(Duration::from_millis(100));

    app.toggle_pause();  // Pause again

    assert!(app.paused_at.is_none());
    assert!(app.paused_duration > Duration::from_secs(5));
}

This test verifies that pausing multiple times accumulates the total paused duration. The sleep is gross but necessary to test time-based logic without mocking the clock (which would require dependency injection, which is overkill for a hobby project).

Testing Library State

Six tests in library.rs covering the artist/album/track hierarchy:

test_add_tracks_creates_structure:

#[test]
fn test_add_tracks_creates_structure() {
    let mut lib = LibraryState::new();

    let tracks = vec![
        create_test_track("Artist A", "Album 1", "Track 1", 1),
        create_test_track("Artist A", "Album 1", "Track 2", 2),
        create_test_track("Artist B", "Album 2", "Track 3", 1),
    ];

    lib.add_tracks(tracks);

    assert_eq!(lib.artists.len(), 2);
    assert_eq!(lib.artists[0].name, "Artist A");
    assert_eq!(lib.artists[0].albums.len(), 1);
    assert_eq!(lib.artists[0].albums[0].tracks.len(), 2);
}

This validates the library builds the correct tree structure when adding tracks. If the grouping logic broke, you'd end up with duplicate artists or albums in the wrong places.

test_visible_tracks_for_album:

#[test]
fn test_visible_tracks_for_album() {
    let mut lib = LibraryState::new();

    lib.add_tracks(vec![
        create_test_track("Artist", "Album 1", "Track 1", 1),
        create_test_track("Artist", "Album 1", "Track 2", 2),
        create_test_track("Artist", "Album 2", "Track 3", 1),
    ]);

    lib.selection = Some(LibrarySelection::Album {
        artist_index: 0,
        album_index: 0,
    });
    let tracks = lib.visible_tracks();

    assert_eq!(tracks.len(), 2);
    assert_eq!(tracks[0].title, "Track 1");
    assert_eq!(tracks[1].title, "Track 2");
}

The visible_tracks method returns different results depending on whether an artist or an album is selected. This test ensures album selection filters correctly.

CI: Keeping Code Quality High

GitHub Actions makes CI trivial for Rust projects. The workflow file:

name: test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4

    - name: install rust
      uses: dtolnay/rust-toolchain@stable
      with:
        components: clippy

    - name: cache dependencies
      uses: actions/cache@v4
      with:
        path: |
          ~/.cargo/bin/
          ~/.cargo/registry/index/
          ~/.cargo/registry/cache/
          ~/.cargo/git/db/
          target/
        key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

    - name: install system dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y libasound2-dev

    - name: run tests
      run: cargo test --verbose

    - name: run clippy
      run: cargo clippy -- -D warnings

The important bits:

Dependency caching: Without caching, every CI run would download and compile all dependencies from scratch. With caching, subsequent runs reuse compiled dependencies, dropping build time from several minutes to under 30 seconds.

System dependencies: The audio libraries (cpal, rodio) need ALSA headers to compile. libasound2-dev provides those on Ubuntu.

Clippy with -D warnings: This flag treats all warnings as errors. It's strict, but it keeps code quality high. If clippy suggests a fix, you either apply it or add an explicit #[allow(...)] annotation explaining why you're ignoring it.

Results

The footer works. Tests pass. CI keeps the codebase clean. Shelltrax now feels like a real music player instead of a tech demo.

Running cargo test shows 10 passing tests:

running 10 tests
test app::tests::test_begin_playback_resets_timers ... ok
test app::tests::test_toggle_pause_accumulates_paused_duration ... ok
test app::tests::test_toggle_pause_cycles_state ... ok
test app::tests::test_toggle_pause_sets_paused_at ... ok
test library::tests::test_add_tracks_creates_structure ... ok
test library::tests::test_toggle_expanded ... ok
test library::tests::test_track_by_path_finds_track ... ok
test library::tests::test_track_by_path_returns_none_for_missing ... ok
test library::tests::test_visible_tracks_for_album ... ok
test library::tests::test_visible_tracks_for_artist ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured

And cargo clippy stays green with zero warnings.

What's Next?

The core functionality is solid, but there are still features I want:

But for now, shelltrax does what I needed it to do: play music in the terminal with a proper UI that shows what's happening.

Code: github.com/ducks/shelltrax