diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7a3431 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build + run: cargo build --verbose + + - name: Run Tests + run: cargo test --verbose + + - name: Run Clippy + run: cargo clippy -- -D warnings + + - name: Run Formatter Check + run: cargo fmt -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..395f170 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + build-and-upload: + name: Build and upload + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # You can add more, for any target you'd like! + include: + - build: linux + os: ubuntu-latest + target: x86_64-unknown-linux-musl + + - build: macos + os: macos-latest + target: x86_64-apple-darwin + + + - build: windows + os: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Get the release version from the tag + shell: bash + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Install Rust + # Or @nightly if you want + uses: dtolnay/rust-toolchain@stable + # Arguments to pass in + with: + # Make Rust compile to our target (defined in the matrix) + targets: ${{ matrix.target }} + + - name: Build + uses: actions-rs/cargo@v1 + with: + use-cross: true + command: build + args: --verbose --release --target ${{ matrix.target }} + + - name: Build archive + shell: bash + run: | + # Replace with the name of your binary + binary_name="gf" + dirname="$binary_name-${{ env.VERSION }}-${{ matrix.target }}" + mkdir "$dirname" + if [ "${{ matrix.os }}" = "windows-latest" ]; then + mv "target/${{ matrix.target }}/release/$binary_name.exe" "$dirname" + else + mv "target/${{ matrix.target }}/release/$binary_name" "$dirname" + fi + if [ "${{ matrix.os }}" = "windows-latest" ]; then + 7z a "$dirname.zip" "$dirname" + echo "ASSET=$dirname.zip" >> $GITHUB_ENV + else + tar -czf "$dirname.tar.gz" "$dirname" + echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV + fi + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: | + ${{ env.ASSET }} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2e3e64a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "gf" +version = "1.0.0" +authors = ["Roman Kvasnytskyi "] +edition = "2021" + +[dependencies] +clap = { version = "4.5.18", features = ["derive"] } +serde = { version = "1.0.210", features = ["derive"] } +atty = "0.2.14" +serde_json = "1.0.128" +dirs = "5.0.1" +colored = "2.1.0" +anyhow = "1.0.89" + + +[dev-dependencies] +assert_cmd = "2.0.16" +predicates = "3.1.2" +tempfile = "3.12.0" + +[[bin]] +name = "gf" +path = "src/main.rs" +bench = true + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" diff --git a/README.md b/README.md index c538d78..0225c16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,174 @@ -# gf -Rust reimplementation of tomnomnom gf +# gf - Grep-like Pattern Manager + +`gf` is a powerful command-line tool written in Rust for managing and using reusable search patterns with grep-like tools. It allows you to save, list, and execute complex search patterns easily, improving your productivity in tasks like code analysis, security auditing, and text processing. + +## Features + +- **Save Search Patterns**: Save complex grep patterns with custom flags and reuse them anytime. +- **Pattern Listing**: List all saved patterns for easy reference. +- **Customizable Engines**: Use different search engines like `grep`, `rg` (ripgrep), or `ag` (The Silver Searcher). +- **Pattern Execution**: Execute saved patterns directly on files or piped input. +- **Command Dumping**: Print the full command that would be executed without running it. + +## Installation + +### Pre-built Binaries + +Download the latest release for your platform from the [Releases](https://github.com/Periecle/gf/releases) page and place the binary in your `$PATH`. + +### Build from Source + +To build `gf` from source, ensure you have Rust and Cargo installed. + +```bash +# Clone the repository +git clone https://github.com/Periecle/gf.git +cd gf + +# Build the project and install it to PATH +cargo install --path . +``` +# Usage + +## Basic Usage + +## Save a pattern + +```bash +gf --save [--engine ] [flags] +``` + +## Use a saved pattern + +```bash +gf [file or directory] +``` +## List all saved patterns + +```bash +gf --list +``` + +## Dump the command that would be executed + +```bash +gf --dump [file or directory] +``` + +# Options + +```bash +Usage: gf [OPTIONS] [NAME] [ARGS]... + +Pattern manager for grep-like tools + +Options: + --save Save a pattern (e.g., gf --save pat-name -Hnri 'search-pattern') + --list List available patterns + --dump Print the command rather than executing it + --engine Specify the engine to use (e.g., 'grep', 'rg', 'ag') + -h, --help Print help information + -V, --version Print version information + +Arguments: + [NAME] The name of the pattern (when saving or using) + [ARGS]... Additional arguments +``` + +# Examples + +## Saving and Using a Pattern + +Save a pattern named find-todos to search for TODO comments: + +```bash +gf --save find-todos -nri "TODO" +``` + +Use the saved pattern to search in the current directory: + +```bash +gf find-todos +``` + +## Saving a Pattern with a Custom Engine + +Save a pattern using rg (ripgrep) as the search engine: + +```bash +gf --save find-errors --engine rg -nri "ERROR" +``` + +Use the saved pattern: + +```bash +gf find-errors /var/log +``` + +## Listing Saved Patterns + +List all saved patterns: + +```bash +gf --list +``` + +## Dumping a Command + +Dump the command that would be executed for a pattern: + +```bash +gf --dump find-todos src/ +``` + +Output: + +```bash +grep -nri "TODO" src/ +``` + +## Executing a Pattern on Piped Input + +Use a pattern with piped input: + +```bash +cat file.txt | gf find-todos +``` + +## Saving a Pattern Without Flags +Save a pattern without any flags: + +```bash +gf --save simple-search "" "pattern-to-search" +``` + +## Handling Errors + +Attempting to use a non-existent pattern: + +```bash +gf nonexistent-pattern +``` + +Output: + +```bash +Error: No such pattern 'nonexistent-pattern' +``` + +# Original Work +This tool was originally written by [tomnomnom in Go](https://github.com/tomnomnom/gf). + + +# Contributing +Contributions are welcome! Please follow these steps: + +Fork the repository. +Create a new branch with your feature or bug fix. +Commit your changes with clear and descriptive messages. +Push to your branch. +Open a pull request on GitHub. +Please ensure that your code adheres to the existing style and passes all tests. + +# License +This project is licensed under the MIT License. See the LICENSE file for details. \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2c1e835 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,246 @@ +use anyhow::{anyhow, bail, Context, Result}; +use atty::Stream; +use clap::Parser; +use colored::*; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::os::unix::fs::OpenOptionsExt; +use std::path::PathBuf; +use std::process::{Command as ProcessCommand, Stdio}; + +/// Represents a saved pattern with optional flags, patterns, and engine. +#[derive(Serialize, Deserialize)] +struct Pattern { + flags: Option, + pattern: Option, + patterns: Option>, + engine: Option, +} + +/// Command-line interface definition using clap. +#[derive(Parser)] +#[command( + name = "gf", + about = "Pattern manager for grep-like tools", + version = "1.0.0" +)] +struct Cli { + /// Save a pattern (e.g., gf --save pat-name -Hnri 'search-pattern') + #[arg(long)] + save: bool, + + /// List available patterns + #[arg(long)] + list: bool, + + /// Print the command rather than executing it + #[arg(long)] + dump: bool, + + /// The name of the pattern (when saving or using) + name: Option, + + /// Specify the engine to use (e.g., 'grep', 'rg', 'ag') + #[arg(long)] + engine: Option, + + /// Additional arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{} {}", "Error:".bright_red().bold(), err); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + + if cli.list { + let patterns = get_patterns().context("Failed to list patterns")?; + for pat in patterns { + println!("{}", pat); + } + return Ok(()); + } + + if cli.save { + let name = cli + .name + .as_ref() + .ok_or_else(|| anyhow!("Name cannot be empty"))?; + let flags = cli.args.first().map(|s| s.to_string()).unwrap_or_default(); + let pattern = cli + .args + .get(1) + .ok_or_else(|| anyhow!("Pattern cannot be empty"))?; + + save_pattern(name, &flags, pattern, cli.engine.clone())?; + return Ok(()); + } + + // Use the pattern + let pat_name = cli + .name + .as_ref() + .ok_or_else(|| anyhow!("Pattern name is required"))?; + let files = cli.args.first().map(|s| s.as_str()).unwrap_or("."); + + let pattern_dir = get_pattern_dir().context("Unable to open user's pattern directory")?; + let filename = pattern_dir.join(format!("{}.json", pat_name)); + + let f = fs::File::open(&filename).with_context(|| format!("No such pattern '{}'", pat_name))?; + + let pat: Pattern = serde_json::from_reader(f) + .with_context(|| format!("Pattern file '{}' is malformed", filename.display()))?; + + let pattern_str = if let Some(pat_pattern) = pat.pattern { + pat_pattern + } else if let Some(pat_patterns) = pat.patterns { + if pat_patterns.is_empty() { + bail!( + "Pattern file '{}' contains no pattern(s)", + filename.display() + ); + } + format!("({})", pat_patterns.join("|")) + } else { + bail!( + "Pattern file '{}' contains no pattern(s)", + filename.display() + ); + }; + + let operator = pat.engine.clone().unwrap_or_else(|| "grep".to_string()); + + if cli.dump { + // Use the operator instead of hardcoding "grep" + let mut command = format!("{} ", operator); + + if let Some(flags) = &pat.flags { + command.push_str(flags); + command.push(' '); + } + + command.push_str(&format!("{:?} {}", pattern_str, files)); + + println!("{}", command); + } else { + let stdin_is_pipe = stdin_is_pipe(); + + let mut cmd = ProcessCommand::new(operator); + + if let Some(flags) = pat.flags { + cmd.args(flags.split_whitespace()); + } + + cmd.arg(pattern_str); + + if !stdin_is_pipe { + cmd.arg(files); + } + + cmd.stdin(Stdio::inherit()); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + + let status = cmd.status().context("Failed to execute command")?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + } + + Ok(()) +} + +/// Determines the pattern directory in the user's home directory. +fn get_pattern_dir() -> Result { + let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; + + let config_dir = home_dir.join(".config/gf"); + + if config_dir.exists() { + return Ok(config_dir); + } + + let gf_dir = home_dir.join(".gf"); + + Ok(gf_dir) +} + +/// Saves a new pattern to the pattern directory. +fn save_pattern(name: &str, flags: &str, pat: &str, engine: Option) -> Result<()> { + if name.is_empty() { + bail!("Name cannot be empty"); + } + if pat.is_empty() { + bail!("Pattern cannot be empty"); + } + + let p = Pattern { + flags: if flags.is_empty() { + None + } else { + Some(flags.to_string()) + }, + pattern: Some(pat.to_string()), + patterns: None, + engine, + }; + + let pattern_dir = get_pattern_dir().context("Failed to determine pattern directory")?; + + fs::create_dir_all(&pattern_dir).context("Failed to create pattern directory")?; + + let path = pattern_dir.join(format!("{}.json", name)); + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true).mode(0o666); + + let f = options.open(&path).with_context(|| { + format!( + "Failed to create pattern file '{}': file may already exist", + path.display() + ) + })?; + + serde_json::to_writer_pretty(f, &p).context("Failed to write pattern file")?; + + Ok(()) +} + +/// Retrieves a list of saved pattern names. +fn get_patterns() -> Result> { + let mut out = Vec::new(); + + let pattern_dir = get_pattern_dir().context("Failed to determine pattern directory")?; + + if !pattern_dir.exists() { + // If the pattern directory doesn't exist, return an empty list + return Ok(out); + } + + let entries = fs::read_dir(&pattern_dir).context("Failed to read pattern directory")?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { + out.push(filename.to_string()); + } + } + } + + Ok(out) +} + +/// Checks if stdin is a pipe. +fn stdin_is_pipe() -> bool { + !atty::is(Stream::Stdin) +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..fab7149 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,250 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs::{self, File}; +use std::io::Write; +use tempfile::tempdir; + +#[test] +fn test_list_patterns_empty() -> Result<(), Box> { + // Test listing patterns when no patterns exist + let temp_dir = tempdir()?; + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("--list"); + cmd.assert().success().stdout(predicate::str::is_empty()); + Ok(()) +} + +#[test] +fn test_save_pattern_and_list() -> Result<(), Box> { + // Test saving a pattern and then listing it + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "testpattern", "-Hnri", "search-pattern"]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("--list"); + cmd.assert() + .success() + .stdout(predicate::str::contains("testpattern")); + + Ok(()) +} + +#[test] +fn test_save_pattern_without_name() -> Result<(), Box> { + // Test saving a pattern without providing a name + let temp_dir = tempdir()?; + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("--save"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Name cannot be empty")); + Ok(()) +} + +#[test] +fn test_save_pattern_without_pattern() -> Result<(), Box> { + // Test saving a pattern without providing the pattern string + let temp_dir = tempdir()?; + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "test pattern"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Pattern cannot be empty")); + Ok(()) +} + +#[test] +fn test_use_nonexistent_pattern() -> Result<(), Box> { + // Test using a pattern that doesn't exist + let temp_dir = tempdir()?; + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("nonexistentpattern"); + cmd.assert().failure().stderr(predicate::str::contains( + "No such pattern 'nonexistentpattern'", + )); + Ok(()) +} + +#[test] +fn test_dump_pattern() -> Result<(), Box> { + // Test dumping a saved pattern + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Save a pattern with an engine and flags + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).args([ + "--save", + "testpattern", + "--engine", + "rg", + "-Hnri", + "search-pattern", + ]); + cmd.assert().success(); + + // Dump the pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--dump", "testpattern", "/path/to/files"]); + cmd.assert().success().stdout(predicate::str::contains( + "rg -Hnri \"search-pattern\" /path/to/files", + )); + Ok(()) +} + +#[test] +fn test_execute_pattern_with_piped_input() -> Result<(), Box> { + // Test executing a pattern with piped input + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Save a simple pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "testpattern", "-nri", "test"]); + cmd.assert().success(); + + // Create a temporary file to grep + let temp_file_path = temp_dir.path().join("testfile.txt"); + let mut temp_file = File::create(&temp_file_path)?; + writeln!(temp_file, "This is a test line")?; + writeln!(temp_file, "Another line")?; + drop(temp_file); + + // Use the pattern with piped input + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .arg("testpattern") + .write_stdin(fs::read_to_string(&temp_file_path)?); + cmd.assert() + .success() + .stdout(predicate::str::contains("This is a test line")); + Ok(()) +} + +#[test] +fn test_pattern_file_malformed() -> Result<(), Box> { + // Test behavior when the pattern file is malformed + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Create a malformed pattern file + let pattern_file_path = gf_dir.join("malformedpattern.json"); + let mut pattern_file = File::create(&pattern_file_path)?; + writeln!(pattern_file, "{{ malformed json")?; + drop(pattern_file); + + // Try to use the malformed pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("malformedpattern"); + cmd.assert().failure().stderr( + predicate::str::contains("Pattern file").and(predicate::str::contains("is malformed")), + ); + Ok(()) +} + +#[test] +fn test_pattern_with_no_patterns() -> Result<(), Box> { + // Test behavior when the pattern file contains no patterns + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Create a pattern file with empty patterns + let pattern_file_path = gf_dir.join("emptypattern.json"); + let mut pattern_file = File::create(&pattern_file_path)?; + writeln!(pattern_file, r#"{{ "flags": "-Hnri" }}"#)?; + drop(pattern_file); + + // Try to use the pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("emptypattern"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("contains no pattern(s)")); + Ok(()) +} + +#[test] +fn test_save_pattern_with_existing_name() -> Result<(), Box> { + // Test saving a pattern when a pattern with the same name already exists + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Save the initial pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "testpattern", "-Hnri", "search-pattern"]); + cmd.assert().success(); + + // Attempt to save another pattern with the same name + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "testpattern", "-Hnri", "another-pattern"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Failed to create pattern file")); + + Ok(()) +} + +#[test] +fn test_dump_pattern_with_no_flags() -> Result<(), Box> { + // Test dumping a pattern that has no flags + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Save a pattern without flags by providing an empty string for flags + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "noflagpattern", "", "search-pattern"]); + cmd.assert().success(); + + // Dump the pattern + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--dump", "noflagpattern", "/path/to/files"]); + cmd.assert().success().stdout(predicate::str::contains( + "grep \"search-pattern\" /path/to/files", + )); + Ok(()) +} + +#[test] +fn test_list_patterns_with_multiple_patterns() -> Result<(), Box> { + // Test listing when multiple patterns exist + let temp_dir = tempdir()?; + let gf_dir = temp_dir.path().join(".config/gf"); + fs::create_dir_all(&gf_dir)?; + + // Save multiple patterns + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "pattern1", "-nri", "test1"]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()) + .args(["--save", "pattern2", "-nri", "test2"]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("gf")?; + cmd.env("HOME", temp_dir.path()).arg("--list"); + cmd.assert() + .success() + .stdout(predicate::str::contains("pattern1").and(predicate::str::contains("pattern2"))); + Ok(()) +}