diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4961f95e..da7ed31b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -294,10 +294,21 @@ directly, and TUI handlers adopt it as the conversion sub-PRs land `App::start_playback_uris` / `start_playback_context` / `start_playback_track_in_context` - never a hand-built `IoEvent::StartPlayback` in an arm. -- No wildcard match arm anywhere under `src/core/action/`: - `wildcard_arms_in_action_tree` is pinned at 0 by a raw text scan that - includes tests, comments, and string literals. Write catch-all test arms - as `_other =>`. +- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: + both deny `clippy::wildcard_enum_match_arm` and + `clippy::match_wildcard_for_single_variants` (a named binding like + `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` + scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the + action tree at 0 by a raw text scan that includes tests, comments, and + string literals. CI clippy never compiles tests, so write a test + catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. +- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, + naming the key or gesture that produces it; a variant no gesture produces + is `Exposure::Unbound("reason")`. A new variant is a compile error until + it has an arm, and a test failure until `sample_actions()` in the same + file has a value for it (an `Unbound` one also goes into the `UNBOUND` + pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's + body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. - `Action` derives serde (the future frontend wire shape); a payload type added to it must stay serde-derivable. @@ -374,8 +385,9 @@ Check `app.user_config.keys.` instead of hard-coding key literals for global actions (`handle_app` in `src/tui/handlers/mod.rs`); `common_key_events::{up,down,left,right}_event` extend this to per-screen navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs` plus a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`. +`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in +`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an +`Action`, that variant's `default_binding` arm pointing at the new field. ### Requirements diff --git a/AGENTS.md b/AGENTS.md index 84391352..84338b88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,10 +296,21 @@ directly, and TUI handlers adopt it as the conversion sub-PRs land `App::start_playback_uris` / `start_playback_context` / `start_playback_track_in_context` - never a hand-built `IoEvent::StartPlayback` in an arm. -- No wildcard match arm anywhere under `src/core/action/`: - `wildcard_arms_in_action_tree` is pinned at 0 by a raw text scan that - includes tests, comments, and string literals. Write catch-all test arms - as `_other =>`. +- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: + both deny `clippy::wildcard_enum_match_arm` and + `clippy::match_wildcard_for_single_variants` (a named binding like + `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` + scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the + action tree at 0 by a raw text scan that includes tests, comments, and + string literals. CI clippy never compiles tests, so write a test + catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. +- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, + naming the key or gesture that produces it; a variant no gesture produces + is `Exposure::Unbound("reason")`. A new variant is a compile error until + it has an arm, and a test failure until `sample_actions()` in the same + file has a value for it (an `Unbound` one also goes into the `UNBOUND` + pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's + body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. - `Action` derives serde (the future frontend wire shape); a payload type added to it must stay serde-derivable. @@ -376,8 +387,9 @@ Check `app.user_config.keys.` instead of hard-coding key literals for global actions (`handle_app` in `src/tui/handlers/mod.rs`); `common_key_events::{up,down,left,right}_event` extend this to per-screen navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs` plus a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`. +`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in +`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an +`Action`, that variant's `default_binding` arm pointing at the new field. ### Requirements diff --git a/CLAUDE.md b/CLAUDE.md index c32a864c..35774d3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,10 +296,21 @@ directly, and TUI handlers adopt it as the conversion sub-PRs land `App::start_playback_uris` / `start_playback_context` / `start_playback_track_in_context` - never a hand-built `IoEvent::StartPlayback` in an arm. -- No wildcard match arm anywhere under `src/core/action/`: - `wildcard_arms_in_action_tree` is pinned at 0 by a raw text scan that - includes tests, comments, and string literals. Write catch-all test arms - as `_other =>`. +- No catch-all match arm under `src/core/action/` or in `tui/keymap.rs`: + both deny `clippy::wildcard_enum_match_arm` and + `clippy::match_wildcard_for_single_variants` (a named binding like + `_other =>` counts as a wildcard; `matches!` and `Option`/`Result` + scrutinees are exempt), and `wildcard_arms_in_action_tree` pins the + action tree at 0 by a raw text scan that includes tests, comments, and + string literals. CI clippy never compiles tests, so write a test + catch-all as `_other =>` and only on a `Result`/`Option` scrutinee. +- Every `Action` variant has an arm in `tui/keymap.rs::default_binding`, + naming the key or gesture that produces it; a variant no gesture produces + is `Exposure::Unbound("reason")`. A new variant is a compile error until + it has an arm, and a test failure until `sample_actions()` in the same + file has a value for it (an `Unbound` one also goes into the `UNBOUND` + pin, which a producer scan of `src/tui/` checks). Feature-gate an arm's + body, never the arm: clippy skips a match when any arm carries a `#[cfg]`. - `Action` derives serde (the future frontend wire shape); a payload type added to it must stay serde-derivable. @@ -376,8 +387,9 @@ Check `app.user_config.keys.` instead of hard-coding key literals for global actions (`handle_app` in `src/tui/handlers/mod.rs`); `common_key_events::{up,down,left,right}_event` extend this to per-screen navigation. Adding a binding means fields on both `KeyBindings` and -`KeyBindingsString` in `src/core/user_config.rs` plus a `help_entries` row in -`src/tui/keymap.rs` with its `Requirement`. +`KeyBindingsString` in `src/core/user_config.rs`, a `help_entries` row in +`src/tui/keymap.rs` with its `Requirement`, and, when the key produces an +`Action`, that variant's `default_binding` arm pointing at the new field. ### Requirements diff --git a/src/core/action/mod.rs b/src/core/action/mod.rs index 34ebf232..3827c089 100644 --- a/src/core/action/mod.rs +++ b/src/core/action/mod.rs @@ -21,14 +21,20 @@ //! - Playback starts go through `App::start_playback_uris` / //! `App::start_playback_context`; no arm builds a `StartPlayback` event //! by hand. -//! - Matches in this module are exhaustive. The -//! `wildcard_arms_in_action_tree` gate keeps the catch-all arm count at -//! zero, tests included. +//! - Matches in this module are exhaustive: the deny below refuses a +//! catch-all arm, and the `wildcard_arms_in_action_tree` gate keeps the +//! count at zero, tests included. //! - Address by identity (URIs, ids, names), never by list ordinal. //! //! The serde derives are the wire shape for future frontend codegen; they //! are deliberately in place before any second frontend consumes them. +// The compiler half of the catch-all gate: a new variant must be placed. +#![deny( + clippy::wildcard_enum_match_arm, + clippy::match_wildcard_for_single_variants +)] + use serde::{Deserialize, Serialize}; use crate::core::app::DiscoverTimeRange; diff --git a/src/infra/scripting/CLAUDE.md b/src/infra/scripting/CLAUDE.md index f8603df0..621b0367 100644 --- a/src/infra/scripting/CLAUDE.md +++ b/src/infra/scripting/CLAUDE.md @@ -4,7 +4,9 @@ Plugins never see `&mut App` or rspotify types: reads are cached serde snapshots from `src/core/plugin_api.rs`; writes are shared-vocabulary `Action`s (`src/core/action/`) the engine drains through `App::apply` while holding `&mut App`, each routed through the same `App` method the equivalent keybinding -uses. Snapshot changes must be additive (`#[serde(default)]`, new keys -only) - removing/renaming a key breaks installed plugins and requires bumping +uses, where the terminal has one (`default_binding` in `src/tui/keymap.rs` +records which variants have none). Snapshot changes must be additive +(`#[serde(default)]`, new keys only) - removing/renaming a key breaks +installed plugins and requires bumping `API_VERSION` and updating `docs/scripting.md`. Validation lives in `scripting/api.rs`; a failing callback is disabled on one strike. diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index b094d23e..afa5c244 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -176,7 +176,7 @@ pub fn handle_app(key: Key, app: &mut App) { if key_matches_open_settings_binding(key, app.user_config.keys.open_settings) || key_matches_open_settings_binding(key, effective_open_settings) { - app.open_settings_screen(); + app.apply(Action::Navigate(NavTarget::Settings)); return; } diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs index 3765382a..8a8cc9dd 100644 --- a/src/tui/keymap.rs +++ b/src/tui/keymap.rs @@ -1,10 +1,17 @@ -//! The terminal's key surface: one table of help rows, each with the -//! [`Requirement`] it needs, filtered by the active source and the session so -//! the help menu never lists a key the session cannot serve. +//! The terminal's key surface: the help rows, each with the [`Requirement`] +//! it needs and filtered by the active source and the session, and the +//! registry naming the key or gesture that produces each [`Action`]. +#![deny( + clippy::wildcard_enum_match_arm, + clippy::match_wildcard_for_single_variants +)] + +use crate::core::action::{Action, CopyTarget, LibraryTarget, NavTarget}; use crate::core::app::App; use crate::core::input::Key; use crate::core::requirement::{Capability, Requirement}; +use crate::core::sort::SortContext; use crate::core::source::Source; use crate::core::user_config::KeyBindings; @@ -477,9 +484,274 @@ pub fn help_rows(app: &App) -> Vec> { .collect() } +/// Where the terminal offers a shared [`Action`]. +// Read by the meta-test only, until the GUI affordance table lands. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub enum TuiSurface { + /// A rebindable key from `config.yml`, read like [`HelpKey::Binding`]. + Binding(fn(&KeyBindings) -> Key), + /// A key one screen hard-codes; `context` names the screen like the help table. + Literal { + key: &'static str, + context: &'static str, + }, + /// A pointer gesture with no key at all. + Mouse(&'static str), +} + +/// Whether a frontend offers an [`Action`] at all. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub enum Exposure { + Bound(S), + /// No producer in this build, and why. + Unbound(&'static str), +} + +const ENTER: &str = ""; +const PLUGIN_ONLY: &str = "plugin-only: the scripting engine is its only producer"; +#[cfg(not(feature = "ai-dj"))] +const NO_AI_DJ: Exposure = + Exposure::Unbound("the AI DJ keys exist only in ai-dj builds"); + +fn binding(read: fn(&KeyBindings) -> Key) -> Exposure { + Exposure::Bound(TuiSurface::Binding(read)) +} + +fn literal(key: &'static str, context: &'static str) -> Exposure { + Exposure::Bound(TuiSurface::Literal { key, context }) +} + +/// Each [`Action`]'s primary terminal surface; `Bound` means reachable in this build. +// No production caller yet: the meta-test below and the GUI affordance table read it. +#[allow(dead_code)] +pub fn default_binding(action: &Action) -> Exposure { + use Exposure::Unbound; + match action { + Action::Play => Unbound("the terminal binds the play/pause toggle, not the play intent"), + Action::Pause => Unbound("the terminal binds the play/pause toggle, not the pause intent"), + Action::TogglePlayback => binding(|k| k.toggle_playback), + Action::NextTrack => binding(|k| k.next_track), + Action::PreviousTrack => binding(|k| k.previous_track), + Action::ForcePreviousTrack => binding(|k| k.force_previous_track), + Action::SeekTo(_) => Exposure::Bound(TuiSurface::Mouse( + "a click or drag on the playbar progress line", + )), + Action::SeekForward => binding(|k| k.seek_forwards), + Action::SeekBackward => binding(|k| k.seek_backwards), + Action::SetVolume(_) => Unbound("the terminal steps the volume, it never sets it"), + Action::VolumeUp => binding(|k| k.increase_volume), + Action::VolumeDown => binding(|k| k.decrease_volume), + Action::SetShuffle(_) => Unbound("the terminal toggles shuffle, it never sets it"), + Action::ToggleShuffle => binding(|k| k.shuffle), + Action::CycleRepeat => binding(|k| k.repeat), + Action::SetRepeat(_) => Unbound("the terminal cycles repeat, it never sets it"), + Action::PlayUris { .. } => literal( + ENTER, + "Track table / search songs / artist top tracks / recently played", + ), + Action::PlayContext { .. } => literal(ENTER, "Album tracks"), + Action::PlayTrackInContext { .. } => literal(ENTER, "Track table (playlist views)"), + Action::TransferPlayback { .. } => literal(ENTER, "Source and device picker"), + Action::AddToQueue(_) => { + Unbound("the queue key feeds the native queue through QueueTrack, not the Web API queue") + } + Action::QueueTrack(_) => binding(|k| k.add_item_to_queue), + Action::PlayQueueItem { .. } => literal(ENTER, "Queue"), + Action::RemoveFromQueue { .. } => binding(|k| k.remove_from_queue), + Action::MoveQueueItem { .. } => literal("J / K", "Queue"), + Action::Search(_) => Unbound("the search box is source-scoped and produces SearchActiveSource"), + Action::SearchActiveSource(_) => literal(ENTER, "Search input"), + Action::SearchPlaylistTracks { .. } => { + literal(", then ", "Track table (playlist views)") + } + Action::CreatePlaylist { .. } => literal(ENTER, "Create playlist form"), + Action::CreateYouTubePlaylist(_) => literal(ENTER, "Create playlist form"), + Action::SearchTracksForPlaylist(_) => literal(ENTER, "Create playlist form"), + Action::AddTrackToPlaylist { .. } => literal(ENTER, "Add to playlist picker"), + Action::RemoveTrackFromPlaylist { .. } => literal(ENTER, "Remove track confirmation"), + Action::FollowPlaylist(_) => literal("w", "Search result"), + Action::UnfollowPlaylist(_) => literal("D", "Playlist"), + Action::DeletePlaylist(_) => literal("D", "Playlist"), + Action::ToggleSaveTrack(_) => literal("s", "Selected block"), + Action::ToggleSaveCurrentItem => binding(|k| k.like_track), + Action::SaveAlbum(_) => literal("w", "Search result"), + Action::UnsaveAlbum(_) => literal("D", "Library -> Albums"), + Action::SaveShow(_) => literal("w", "Search result"), + Action::UnsaveShow(_) => literal("D", "Library -> Podcasts"), + Action::FollowArtist(_) => literal("w", "Search result"), + Action::UnfollowArtist(_) => literal("D", "Library -> Artists"), + Action::AddFriendByCode(_) => literal(ENTER, "Add friend dialog"), + Action::AddFriendById(_) => literal(ENTER, "Add friend dialog"), + Action::UnfollowFriend(_) => literal("u", "Friends"), + Action::SearchFriendUsers(_) => literal("typing", "Add friend dialog"), + Action::FavoriteRadioStation(_) => binding(|k| k.like_track), + Action::RemoveRadioStation(_) => literal("D", "Radio"), + Action::Notify(..) => Unbound("a status message is a consequence, never a gesture"), + Action::NotifyError(..) => Unbound("a status message is a consequence, never a gesture"), + Action::Navigate(NavTarget::Home) => { + Unbound("the terminal reaches Home by popping the stack, never by a key") + } + Action::Navigate(NavTarget::Queue) => binding(|k| k.show_queue), + Action::Navigate(NavTarget::Settings) => binding(|k| k.open_settings), + Action::Navigate(NavTarget::Devices) => binding(|k| k.manage_devices), + Action::Navigate(NavTarget::Help) => binding(|k| k.help), + Action::Navigate(NavTarget::Lyrics) => binding(|k| k.lyrics_view), + Action::Navigate(NavTarget::RecentlyPlayed) => { + Unbound("the sidebar opens Recently Played through OpenLibrary") + } + Action::Navigate(NavTarget::Party) => binding(|k| k.listening_party), + Action::Navigate(NavTarget::Analysis) => binding(|k| k.audio_analysis), + Action::Navigate(NavTarget::MiniPlayer) => binding(|k| k.miniplayer_view), + Action::Back => Unbound( + "the back key runs the runner's richer path: filter clear, settings prompt, announcement dismissal, search double-pop, exit prompt", + ), + Action::LoadMore(_) => binding(|k| k.next_page), + Action::Sort { + context: SortContext::PlaylistTracks | SortContext::SavedAlbums | SortContext::SavedArtists, + .. + } => literal(ENTER, "Sort menu"), + Action::Sort { + context: SortContext::RecentlyPlayed, + .. + } => Unbound("no sort menu opens on Recently Played"), + Action::ToggleSortOrder( + SortContext::PlaylistTracks | SortContext::SavedAlbums | SortContext::SavedArtists, + ) => literal("uppercase field shortcut", "Sort menu"), + Action::ToggleSortOrder(SortContext::RecentlyPlayed) => { + Unbound("no sort menu opens on Recently Played") + } + Action::Open(_) => literal(ENTER, "Selected block"), + Action::OpenShowEpisodes(_) => literal(ENTER, "Library -> Podcasts"), + Action::OpenLibrary( + LibraryTarget::Discover + | LibraryTarget::RecentlyPlayed + | LibraryTarget::Friends + | LibraryTarget::Stats + | LibraryTarget::LikedSongs + | LibraryTarget::Albums + | LibraryTarget::Artists + | LibraryTarget::Podcasts, + ) => literal(ENTER, "Library sidebar"), + Action::OpenLibrary(LibraryTarget::LocalFiles) => { + #[cfg(feature = "local-files")] + { + literal(ENTER, "Library sidebar") + } + #[cfg(not(feature = "local-files"))] + { + Unbound("the Local Files row exists only in local-files builds") + } + } + Action::OpenLibrary(LibraryTarget::AiDj) => { + #[cfg(feature = "ai-dj")] + { + binding(|k| k.dj_open) + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + Action::OpenDiscover(_) => literal(ENTER, "Discover"), + Action::SelectSource(_) => literal(ENTER, "Source and device picker"), + Action::OpenAddTrackDialog => literal("w", "Track table"), + Action::OpenAddTrackDialogFor { .. } => literal( + "w", + "Track table / search songs / artist top tracks / recently played", + ), + Action::OpenAddPlayingTrackDialog => literal("W", "Global"), + Action::OpenRemoveTrackDialog => literal("x", "Track table (playlist views)"), + Action::JumpToAlbum => binding(|k| k.jump_to_album), + Action::JumpToArtist => binding(|k| k.jump_to_artist_album), + Action::JumpToContext => binding(|k| k.jump_to_context), + Action::CopyUrl(CopyTarget::CurrentSong) => binding(|k| k.copy_song_url), + Action::CopyUrl(CopyTarget::CurrentAlbum) => binding(|k| k.copy_album_url), + Action::GenerateRecap => binding(|k| k.generate_recap), + Action::CycleStatsPeriod { .. } => literal("[ / ]", "Stats"), + Action::RecommendFromTrack(_) => literal("r", "Selected block"), + Action::RecommendFromArtist { .. } => literal("r", "Selected block"), + Action::RecommendFromTrackId { .. } => literal("r", "Selected block"), + Action::StartParty => literal("h", "Listening Party menu"), + Action::JoinParty { .. } => literal(ENTER, "Listening Party menu"), + Action::LeaveParty => literal("l", "Listening Party menu"), + Action::TogglePartyControlMode => literal("c", "Listening Party menu"), + Action::SetPlaybarSegment { .. } => Unbound(PLUGIN_ONLY), + Action::ShowPopup(_) => Unbound(PLUGIN_ONLY), + Action::ClosePopup => literal("", "Plugin popup"), + Action::SetTheme(_) => Unbound(PLUGIN_ONLY), + Action::SaveSettings => binding(|k| k.save_settings), + Action::CycleVisualizerStyle => literal("V", "Audio analysis"), + Action::SetScreenContent { .. } => Unbound(PLUGIN_ONLY), + Action::ShowScreen(_) => Unbound(PLUGIN_ONLY), + Action::CloseScreen(_) => Unbound(PLUGIN_ONLY), + Action::QueueTracks(_) => Unbound("the DJ and MCP queue tools have no gesture"), + Action::SetDjVibe(_) => Unbound("the DJ and MCP vibe tools have no gesture"), + Action::AskDj(_) => { + #[cfg(feature = "ai-dj")] + { + literal(ENTER, "AI DJ") + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + Action::DjVibeShift => { + #[cfg(feature = "ai-dj")] + { + binding(|k| k.dj_vibe_shift) + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + Action::ToggleDjAutoQueue => { + #[cfg(feature = "ai-dj")] + { + binding(|k| k.dj_toggle_auto_queue) + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + Action::ToggleDjFreshOnly => { + #[cfg(feature = "ai-dj")] + { + binding(|k| k.dj_toggle_fresh_only) + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + Action::OpenDjSetup => { + #[cfg(feature = "ai-dj")] + { + binding(|k| k.dj_pick_model) + } + #[cfg(not(feature = "ai-dj"))] + { + NO_AI_DJ + } + } + } +} + #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use std::path::{Path, PathBuf}; + use super::*; + use crate::core::action::{DiscoverTarget, ListTarget, OpenTarget, RepeatSetting}; + use crate::core::plugin_api::{PluginPopup, PluginScreenContent, ShowInfo, TrackInfo}; + use crate::core::sort::SortField; + use crate::core::test_helpers::full_track; + use crate::core::theme::{Color, ThemeField}; fn descriptions(app: &App) -> Vec { help_rows(app) @@ -564,4 +836,443 @@ mod tests { assert_eq!(row[1], "N"); assert_eq!(row[2], "General"); } + + /// The variants no terminal gesture produces, in every build. + const UNBOUND: &[&str] = &[ + "AddToQueue", + "Back", + "CloseScreen", + "Notify", + "NotifyError", + "Pause", + "Play", + "QueueTracks", + "Search", + "SetDjVibe", + "SetPlaybarSegment", + "SetRepeat", + "SetScreenContent", + "SetShuffle", + "SetTheme", + "SetVolume", + "ShowPopup", + "ShowScreen", + ]; + + /// Bound only with `ai-dj`: their producers are on disk but compiled out here. + #[cfg(not(feature = "ai-dj"))] + const FEATURE_UNBOUND: &[&str] = &[ + "AskDj", + "DjVibeShift", + "OpenDjSetup", + "ToggleDjAutoQueue", + "ToggleDjFreshOnly", + ]; + #[cfg(feature = "ai-dj")] + const FEATURE_UNBOUND: &[&str] = &[]; + + fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + } + + fn rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rs_files(&path, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + out.push(path); + } + } + } + + /// `source` without its test items and comment lines: a gated block item + /// runs to its column-0 `}` (rustfmt's shape), a braceless one to its `;`. + fn production_half(source: &str) -> String { + let mut out = String::new(); + let mut gated = false; + let mut in_block = false; + let mut in_item = false; + for line in source.lines() { + if in_block { + in_block = line != "}"; + continue; + } + if in_item { + in_item = !line.ends_with(';'); + continue; + } + if gated { + gated = line.starts_with("#["); + if !gated { + in_block = line.ends_with('{'); + in_item = !in_block && !line.ends_with(';') && !line.ends_with('}'); + } + continue; + } + if line.starts_with("#[cfg(test)]") || line.starts_with("#[cfg(all(test") { + gated = true; + continue; + } + if line.trim_start().starts_with("//") { + continue; + } + out.push_str(line); + out.push('\n'); + } + out + } + + fn leading_ident(text: &str) -> &str { + let end = text + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(text.len()); + &text[..end] + } + + /// The variant names of `pub enum Action`, read from its source: the lines + /// at exactly two spaces whose identifier is followed by `,`, `(` or ` {`. + fn action_variant_names() -> BTreeSet { + let source = std::fs::read_to_string(repo_root().join("src/core/action/mod.rs")).unwrap(); + source + .lines() + .skip_while(|line| *line != "pub enum Action {") + .skip(1) + .take_while(|line| *line != "}") + .filter_map(|line| { + let rest = line.strip_prefix(" ")?; + let name = leading_ident(rest); + let tail = &rest[name.len()..]; + let is_variant = !name.is_empty() + && (tail.starts_with(',') || tail.starts_with('(') || tail.starts_with(" {")); + is_variant.then(|| name.to_string()) + }) + .collect() + } + + /// `Action::` in `source`, bounded on both sides so `PlaybackAction::` + /// is skipped and `Action::Play` is not found inside `Action::PlayUris`. + fn producers_in(source: &str) -> BTreeSet { + let production = production_half(source); + let mut found = BTreeSet::new(); + for (start, needle) in production.match_indices("Action::") { + let before = start.checked_sub(1).map(|i| production.as_bytes()[i]); + if before.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_') { + continue; + } + let name = leading_ident(&production[start + needle.len()..]); + if !name.is_empty() { + found.insert(name.to_string()); + } + } + found + } + + /// Every variant some production file under `src/tui/` builds. The + /// registry itself is skipped: it names every variant and produces none. + fn tui_producers() -> BTreeSet { + let mut files = Vec::new(); + rs_files(&repo_root().join("src/tui"), &mut files); + let registry = repo_root().join("src/tui/keymap.rs"); + files + .iter() + .filter(|path| **path != registry) + .flat_map(|path| producers_in(&std::fs::read_to_string(path).unwrap())) + .collect() + } + + /// Derived `Debug` starts with the variant name: `Play`, `SeekTo(0)`, + /// `PlayUris { .. }`. + fn variant_name(action: &Action) -> String { + format!("{action:?}") + .split(['(', ' ']) + .next() + .unwrap() + .to_string() + } + + /// One value per `Action` variant, with a reachable payload where the + /// registry answers per payload. + fn sample_actions() -> Vec { + let text = String::new; + let track = || TrackInfo::from(&full_track("4uLU6hMCjMI75M1A2tKUQC", "T")); + vec![ + Action::Play, + Action::Pause, + Action::TogglePlayback, + Action::NextTrack, + Action::PreviousTrack, + Action::ForcePreviousTrack, + Action::SeekTo(0), + Action::SeekForward, + Action::SeekBackward, + Action::SetVolume(0), + Action::VolumeUp, + Action::VolumeDown, + Action::SetShuffle(false), + Action::ToggleShuffle, + Action::CycleRepeat, + Action::SetRepeat(RepeatSetting::Off), + Action::PlayUris { + uris: vec![], + offset: None, + }, + Action::PlayContext { + uri: text(), + offset: None, + }, + Action::PlayTrackInContext { + context: text(), + track: text(), + }, + Action::TransferPlayback { + device_id: text(), + persist: false, + }, + Action::AddToQueue(text()), + Action::QueueTrack(track()), + Action::PlayQueueItem { + uri: text(), + position: 0, + }, + Action::RemoveFromQueue { + uri: text(), + position: 0, + }, + Action::MoveQueueItem { + uri: text(), + from: 0, + to: 0, + }, + Action::Search(text()), + Action::SearchActiveSource(text()), + Action::SearchPlaylistTracks { + playlist_id: text(), + query: text(), + }, + Action::CreatePlaylist { + name: text(), + track_uris: vec![], + }, + Action::CreateYouTubePlaylist(text()), + Action::SearchTracksForPlaylist(text()), + Action::AddTrackToPlaylist { + playlist: text(), + track: text(), + }, + Action::RemoveTrackFromPlaylist { + playlist: text(), + track: text(), + position: 0, + }, + Action::FollowPlaylist(text()), + Action::UnfollowPlaylist(text()), + Action::DeletePlaylist(text()), + Action::ToggleSaveTrack(text()), + Action::ToggleSaveCurrentItem, + Action::SaveAlbum(text()), + Action::UnsaveAlbum(text()), + Action::SaveShow(text()), + Action::UnsaveShow(text()), + Action::FollowArtist(text()), + Action::UnfollowArtist(text()), + Action::AddFriendByCode(text()), + Action::AddFriendById(text()), + Action::UnfollowFriend(text()), + Action::SearchFriendUsers(text()), + Action::FavoriteRadioStation(track()), + Action::RemoveRadioStation(text()), + Action::Notify(text(), 0), + Action::NotifyError(text(), 0), + Action::Navigate(NavTarget::Queue), + Action::Back, + Action::LoadMore(ListTarget::PlaylistTracks), + Action::Sort { + context: SortContext::PlaylistTracks, + field: SortField::default(), + }, + Action::ToggleSortOrder(SortContext::PlaylistTracks), + Action::Open(OpenTarget::SavedAlbum(text())), + Action::OpenShowEpisodes(ShowInfo::default()), + Action::OpenLibrary(LibraryTarget::Discover), + Action::OpenDiscover(DiscoverTarget::ArtistsMix), + Action::SelectSource(Source::Spotify), + Action::OpenAddTrackDialog, + Action::OpenAddTrackDialogFor { + track_id: None, + track_name: text(), + }, + Action::OpenAddPlayingTrackDialog, + Action::OpenRemoveTrackDialog, + Action::JumpToAlbum, + Action::JumpToArtist, + Action::JumpToContext, + Action::CopyUrl(CopyTarget::CurrentSong), + Action::GenerateRecap, + Action::CycleStatsPeriod { forward: true }, + Action::RecommendFromTrack(track()), + Action::RecommendFromArtist { + id: text(), + name: text(), + }, + Action::RecommendFromTrackId { + id: text(), + name: text(), + }, + Action::StartParty, + Action::JoinParty { + code: text(), + name: text(), + }, + Action::LeaveParty, + Action::TogglePartyControlMode, + Action::SetPlaybarSegment { + plugin: text(), + text: None, + }, + Action::ShowPopup(PluginPopup { + title: text(), + lines: vec![], + }), + Action::ClosePopup, + Action::SetTheme(vec![(ThemeField::Active, Color::Reset)]), + Action::SaveSettings, + Action::CycleVisualizerStyle, + Action::SetScreenContent { + name: text(), + content: PluginScreenContent::default(), + }, + Action::ShowScreen(text()), + Action::CloseScreen(text()), + Action::QueueTracks(vec![]), + Action::SetDjVibe(None), + Action::AskDj(text()), + Action::DjVibeShift, + Action::ToggleDjAutoQueue, + Action::ToggleDjFreshOnly, + Action::OpenDjSetup, + ] + } + + #[test] + fn every_action_variant_has_exactly_one_sample() { + let names: Vec = sample_actions().iter().map(variant_name).collect(); + let sampled: BTreeSet = names.iter().cloned().collect(); + assert_eq!(sampled.len(), names.len(), "a variant is sampled twice"); + assert_eq!( + sampled, + action_variant_names(), + "sample_actions() and `pub enum Action` disagree" + ); + } + + #[test] + fn the_unbound_set_is_pinned_by_name() { + let measured: BTreeSet = sample_actions() + .iter() + .filter(|action| matches!(default_binding(action), Exposure::Unbound(_))) + .map(variant_name) + .collect(); + let pinned: BTreeSet = UNBOUND + .iter() + .chain(FEATURE_UNBOUND) + .map(|name| name.to_string()) + .collect(); + assert_eq!(measured, pinned, "move UNBOUND together with the registry"); + } + + #[test] + fn bound_variants_have_a_terminal_producer_and_unbound_ones_have_none() { + let producers = tui_producers(); + for action in sample_actions() { + let name = variant_name(&action); + match default_binding(&action) { + Exposure::Bound(_) => assert!( + producers.contains(&name), + "Action::{name} claims a surface but nothing under src/tui/ builds it" + ), + Exposure::Unbound(reason) => assert!( + FEATURE_UNBOUND.contains(&name.as_str()) || !producers.contains(&name), + "Action::{name} is Unbound ({reason}) but src/tui/ builds it" + ), + } + } + } + + #[test] + fn the_payloads_no_gesture_reaches_are_pinned_by_hand() { + let unreached = [ + Action::Navigate(NavTarget::Home), + Action::Navigate(NavTarget::RecentlyPlayed), + Action::Sort { + context: SortContext::RecentlyPlayed, + field: SortField::default(), + }, + Action::ToggleSortOrder(SortContext::RecentlyPlayed), + ]; + for action in &unreached { + assert!( + matches!(default_binding(action), Exposure::Unbound(_)), + "{action:?}" + ); + } + for target in NavTarget::ALL { + if target != NavTarget::Home && target != NavTarget::RecentlyPlayed { + assert!( + matches!( + default_binding(&Action::Navigate(target)), + Exposure::Bound(_) + ), + "{target:?}" + ); + } + } + let bound = |target| { + matches!( + default_binding(&Action::OpenLibrary(target)), + Exposure::Bound(_) + ) + }; + assert_eq!(bound(LibraryTarget::AiDj), cfg!(feature = "ai-dj")); + assert_eq!( + bound(LibraryTarget::LocalFiles), + cfg!(feature = "local-files") + ); + } + + #[test] + fn the_enum_scanner_keeps_variants_and_drops_docs_attributes_and_fields() { + let names = action_variant_names(); + assert!( + names.contains("QueueTracks"), + "a variant after a cfg_attr line" + ); + assert!(names.contains("DjVibeShift"), "a variant with no doc line"); + assert!(names.contains("PlayUris"), "a struct variant"); + assert!(!names.contains("position"), "a struct field"); + assert!(!names.contains("cfg_attr")); + } + + #[test] + fn the_producer_scan_is_identifier_bounded_and_skips_tests_and_comments() { + let source = "app.apply(Action::PlayUris { uris: vec![] });\n\ + PlaybackAction::Play;\n\ + /// through the shared `Action::Pause` vocabulary\n\ + #[cfg(test)]\n\ + mod tests {\n Action::Back\n}\n\ + #[cfg(test)]\n\ + const HIDDEN: Option = Some(\n Action::Notify(String::new(), 0),\n);\n\ + #[cfg(test)]\n\ + #[allow(dead_code)]\n\ + fn probe() -> Action { Action::Play }\n\ + let seek = Action::Search(query);\n"; + let expected: BTreeSet = ["PlayUris", "Search"] + .into_iter() + .map(str::to_string) + .collect(); + assert_eq!(producers_in(source), expected); + } } diff --git a/tools/gates.count b/tools/gates.count index 25ae2b35..e2f2a12e 100644 --- a/tools/gates.count +++ b/tools/gates.count @@ -12,5 +12,5 @@ ioevent_refs_in_tui = 51 # target 0 synthetic_keys_in_mouse_handler = 3 # target 0 (the content-table re-entries through handle_block_events) wildcard_arms_in_action_tree = 0 # target 0, must stay 0 view_writes_outside_tui = 12 # target 0 (producers outside tui/ and core/app/ writing App::view) -action_refs_in_tui_handlers = 173 # adoption: may only rise -test_attribute_total = 1803 # adoption: may only rise +action_refs_in_tui_handlers = 174 # adoption: may only rise +test_attribute_total = 1809 # adoption: may only rise