diff --git a/Cargo.lock b/Cargo.lock index 34267e2..e90be02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -698,7 +698,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "plinks" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index f923565..42aa993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "plinks" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "Project-local link manager with shell commands and a ratatui TUI" license = "MIT" diff --git a/src/cli.rs b/src/cli.rs index f0016ab..446e604 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,13 +1,16 @@ -use clap::{ArgGroup, Args, Parser, Subcommand}; +use clap::{ArgAction, ArgGroup, Args, Parser, Subcommand}; #[derive(Debug, Parser)] #[command( name = "plinks", + version, about = "Manage shared project links from the command line or TUI", long_about = "Manage shared project links stored in project-links.toml.\n\nUse plinks to save docs, dashboards, tickets, and runbooks under stable project-local names so the whole repository can share them.", after_help = "Examples:\n plinks add docs https://docs.rs --alias api --tag rust\n plinks open docs\n plinks open api\n plinks open --tag rust\n plinks list --tag rust\n plinks manage\n\nUse `plinks help ` for command-specific examples." )] pub struct Cli { + #[arg(short = 'v', action = ArgAction::Version, help = "Print version")] + pub version_flag: Option, #[command(subcommand)] pub command: Option, } diff --git a/src/tui/events.rs b/src/tui/events.rs index f2be63c..db6f7a7 100644 --- a/src/tui/events.rs +++ b/src/tui/events.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use super::editor::EditorState; use super::state::{App, Mode}; @@ -15,6 +15,10 @@ pub enum EventResult { } pub fn handle_key(app: &mut App, key: KeyEvent) -> Result { + if matches!(key.kind, KeyEventKind::Release) { + return Ok(EventResult::None); + } + match app.mode.clone() { Mode::Normal => handle_normal_mode(app, key), Mode::Filter => Ok(handle_filter_mode(app, key)), @@ -156,3 +160,102 @@ fn handle_discard_confirm_mode(app: &mut App, key: KeyEvent) -> EventResult { _ => EventResult::None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CandidateLink, Config}; + use crate::tui::editor::Field; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) + } + + fn release_key(code: KeyCode) -> KeyEvent { + KeyEvent::new_with_kind(code, KeyModifiers::empty(), KeyEventKind::Release) + } + + fn sample_app() -> App { + let mut config = Config::default(); + config + .save_link( + None, + CandidateLink::new( + "docs".into(), + "https://docs.rs".into(), + vec!["api".into()], + vec!["rust".into()], + None, + ) + .unwrap(), + ) + .unwrap(); + App::new(config) + } + + #[test] + fn ignores_release_events_in_filter_mode() { + let mut app = sample_app(); + app.mode = Mode::Filter; + + assert_eq!( + handle_key(&mut app, key(KeyCode::Char('a'))).unwrap(), + EventResult::None + ); + assert_eq!(app.filter, "a"); + + assert_eq!( + handle_key(&mut app, release_key(KeyCode::Char('a'))).unwrap(), + EventResult::None + ); + assert_eq!(app.filter, "a"); + } + + #[test] + fn ignores_release_events_in_editor_mode() { + let mut app = sample_app(); + app.begin_new(); + + assert_eq!( + handle_key(&mut app, key(KeyCode::Char('a'))).unwrap(), + EventResult::None + ); + let Mode::Editor(editor) = &app.mode else { + panic!("expected editor mode"); + }; + assert_eq!(editor.primary, "a"); + + assert_eq!( + handle_key(&mut app, release_key(KeyCode::Char('a'))).unwrap(), + EventResult::None + ); + let Mode::Editor(editor) = &app.mode else { + panic!("expected editor mode"); + }; + assert_eq!(editor.primary, "a"); + } + + #[test] + fn ignores_release_events_for_tab_navigation() { + let mut app = sample_app(); + app.begin_new(); + + assert_eq!( + handle_key(&mut app, key(KeyCode::Tab)).unwrap(), + EventResult::None + ); + let Mode::Editor(editor) = &app.mode else { + panic!("expected editor mode"); + }; + assert_eq!(editor.active_field, Field::Url); + + assert_eq!( + handle_key(&mut app, release_key(KeyCode::Tab)).unwrap(), + EventResult::None + ); + let Mode::Editor(editor) = &app.mode else { + panic!("expected editor mode"); + }; + assert_eq!(editor.active_field, Field::Url); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index fc81766..04ba644 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -64,6 +64,13 @@ fn help_output(args: &[&str]) -> String { String::from_utf8(output.stdout).unwrap() } +fn command_output(args: &[&str]) -> std::process::Output { + ProcessCommand::new(env!("CARGO_BIN_EXE_plinks")) + .args(args) + .output() + .unwrap() +} + #[test] fn add_creates_file_when_absent() { let temp = TempDir::new().unwrap(); @@ -317,6 +324,8 @@ fn top_level_help_lists_command_summaries_and_examples() { let help = help_output(&["-h"]); assert!(help.contains("Manage shared project links from the command line or TUI")); + assert!(help.contains("-v")); + assert!(help.contains("--version")); assert!(help.contains("open")); assert!(help.contains("Open a saved link by primary name, alias, or tag")); assert!(help.contains("list")); @@ -336,6 +345,24 @@ fn no_args_defaults_to_manage_command() { assert!(cli.command.is_none()); } +#[test] +fn version_flags_print_crate_version() { + for args in [&["--version"][..], &["-v"][..]] { + let output = command_output(args); + assert!( + output.status.success(), + "version command failed with status {}.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + assert_eq!(stdout, format!("plinks {}\n", env!("CARGO_PKG_VERSION"))); + assert!(output.stderr.is_empty()); + } +} + #[test] fn add_help_explains_primary_name_and_options() { let help = help_output(&["add", "-h"]);