Compare commits

...

10 commits

Author SHA1 Message Date
Mohammad Kanaan
de79955778 refactor: remove pattern from matches as it's not needed on each
instance
2026-07-27 21:40:32 +03:00
Mohammad Kanaan
ea6c75a0a8 matches: store path instead of file name 2026-07-27 21:32:25 +03:00
Mohammad Kanaan
a1c13d4342 fix read_lines to better handle errors 2026-07-27 21:29:57 +03:00
Mohammad Kanaan
0f57d037e0 refactor: extract Cli and add CliOptions struct
test: fix failing test
fix: applied clippy suggested fixes
2026-07-27 21:19:14 +03:00
Mohammad Kanaan
0e20aff140 fix: build regex once instead of once per file 2026-07-27 20:44:53 +03:00
Mohammad Kanaan
8452eae0f4 feat: add case insensitive mode 2026-07-27 20:44:32 +03:00
Mohammad Kanaan
ad6dc24d7e feat: add support for multiple files
refactor: replace String with PathBuf
2026-07-27 19:38:46 +03:00
Mohammad Kanaan
3f1a5c5ef3 fix: remove useless == true 2026-07-27 13:57:18 +03:00
Mohammad Kanaan
f8181ee583 feat: implement proper cli exit codes 2026-07-27 13:50:12 +03:00
Mohammad Kanaan
3a7df31b08 parser: test: add some tests 2026-07-27 13:20:13 +03:00
5 changed files with 144 additions and 44 deletions

23
src/cli.rs Normal file
View file

@ -0,0 +1,23 @@
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
pub struct Cli {
pub(crate) pattern: String,
#[arg(value_name = "FILES", num_args = 1..)]
pub(crate) file_paths: Vec<PathBuf>,
#[arg(short, long)]
pub(crate) ignore_case: bool,
}
pub struct CliOptions {
pub ignore_case: bool,
}
impl Cli {
pub fn get_options(&self) -> CliOptions {
CliOptions { ignore_case: self.ignore_case }
}
}

View file

