Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 60 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>
```

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:
Expand All @@ -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 -- <command>
plinks --help
plinks help add
plinks open --help
```

## Usage
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
55 changes: 46 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -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 <command>` for command-specific examples."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
pub command: Option<Command>,
}

#[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<String>,
#[arg(long)]
#[arg(long, value_name = "TAG", help = "Open every saved link with this tag")]
pub tag: Option<String>,
}

#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
#[arg(long, value_name = "TAG", help = "Only show saved links with this tag")]
pub tag: Option<String>,
}

#[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<String>,
#[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<String>,
#[arg(long)]
#[arg(long, value_name = "TEXT", help = "Optional human-readable note")]
pub note: Option<String>,
#[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,
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
102 changes: 102 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 <command>"));
}

#[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}"
);
}
Loading