pub mod style;
pub use style::Style;
pub mod args;
pub mod buffer;
pub mod colorizer;
pub mod enhanced_regex;
pub mod grc;
pub mod utils;
use std::fs::File;
use std::io::BufRead;
use std::str::FromStr;
use grc::{GrcConfigReader, GrcatConfigEntry, GrcatConfigReader};
fn expand_tilde(path: &str) -> String {
if let Some(stripped) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{}/{}", home, stripped);
}
path.to_string()
}
#[cfg(feature = "embed-configs")]
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(feature = "embed-configs")]
include!(concat!(env!("OUT_DIR"), "/embedded_configs.rs"));
#[cfg(feature = "embed-configs")]
pub const EMBEDDED_GRC_CONF: &str = include_str!("../etc/rgrc.conf");
#[cfg(feature = "embed-configs")]
pub fn flush_and_rebuild_cache() -> Option<(std::path::PathBuf, usize)> {
let cache_dir = get_cache_dir()?;
if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir).ok()?;
}
let new_cache_dir = ensure_cache_populated()?;
let conf_dir = new_cache_dir.join("conf");
let config_count = if conf_dir.exists() {
std::fs::read_dir(&conf_dir)
.map(|entries| entries.count())
.unwrap_or(0)
} else {
0
};
Some((new_cache_dir, config_count))
}
#[cfg(feature = "embed-configs")]
fn get_cache_dir() -> Option<std::path::PathBuf> {
std::env::var("HOME")
.ok()
.map(std::path::PathBuf::from)
.map(|h| h.join(".cache").join("rgrc").join(VERSION))
}
#[cfg(feature = "embed-configs")]
fn ensure_cache_populated() -> Option<std::path::PathBuf> {
let cache_dir = get_cache_dir()?;
let grc_conf_path = cache_dir.join("rgrc.conf");
let conf_dir = cache_dir.join("conf");
if grc_conf_path.exists() {
if conf_dir.exists()
&& let Ok(mut entries) = std::fs::read_dir(&conf_dir)
&& entries.next().is_some()
{
return Some(cache_dir);
}
}
std::fs::create_dir_all(&cache_dir).ok()?;
let conf_dir = cache_dir.join("conf");
std::fs::create_dir_all(&conf_dir).ok()?;
std::fs::write(&grc_conf_path, EMBEDDED_GRC_CONF).ok()?;
let mut any_success = false;
for (filename, content) in EMBEDDED_CONFIGS {
let file_path = conf_dir.join(filename);
if std::fs::write(file_path, content).is_ok() {
any_success = true;
}
}
if any_success { Some(cache_dir) } else { None }
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ColorMode {
On,
Off,
Auto,
}
impl FromStr for ColorMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"on" => Ok(ColorMode::On),
"off" => Ok(ColorMode::Off),
"auto" => Ok(ColorMode::Auto),
_ => Err(()),
}
}
}
pub const RESOURCE_PATHS: &[&str] = &[
"share", "~/.config/rgrc",
"~/.local/share/rgrc",
"/usr/local/share/rgrc",
"/usr/share/rgrc",
"~/.config/grc",
"~/.local/share/grc",
"/usr/local/share/grc",
"/usr/share/grc",
];
pub fn load_config(path: &str, pseudo_command: &str) -> Vec<GrcatConfigEntry> {
let filesystem_result = File::open(path).ok().and_then(|f| {
let bufreader = std::io::BufReader::new(f);
let configreader = GrcConfigReader::new(bufreader.lines());
for (re, config) in configreader {
if re.is_match(pseudo_command) {
if std::env::var_os("RGRC_DEBUG").is_some() {
eprintln!(
"rgrc: matched pattern '{}' in {} for '{}'",
re.as_str(),
path,
pseudo_command
);
}
return Some(config);
}
}
None
});
if let Some(config) = filesystem_result {
for base_path in RESOURCE_PATHS {
let expanded_path = expand_tilde(base_path);
let config_path = format!("{}/{}", expanded_path, config);
if std::env::var_os("RGRC_DEBUG").is_some() {
eprintln!("rgrc: checking for config file {}", config_path);
}
match file_exists_and_parse(&config_path) {
Some(rules) => {
if std::env::var_os("RGRC_DEBUG").is_some() {
eprintln!(
"rgrc: found config file {} ({} rules)",
config_path,
rules.len()
);
}
return rules; }
None => continue, }
}
}
Vec::new()
}
fn file_exists_and_parse(filename: &str) -> Option<Vec<GrcatConfigEntry>> {
if let Ok(grcat_config_file) = File::open(filename) {
let bufreader = std::io::BufReader::new(grcat_config_file);
let configreader = GrcatConfigReader::new(bufreader.lines());
let entries: Vec<_> = configreader.collect();
return Some(entries);
}
#[cfg(feature = "embed-configs")]
{
let config_name = filename;
if let Some(cache_dir) = ensure_cache_populated() {
let conf_dir = cache_dir.join("conf");
let config_path = conf_dir.join(config_name);
if let Ok(grcat_config_file) = File::open(&config_path) {
let bufreader = std::io::BufReader::new(grcat_config_file);
let configreader = GrcatConfigReader::new(bufreader.lines());
let entries: Vec<_> = configreader.collect();
return Some(entries);
}
}
}
None
}
pub fn load_grcat_config<T: AsRef<str>>(filename: T) -> Vec<GrcatConfigEntry> {
let filename_str = filename.as_ref();
if filename_str.is_empty() {
return Vec::new();
}
if let Ok(grcat_config_file) = File::open(filename_str) {
let bufreader = std::io::BufReader::new(grcat_config_file);
let configreader = GrcatConfigReader::new(bufreader.lines());
let entries: Vec<_> = configreader.collect();
if !entries.is_empty() {
return entries;
}
}
#[cfg(feature = "embed-configs")]
{
let config_name = filename_str;
if let Some(cache_dir) = ensure_cache_populated() {
let conf_dir = cache_dir.join("conf");
let config_path = conf_dir.join(config_name);
if let Ok(grcat_config_file) = File::open(&config_path) {
let bufreader = std::io::BufReader::new(grcat_config_file);
let configreader = GrcatConfigReader::new(bufreader.lines());
let entries: Vec<_> = configreader.collect();
if !entries.is_empty() {
return entries;
}
}
}
}
Vec::new()
}
const CONFIG_PATHS: &[&str] = &[
"etc/rgrc.conf", "~/.rgrc",
"~/.config/rgrc/rgrc.conf",
"/usr/local/etc/rgrc.conf",
"/etc/rgrc.conf",
"~/.grc",
"~/.config/grc/grc.conf",
"/usr/local/etc/grc.conf",
"/etc/grc.conf",
];
#[allow(dead_code)]
pub fn load_rules_for_command(pseudo_command: &str) -> Vec<GrcatConfigEntry> {
let user_config_path = "~/.config/rgrc/rgrc.conf";
let expanded_user_config = expand_tilde(user_config_path);
let rules = load_config(&expanded_user_config, pseudo_command);
if !rules.is_empty() {
return rules;
}
#[cfg(feature = "embed-configs")]
{
let embedded_rules = load_config_from_embedded(pseudo_command);
if !embedded_rules.is_empty() {
return embedded_rules;
}
}
for config_path in CONFIG_PATHS {
if *config_path == "~/.config/rgrc/rgrc.conf" {
continue; }
let expanded_path = expand_tilde(config_path);
let rules = load_config(&expanded_path, pseudo_command);
if !rules.is_empty() {
return rules; }
}
Vec::new()
}
#[cfg(feature = "debug")]
fn format_style_info(_style: &Style) -> String {
String::new()
}
#[cfg(feature = "debug")]
pub fn colorize_regex_with_debug<R, W>(
reader: &mut R,
writer: &mut W,
rules: &[GrcatConfigEntry],
debug_level: crate::args::DebugLevel,
) -> Result<(), Box<dyn std::error::Error>>
where
R: std::io::Read,
W: std::io::Write,
{
use crate::args::DebugLevel;
use std::io::{BufRead, BufReader};
let buffered_reader = BufReader::new(reader);
let mut line_num = 0;
for line_result in buffered_reader.lines() {
let line = line_result?;
line_num += 1;
let mut matched_rules = Vec::new();
for (rule_idx, rule) in rules.iter().enumerate() {
if rule.regex.is_match(&line) {
matched_rules.push((rule_idx, rule));
}
}
use std::io::Cursor;
let mut line_reader = Cursor::new(format!("{}\n", line).into_bytes());
let mut temp_output = Vec::new();
colorizer::colorize_regex(&mut line_reader, &mut temp_output, rules)?;
writer.write_all(&temp_output)?;
match debug_level {
DebugLevel::Off => {
}
DebugLevel::Basic => {
if matched_rules.is_empty() {
let line_marker_str = format!("[Line {}]", line_num);
eprintln!(
"{} ℹ️ No rules matched",
Style::new().cyan().apply_to(&line_marker_str)
);
} else {
let line_marker_str = format!("[Line {}]", line_num);
let line_marker = Style::new().cyan().apply_to(&line_marker_str);
eprintln!(
"{} ✓ Matched {} rule(s): {}",
line_marker,
matched_rules.len(),
matched_rules
.iter()
.map(|(idx, rule)| {
let colors_display = if rule.colors.is_empty() {
"no-style".to_string()
} else {
format!("{} style(s)", rule.colors.len())
};
format!("#{} ({})", idx + 1, colors_display)
})
.collect::<Vec<_>>()
.join(", ")
);
}
}
DebugLevel::Verbose => {
let line_marker_str = format!("[Line {}]", line_num);
let line_marker = Style::new().cyan().apply_to(&line_marker_str);
if matched_rules.is_empty() {
eprintln!("{} ℹ️ No rules matched", line_marker);
} else {
eprintln!("{} ✓ Matched {} rule(s):", line_marker, matched_rules.len());
for (idx, rule) in matched_rules.iter() {
let rule_display = format!("Rule #{}: {}", idx + 1, rule.regex.as_str());
eprintln!(" {}", Style::new().bold().apply_to(&rule_display));
if let Some(captures) = rule.regex.captures_from_pos(&line, 0) {
if let Some(_full_match) = captures.get(0) {
let mut styled_groups = Vec::new();
for group_idx in 1..captures.len() {
if let Some(cap) = captures.get(group_idx) {
let text = cap.as_str();
if group_idx <= rule.colors.len() {
styled_groups.push(format!(
"{}",
rule.colors[group_idx - 1].apply_to(text)
));
} else {
styled_groups.push(text.to_string());
}
}
}
if !styled_groups.is_empty() {
let matched_text = styled_groups.join(" ");
eprintln!(
" {}",
Style::new()
.dim()
.apply_to(&format!("Matched: {}", matched_text))
);
}
}
}
if rule.colors.is_empty() {
eprintln!(" {}", Style::new().dim().apply_to("Styles: (none)"));
} else {
eprintln!(" {}:", Style::new().dim().apply_to("Styles"));
for (color_idx, color) in rule.colors.iter().enumerate() {
let _color_display = format_style_info(color);
eprintln!(
" {}",
color.apply_to(&format!("Group {}: applied", color_idx + 1))
);
}
}
}
}
}
}
}
Ok(())
}
#[cfg(feature = "embed-configs")]
fn load_config_from_embedded(pseudo_command: &str) -> Vec<GrcatConfigEntry> {
let cache_dir = match ensure_cache_populated() {
Some(dir) => dir,
None => return Vec::new(), };
let grc_conf_path = cache_dir.join("rgrc.conf");
let conf_dir = cache_dir.join("conf");
if let Ok(f) = File::open(&grc_conf_path) {
let bufreader = std::io::BufReader::new(f);
let configreader = GrcConfigReader::new(bufreader.lines());
for (re, config_file) in configreader {
if re.is_match(pseudo_command) {
if std::env::var_os("RGRC_DEBUG").is_some() {
eprintln!(
"rgrc: embedded matched pattern '{}' -> {}",
re.as_str(),
config_file
);
}
let config_path = conf_dir.join(&config_file);
if let Some(config_str) = config_path.to_str() {
return load_grcat_config(config_str);
}
}
}
}
Vec::new()
}
#[cfg(test)]
mod lib_test {
use super::*;
#[cfg(test)]
#[test]
fn test_load_rules_for_command() {
let rules = load_rules_for_command("ping");
#[cfg(feature = "embed-configs")]
{
use tempfile::TempDir;
let td = TempDir::new().expect("create tempdir");
let prev_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", td.path());
}
let rules_after = load_rules_for_command("ping");
if let Some(h) = prev_home {
unsafe {
std::env::set_var("HOME", h);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
assert!(
!rules_after.is_empty(),
"Should load rules for ping command from embedded configs when embed-configs is enabled"
);
}
#[cfg(not(feature = "embed-configs"))]
{
for rule in &rules {
assert!(
!rule.regex.as_str().is_empty(),
"Rule should have a regex pattern"
);
}
}
for rule in &rules {
assert!(
!rule.regex.as_str().is_empty(),
"Rule should have a regex pattern"
);
}
let no_rules = load_rules_for_command("nonexistent_command_xyz");
assert!(
no_rules.is_empty(),
"Nonexistent command should return no rules"
);
#[cfg(not(debug_assertions))]
{
use std::time::Instant;
let start = Instant::now();
for _ in 0..10 {
let _rules = load_rules_for_command("ping");
}
let duration = start.elapsed();
let avg_time = duration / 10;
println!("Average time to load ping rules: {:?}", avg_time);
assert!(
avg_time.as_millis() < 1500,
"Loading rules should be reasonably fast (< 1500ms)"
);
}
}
#[test]
fn test_config_priority_order() {
use tempfile::TempDir;
let user_config_dir = TempDir::new().expect("create user config dir");
let system_config_dir = TempDir::new().expect("create system config dir");
let user_conf_file = user_config_dir.path().join("conf.testcmd");
let system_conf_file = system_config_dir.path().join("conf.testcmd");
std::fs::write(&user_conf_file, "regexp=^USER\ncolours=green").expect("write user config");
std::fs::write(&system_conf_file, "regexp=^SYSTEM\ncolours=red")
.expect("write system config");
let user_rules = load_grcat_config(user_conf_file.to_string_lossy());
assert!(
!user_rules.is_empty(),
"Should load rules from user config file"
);
let has_user_pattern = user_rules
.iter()
.any(|rule| rule.regex.as_str().contains("USER"));
assert!(
has_user_pattern,
"User config should contain USER pattern, proving user config was loaded (not system)"
);
let system_rules = load_grcat_config(system_conf_file.to_string_lossy());
assert!(
!system_rules.is_empty(),
"Should load rules from system config file"
);
let has_system_pattern = system_rules
.iter()
.any(|rule| rule.regex.as_str().contains("SYSTEM"));
assert!(
has_system_pattern,
"System config should contain SYSTEM pattern"
);
}
#[test]
fn test_load_config_stops_at_first_match() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("create temp dir");
let grc_conf_path = temp_dir.path().join("grc.conf");
let conf_dir1 = TempDir::new().expect("create conf dir 1");
let conf_dir2 = TempDir::new().expect("create conf dir 2");
std::fs::write(&grc_conf_path, "^testcmd\tconf.testcmd").expect("write grc.conf");
let conf_file_1 = conf_dir1.path().join("conf.testcmd");
std::fs::write(&conf_file_1, "regexp=^USER\ncolours=green").expect("write conf file 1");
let conf_file_2 = conf_dir2.path().join("conf.testcmd");
std::fs::write(&conf_file_2, "regexp=^SYSTEM\ncolours=red").expect("write conf file 2");
let rules_1 = load_grcat_config(conf_file_1.to_string_lossy());
assert!(
!rules_1.is_empty(),
"Should load rules from first config file"
);
let has_user = rules_1
.iter()
.any(|rule| rule.regex.as_str().contains("USER"));
assert!(
has_user,
"Should load USER pattern from first config file (not SYSTEM)"
);
}
#[test]
fn test_empty_config_file_stops_search() {
use tempfile::TempDir;
let user_config_dir = TempDir::new().expect("create user config dir");
let system_config_dir = TempDir::new().expect("create system config dir");
let user_grc_conf = user_config_dir.path().join("grc.conf");
let system_grc_conf = system_config_dir.path().join("grc.conf");
std::fs::write(&user_grc_conf, "^testcmd\tconf.testcmd").expect("write user grc.conf");
std::fs::write(&system_grc_conf, "^testcmd\tconf.testcmd").expect("write system grc.conf");
let user_conf_file = user_config_dir.path().join("conf.testcmd");
let system_conf_file = system_config_dir.path().join("conf.testcmd");
std::fs::write(&user_conf_file, "").expect("write empty user config");
std::fs::write(&system_conf_file, "regexp=^SYSTEM\ncolours=red")
.expect("write system config");
let rules_user = load_grcat_config(user_conf_file.to_string_lossy());
assert!(
rules_user.is_empty(),
"Empty user config file should return no rules (NOT fall back to system)"
);
let rules_system = load_grcat_config(system_conf_file.to_string_lossy());
assert!(!rules_system.is_empty(), "System config should have rules");
let has_system = rules_system
.iter()
.any(|rule| rule.regex.as_str().contains("SYSTEM"));
assert!(has_system, "System config should contain SYSTEM pattern");
}
#[test]
fn test_expand_tilde() {
unsafe {
std::env::set_var("HOME", "/home/testuser");
}
assert_eq!(expand_tilde("~/Documents"), "/home/testuser/Documents");
assert_eq!(expand_tilde("~/"), "/home/testuser/");
assert_eq!(expand_tilde("~"), "~");
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
assert_eq!(expand_tilde("relative/path"), "relative/path");
assert_eq!(expand_tilde(""), "");
assert_eq!(expand_tilde("path~/to/file"), "path~/to/file");
assert_eq!(expand_tilde("path~"), "path~");
unsafe {
std::env::remove_var("HOME");
}
assert_eq!(expand_tilde("~/Documents"), "~/Documents");
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
unsafe {
std::env::set_var("HOME", "/home/testuser");
}
}
}