feat(add tui): add ratatui tui.rs
This commit is contained in:
+532
@@ -0,0 +1,532 @@
|
|||||||
|
#![allow(clippy::struct_excessive_bools)]
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
cursor::MoveTo,
|
||||||
|
event::{
|
||||||
|
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
style::{
|
||||||
|
Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor, Stylize,
|
||||||
|
},
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType},
|
||||||
|
};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TuiConfig {
|
||||||
|
pub start_url: String,
|
||||||
|
pub output_dir: String,
|
||||||
|
pub subdomains: bool,
|
||||||
|
pub single: bool,
|
||||||
|
pub no_scope: bool,
|
||||||
|
pub no_doc_conversion: bool,
|
||||||
|
pub delay_ms: u64,
|
||||||
|
pub types: Option<String>,
|
||||||
|
pub exclude_types: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TuiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
start_url: String::new(),
|
||||||
|
output_dir: String::new(),
|
||||||
|
subdomains: false,
|
||||||
|
single: false,
|
||||||
|
no_scope: false,
|
||||||
|
no_doc_conversion: false,
|
||||||
|
delay_ms: 1000,
|
||||||
|
types: None,
|
||||||
|
exclude_types: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiConfig {
|
||||||
|
pub fn to_cli_args(&self) -> Vec<String> {
|
||||||
|
let mut args = vec![self.start_url.clone()];
|
||||||
|
|
||||||
|
if !self.output_dir.is_empty() {
|
||||||
|
args.extend(["-o".to_string(), self.output_dir.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.subdomains {
|
||||||
|
args.push("--subdomains".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.single {
|
||||||
|
args.push("--single".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.no_scope {
|
||||||
|
args.push("--no-scope".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.no_doc_conversion {
|
||||||
|
args.push("--no-doc-conversion".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.delay_ms != 1000 {
|
||||||
|
args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref t) = self.types {
|
||||||
|
args.extend(["--types".to_string(), t.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref t) = self.exclude_types {
|
||||||
|
args.extend(["--exclude-types".to_string(), t.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_command(&self) -> String {
|
||||||
|
format!("web-scraper {}", self.to_cli_args().join(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_comma_list(input: &str) -> Result<(), String> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<&str> = trimmed
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
return Err("List cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in &items {
|
||||||
|
let check = item.strip_prefix('.').unwrap_or(item);
|
||||||
|
if check.is_empty() || !check.chars().all(char::is_alphanumeric) {
|
||||||
|
return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FieldIndex {
|
||||||
|
StartUrl,
|
||||||
|
OutputDir,
|
||||||
|
Subdomains,
|
||||||
|
Single,
|
||||||
|
NoScope,
|
||||||
|
NoDocConversion,
|
||||||
|
DelayMs,
|
||||||
|
TypesInclude,
|
||||||
|
TypesExclude,
|
||||||
|
Submit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldIndex {
|
||||||
|
fn next(self) -> Self {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => FieldIndex::OutputDir,
|
||||||
|
FieldIndex::OutputDir => FieldIndex::Subdomains,
|
||||||
|
FieldIndex::Subdomains => FieldIndex::Single,
|
||||||
|
FieldIndex::Single => FieldIndex::NoScope,
|
||||||
|
FieldIndex::NoScope => FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::NoDocConversion => FieldIndex::DelayMs,
|
||||||
|
FieldIndex::DelayMs => FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::TypesInclude => FieldIndex::TypesExclude,
|
||||||
|
FieldIndex::TypesExclude => FieldIndex::Submit,
|
||||||
|
FieldIndex::Submit => FieldIndex::StartUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prev(self) -> Self {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => FieldIndex::Submit,
|
||||||
|
FieldIndex::OutputDir => FieldIndex::StartUrl,
|
||||||
|
FieldIndex::Subdomains => FieldIndex::OutputDir,
|
||||||
|
FieldIndex::Single => FieldIndex::Subdomains,
|
||||||
|
FieldIndex::NoScope => FieldIndex::Single,
|
||||||
|
FieldIndex::NoDocConversion => FieldIndex::NoScope,
|
||||||
|
FieldIndex::DelayMs => FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::TypesInclude => FieldIndex::DelayMs,
|
||||||
|
FieldIndex::TypesExclude => FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::Submit => FieldIndex::TypesExclude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => "Start URL",
|
||||||
|
FieldIndex::OutputDir => "Output directory",
|
||||||
|
FieldIndex::Subdomains => "Include subdomains?",
|
||||||
|
FieldIndex::Single => "Single page mode?",
|
||||||
|
FieldIndex::NoScope => "No path scoping?",
|
||||||
|
FieldIndex::NoDocConversion => "Skip document conversion?",
|
||||||
|
FieldIndex::DelayMs => "Delay (ms)",
|
||||||
|
FieldIndex::TypesInclude => "Include types",
|
||||||
|
FieldIndex::TypesExclude => "Exclude types",
|
||||||
|
FieldIndex::Submit => "Submit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hint(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldIndex::StartUrl => "https://example.com/docs",
|
||||||
|
FieldIndex::OutputDir => "<domain>_scraped",
|
||||||
|
FieldIndex::Subdomains
|
||||||
|
| FieldIndex::Single
|
||||||
|
| FieldIndex::NoScope
|
||||||
|
| FieldIndex::NoDocConversion => "[y/N]",
|
||||||
|
FieldIndex::DelayMs => "1000",
|
||||||
|
FieldIndex::TypesInclude => ".pdf,docx,.html",
|
||||||
|
FieldIndex::TypesExclude => "png,jpg,.gif",
|
||||||
|
FieldIndex::Submit => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text_field(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
FieldIndex::StartUrl
|
||||||
|
| FieldIndex::OutputDir
|
||||||
|
| FieldIndex::DelayMs
|
||||||
|
| FieldIndex::TypesInclude
|
||||||
|
| FieldIndex::TypesExclude
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_boolean_field(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
FieldIndex::Subdomains
|
||||||
|
| FieldIndex::Single
|
||||||
|
| FieldIndex::NoScope
|
||||||
|
| FieldIndex::NoDocConversion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_FIELDS: [FieldIndex; 10] = [
|
||||||
|
FieldIndex::StartUrl,
|
||||||
|
FieldIndex::OutputDir,
|
||||||
|
FieldIndex::Subdomains,
|
||||||
|
FieldIndex::Single,
|
||||||
|
FieldIndex::NoScope,
|
||||||
|
FieldIndex::NoDocConversion,
|
||||||
|
FieldIndex::DelayMs,
|
||||||
|
FieldIndex::TypesInclude,
|
||||||
|
FieldIndex::TypesExclude,
|
||||||
|
FieldIndex::Submit,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn run_tui() -> io::Result<Option<TuiConfig>> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnableMouseCapture)?;
|
||||||
|
|
||||||
|
let mut config = TuiConfig::default();
|
||||||
|
let mut current_field = FieldIndex::StartUrl;
|
||||||
|
let mut text_input = String::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);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if last_render.elapsed().as_millis() > 50 {
|
||||||
|
render_ui(&mut stdout, &config, current_field, &errors)?;
|
||||||
|
last_render = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if event::poll(std::time::Duration::from_millis(50))?
|
||||||
|
&& let Event::Key(key) = event::read()?
|
||||||
|
&& key.kind == KeyEventKind::Press
|
||||||
|
{
|
||||||
|
handle_key_event(
|
||||||
|
&mut config,
|
||||||
|
&mut current_field,
|
||||||
|
&mut text_input,
|
||||||
|
&mut errors,
|
||||||
|
key.code,
|
||||||
|
key.modifiers,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_ui(stdout: &mut io::Stdout, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) -> io::Result<()> {
|
||||||
|
execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?;
|
||||||
|
|
||||||
|
writeln!(stdout, "{}", "Interactive Web Scraper Configuration".cyan().bold())?;
|
||||||
|
writeln!(stdout, "{}", "=".repeat(60).blue())?;
|
||||||
|
writeln!(stdout)?;
|
||||||
|
|
||||||
|
for &field in &ALL_FIELDS {
|
||||||
|
let marker = if current_field == field { ">" } else { " " };
|
||||||
|
let value_text = get_field_display(config, field);
|
||||||
|
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();
|
||||||
|
writeln!(stdout)?;
|
||||||
|
writeln!(stdout, "{}", "Command preview:".yellow())?;
|
||||||
|
writeln!(stdout, " {cmd_preview}", cmd_preview = cmd_preview.green())?;
|
||||||
|
|
||||||
|
stdout.flush()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool {
|
||||||
|
match field {
|
||||||
|
FieldIndex::Subdomains => config.subdomains,
|
||||||
|
FieldIndex::Single => config.single,
|
||||||
|
FieldIndex::NoScope => config.no_scope,
|
||||||
|
FieldIndex::NoDocConversion => config.no_doc_conversion,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_field_display(config: &TuiConfig, field: FieldIndex) -> String {
|
||||||
|
match field {
|
||||||
|
FieldIndex::StartUrl => config.start_url.clone(),
|
||||||
|
FieldIndex::OutputDir => {
|
||||||
|
if config.output_dir.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
config.output_dir.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::DelayMs => config.delay_ms.to_string(),
|
||||||
|
FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(),
|
||||||
|
FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_field_into_input(text_input: &mut String, config: &TuiConfig, field: FieldIndex) {
|
||||||
|
*text_input = match field {
|
||||||
|
FieldIndex::StartUrl => config.start_url.clone(),
|
||||||
|
FieldIndex::OutputDir => config.output_dir.clone(),
|
||||||
|
FieldIndex::DelayMs => config.delay_ms.to_string(),
|
||||||
|
FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(),
|
||||||
|
FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) {
|
||||||
|
match field {
|
||||||
|
FieldIndex::StartUrl => { config.start_url = text_input.to_string(); }
|
||||||
|
FieldIndex::OutputDir => { config.output_dir = text_input.to_string(); }
|
||||||
|
FieldIndex::DelayMs => {
|
||||||
|
if let Ok(val) = text_input.trim().parse::<u64>() {
|
||||||
|
config.delay_ms = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::TypesInclude => {
|
||||||
|
let trimmed = text_input.trim();
|
||||||
|
config.types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) };
|
||||||
|
}
|
||||||
|
FieldIndex::TypesExclude => {
|
||||||
|
let trimmed = text_input.trim();
|
||||||
|
config.exclude_types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) };
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key_event(
|
||||||
|
config: &mut TuiConfig,
|
||||||
|
current_field: &mut FieldIndex,
|
||||||
|
text_input: &mut String,
|
||||||
|
errors: &mut Vec<(FieldIndex, String)>,
|
||||||
|
code: KeyCode,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> io::Result<bool> {
|
||||||
|
match code {
|
||||||
|
KeyCode::Esc
|
||||||
|
| KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(io::stdout(), DisableMouseCapture)?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
errors.clear();
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
*current_field = current_field.prev();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
errors.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
process_enter(config, current_field, text_input, errors);
|
||||||
|
}
|
||||||
|
KeyCode::Char(ch) if current_field.is_text_field() => {
|
||||||
|
text_input.push(ch);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace if current_field.is_text_field() => {
|
||||||
|
text_input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Delete if current_field.is_text_field() => {
|
||||||
|
text_input.clear();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_enter(
|
||||||
|
config: &mut TuiConfig,
|
||||||
|
current_field: &mut FieldIndex,
|
||||||
|
text_input: &mut String,
|
||||||
|
errors: &mut Vec<(FieldIndex, String)>,
|
||||||
|
) {
|
||||||
|
match *current_field {
|
||||||
|
FieldIndex::Subdomains => {
|
||||||
|
config.subdomains = !config.subdomains;
|
||||||
|
}
|
||||||
|
FieldIndex::Single => {
|
||||||
|
config.single = !config.single;
|
||||||
|
}
|
||||||
|
FieldIndex::NoScope => {
|
||||||
|
config.no_scope = !config.no_scope;
|
||||||
|
}
|
||||||
|
FieldIndex::NoDocConversion => {
|
||||||
|
config.no_doc_conversion = !config.no_doc_conversion;
|
||||||
|
}
|
||||||
|
FieldIndex::StartUrl => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
if config.start_url.trim().is_empty() {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::StartUrl);
|
||||||
|
errors.push((FieldIndex::StartUrl, "Required".into()));
|
||||||
|
} else {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::OutputDir | FieldIndex::DelayMs => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
FieldIndex::TypesInclude => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
let val = config.types.as_deref().unwrap_or("");
|
||||||
|
match validate_comma_list(val) {
|
||||||
|
Ok(()) => {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesInclude);
|
||||||
|
errors.push((FieldIndex::TypesInclude, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::TypesExclude => {
|
||||||
|
save_input_to_field(config, *current_field, text_input);
|
||||||
|
let val = config.exclude_types.as_deref().unwrap_or("");
|
||||||
|
match validate_comma_list(val) {
|
||||||
|
Ok(()) => {
|
||||||
|
errors.clear();
|
||||||
|
*current_field = current_field.next();
|
||||||
|
load_field_into_input(text_input, config, *current_field);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesExclude);
|
||||||
|
errors.push((FieldIndex::TypesExclude, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldIndex::Submit => {
|
||||||
|
if config.start_url.trim().is_empty() {
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::StartUrl);
|
||||||
|
errors.push((FieldIndex::StartUrl, "Required".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref t) = config.types
|
||||||
|
&& let Err(e) = validate_comma_list(t)
|
||||||
|
{
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesInclude);
|
||||||
|
errors.push((FieldIndex::TypesInclude, e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(ref t) = config.exclude_types
|
||||||
|
&& let Err(e) = validate_comma_list(t)
|
||||||
|
{
|
||||||
|
errors.retain(|(f, _)| *f != FieldIndex::TypesExclude);
|
||||||
|
errors.push((FieldIndex::TypesExclude, e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.types.is_some() && config.exclude_types.is_some() {
|
||||||
|
errors.push((FieldIndex::Submit, "types and exclude-types are mutually exclusive".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_raw_mode().unwrap();
|
||||||
|
execute!(io::stdout(), DisableMouseCapture).unwrap();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user