mod backup_manager;
mod bre_converter;
mod cli;
mod command;
mod config;
mod diff_formatter;
mod disk_space;
mod ere_converter;
mod file_processor;
mod logger;
mod parser;
mod regex_error;
mod sed_parser;
use anyhow::{Context, Result};
use cli::{Args, RegexFlavor, parse_args};
use command::{Address, Command};
use config::{config_file_path, ensure_complete_config, load_config};
use logger::init_debug_logging;
use parser::Parser;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::Instant;
fn main() -> Result<()> {
let args = parse_args()?;
let log_path = if matches!(args, Args::Execute { .. }) {
let config = load_config();
match config {
Ok(cfg) => {
let debug_enabled = cfg.processing.debug.unwrap_or(false);
init_debug_logging(debug_enabled)?
}
Err(_) => None, }
} else {
None
};
if let Some(ref path) = log_path {
tracing::info!("Debug logging enabled. Log file: {}", path.display());
}
match args {
Args::Execute {
expression,
files,
dry_run,
interactive,
context,
streaming,
regex_flavor,
no_backup,
backup_dir,
quiet,
} => {
if files.is_empty() {
execute_stdin(&expression, regex_flavor, quiet)?;
} else {
execute_command(
&expression,
&files,
dry_run,
interactive,
context,
streaming,
regex_flavor,
no_backup,
backup_dir,
quiet,
)?;
}
}
Args::Rollback { id } => {
rollback(id)?;
}
Args::History => {
show_history()?;
}
Args::Status => {
show_status()?;
}
Args::BackupList { verbose } => {
backup_list(verbose)?;
}
Args::BackupShow { id } => {
backup_show(&id)?;
}
Args::BackupRestore { id } => {
backup_restore(&id)?;
}
Args::BackupRemove { id, force } => {
backup_remove(&id, force)?;
}
Args::BackupPrune {
keep,
keep_days,
force,
} => {
backup_prune(keep, keep_days, force)?;
}
Args::Config { show, log_path } => {
if log_path {
config_log_path()?;
} else if show {
config_show()?;
} else {
config_edit()?;
}
}
}
Ok(())
}
fn execute_stdin(expression: &str, regex_flavor: RegexFlavor, quiet: bool) -> Result<()> {
let debug_enabled = load_config()
.map(|c| c.processing.debug.unwrap_or(false))
.unwrap_or(false);
let start_time = Instant::now();
if debug_enabled {
tracing::info!(
expression = expression,
regex_flavor = ?regex_flavor,
mode = "stdin",
"Stdin processing started"
);
}
let parser = Parser::new(regex_flavor);
let commands = match parser.parse(expression) {
Ok(cmds) => cmds,
Err(e) => {
if debug_enabled {
tracing::error!(
expression = expression,
error = %e,
"Failed to parse expression"
);
}
return Err(e.context("Failed to parse expression"));
}
};
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let lines: Vec<String> = input.lines().map(|s| s.to_string()).collect();
let mut processor =
file_processor::FileProcessor::with_regex_flavor(commands.clone(), regex_flavor);
processor.set_no_default_output(quiet);
let result_lines = processor.apply_cycle_based(lines)?;
let output_line_count = result_lines.len();
for line in result_lines {
println!("{}", line);
}
if debug_enabled {
let elapsed = start_time.elapsed();
tracing::info!(
status = "success",
output_lines = output_line_count,
elapsed_ms = elapsed.as_millis(),
"Stdin processing completed"
);
}
Ok(())
}
fn can_use_streaming(commands: &[Command]) -> bool {
use Command::*;
for cmd in commands {
match cmd {
Group { .. } => {
return true;
}
Hold { .. } | HoldAppend { .. } | Get { .. } | GetAppend { .. } | Exchange { .. } => {
if let Some(range) = get_command_range_option(cmd)
&& !is_range_supported_in_streaming(&range)
{
return false;
}
}
_ => {
if let Some(range) = get_command_range_option(cmd)
&& !is_range_supported_in_streaming(&range)
{
return false;
}
}
}
}
true
}
fn get_command_range_option(cmd: &Command) -> Option<(Address, Address)> {
fn unsupported() -> (Address, Address) {
(
Address::Negated(Box::new(Address::LineNumber(0))),
Address::LineNumber(0),
)
}
match cmd {
Command::Substitution { range, .. } => range.as_ref().map(|r| (r.0.clone(), r.1.clone())),
Command::Delete { range } => Some(range.clone()),
Command::Print { range } => Some(range.clone()),
Command::Insert {
address: Address::LineNumber(_),
..
} => Some((Address::LineNumber(0), Address::LineNumber(0))),
Command::Insert { .. } => Some(unsupported()),
Command::Append {
address: Address::LineNumber(_),
..
} => Some((Address::LineNumber(0), Address::LineNumber(0))),
Command::Append { .. } => Some(unsupported()),
Command::Change {
range: (Address::LineNumber(s), Address::LineNumber(e)),
..
} if s == e => Some((Address::LineNumber(0), Address::LineNumber(0))),
Command::Change { .. } => Some(unsupported()),
Command::Quit {
address: Some(Address::LineNumber(_)) | None,
..
}
| Command::Quit {
address: Some(Address::LastLine),
..
} => Some((Address::LineNumber(0), Address::LineNumber(0))),
_ => None,
}
}
fn is_range_supported_in_streaming(range: &(Address, Address)) -> bool {
use Address::*;
match (&range.0, &range.1) {
(Pattern(_), Pattern(_)) => true, (LineNumber(1), LastLine) => true, (LineNumber(_), LineNumber(_)) => true, (Pattern(_), LineNumber(_)) => true, (LineNumber(_), Pattern(_)) => true, (Pattern(_), Relative { base: _, offset: _ }) => true,
(Step { .. }, _) | (_, Step { .. }) => true,
(Negated(_), _) | (_, Negated(_)) => false, _ => false,
}
}
#[allow(clippy::too_many_arguments)]
fn execute_command(
expression: &str,
files: &[String],
dry_run: bool,
interactive: bool,
context: usize,
streaming: bool,
regex_flavor: RegexFlavor,
no_backup: bool,
backup_dir: Option<String>,
quiet: bool,
) -> Result<()> {
let start_time = Instant::now();
let config = load_config()?;
let backup_dir = backup_dir.or_else(|| config.backup.backup_dir.clone());
let debug_enabled = config.processing.debug.unwrap_or(false);
if debug_enabled {
tracing::info!(
expression = expression,
regex_flavor = ?regex_flavor,
dry_run = dry_run,
files_count = files.len(),
"Operation started"
);
}
let parser = Parser::new(regex_flavor);
let commands = match parser.parse(expression) {
Ok(cmds) => cmds,
Err(e) => {
if debug_enabled {
tracing::error!(
expression = expression,
error = %e,
"Failed to parse expression"
);
}
return Err(e.context("Failed to parse expression"));
}
};
if debug_enabled {
tracing::info!(
command_count = commands.len(),
"Expression parsed successfully"
);
}
let can_modify_files = commands_can_modify_files(&commands);
let supports_streaming = can_use_streaming(&commands);
let file_paths: Vec<PathBuf> = files
.iter()
.filter_map(|f| {
fs::canonicalize(Path::new(f))
.inspect_err(|e| eprintln!("Error resolving {}: {}", f, e))
.ok()
})
.collect();
let mut diffs = Vec::new();
let mut streaming_files: Vec<PathBuf> = Vec::new();
for file_path in &file_paths {
let metadata = match fs::metadata(file_path) {
Ok(meta) => meta,
Err(e) => {
if debug_enabled {
tracing::warn!(
file = %file_path.display(),
error = %e,
"Failed to read file"
);
}
eprintln!("Error reading file {}: {}", file_path.display(), e);
continue;
}
};
let file_size_mb = metadata.len() / 1024 / 1024;
let streaming_threshold_mb = config.processing.max_memory_mb.unwrap_or(100);
let streaming_threshold_bytes = (streaming_threshold_mb * 1024 * 1024) as u64;
let use_streaming = if !supports_streaming {
false } else if streaming {
true } else if metadata.len() >= streaming_threshold_bytes {
eprintln!(
"📊 Streaming mode activated for {} ({} MB, threshold: {} MB)",
file_path.display(),
file_size_mb,
streaming_threshold_mb
);
true
} else {
true
};
if use_streaming {
streaming_files.push(file_path.clone());
}
let diff = if use_streaming {
let mut stream_processor =
file_processor::StreamProcessor::with_regex_flavor(commands.clone(), regex_flavor)
.with_context_size(context)
.with_dry_run(true); stream_processor.process_streaming_forced(file_path)
} else {
let mut processor =
file_processor::FileProcessor::with_regex_flavor(commands.clone(), regex_flavor);
processor.set_no_default_output(quiet); processor.process_file_with_context(file_path)
};
match diff {
Ok(diff) => diffs.push(diff),
Err(e) => {
if debug_enabled {
tracing::error!(
file = %file_path.display(),
error = %e,
"Failed to process file"
);
}
eprintln!("Error processing {}: {}", file_path.display(), e);
}
}
}
let total_changes: usize = diffs.iter().map(|d| d.changes.len()).sum();
let has_printed_lines: bool = diffs.iter().any(|d| !d.printed_lines.is_empty());
if total_changes == 0 && !has_printed_lines {
if debug_enabled {
tracing::info!("No changes would be made");
}
println!("No changes would be made.");
return Ok(());
}
if debug_enabled {
tracing::info!(
total_changes = total_changes,
files_processed = diffs.len(),
"Changes detected"
);
}
if dry_run || interactive {
let header = diff_formatter::DiffFormatter::format_dry_run_header(expression);
println!("{}", header);
for diff in &diffs {
let output =
diff_formatter::DiffFormatter::format_diff_with_context(diff, context, expression);
print!("{}", output);
}
}
if interactive && !dry_run {
print!("Apply changes? [y/N] ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input != "y" && input != "yes" {
if debug_enabled {
tracing::info!("User declined changes in interactive mode");
}
println!("Changes not applied.");
return Ok(());
}
}
if dry_run {
if debug_enabled {
tracing::info!("Dry run completed, no changes applied");
}
return Ok(());
}
let backup_id = if no_backup {
if debug_enabled {
tracing::warn!("Backup skipped (--no-backup flag)");
}
println!("⚠️ Skipping backup (changes cannot be undone)");
None
} else if !can_modify_files {
if debug_enabled {
tracing::info!("No backup created (read-only command)");
}
println!("ℹ️ No backup needed (read-only command)");
None
} else {
let mut backup_manager = if let Some(dir) = backup_dir {
backup_manager::BackupManager::with_directory(dir)?
} else {
backup_manager::BackupManager::new()?
};
match backup_manager.create_backup(expression, &file_paths) {
Ok(id) => {
if debug_enabled {
tracing::info!(backup_id = %id, "Backup created");
}
println!("✅ Backup created: {}", id);
Some(id)
}
Err(e) => {
if debug_enabled {
tracing::error!(
error = %e,
"Failed to create backup"
);
}
return Err(e);
}
}
};
let mut apply_errors = Vec::new();
if can_modify_files {
for file_path in &file_paths {
if streaming_files.contains(file_path) {
let mut stream_processor = file_processor::StreamProcessor::with_regex_flavor(
commands.clone(),
regex_flavor,
)
.with_context_size(context)
.with_dry_run(false); match stream_processor.process_streaming_forced(file_path) {
Ok(_) => {
if debug_enabled {
tracing::debug!(
file = %file_path.display(),
mode = "streaming",
"Changes applied successfully"
);
}
}
Err(e) => {
if debug_enabled {
tracing::error!(
file = %file_path.display(),
error = %e,
"Failed to apply changes"
);
}
eprintln!("Error applying to {}: {}", file_path.display(), e);
apply_errors.push((file_path.clone(), e));
}
}
} else {
let mut processor = file_processor::FileProcessor::with_regex_flavor(
commands.clone(),
regex_flavor,
);
processor.set_no_default_output(quiet); match processor.apply_to_file(file_path) {
Ok(_) => {
if debug_enabled {
tracing::debug!(
file = %file_path.display(),
mode = "in-memory",
"Changes applied successfully"
);
}
}
Err(e) => {
if debug_enabled {
tracing::error!(
file = %file_path.display(),
error = %e,
"Failed to apply changes"
);
}
eprintln!("Error applying to {}: {}", file_path.display(), e);
apply_errors.push((file_path.clone(), e));
}
}
}
}
}
if !interactive {
for diff in &diffs {
let output =
diff_formatter::DiffFormatter::format_diff_with_context(diff, context, expression);
print!("{}", output);
}
}
if let Some(id) = backup_id {
println!("\nBackup ID: {}", id);
println!("Rollback with: sedx rollback {}", id);
} else if can_modify_files {
println!("\nNo backup created - changes cannot be undone");
}
let elapsed = start_time.elapsed();
if debug_enabled {
let status = if apply_errors.is_empty() {
"success"
} else {
"partial_failure"
};
tracing::info!(
status = status,
elapsed_ms = elapsed.as_millis(),
files_processed = file_paths.len() - apply_errors.len(),
errors = apply_errors.len(),
"Operation completed"
);
}
if !apply_errors.is_empty() {
Err(anyhow::anyhow!(
"Failed to apply changes to {} file(s)",
apply_errors.len()
))
} else {
Ok(())
}
}
fn commands_can_modify_files(commands: &[crate::command::Command]) -> bool {
use crate::command::Command;
for cmd in commands {
match cmd {
Command::Print { .. } | Command::Quit { .. } | Command::QuitWithoutPrint { .. }
| Command::Next { .. } | Command::NextAppend { .. } | Command::PrintFirstLine { .. }
| Command::Label { .. } | Command::Branch { .. } | Command::Test { .. } | Command::TestFalse { .. }
| Command::PrintLineNumber { .. } | Command::PrintFilename { .. }
=> continue,
Command::Group { commands: inner, .. } => {
if commands_can_modify_files(inner) {
return true;
}
}
Command::Substitution { .. } | Command::Delete { .. }
| Command::Insert { .. } | Command::Append { .. } | Command::Change { .. }
| Command::Hold { .. } | Command::HoldAppend { .. } | Command::Get { .. }
| Command::GetAppend { .. } | Command::Exchange { .. }
| Command::DeleteFirstLine { .. }
| Command::ReadFile { .. } | Command::WriteFile { .. } | Command::ReadLine { .. } | Command::WriteFirstLine { .. }
| Command::ClearPatternSpace { .. }
=> return true, }
}
false
}
fn rollback(id: Option<String>) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backup_id = match id {
Some(id) => id,
None => match backup_manager.get_last_backup_id()? {
Some(id) => {
println!("Rolling back last operation: {}\n", id);
id
}
None => {
anyhow::bail!("No backups found to rollback");
}
},
};
backup_manager.restore_backup(&backup_id)?;
println!("\n✅ Rollback complete");
Ok(())
}
fn show_history() -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
let output = diff_formatter::DiffFormatter::format_history(backups);
println!("{}", output);
Ok(())
}
fn show_status() -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
println!("Current backup status:\n");
println!("Total backups: {}\n", backups.len());
if let Some(last) = backups.last() {
println!("Last operation:");
println!(" ID: {}", last.id);
println!(" Time: {}", last.timestamp.format("%Y-%m-%d %H:%M:%S"));
println!(" Command: {}", last.expression);
}
Ok(())
}
fn backup_list(verbose: bool) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
if backups.is_empty() {
println!("No backups found.");
return Ok(());
}
println!("Backups ({} total):\n", backups.len());
for backup in backups.iter().rev() {
println!("ID: {}", backup.id);
println!(" Time: {}", backup.timestamp.format("%Y-%m-%d %H:%M:%S"));
println!(" Expression: {}", backup.expression);
println!(" Files: {}", backup.files.len());
if verbose {
println!(" Details:");
for file_backup in &backup.files {
let size = std::fs::metadata(&file_backup.backup_path)
.map(|m| m.len())
.unwrap_or(0);
println!(
" - {} ({} bytes)",
file_backup.original_path.display(),
disk_space::DiskSpaceInfo::bytes_to_human(size)
);
}
}
println!();
}
Ok(())
}
fn backup_show(id: &str) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
let backup = backups
.iter()
.find(|b| b.id.starts_with(id))
.ok_or_else(|| anyhow::anyhow!("Backup not found: {}", id))?;
println!("Backup Details:\n");
println!("ID: {}", backup.id);
println!("Time: {}", backup.timestamp.format("%Y-%m-%d %H:%M:%S UTC"));
println!("Expression: {}", backup.expression);
println!("Files: {}\n", backup.files.len());
for file_backup in &backup.files {
let size = std::fs::metadata(&file_backup.backup_path)
.map(|m| m.len())
.unwrap_or(0);
println!(" {}", file_backup.original_path.display());
println!(" Backup: {}", file_backup.backup_path.display());
println!(
" Size: {}",
disk_space::DiskSpaceInfo::bytes_to_human(size)
);
println!();
}
Ok(())
}
fn backup_restore(id: &str) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
println!("Restoring backup: {}", id);
println!("This will replace current files with backed up versions.\n");
backup_manager.restore_backup(id)?;
Ok(())
}
fn backup_remove(id: &str, force: bool) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
let backup = backups
.iter()
.find(|b| b.id.starts_with(id))
.ok_or_else(|| anyhow::anyhow!("Backup not found: {}", id))?;
if !force {
println!("This will permanently delete backup: {}", backup.id);
print!("Are you sure? [y/N] ");
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
if !confirm.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
let backup_dir = backup_manager.backups_dir().join(&backup.id);
fs::remove_dir_all(&backup_dir)
.with_context(|| format!("Failed to remove backup: {}", backup.id))?;
println!("✅ Backup removed: {}", backup.id);
Ok(())
}
fn backup_prune(keep: Option<usize>, keep_days: Option<usize>, force: bool) -> Result<()> {
let backup_manager = backup_manager::BackupManager::new()?;
let backups = backup_manager.list_backups()?;
if backups.is_empty() {
println!("No backups to prune.");
return Ok(());
}
let keep = keep.unwrap_or(10);
let mut to_remove = Vec::new();
if let Some(days) = keep_days {
let cutoff_date = chrono::Utc::now() - chrono::Duration::days(days as i64);
for backup in &backups {
if backup.timestamp < cutoff_date {
to_remove.push(backup.clone());
}
}
println!("Pruning backups older than {} days:", days);
} else {
let sorted = backups.clone();
let mut backups_by_date = sorted.into_iter().enumerate().collect::<Vec<_>>();
backups_by_date.sort_by_key(|(_, b)| b.timestamp);
for (_idx, backup) in backups_by_date.into_iter().rev().skip(keep) {
to_remove.push(backup);
}
println!("Pruning backups, keeping only {} most recent:", keep);
}
if to_remove.is_empty() {
println!("No backups to remove.");
return Ok(());
}
println!("\nBackups to be removed:");
for backup in &to_remove {
println!(
" - {} ({})",
backup.id,
backup.timestamp.format("%Y-%m-%d %H:%M:%S")
);
}
println!("\nTotal: {} backup(s)", to_remove.len());
if !force {
print!("Continue? [y/N] ");
io::stdout().flush()?;
let mut confirm = String::new();
io::stdin().read_line(&mut confirm)?;
if !confirm.trim().eq_ignore_ascii_case("y") {
println!("Cancelled.");
return Ok(());
}
}
for backup in to_remove {
let backup_dir = backup_manager.backups_dir().join(&backup.id);
fs::remove_dir_all(&backup_dir)
.with_context(|| format!("Failed to remove backup: {}", backup.id))?;
println!("✅ Removed: {}", backup.id);
}
Ok(())
}
fn config_show() -> Result<()> {
let config = load_config()?;
let config_path = config_file_path()?;
println!("SedX Configuration:");
println!(" File: {}\n", config_path.display());
println!("[backup]");
if let Some(max_size_gb) = config.backup.max_size_gb {
println!(" max_size_gb = {}", max_size_gb);
} else {
println!(" max_size_gb = (not set)");
}
if let Some(max_disk) = config.backup.max_disk_usage_percent {
println!(" max_disk_usage_percent = {}", max_disk);
} else {
println!(" max_disk_usage_percent = (not set)");
}
if let Some(ref dir) = config.backup.backup_dir {
println!(" backup_dir = \"{}\"", dir);
} else {
println!(" backup_dir = (not set)");
}
println!("\n[compatibility]");
if let Some(ref mode) = config.compatibility.mode {
println!(" mode = \"{}\"", mode);
} else {
println!(" mode = (not set)");
}
if let Some(show_warn) = config.compatibility.show_warnings {
println!(" show_warnings = {}", show_warn);
} else {
println!(" show_warnings = (not set)");
}
println!("\n[processing]");
if let Some(ctx) = config.processing.context_lines {
println!(" context_lines = {}", ctx);
} else {
println!(" context_lines = (not set)");
}
if let Some(max_mem) = config.processing.max_memory_mb {
println!(" max_memory_mb = {}", max_mem);
} else {
println!(" max_memory_mb = (not set)");
}
if let Some(stream) = config.processing.streaming {
println!(" streaming = {}", stream);
} else {
println!(" streaming = (not set)");
}
if let Some(debug) = config.processing.debug {
println!(" debug = {}", debug);
} else {
println!(" debug = (not set)");
}
Ok(())
}
fn config_edit() -> Result<()> {
use config::{Config, validate_config};
let config_path = config_file_path()?;
let file_existed = config_path.exists();
if !file_existed {
println!("Creating new configuration file: {}", config_path.display());
}
ensure_complete_config()?;
if !file_existed {
println!("✅ Created default configuration file\n");
}
let editor = std::env::var("EDITOR")
.or_else(|_| std::env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(unix) {
if which::which("vim").is_ok() {
"vim".to_string()
} else if which::which("nano").is_ok() {
"nano".to_string()
} else {
"vi".to_string()
}
} else {
"notepad".to_string()
}
});
println!("Opening {} in editor: {}", config_path.display(), editor);
println!("After saving and exiting, the configuration will be validated.\n");
let status = ProcessCommand::new(&editor)
.arg(&config_path)
.status()
.with_context(|| format!("Failed to open editor: {}", editor))?;
if !status.success() {
anyhow::bail!("Editor exited with non-zero status: {}", status);
}
let config_str = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&config_str)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
validate_config(&config)?;
println!("\n✅ Configuration is valid!");
Ok(())
}
fn config_log_path() -> Result<()> {
use logger::get_current_log_path;
let config = load_config()?;
let debug_enabled = config.processing.debug.unwrap_or(false);
println!("SedX Log File:");
println!(" Path: {}", get_current_log_path().display());
println!(
" Status: {}",
if debug_enabled { "enabled" } else { "disabled" }
);
println!();
if !debug_enabled {
println!("Debug logging is currently disabled.");
println!("To enable it, edit ~/.sedx/config.toml and set:");
println!("\n [processing]");
println!(" debug = true\n");
println!("After enabling, logs will be written to the path above.");
} else {
println!("Debug logging is enabled. Operations are being logged.");
}
Ok(())
}