diff --git a/README.md b/README.md index a86dd6b..fabcd43 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,37 @@ - Group related links with tags. - Manage the file directly from the terminal with shell commands or an interactive TUI. +## Build + +Build a development binary from the checkout: + +```bash +cargo build +``` + +Build an optimized release binary: + +```bash +cargo build --release +``` + +The compiled binary is written to: + +- `target/debug/plinks` for development builds +- `target/release/plinks` for release builds + +Run the binary directly from the checkout: + +```bash +cargo run -- +``` + +Install from the local checkout into Cargo's bin directory: + +```bash +cargo install --path . +``` + ## Install Prebuilt binaries are published on GitHub Releases for these targets: @@ -21,16 +52,12 @@ Each asset is target-specific. Windows, Linux, and macOS binaries are not interc Windows releases are unsigned portable `.zip` archives containing `plinks.exe`, `LICENSE`, and `README.md`. Linux and macOS releases are `.tar.gz` archives containing `plinks`, `LICENSE`, and `README.md`. Depending on local policy, Windows may show SmartScreen or other trust warnings before first launch. -Build from source: +Get command help: ```bash -cargo install --path . -``` - -Or run it from the checkout: - -```bash -cargo run -- +plinks --help +plinks help add +plinks open --help ``` ## Usage @@ -55,6 +82,8 @@ plinks open docs plinks open api ``` +In `plinks add docs https://docs.rs`, `docs` is the link's primary name. The primary name is the stable project-local identifier used by commands like `plinks open docs` and `plinks remove docs`. + Open every link with a tag: ```bash @@ -103,16 +132,22 @@ Primary names, aliases, and tags are normalized to lowercase and may contain let ## Development -Build: +Test: ```bash -cargo build --release +cargo test ``` -Test: +Install the repository Git hooks: ```bash -cargo test +./scripts/install-git-hooks.sh +``` + +The pre-commit hook runs the same lint commands as CI: + +```bash +./scripts/run-linters.sh ``` ## Releases @@ -136,21 +171,30 @@ Arch packaging remains a separate distribution path and is still generated with ## Arch Linux Packaging -Generate Arch packaging artifacts: +Build the Arch distribution artifacts: ```bash ./scripts/build-arch-package.sh ``` -This writes the source tarball and `PKGBUILD` to `dist/arch/`. +This writes the source tarball and `PKGBUILD` to `dist/arch/`. It is a packaging flow for Arch, separate from the normal Rust build in the `Build` section above. -Build the package with `makepkg`: +Build the package locally with `makepkg`: ```bash cd dist/arch -makepkg -f +makepkg -Cf ``` +Build and install the package with `makepkg`: + +```bash +cd dist/arch +makepkg -Csi +``` + +`./scripts/build-arch-package.sh` removes previously built `pkg.tar.*` artifacts in `dist/arch/`, so rerunning this sequence rebuilds the package instead of reusing an older archive. + ## License MIT. See [LICENSE](LICENSE). diff --git a/src/cli.rs b/src/cli.rs index 625dc2b..f0016ab 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,51 +1,88 @@ use clap::{ArgGroup, Args, Parser, Subcommand}; #[derive(Debug, Parser)] -#[command(name = "plinks", about = "Project-local link manager")] +#[command( + name = "plinks", + 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 { #[command(subcommand)] - pub command: Command, + pub command: Option, } #[derive(Debug, Subcommand)] pub enum Command { + #[command(about = "Open a saved link by primary name, alias, or tag")] Open(OpenArgs), #[command(alias = "ls")] + #[command(about = "List saved links")] List(ListArgs), + #[command(about = "Add a saved link")] Add(AddArgs), + #[command(about = "Remove a saved link by primary name")] Remove(RemoveArgs), + #[command(about = "Open the interactive terminal UI")] Manage, } #[derive(Debug, Args)] -#[command(group(ArgGroup::new("target").required(true).args(["name", "tag"])))] +#[command( + group(ArgGroup::new("target").required(true).args(["name", "tag"])), + after_help = "Examples:\n plinks open docs\n plinks open api\n plinks open --tag rust" +)] pub struct OpenArgs { + #[arg( + value_name = "NAME_OR_ALIAS", + help = "Primary name or alias of the saved link to open" + )] pub name: Option, - #[arg(long)] + #[arg(long, value_name = "TAG", help = "Open every saved link with this tag")] pub tag: Option, } #[derive(Debug, Args)] pub struct ListArgs { - #[arg(long)] + #[arg(long, value_name = "TAG", help = "Only show saved links with this tag")] pub tag: Option, } #[derive(Debug, Args)] +#[command( + after_help = "Examples:\n plinks add docs https://docs.rs\n plinks add docs https://docs.rs --alias api --tag rust --note \"Rust API docs\"\n plinks add docs https://docs.rs --force" +)] pub struct AddArgs { + #[arg( + value_name = "PRIMARY_NAME", + help = "Stable primary name for the link, used by `plinks open` and `plinks remove`" + )] pub primary: String, + #[arg(value_name = "URL", help = "URL to store for this link")] pub url: String, - #[arg(long = "alias")] + #[arg( + long = "alias", + value_name = "ALIAS", + help = "Additional name that can also be used with `plinks open`" + )] pub aliases: Vec, - #[arg(long = "tag")] + #[arg( + long = "tag", + value_name = "TAG", + help = "Tag used to group links for `plinks list --tag` or `plinks open --tag`" + )] pub tags: Vec, - #[arg(long)] + #[arg(long, value_name = "TEXT", help = "Optional human-readable note")] pub note: Option, - #[arg(long)] + #[arg(long, help = "Replace an existing link with the same primary name")] pub force: bool, } #[derive(Debug, Args)] pub struct RemoveArgs { + #[arg( + value_name = "PRIMARY_NAME", + help = "Primary name of the saved link to remove" + )] pub primary: String, } diff --git a/src/lib.rs b/src/lib.rs index e800fd3..0b18aae 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,7 @@ use open_link::LinkOpener; use project_root::resolve_config_path; pub fn run(cli: Cli, cwd: &Path, opener: &dyn LinkOpener, out: &mut dyn Write) -> Result<()> { - match cli.command { + match cli.command.unwrap_or(Command::Manage) { Command::Open(args) => run_open(args, cwd, opener), Command::List(args) => run_list(args, cwd, out), Command::Add(args) => run_add(args, cwd, out), diff --git a/tests/cli.rs b/tests/cli.rs index 69c9f31..fc81766 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::fs; use std::path::Path; +use std::process::Command as ProcessCommand; use anyhow::Result; use clap::Parser; @@ -47,6 +48,22 @@ fn load_config(path: &Path) -> Config { .unwrap() } +fn help_output(args: &[&str]) -> String { + let output = ProcessCommand::new(env!("CARGO_BIN_EXE_plinks")) + .args(args) + .output() + .unwrap(); + + assert!( + output.status.success(), + "help command failed with status {}.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + #[test] fn add_creates_file_when_absent() { let temp = TempDir::new().unwrap(); @@ -294,3 +311,88 @@ fn git_root_resolution_is_used_from_nested_directory() { assert!(repo.join("project-links.toml").exists()); } + +#[test] +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("open")); + assert!(help.contains("Open a saved link by primary name, alias, or tag")); + assert!(help.contains("list")); + assert!(help.contains("List saved links")); + assert!(help.contains("add")); + assert!(help.contains("Add a saved link")); + assert!(help.contains("remove")); + assert!(help.contains("Remove a saved link by primary name")); + assert!(help.contains("manage")); + assert!(help.contains("Open the interactive terminal UI")); + assert!(help.contains("plinks help ")); +} + +#[test] +fn no_args_defaults_to_manage_command() { + let cli = Cli::parse_from(["plinks"]); + assert!(cli.command.is_none()); +} + +#[test] +fn add_help_explains_primary_name_and_options() { + let help = help_output(&["add", "-h"]); + + assert!( + help.contains("Add a saved link"), + "unexpected add help output:\n{help}" + ); + assert!( + help.contains("PRIMARY_NAME"), + "unexpected add help output:\n{help}" + ); + assert!(help.contains("URL"), "unexpected add help output:\n{help}"); + assert!( + help.contains("Stable primary name for the link"), + "unexpected add help output:\n{help}" + ); + assert!( + help.contains("Additional name that can also be used with `plinks open`"), + "unexpected add help output:\n{help}" + ); + assert!( + help.contains("Tag used to group links"), + "unexpected add help output:\n{help}" + ); + assert!( + help.contains("plinks add docs https://docs.rs --alias api --tag rust"), + "unexpected add help output:\n{help}" + ); +} + +#[test] +fn open_help_explains_name_alias_and_tag_modes() { + let help = help_output(&["open", "-h"]); + + assert!( + help.contains("Open a saved link by primary name, alias, or tag"), + "unexpected open help output:\n{help}" + ); + assert!( + help.contains("NAME_OR_ALIAS"), + "unexpected open help output:\n{help}" + ); + assert!( + help.contains("--tag"), + "unexpected open help output:\n{help}" + ); + assert!( + help.contains("Primary name or alias of the saved link to open"), + "unexpected open help output:\n{help}" + ); + assert!( + help.contains("Open every saved link with this tag"), + "unexpected open help output:\n{help}" + ); + assert!( + help.contains("plinks open --tag rust"), + "unexpected open help output:\n{help}" + ); +}