pass clippy

This commit is contained in:
2026-08-28 00:18:23 +01:00
parent eb39513960
commit fb3cdf5322
+216 -121
View File
@@ -1,18 +1,19 @@
#![allow(clippy::struct_excessive_bools)] #![allow(clippy::struct_excessive_bools)]
use crossterm::{ use crossterm::{
cursor::MoveTo, event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
},
execute, execute,
style::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor, Stylize,
},
terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
}; };
use std::io::{self, Write}; use ratatui::{
use std::time::Instant; backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph},
Frame, Terminal,
};
use std::io;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TuiConfig { pub struct TuiConfig {
@@ -50,35 +51,27 @@ impl TuiConfig {
if !self.output_dir.is_empty() { if !self.output_dir.is_empty() {
args.extend(["-o".to_string(), self.output_dir.clone()]); args.extend(["-o".to_string(), self.output_dir.clone()]);
} }
if self.subdomains { if self.subdomains {
args.push("--subdomains".to_string()); args.push("--subdomains".to_string());
} }
if self.single { if self.single {
args.push("--single".to_string()); args.push("--single".to_string());
} }
if self.no_scope { if self.no_scope {
args.push("--no-scope".to_string()); args.push("--no-scope".to_string());
} }
if self.no_doc_conversion { if self.no_doc_conversion {
args.push("--no-doc-conversion".to_string()); args.push("--no-doc-conversion".to_string());
} }
if self.delay_ms != 1000 { if self.delay_ms != 1000 {
args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]); args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]);
} }
if let Some(ref t) = self.types { if let Some(ref t) = self.types {
args.extend(["--types".to_string(), t.clone()]); args.extend(["--types".to_string(), t.clone()]);
} }
if let Some(ref t) = self.exclude_types { if let Some(ref t) = self.exclude_types {
args.extend(["--exclude-types".to_string(), t.clone()]); args.extend(["--exclude-types".to_string(), t.clone()]);
} }
args args
} }
@@ -92,24 +85,16 @@ fn validate_comma_list(input: &str) -> Result<(), String> {
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(()); return Ok(());
} }
let items: Vec<&str> = trimmed.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
let items: Vec<&str> = trimmed
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if items.is_empty() { if items.is_empty() {
return Err("List cannot be empty".to_string()); return Err("List cannot be empty".to_string());
} }
for item in &items { for item in &items {
let check = item.strip_prefix('.').unwrap_or(item); let check = item.strip_prefix('.').unwrap_or(item);
if check.is_empty() || !check.chars().all(char::is_alphanumeric) { if check.is_empty() || !check.chars().all(char::is_alphanumeric) {
return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'")); return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'"));
} }
} }
Ok(()) Ok(())
} }
@@ -208,6 +193,14 @@ impl FieldIndex {
| FieldIndex::NoDocConversion | FieldIndex::NoDocConversion
) )
} }
fn subtitle(self) -> &'static str {
match self {
FieldIndex::DelayMs => "[enter positive integer]",
FieldIndex::TypesInclude | FieldIndex::TypesExclude => "[comma-separated, dots optional]",
_ => "",
}
}
} }
const ALL_FIELDS: [FieldIndex; 10] = [ const ALL_FIELDS: [FieldIndex; 10] = [
@@ -223,108 +216,191 @@ const ALL_FIELDS: [FieldIndex; 10] = [
FieldIndex::Submit, FieldIndex::Submit,
]; ];
enum Action {
Continue,
Cancel,
Submit,
}
pub fn run_tui() -> io::Result<Option<TuiConfig>> { pub fn run_tui() -> io::Result<Option<TuiConfig>> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut config = TuiConfig::default(); let mut config = TuiConfig::default();
let mut current_field = FieldIndex::StartUrl; let mut current_field = FieldIndex::StartUrl;
let mut text_input = String::new(); let mut text_input = String::new();
let mut errors: Vec<(FieldIndex, String)> = Vec::new(); let mut errors: Vec<(FieldIndex, String)> = Vec::new();
let mut last_render = Instant::now();
load_field_into_input(&mut text_input, &config, current_field); load_field_into_input(&mut text_input, &config, current_field);
loop { loop {
if last_render.elapsed().as_millis() > 50 { terminal.draw(|frame| ui(frame, &config, current_field, &errors))?;
render_ui(&mut stdout, &config, current_field, &errors)?;
last_render = Instant::now();
}
if event::poll(std::time::Duration::from_millis(50))? if event::poll(std::time::Duration::from_millis(50))?
&& let Event::Key(key) = event::read()? && let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press && key.kind == KeyEventKind::Press
{ {
handle_key_event( let action = handle_key_event(
&mut config, &mut config,
&mut current_field, &mut current_field,
&mut text_input, &mut text_input,
&mut errors, &mut errors,
key.code, key.code,
key.modifiers, key.modifiers,
)?; );
match action {
Action::Continue => {}
Action::Cancel => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
return Ok(None);
}
Action::Submit => {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
return Ok(Some(config));
}
}
} }
} }
} }
fn render_ui(stdout: &mut io::Stdout, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) -> io::Result<()> { #[allow(clippy::too_many_lines)]
execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?; fn ui(frame: &mut Frame, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) {
let title_line = Line::from(Span::styled(
"Interactive Web Scraper Configuration",
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD),
));
writeln!(stdout, "{}", "Interactive Web Scraper Configuration".cyan().bold())?; let controls_lines: Vec<Line> = vec![
writeln!(stdout, "{}", "=".repeat(60).blue())?; Line::from(Span::styled("Controls:", Style::default().fg(Color::DarkGray))),
writeln!(stdout)?; Line::from(" Enter : Next field / Toggle boolean / Submit"),
Line::from(" Tab : Next field"),
for &field in &ALL_FIELDS { Line::from(" Shift+Tab: Previous field"),
let marker = if current_field == field { ">" } else { " " }; Line::from(" Ctrl+C : Exit without running"),
let value_text = get_field_display(config, field); Line::from(" Esc : Exit without running"),
let hint = field.hint(); ];
if current_field == field {
execute!(
stdout,
SetBackgroundColor(Color::DarkGrey),
Print(format!("{marker} {:30}: ", field.label())),
ResetColor
)?;
} else {
write!(stdout, "{marker} {:30}: ", field.label())?;
}
if !value_text.is_empty() {
write!(stdout, "{value_text}")?;
} else if !hint.is_empty() {
write!(stdout, "{}", hint.dark_grey())?;
}
if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) {
execute!(
stdout,
SetForegroundColor(Color::Red),
Print(format!(" [err: {err}]")),
ResetColor
)?;
}
writeln!(stdout)?;
if field.is_boolean_field() {
let enabled = get_bool_field(config, field);
writeln!(stdout, " [{}]",
if enabled { "enabled" } else { "disabled" })?;
} else if field == FieldIndex::DelayMs {
writeln!(stdout, " [enter positive integer]")?;
} else if field == FieldIndex::TypesInclude || field == FieldIndex::TypesExclude {
writeln!(stdout, " [comma-separated, dots optional e.g. .pdf,xlsx]")?;
}
}
writeln!(stdout)?;
writeln!(stdout, "{}", "Controls:".dark_grey())?;
writeln!(stdout, " Enter : Next field / Toggle boolean / Submit")?;
writeln!(stdout, " Tab : Next field")?;
writeln!(stdout, " Shift+Tab: Previous field")?;
writeln!(stdout, " Ctrl+C : Exit without running")?;
writeln!(stdout, " Esc : Exit without running")?;
let cmd_preview = config.build_command(); let cmd_preview = config.build_command();
writeln!(stdout)?; let cmd_lines: Vec<Line> = vec![
writeln!(stdout, "{}", "Command preview:".yellow())?; Line::from(Span::styled(
writeln!(stdout, " {cmd_preview}", cmd_preview = cmd_preview.green())?; "Command preview:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(format!(" {cmd_preview}"), Style::default().fg(Color::Yellow))),
];
stdout.flush()?; let status_text = if config.start_url.is_empty() {
"Waiting for Start URL..."
} else {
"Ready. Press Enter on Submit to run."
};
let status_style = if config.start_url.is_empty() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
};
let status_line = Line::from(Span::styled(status_text, status_style));
Ok(()) let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(6),
Constraint::Length(3),
Constraint::Length(1),
])
.split(frame.area());
frame.render_widget(Paragraph::new(title_line), chunks[0]);
let field_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(std::iter::repeat_n(Constraint::Length(2), ALL_FIELDS.len()).collect::<Vec<_>>())
.split(chunks[1]);
for (i, &field) in ALL_FIELDS.iter().enumerate() {
render_field(frame, field_chunks[i], config, current_field, field, errors);
}
frame.render_widget(Paragraph::new(controls_lines), chunks[2]);
frame.render_widget(Paragraph::new(cmd_lines), chunks[3]);
let error_line = if let Some((_, err)) = errors.iter().find(|(f, _)| *f == FieldIndex::Submit) {
Line::from(Span::styled(format!("[ERR: {err}]"), Style::default().fg(Color::Red)))
} else {
status_line
};
frame.render_widget(
Paragraph::new(error_line).alignment(Alignment::Center),
chunks[4],
);
}
fn render_field(frame: &mut Frame, area: ratatui::layout::Rect, config: &TuiConfig, current_field: FieldIndex, field: FieldIndex, errors: &[(FieldIndex, String)]) {
let value_text = get_field_display(config, field);
let hint = field.hint();
let is_active = current_field == field;
let display_value = if !value_text.is_empty() {
value_text.as_str()
} else if !hint.is_empty() {
hint
} else {
""
};
let bool_state = if field.is_boolean_field() {
format!(" [{}]", if get_bool_field(config, field) { "enabled" } else { "disabled" })
} else {
String::new()
};
let prefix = if is_active { ">" } else { " " };
let label = format!("{:30} ", format!("{}:", field.label()));
let subtitle = field.subtitle();
let value_style = if is_active {
Style::default()
} else if display_value.is_empty() || display_value == hint {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(
format!("{prefix} {label}"),
if is_active {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
},
),
Span::styled(display_value.to_string(), value_style),
Span::styled(bool_state, Style::default().fg(Color::DarkGray)),
Span::styled(format!(" {subtitle}"), Style::default().fg(Color::DarkGray)),
];
if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) {
spans.push(Span::styled(format!(" [ERR: {err}]"), Style::default().fg(Color::Red)));
}
let line = Line::from(spans);
let block = if is_active {
Block::default().style(Style::default().bg(Color::DarkGray))
} else {
Block::default()
};
let widget = Paragraph::new(line).block(block);
frame.render_widget(widget, area);
} }
fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool { fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool {
@@ -367,8 +443,12 @@ fn load_field_into_input(text_input: &mut String, config: &TuiConfig, field: Fie
fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) { fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) {
match field { match field {
FieldIndex::StartUrl => { config.start_url = text_input.to_string(); } FieldIndex::StartUrl => {
FieldIndex::OutputDir => { config.output_dir = text_input.to_string(); } config.start_url = text_input.to_string();
}
FieldIndex::OutputDir => {
config.output_dir = text_input.to_string();
}
FieldIndex::DelayMs => { FieldIndex::DelayMs => {
if let Ok(val) = text_input.trim().parse::<u64>() { if let Ok(val) = text_input.trim().parse::<u64>() {
config.delay_ms = val; config.delay_ms = val;
@@ -376,11 +456,19 @@ fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &s
} }
FieldIndex::TypesInclude => { FieldIndex::TypesInclude => {
let trimmed = text_input.trim(); let trimmed = text_input.trim();
config.types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; config.types = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
} }
FieldIndex::TypesExclude => { FieldIndex::TypesExclude => {
let trimmed = text_input.trim(); let trimmed = text_input.trim();
config.exclude_types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; config.exclude_types = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
} }
_ => {} _ => {}
} }
@@ -393,42 +481,43 @@ fn handle_key_event(
errors: &mut Vec<(FieldIndex, String)>, errors: &mut Vec<(FieldIndex, String)>,
code: KeyCode, code: KeyCode,
modifiers: KeyModifiers, modifiers: KeyModifiers,
) -> io::Result<bool> { ) -> Action {
match code { match code {
KeyCode::Esc KeyCode::Esc
| KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
disable_raw_mode()?; Action::Cancel
execute!(io::stdout(), DisableMouseCapture)?;
return Ok(false);
} }
KeyCode::Tab => { KeyCode::Tab => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
*current_field = current_field.next(); *current_field = current_field.next();
load_field_into_input(text_input, config, *current_field); load_field_into_input(text_input, config, *current_field);
errors.clear(); errors.clear();
Action::Continue
} }
KeyCode::BackTab => { KeyCode::BackTab => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
*current_field = current_field.prev(); *current_field = current_field.prev();
load_field_into_input(text_input, config, *current_field); load_field_into_input(text_input, config, *current_field);
errors.clear(); errors.clear();
Action::Continue
} }
KeyCode::Enter => { KeyCode::Enter => {
process_enter(config, current_field, text_input, errors); process_enter(config, current_field, text_input, errors)
} }
KeyCode::Char(ch) if current_field.is_text_field() => { KeyCode::Char(ch) if current_field.is_text_field() => {
text_input.push(ch); text_input.push(ch);
Action::Continue
} }
KeyCode::Backspace if current_field.is_text_field() => { KeyCode::Backspace if current_field.is_text_field() => {
text_input.pop(); text_input.pop();
Action::Continue
} }
KeyCode::Delete if current_field.is_text_field() => { KeyCode::Delete if current_field.is_text_field() => {
text_input.clear(); text_input.clear();
Action::Continue
} }
_ => {} _ => Action::Continue,
} }
Ok(true)
} }
fn process_enter( fn process_enter(
@@ -436,19 +525,23 @@ fn process_enter(
current_field: &mut FieldIndex, current_field: &mut FieldIndex,
text_input: &mut String, text_input: &mut String,
errors: &mut Vec<(FieldIndex, String)>, errors: &mut Vec<(FieldIndex, String)>,
) { ) -> Action {
match *current_field { match *current_field {
FieldIndex::Subdomains => { FieldIndex::Subdomains => {
config.subdomains = !config.subdomains; config.subdomains = !config.subdomains;
Action::Continue
} }
FieldIndex::Single => { FieldIndex::Single => {
config.single = !config.single; config.single = !config.single;
Action::Continue
} }
FieldIndex::NoScope => { FieldIndex::NoScope => {
config.no_scope = !config.no_scope; config.no_scope = !config.no_scope;
Action::Continue
} }
FieldIndex::NoDocConversion => { FieldIndex::NoDocConversion => {
config.no_doc_conversion = !config.no_doc_conversion; config.no_doc_conversion = !config.no_doc_conversion;
Action::Continue
} }
FieldIndex::StartUrl => { FieldIndex::StartUrl => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
@@ -460,12 +553,14 @@ fn process_enter(
*current_field = current_field.next(); *current_field = current_field.next();
load_field_into_input(text_input, config, *current_field); load_field_into_input(text_input, config, *current_field);
} }
Action::Continue
} }
FieldIndex::OutputDir | FieldIndex::DelayMs => { FieldIndex::OutputDir | FieldIndex::DelayMs => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
errors.clear(); errors.clear();
*current_field = current_field.next(); *current_field = current_field.next();
load_field_into_input(text_input, config, *current_field); load_field_into_input(text_input, config, *current_field);
Action::Continue
} }
FieldIndex::TypesInclude => { FieldIndex::TypesInclude => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
@@ -481,6 +576,7 @@ fn process_enter(
errors.push((FieldIndex::TypesInclude, e)); errors.push((FieldIndex::TypesInclude, e));
} }
} }
Action::Continue
} }
FieldIndex::TypesExclude => { FieldIndex::TypesExclude => {
save_input_to_field(config, *current_field, text_input); save_input_to_field(config, *current_field, text_input);
@@ -496,37 +592,36 @@ fn process_enter(
errors.push((FieldIndex::TypesExclude, e)); errors.push((FieldIndex::TypesExclude, e));
} }
} }
Action::Continue
} }
FieldIndex::Submit => { FieldIndex::Submit => {
if config.start_url.trim().is_empty() { if config.start_url.trim().is_empty() {
errors.retain(|(f, _)| *f != FieldIndex::StartUrl); errors.retain(|(f, _)| *f != FieldIndex::StartUrl);
errors.push((FieldIndex::StartUrl, "Required".into())); errors.push((FieldIndex::StartUrl, "Required".into()));
return; return Action::Continue;
} }
if let Some(ref t) = config.types if let Some(ref t) = config.types
&& let Err(e) = validate_comma_list(t) && let Err(e) = validate_comma_list(t)
{ {
errors.retain(|(f, _)| *f != FieldIndex::TypesInclude); errors.retain(|(f, _)| *f != FieldIndex::TypesInclude);
errors.push((FieldIndex::TypesInclude, e)); errors.push((FieldIndex::TypesInclude, e));
return; return Action::Continue;
} }
if let Some(ref t) = config.exclude_types if let Some(ref t) = config.exclude_types
&& let Err(e) = validate_comma_list(t) && let Err(e) = validate_comma_list(t)
{ {
errors.retain(|(f, _)| *f != FieldIndex::TypesExclude); errors.retain(|(f, _)| *f != FieldIndex::TypesExclude);
errors.push((FieldIndex::TypesExclude, e)); errors.push((FieldIndex::TypesExclude, e));
return; return Action::Continue;
} }
if config.types.is_some() && config.exclude_types.is_some() { if config.types.is_some() && config.exclude_types.is_some() {
errors.push((FieldIndex::Submit, "types and exclude-types are mutually exclusive".into())); errors.push((
return; FieldIndex::Submit,
"types and exclude-types are mutually exclusive".into(),
));
return Action::Continue;
} }
Action::Submit
disable_raw_mode().unwrap();
execute!(io::stdout(), DisableMouseCapture).unwrap();
std::process::exit(0);
} }
} }
} }