@ -1,23 +1,35 @@
use clap::Parser;
use std::process::ExitCode;
use crate::{cli::Cli, matches::Matches, parser::process_files};
mod cli;
mod matches;
mod parser;
#[derive(Parser)]
struct Cli {
pattern: String,
file_path: String,
use clap::Parser;
fn main() -> ExitCode {
match run() {
Ok(matches) if matches.is_empty() => ExitCode::FAILURE,
Ok(matches) => {
println!("{}", matches);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("{}", error);
ExitCode::from(2)
}
}
}
fn main() -> anyhow::Result<()> {
fn run() -> anyhow::Result<Matches> {
let cli = Cli::parse();
let lines_iter = parser::read_lines(&cli.file_path)?;
let matches = parser::match_pattern(lines_iter, &cli.pattern, &cli.file_path);
// println!("{:?} {}", cli.file_paths, cli.pattern);
println!("Pattern: {}", cli.pattern);
println!("File path: {}", cli.file_path);
println!("---");
println!("{}", matches?);
Ok(())
let matches = process_files(&cli.file_paths, &cli.pattern, &cli.get_options())?;
if !matches.is_empty() {
println!("Matches for pattern {}:", cli.pattern);
}
Ok(matches)
}

View file

@ -1,26 +1,33 @@
use std::fmt::Display;
use std::{fmt::Display, path::PathBuf};
pub struct Match {
pub file_name: String,
pub file_path: PathBuf,
pub line_number: usize,
pub text: String,
}
pub struct Matches {
pub items: Vec<Match>,
pub pattern: String,
}
impl Matches {
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn append(&mut self, matches: &mut Self) {
self.items.append(&mut matches.items);
}
}
impl Display for Match {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: line {}: {}", self.file_name, self.line_number, self.text)
write!(f, "{}: line {}: {}", self.file_path.display(), self.line_number, self.text)
}
}
impl Display for Matches {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Matches for pattern {}:", self.pattern)?;
for (idx, item) in self.items.iter().enumerate() {
if idx > 0 {
writeln!(f)?;

View file

@ -1,56 +1,112 @@
use std::{
fs::File,
io::{BufRead, BufReader},
path::Path,
path::{Path, PathBuf},
};
use anyhow::Context;
use regex::Regex;
use regex::{Regex, RegexBuilder};
use crate::matches::{Match, Matches};
use crate::{
cli::CliOptions,
matches::{Match, Matches},
};
pub fn read_lines(
file_path: &str,
) -> anyhow::Result<impl Iterator<Item = std::io::Result<String>>, anyhow::Error> {
if file_path.is_empty() {
return Err(anyhow::anyhow!("File path cannot be empty"));
pub fn process_files(
file_paths: &Vec<PathBuf>,
pattern: &str,
cli_options: &CliOptions,
) -> anyhow::Result<Matches, anyhow::Error> {
let reg = RegexBuilder::new(pattern)
.case_insensitive(cli_options.ignore_case)
.build()
.with_context(|| format!("invalid regex: {:?}", pattern))?;
let mut matches = Matches { items: vec![] };
// fails and stops everything when one file fails
for file_path in file_paths {
let lines_iter = read_lines(file_path)?;
let mut _matches = match_pattern(lines_iter, pattern, file_path, &reg)?;
matches.append(&mut _matches);
}
let file =
File::open(file_path).with_context(|| format!("failed to open file: {file_path}"))?;
// let lines_iter = read_lines(file_path)?;
// let matches = match_pattern(lines_iter, pattern, file_path)?;
Ok(matches)
}
pub fn read_lines(
file_path: &Path,
) -> anyhow::Result<impl Iterator<Item = std::io::Result<String>>, anyhow::Error> {
let file = File::open(file_path)
.with_context(|| format!("failed to open file: {}", file_path.display()))?;
let reader = BufReader::new(file);
Ok(reader.lines())
}
fn get_file_name_from_path(file_path: &str) -> String {
match Path::new(file_path).file_name() {
Some(name) => name.to_string_lossy().to_string(),
None => String::from("unknown"),
}
}
// fn get_file_name_from_path(file_path: &Path) -> String {
// match file_path.file_name() {
// Some(name) => name.to_string_lossy().to_string(),
// None => String::from("unknown"),
// }
// }
pub fn match_pattern(
iter: impl Iterator<Item = std::io::Result<String>>,
pattern: &str,
file_path: &str,
file_path: &Path,
regex: &Regex,
) -> anyhow::Result<Matches, anyhow::Error> {
let reg = Regex::new(pattern).with_context(|| format!("invalid regex: {:?}", pattern))?;
let mut matches = Vec::new();
for (idx, line) in iter.enumerate() {
let line = line?;
if reg.is_match(&line) {
let m = Match {
line_number: idx + 1,
text: line,
file_name: get_file_name_from_path(file_path),
};
if regex.is_match(&line) {
let m = Match { line_number: idx + 1, text: line, file_path: PathBuf::from(file_path) };
matches.push(m);
}
}
Ok(Matches { items: matches, pattern: String::from(pattern) })
Ok(Matches { items: matches })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_file_name_from_path() {
let path = Path::new("/path/to/file.txt");
let file_name = get_file_name_from_path(path);
assert_eq!(file_name, "file.txt");
let path = Path::new("file.txt");
let file_name = get_file_name_from_path(path);
assert_eq!(file_name, "file.txt");
}
#[test]
fn matches_lines_and_tracks_line_numbers() {
let input: Vec<std::io::Result<String>> =
vec![Ok("hola".into()), Ok("no".into()), Ok("hola again".into())];
let reg = Regex::new("hola").unwrap();
let result =
match_pattern(input.into_iter(), r"^hola", Path::new("/tmp/test.txt"), &reg).unwrap();
assert_eq!(result.items.len(), 2);
assert_eq!(result.items[0].text, "hola");
assert_eq!(result.items[0].line_number, 1);
assert_eq!(result.items[0].file_name, "test.txt");
assert_eq!(result.items[1].text, "hola again");
assert_eq!(result.items[1].line_number, 3);
}
}

View file

@ -1,2 +1,4 @@
hola
no
hola again
yep it's me