mod tree;
mod render;
mod handlers;
use tree::{EntryKind, Entry, TreeNode, VisibleRow, load_entries, load_entry, build_tree, collect_folder_paths, visible_rows, detect_nerd_fonts};
use anyhow::Result;
use crossterm::{
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEvent,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, widgets::ListState, Terminal};
use std::{collections::{HashMap, HashSet}, fs, io};
use crate::config::{dir_for, prune_empty_parents, global_config_path, local_config_path, ChainConfig, RequestConfig};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum PendingAction {
Run,
Test,
Curl,
}
pub(crate) enum ResponseKind {
Http(u16),
Error,
Curl { copied: Result<(), String> },
}
enum Mode {
Browse,
ProfileSelect {
entry_name: String,
profiles: Vec<crate::config::Profile>,
selected: usize, action: PendingAction,
},
VarInput {
entry_name: String,
vars: Vec<(String, String)>, focused: usize, action: PendingAction,
},
TestInput {
entry_name: String,
vars: Vec<(String, String)>,
iterations: String,
focused: usize, },
Response {
kind: ResponseKind,
body: String,
scroll: u16,
response_filter: String,
response_filter_active: bool,
cursor: usize,
anchor: Option<usize>,
status: Option<String>,
},
TestResponse {
results: crate::tester::TestResults,
},
NewRequest {
draft: RequestDraft,
error: Option<String>,
},
ImportCurl {
buffer: String,
error: Option<String>,
},
ConfirmDelete {
entry_name: String,
global: bool,
},
EditHeaders {
draft: RequestDraft,
pairs: Vec<(String, String)>,
focused: usize,
error: Option<String>,
},
ProfileList {
draft: RequestDraft,
selected: usize,
},
NewProfile {
draft: RequestDraft,
editing: Option<usize>,
name: String,
params: Vec<(String, String)>,
focused: usize,
error: Option<String>,
},
}
#[derive(Clone, Default)]
pub(crate) struct RequestDraft {
pub(crate) fields: Vec<(String, String)>,
pub(crate) focused: usize,
pub(crate) profiles: Vec<crate::config::Profile>,
pub(crate) original_name: Option<String>,
pub(crate) global: bool,
headers: HashMap<String, String>,
query: HashMap<String, String>,
body: Option<crate::config::Body>,
extract: Option<HashMap<String, String>>,
}
impl RequestDraft {
fn blank() -> Self {
Self {
fields: vec![
("name".to_string(), String::new()),
("method".to_string(), "GET".to_string()),
("url".to_string(), String::new()),
("description".to_string(), String::new()),
],
..Default::default()
}
}
fn from_config(config: RequestConfig, original_name: Option<String>, global: bool) -> Self {
Self {
fields: vec![
("name".to_string(), original_name.clone().unwrap_or_default()),
("method".to_string(), config.method),
("url".to_string(), config.url),
("description".to_string(), config.description.unwrap_or_default()),
],
focused: 0,
profiles: config.profiles.unwrap_or_default(),
original_name,
global,
headers: config.headers,
query: config.query,
body: config.body,
extract: config.extract,
}
}
pub(crate) fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
}
struct App {
entries: Vec<Entry>,
tree: Vec<TreeNode>,
collapsed_folders: HashSet<String>,
use_nerd_fonts: bool,
list_state: ListState,
pending_g: bool,
response_view_height: u16,
filter: String,
filter_active: bool,
show_help: bool,
mode: Mode,
}
impl App {
fn new() -> Result<Self> {
let entries = load_entries()?;
let tree = build_tree(&entries);
let use_nerd_fonts = detect_nerd_fonts();
let mut collapsed_folders = HashSet::new();
collect_folder_paths(&tree, &mut collapsed_folders);
let mut list_state = ListState::default();
let initial_count = visible_rows(&tree, &collapsed_folders).len();
if initial_count > 0 {
list_state.select(Some(0));
}
Ok(Self {
entries,
tree,
collapsed_folders,
use_nerd_fonts,
list_state,
pending_g: false,
response_view_height: 0,
filter: String::new(),
filter_active: false,
show_help: false,
mode: Mode::Browse,
})
}
fn using_tree(&self) -> bool {
self.filter.is_empty()
}
fn filtered_indices(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.entries.len()).collect();
}
let q = self.filter.to_lowercase();
self.entries
.iter()
.enumerate()
.filter(|(_, e)| {
if e.name.to_lowercase().contains(&q) {
return true;
}
if let EntryKind::Request { method, url, .. } = &e.kind {
return method.to_lowercase().contains(&q) || url.to_lowercase().contains(&q);
}
false
})
.map(|(i, _)| i)
.collect()
}
fn visible_count(&self) -> usize {
if self.using_tree() {
visible_rows(&self.tree, &self.collapsed_folders).len()
} else {
self.filtered_indices().len()
}
}
fn move_down(&mut self) {
let count = self.visible_count();
if count == 0 { return; }
let next = match self.list_state.selected() {
Some(i) => (i + 1).min(count - 1),
None => 0,
};
self.list_state.select(Some(next));
}
fn move_up(&mut self) {
let count = self.visible_count();
if count == 0 { return; }
let next = match self.list_state.selected() {
Some(i) => i.saturating_sub(1),
None => 0,
};
self.list_state.select(Some(next));
}
fn move_top(&mut self) {
if self.visible_count() > 0 {
self.list_state.select(Some(0));
}
}
fn move_bottom(&mut self) {
let count = self.visible_count();
if count > 0 {
self.list_state.select(Some(count - 1));
}
}
fn selected_entry_index(&self) -> Option<usize> {
if self.using_tree() {
let rows = visible_rows(&self.tree, &self.collapsed_folders);
self.list_state.selected().and_then(|pos| {
rows.get(pos).and_then(|row| match row {
VisibleRow::Leaf { entry_idx, .. } => Some(*entry_idx),
VisibleRow::Folder { .. } => None,
})
})
} else {
let filtered = self.filtered_indices();
self.list_state.selected().and_then(|pos| filtered.get(pos).copied())
}
}
fn toggle_folder(&mut self, path: String) {
if self.collapsed_folders.contains(&path) {
self.collapsed_folders.remove(&path);
} else {
self.collapsed_folders.insert(path.clone());
let rows = visible_rows(&self.tree, &self.collapsed_folders);
let count = rows.len();
if let Some(pos) = self.list_state.selected()
&& pos >= count {
let folder_pos = rows.iter().position(|row| {
matches!(row, VisibleRow::Folder { path: p, .. } if *p == path)
});
self.list_state.select(Some(folder_pos.unwrap_or(count.saturating_sub(1))));
}
}
}
fn show_error(&mut self, e: anyhow::Error) {
self.show_message(ResponseKind::Error, format!("Error: {e:#}"));
}
fn show_message(&mut self, kind: ResponseKind, body: String) {
self.mode = Mode::Response {
kind,
body,
scroll: 0,
response_filter: String::new(),
response_filter_active: false,
cursor: 0,
anchor: None,
status: None,
};
}
fn reload_selected(&mut self, idx: usize) -> bool {
match load_entry(&self.entries[idx].name) {
Ok(entry) => {
self.entries[idx] = entry;
true
}
Err(e) => {
self.show_error(e);
false
}
}
}
fn reload_entries(&mut self, select: Option<&str>) {
let target = select
.map(str::to_string)
.or_else(|| self.selected_entry_index().map(|i| self.entries[i].name.clone()));
let Ok(entries) = load_entries() else { return };
self.tree = build_tree(&entries);
self.entries = entries;
let count = self.visible_count();
if count == 0 {
self.list_state.select(None);
return;
}
let pos = target
.and_then(|name| self.entries.iter().position(|e| e.name == name))
.and_then(|idx| self.row_position(idx));
let fallback = self.list_state.selected().unwrap_or(0).min(count - 1);
self.list_state.select(Some(pos.unwrap_or(fallback)));
}
fn row_position(&self, entry_idx: usize) -> Option<usize> {
if self.using_tree() {
visible_rows(&self.tree, &self.collapsed_folders)
.iter()
.position(|row| matches!(row, VisibleRow::Leaf { entry_idx: i, .. } if *i == entry_idx))
} else {
self.filtered_indices().iter().position(|&i| i == entry_idx)
}
}
fn try_run_selected(&mut self) {
let Some(idx) = self.selected_entry_index() else { return };
if !self.reload_selected(idx) {
return;
}
let entry = &self.entries[idx];
let entry_name = entry.name.clone();
let (var_names, is_chain, profiles) = match &entry.kind {
EntryKind::Chain { .. } => (vec![], true, vec![]),
EntryKind::Request { url, headers, query, body_data, profiles, .. } => {
(extract_var_names(url, headers, query, body_data.as_deref()), false, profiles.clone())
}
};
if is_chain {
self.start_chain(&entry_name);
return;
}
if !profiles.is_empty() {
self.mode = Mode::ProfileSelect { entry_name, profiles, selected: 0, action: PendingAction::Run };
return;
}
let vars: Vec<(String, String)> = var_names
.into_iter()
.map(|name| {
let value = std::env::var(&name).unwrap_or_default();
(name, value)
})
.collect();
if vars.is_empty() {
self.execute_request(&entry_name, &HashMap::new());
} else {
self.mode = Mode::VarInput { entry_name, vars, focused: 0, action: PendingAction::Run };
}
}
fn start_chain(&mut self, entry_name: &str) {
let chain = match ChainConfig::load(entry_name) {
Ok(c) => c,
Err(e) => {
self.show_error(e);
return;
}
};
let profiles = chain.profiles.clone().unwrap_or_default();
if !profiles.is_empty() {
self.mode = Mode::ProfileSelect {
entry_name: entry_name.to_string(),
profiles,
selected: 0,
action: PendingAction::Run,
};
return;
}
let vars: Vec<(String, String)> = chain_var_names(&chain.steps)
.into_iter()
.map(|name| {
let value = std::env::var(&name).unwrap_or_default();
(name, value)
})
.collect();
if vars.is_empty() {
self.execute_chain(entry_name, &HashMap::new());
} else {
self.mode = Mode::VarInput {
entry_name: entry_name.to_string(),
vars,
focused: 0,
action: PendingAction::Run,
};
}
}
fn try_test_selected(&mut self) {
let Some(idx) = self.selected_entry_index() else { return };
if !self.reload_selected(idx) {
return;
}
let entry = &self.entries[idx];
let entry_name = entry.name.clone();
let is_chain = matches!(&entry.kind, EntryKind::Chain { .. });
if is_chain {
self.show_message(ResponseKind::Error, "Testing chains is not supported.".to_string());
return;
}
let (var_names, profiles) = match &entry.kind {
EntryKind::Request { url, headers, query, body_data, profiles, .. } => {
(extract_var_names(url, headers, query, body_data.as_deref()), profiles.clone())
}
_ => (vec![], vec![]),
};
if !profiles.is_empty() {
self.mode = Mode::ProfileSelect { entry_name, profiles, selected: 0, action: PendingAction::Test };
return;
}
let vars: Vec<(String, String)> = var_names
.into_iter()
.map(|name| {
let value = std::env::var(&name).unwrap_or_default();
(name, value)
})
.collect();
self.mode = Mode::TestInput { entry_name, vars, iterations: "10".to_string(), focused: 0 };
}
fn try_copy_curl_selected(&mut self) {
let Some(idx) = self.selected_entry_index() else { return };
if !self.reload_selected(idx) {
return;
}
let entry = &self.entries[idx];
let entry_name = entry.name.clone();
let (var_names, profiles) = match &entry.kind {
EntryKind::Chain { .. } => {
self.show_message(
ResponseKind::Error,
"Copying chains as cURL is not supported.".to_string(),
);
return;
}
EntryKind::Request { url, headers, query, body_data, profiles, .. } => (
extract_var_names(url, headers, query, body_data.as_deref()),
profiles.clone(),
),
};
if !profiles.is_empty() {
self.mode = Mode::ProfileSelect {
entry_name,
profiles,
selected: 0,
action: PendingAction::Curl,
};
return;
}
let vars: Vec<(String, String)> = var_names
.into_iter()
.map(|name| {
let value = std::env::var(&name).unwrap_or_default();
(name, value)
})
.collect();
if vars.is_empty() {
self.render_curl(&entry_name, &HashMap::new());
} else {
self.mode = Mode::VarInput { entry_name, vars, focused: 0, action: PendingAction::Curl };
}
}
fn render_curl(&mut self, entry_name: &str, vars: &HashMap<String, String>) {
let config = match RequestConfig::load(entry_name) {
Ok(c) => c,
Err(e) => {
self.show_error(e);
return;
}
};
let command = crate::curl::to_curl(&config, vars);
let copied = render::copy_to_clipboard(&command);
self.show_message(ResponseKind::Curl { copied }, command);
}
fn is_chain_entry(&self, name: &str) -> bool {
self.entries
.iter()
.any(|e| e.name == name && matches!(e.kind, EntryKind::Chain { .. }))
}
fn confirm_profile_select(&mut self) {
let (entry_name, profile_params, action) = match &self.mode {
Mode::ProfileSelect { entry_name, profiles, selected, action } => {
let params: HashMap<String, String> = if *selected == 0 {
HashMap::new()
} else {
profiles[*selected - 1].params.clone()
};
(entry_name.clone(), params, *action)
}
_ => return,
};
let is_chain = self.is_chain_entry(&entry_name);
let (var_names, all_covered) = if is_chain {
match ChainConfig::load(&entry_name) {
Ok(chain) => {
let names = chain_var_names(&chain.steps);
let covered = names.iter().all(|n| profile_params.contains_key(n));
(names, covered)
}
Err(_) => (vec![], true),
}
} else {
match load_entry(&entry_name) {
Ok(Entry { kind: EntryKind::Request { url, headers, query, body_data, .. }, .. }) => {
let names = extract_var_names(&url, &headers, &query, body_data.as_deref());
let covered = names.iter().all(|n| profile_params.contains_key(n));
(names, covered)
}
Ok(_) => (vec![], true),
Err(e) => {
self.show_error(e);
return;
}
}
};
let vars: Vec<(String, String)> = var_names
.into_iter()
.map(|name| {
let value = profile_params.get(&name)
.cloned()
.or_else(|| std::env::var(&name).ok())
.unwrap_or_default();
(name, value)
})
.collect();
if action == PendingAction::Test {
self.mode = Mode::TestInput { entry_name, vars, iterations: "10".to_string(), focused: 0 };
} else if all_covered && vars.iter().all(|(_, v)| !v.is_empty()) {
let var_map = vars.into_iter().collect();
match action {
PendingAction::Curl => self.render_curl(&entry_name, &var_map),
_ if is_chain => self.execute_chain(&entry_name, &var_map),
_ => self.execute_request(&entry_name, &var_map),
}
} else {
self.mode = Mode::VarInput { entry_name, vars, focused: 0, action };
}
}
fn execute_test(&mut self, entry_name: &str, vars: &HashMap<String, String>, iterations: usize) {
match RequestConfig::load(entry_name)
.and_then(|config| crate::tester::collect_test_results(&config, vars, iterations))
{
Ok(results) => self.mode = Mode::TestResponse { results },
Err(e) => self.show_message(ResponseKind::Error, format!("Error: {e}")),
}
}
fn execute_request(&mut self, entry_name: &str, vars: &HashMap<String, String>) {
let result = RequestConfig::load(entry_name)
.and_then(|config| crate::utils::send_request(&config, vars, false));
let (status, body) = match result {
Ok(response) => {
let status = response.status().as_u16();
let text = response.text().unwrap_or_else(|e| e.to_string());
let body = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or(text);
(status, body)
}
Err(e) => (0, format!("Error: {e}")),
};
let kind = if status == 0 { ResponseKind::Error } else { ResponseKind::Http(status) };
self.show_message(kind, body);
}
fn execute_chain(&mut self, entry_name: &str, vars: &HashMap<String, String>) {
let result = (|| -> Result<(u16, String)> {
let chain = ChainConfig::load(entry_name)?;
let mut current_vars = vars.clone();
let mut out = String::new();
let mut last_status = 0u16;
let step_count = chain.steps.len();
for (i, step) in chain.steps.iter().enumerate() {
let response = crate::utils::send_request(step, ¤t_vars, false)?;
let status = response.status().as_u16();
last_status = status;
let is_json = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("application/json"))
.unwrap_or(false);
let text = response.text().unwrap_or_default();
let pretty = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| text.clone());
out.push_str(&format!("▸ {} [{}]\n", step.name, status));
if pretty.is_empty() {
out.push_str("(empty body)\n");
} else {
out.push_str(&pretty);
out.push('\n');
}
let extracted = crate::runner::extract_values(&step.extract, &text, is_json)?;
for (k, v) in &extracted {
out.push_str(&format!(" ← {k}: {v}\n"));
}
if i + 1 < step_count {
out.push('\n'); }
current_vars.extend(extracted); }
Ok((last_status, out))
})();
let (status, body) = match result {
Ok((s, b)) => (s, b),
Err(e) => (0, format!("Error: {e}")),
};
let kind = if status == 0 { ResponseKind::Error } else { ResponseKind::Http(status) };
self.show_message(kind, body);
}
fn confirm_import_curl(&mut self) {
let Mode::ImportCurl { buffer, .. } = &self.mode else { return };
match crate::curl::from_curl(buffer) {
Ok(config) => {
let draft = RequestDraft::from_config(config, None, false);
self.mode = Mode::NewRequest { draft, error: None };
}
Err(e) => {
if let Mode::ImportCurl { error, .. } = &mut self.mode {
*error = Some(format!("{e:#}"));
}
}
}
}
fn edit_selected(&mut self) {
let Some((name, global)) = self.selected_request() else { return };
match RequestConfig::load(&name) {
Ok(config) => {
let draft = RequestDraft::from_config(config, Some(name), global);
self.mode = Mode::NewRequest { draft, error: None };
}
Err(e) => self.show_error(e),
}
}
fn edit_headers_selected(&mut self) {
let Some((name, global)) = self.selected_request() else { return };
match RequestConfig::load(&name) {
Ok(config) => {
let draft = RequestDraft::from_config(config, Some(name), global);
self.mode = Mode::EditHeaders {
pairs: header_pairs(&draft),
draft,
focused: 0,
error: None,
};
}
Err(e) => self.show_error(e),
}
}
fn edit_profiles_selected(&mut self) {
let Some((name, global)) = self.selected_request() else { return };
match RequestConfig::load(&name) {
Ok(config) => {
let draft = RequestDraft::from_config(config, Some(name), global);
self.mode = Mode::ProfileList { draft, selected: 0 };
}
Err(e) => self.show_error(e),
}
}
fn clone_selected(&mut self) {
let Some((name, _)) = self.selected_request() else { return };
match RequestConfig::load(&name) {
Ok(config) => {
let draft = RequestDraft::from_config(config, None, false);
self.mode = Mode::NewRequest { draft, error: None };
}
Err(e) => self.show_error(e),
}
}
fn selected_request(&self) -> Option<(String, bool)> {
let idx = self.selected_entry_index()?;
let entry = &self.entries[idx];
match entry.kind {
EntryKind::Request { .. } => Some((entry.name.clone(), entry.global)),
EntryKind::Chain { .. } => None,
}
}
fn save_new_profile(&mut self) {
let (profile_name, params, editing, mut draft) = match &self.mode {
Mode::NewProfile { name, params, editing, draft, .. } => {
(name.trim().to_string(), params.clone(), *editing, draft.clone())
}
_ => return,
};
if profile_name.is_empty() {
if let Mode::NewProfile { error, .. } = &mut self.mode {
*error = Some("Profile name is required".to_string());
}
return;
}
let filtered_params: HashMap<String, String> = params
.into_iter()
.filter(|(k, _)| !k.trim().is_empty())
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect();
let profile = crate::config::Profile { name: profile_name, params: filtered_params };
match upsert_profile(&mut draft.profiles, profile, editing) {
Ok(selected) => self.mode = Mode::ProfileList { draft, selected },
Err(message) => {
if let Mode::NewProfile { error, .. } = &mut self.mode {
*error = Some(message);
}
}
}
}
fn save_new_request(&mut self) {
let draft = match &self.mode {
Mode::NewRequest { draft, .. } => draft.clone(),
_ => return,
};
let name = draft.fields[0].1.trim().to_string();
let method = draft.fields[1].1.trim().to_uppercase();
let url = draft.fields[2].1.trim().to_string();
let description = draft.fields[3].1.trim().to_string();
let RequestDraft { profiles, original_name, global, headers, query, body, extract, .. } = draft;
let set_error = |mode: &mut Mode, msg: &str| {
if let Mode::NewRequest { error, .. } = mode {
*error = Some(msg.to_string());
}
};
if name.is_empty() {
set_error(&mut self.mode, "Name is required");
return;
}
if name.chars().any(|c| c == '\\' || c == '.')
|| name.starts_with('/')
|| name.ends_with('/')
|| name.contains("//")
{
set_error(&mut self.mode, "Invalid name (use folder/name for subfolders)");
return;
}
if method.is_empty() {
set_error(&mut self.mode, "Method is required");
return;
}
if url.is_empty() {
set_error(&mut self.mode, "URL is required");
return;
}
let path = if global {
global_config_path(&name)
} else {
local_config_path(&name)
};
let is_rename = original_name.as_deref() != Some(name.as_str());
if path.exists() && (original_name.is_none() || is_rename) {
set_error(&mut self.mode, &format!("'{}' already exists", name));
return;
}
let config = RequestConfig {
name: name.clone(),
method,
url,
description: if description.is_empty() { None } else { Some(description) },
headers,
query,
body,
extract,
profiles: if profiles.is_empty() { None } else { Some(profiles) },
};
let dir = path.parent().expect("config path has no parent");
if let Err(e) = fs::create_dir_all(dir) {
set_error(&mut self.mode, &format!("Failed to create directory: {e}"));
return;
}
match serde_yaml::to_string(&config) {
Ok(yaml) => {
if let Err(e) = fs::write(&path, yaml) {
set_error(&mut self.mode, &format!("Failed to write file: {e}"));
return;
}
}
Err(e) => {
set_error(&mut self.mode, &format!("Serialization error: {e}"));
return;
}
}
if let Some(ref old_name) = original_name
&& is_rename {
let old_path = if global {
global_config_path(old_name)
} else {
local_config_path(old_name)
};
let _ = fs::remove_file(&old_path);
prune_empty_parents(&old_path, &dir_for(global));
}
self.reload_entries(Some(&name));
self.mode = Mode::Browse;
}
fn handle_paste(&mut self, text: &str) {
let single_line = || text.replace(['\r', '\n'], " ");
if self.filter_active && matches!(self.mode, Mode::Browse) {
self.filter.push_str(&single_line());
let count = self.visible_count();
self.list_state.select(if count == 0 { None } else { Some(0) });
return;
}
match &mut self.mode {
Mode::ImportCurl { buffer, error } => {
buffer.push_str(text);
*error = None;
}
Mode::NewRequest { draft, error } => {
let focused = draft.focused;
draft.fields[focused].1.push_str(&single_line());
*error = None;
}
Mode::NewProfile { name, params, focused, error, .. } => {
*error = None;
if *focused == 0 {
name.push_str(&single_line());
} else {
let idx = (*focused - 1) / 2;
let is_value = (*focused - 1) % 2 == 1;
if let Some((k, v)) = params.get_mut(idx) {
if is_value { v.push_str(&single_line()) } else { k.push_str(&single_line()) }
}
}
}
Mode::VarInput { vars, focused, .. } => {
if let Some((_, value)) = vars.get_mut(*focused) {
value.push_str(&single_line());
}
}
Mode::TestInput { vars, focused, iterations, .. } => {
if *focused < vars.len() {
vars[*focused].1.push_str(&single_line());
} else {
iterations.extend(text.chars().filter(char::is_ascii_digit));
}
}
Mode::Response { response_filter, response_filter_active: true, .. } => {
response_filter.push_str(&single_line());
}
_ => {}
}
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
if self.show_help {
self.show_help = false;
return false;
}
if self.filter_active && matches!(self.mode, Mode::Browse) {
return handlers::handle_key_filter(self, key);
}
if matches!(self.mode, Mode::Browse) {
return handlers::handle_key_browse(self, key.code);
}
if matches!(self.mode, Mode::ProfileSelect { .. }) {
return handlers::handle_key_profile_select(self, key.code);
}
if matches!(self.mode, Mode::VarInput { .. }) {
return handlers::handle_key_var_input(self, key);
}
if matches!(self.mode, Mode::TestInput { .. }) {
return handlers::handle_key_test_input(self, key);
}
if matches!(self.mode, Mode::NewRequest { .. }) {
return handlers::handle_key_new_request(self, key);
}
if matches!(self.mode, Mode::ImportCurl { .. }) {
return handlers::handle_key_import_curl(self, key);
}
if matches!(self.mode, Mode::EditHeaders { .. }) {
return handlers::handle_key_edit_headers(self, key);
}
if matches!(self.mode, Mode::ProfileList { .. }) {
return handlers::handle_key_profile_list(self, key);
}
if matches!(self.mode, Mode::NewProfile { .. }) {
return handlers::handle_key_new_profile(self, key);
}
if matches!(self.mode, Mode::ConfirmDelete { .. }) {
return handlers::handle_key_confirm_delete(self, key.code);
}
if matches!(self.mode, Mode::Response { response_filter_active: true, .. }) {
return handlers::handle_key_response_filter(self, key);
}
handlers::handle_key_response(self, key.code)
}
}
pub fn run() -> Result<()> {
let mut app = App::new()?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = event_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), DisableBracketedPaste, LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|frame| render::draw(frame, app))?;
match event::read()? {
Event::Key(key) => {
if app.handle_key(key) {
break;
}
}
Event::Paste(text) => app.handle_paste(&text),
_ => {}
}
}
Ok(())
}
fn apply_headers(draft: &mut RequestDraft, pairs: Vec<(String, String)>) -> Result<(), String> {
let mut kept: Vec<(String, String)> = Vec::with_capacity(pairs.len());
for (name, value) in pairs {
let name = name.trim().to_string();
if name.is_empty() {
continue;
}
if kept.iter().any(|(k, _): &(String, String)| k.eq_ignore_ascii_case(&name)) {
return Err(format!("Duplicate header '{name}'"));
}
kept.push((name, value.trim().to_string()));
}
if let (Some(body), Some(pos)) = (
draft.body.as_mut(),
kept.iter().position(|(k, _)| k.eq_ignore_ascii_case("content-type")),
) {
let (_, value) = kept.remove(pos);
if !value.is_empty() {
body.content_type = value;
}
}
draft.headers = kept.into_iter().collect();
Ok(())
}
fn header_pairs(draft: &RequestDraft) -> Vec<(String, String)> {
let mut pairs: Vec<(String, String)> = draft
.headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs
}
pub(super) fn visible_response_lines<'a>(body: &'a str, filter: &str) -> Vec<&'a str> {
let q = filter.to_lowercase();
body.lines()
.filter(|line| q.is_empty() || line.to_lowercase().contains(&q))
.collect()
}
pub(super) fn selection_range(cursor: usize, anchor: Option<usize>) -> (usize, usize) {
match anchor {
Some(a) if a <= cursor => (a, cursor),
Some(a) => (cursor, a),
None => (cursor, cursor),
}
}
fn upsert_profile(
profiles: &mut Vec<crate::config::Profile>,
profile: crate::config::Profile,
editing: Option<usize>,
) -> Result<usize, String> {
let clashes = profiles
.iter()
.enumerate()
.any(|(i, p)| p.name == profile.name && Some(i) != editing);
if clashes {
return Err(format!("A profile named '{}' already exists", profile.name));
}
match editing {
Some(i) if i < profiles.len() => {
profiles[i] = profile;
Ok(i)
}
_ => {
profiles.push(profile);
Ok(profiles.len() - 1)
}
}
}
fn chain_var_names(steps: &[RequestConfig]) -> Vec<String> {
let mut produced: HashSet<String> = HashSet::new();
for step in steps {
if let Some(extract) = &step.extract {
for key in extract.keys() {
produced.insert(key.clone());
}
}
}
let mut names: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for step in steps {
let body = step.body.as_ref().map(|b| b.data.as_str());
for name in extract_var_names(&step.url, &step.headers, &step.query, body) {
if !produced.contains(&name) && seen.insert(name.clone()) {
names.push(name);
}
}
}
names
}
fn extract_var_names(
url: &str,
headers: &HashMap<String, String>,
query: &HashMap<String, String>,
body_data: Option<&str>,
) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let sources = std::iter::once(url)
.chain(headers.values().map(String::as_str))
.chain(query.values().map(String::as_str))
.chain(body_data);
for s in sources {
let mut rest = s;
while let Some(start) = rest.find("{{") {
rest = &rest[start + 2..];
if let Some(end) = rest.find("}}") {
let name = rest[..end].to_string();
if seen.insert(name.clone()) {
names.push(name);
}
rest = &rest[end + 2..];
}
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Body;
fn step(url: &str) -> RequestConfig {
RequestConfig {
name: "step".to_string(),
method: "GET".to_string(),
url: url.to_string(),
description: None,
headers: HashMap::new(),
query: HashMap::new(),
body: None,
extract: None,
profiles: None,
}
}
#[test]
fn chain_var_names_collects_across_fields() {
let mut s = step("https://api/{{USER_ID}}");
s.query.insert("page".to_string(), "{{PAGE}}".to_string());
s.body = Some(Body {
content_type: "application/json".to_string(),
data: "{\"q\": \"{{SEARCH}}\"}".to_string(),
});
let names = chain_var_names(&[s]);
assert!(names.contains(&"USER_ID".to_string()));
assert!(names.contains(&"PAGE".to_string()));
assert!(names.contains(&"SEARCH".to_string()));
}
#[test]
fn chain_var_names_excludes_extracted_and_dedupes() {
let mut s1 = step("https://api/users/{{USER_ID}}");
let mut extract = HashMap::new();
extract.insert("USERNAME".to_string(), "$.username".to_string());
s1.extract = Some(extract);
let mut s2 = step("https://api/posts");
s2.query.insert("userId".to_string(), "{{USER_ID}}".to_string());
s2.headers.insert("X-User".to_string(), "{{USERNAME}}".to_string());
let names = chain_var_names(&[s1, s2]);
assert_eq!(names, vec!["USER_ID".to_string()]);
}
fn full_config() -> RequestConfig {
let mut c = step("https://api/x");
c.method = "POST".to_string();
c.description = Some("desc".to_string());
c.headers.insert("Accept".to_string(), "application/json".to_string());
c.query.insert("page".to_string(), "2".to_string());
c.body = Some(Body {
content_type: "application/json".to_string(),
data: r#"{"a":1}"#.to_string(),
});
c.extract = Some(HashMap::from([("TOKEN".to_string(), "$.token".to_string())]));
c.profiles = Some(vec![crate::config::Profile {
name: "dev".to_string(),
params: HashMap::new(),
}]);
c
}
#[test]
fn draft_carries_the_fields_the_form_does_not_edit() {
let draft = RequestDraft::from_config(full_config(), Some("api/x".to_string()), false);
assert_eq!(draft.fields[0].1, "api/x");
assert_eq!(draft.fields[1].1, "POST");
assert_eq!(draft.fields[2].1, "https://api/x");
assert_eq!(draft.fields[3].1, "desc");
assert_eq!(draft.headers.get("Accept").unwrap(), "application/json");
assert_eq!(draft.query.get("page").unwrap(), "2");
assert_eq!(draft.body.expect("body").data, r#"{"a":1}"#);
assert_eq!(draft.extract.expect("extract").get("TOKEN").unwrap(), "$.token");
assert_eq!(draft.profiles.len(), 1);
}
#[test]
fn a_clone_draft_keeps_everything_but_the_name() {
let draft = RequestDraft::from_config(full_config(), None, false);
assert_eq!(draft.fields[0].1, "", "a clone starts unnamed");
assert!(draft.original_name.is_none());
assert_eq!(draft.headers.len(), 1);
assert!(draft.body.is_some());
assert!(draft.extract.is_some());
}
#[test]
fn an_imported_draft_carries_the_parsed_request() {
let config = crate::curl::from_curl(
r#"curl -X POST 'https://api/x?page=2' -H 'Accept: application/json' --data-raw '{"a":1}'"#,
)
.expect("parses");
let draft = RequestDraft::from_config(config, None, false);
assert_eq!(draft.fields[0].1, "", "the user names an imported request");
assert_eq!(draft.fields[1].1, "POST");
assert_eq!(draft.fields[2].1, "https://api/x");
assert_eq!(draft.query.get("page").unwrap(), "2");
assert_eq!(draft.headers.get("Accept").unwrap(), "application/json");
assert_eq!(draft.body.expect("body").data, r#"{"a":1}"#);
}
fn pairs(rows: &[(&str, &str)]) -> Vec<(String, String)> {
rows.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn headers_are_replaced_wholesale_so_a_removed_row_is_gone() {
let mut draft = RequestDraft::from_config(full_config(), None, false);
assert!(draft.headers.contains_key("Accept"));
apply_headers(&mut draft, pairs(&[("X-Only", "1")])).expect("valid");
assert_eq!(draft.headers.len(), 1);
assert!(!draft.headers.contains_key("Accept"), "removed row survived");
assert_eq!(draft.headers.get("X-Only").unwrap(), "1");
}
#[test]
fn a_blank_name_is_dropped_rather_than_stored() {
let mut draft = RequestDraft::blank();
apply_headers(&mut draft, pairs(&[("Accept", "a"), (" ", "orphan"), ("", "")]))
.expect("blank rows are not an error");
assert_eq!(draft.headers.len(), 1);
}
#[test]
fn names_and_values_are_trimmed() {
let mut draft = RequestDraft::blank();
apply_headers(&mut draft, pairs(&[(" Accept ", " application/json ")])).unwrap();
assert_eq!(draft.headers.get("Accept").unwrap(), "application/json");
}
#[test]
fn a_duplicate_name_is_refused_ignoring_case() {
let mut draft = RequestDraft::blank();
let err = apply_headers(&mut draft, pairs(&[("Accept", "a"), ("accept", "b")]))
.expect_err("same header twice");
assert!(err.contains("accept"), "the error names the header: {err}");
assert!(draft.headers.is_empty(), "nothing applied on refusal");
}
#[test]
fn a_content_type_row_moves_into_the_body() {
let mut draft = RequestDraft::from_config(full_config(), None, false);
apply_headers(&mut draft, pairs(&[("Content-Type", "text/plain"), ("Accept", "a")]))
.unwrap();
assert_eq!(draft.body.as_ref().unwrap().content_type, "text/plain");
assert!(
!draft.headers.keys().any(|k| k.eq_ignore_ascii_case("content-type")),
"content type left in headers: {:?}",
draft.headers
);
assert_eq!(draft.headers.get("Accept").unwrap(), "a");
}
#[test]
fn a_content_type_row_stays_a_header_when_there_is_no_body() {
let mut draft = RequestDraft::blank();
apply_headers(&mut draft, pairs(&[("Content-Type", "text/plain")])).unwrap();
assert_eq!(draft.headers.get("Content-Type").unwrap(), "text/plain");
}
const BODY: &str = "alpha\nBETA line\ngamma\nbeta again\ndelta";
#[test]
fn an_empty_filter_shows_every_line() {
assert_eq!(visible_response_lines(BODY, "").len(), 5);
}
#[test]
fn filtering_is_case_insensitive_and_by_substring() {
assert_eq!(
visible_response_lines(BODY, "beta"),
vec!["BETA line", "beta again"]
);
}
#[test]
fn a_filtered_copy_is_only_the_visible_lines() {
let copied = visible_response_lines(BODY, "beta").join("\n");
assert_eq!(copied, "BETA line\nbeta again");
assert!(!copied.contains("alpha"));
}
#[test]
fn with_no_anchor_the_range_is_the_cursor_line_alone() {
assert_eq!(selection_range(3, None), (3, 3));
}
#[test]
fn a_selection_dragged_upward_covers_the_same_lines_as_one_dragged_down() {
assert_eq!(selection_range(1, Some(4)), (1, 4));
assert_eq!(selection_range(4, Some(1)), (1, 4));
}
#[test]
fn a_selection_is_inclusive_of_both_ends() {
let lines = visible_response_lines(BODY, "");
let (from, to) = selection_range(2, Some(1));
assert_eq!(lines[from..=to], ["BETA line", "gamma"]);
}
fn profile(name: &str, params: &[(&str, &str)]) -> crate::config::Profile {
crate::config::Profile {
name: name.to_string(),
params: params
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn a_new_profile_is_appended() {
let mut profiles = vec![profile("dev", &[])];
let at = upsert_profile(&mut profiles, profile("prod", &[("HOST", "api")]), None)
.expect("no clash");
assert_eq!(at, 1);
assert_eq!(profiles.len(), 2);
assert_eq!(profiles[1].name, "prod");
}
#[test]
fn editing_a_profile_replaces_it_rather_than_appending() {
let mut profiles = vec![profile("dev", &[("TOKEN", "old")]), profile("prod", &[])];
let at = upsert_profile(
&mut profiles,
profile("dev", &[("TOKEN", "new")]),
Some(0),
)
.expect("keeping its own name is not a clash");
assert_eq!(at, 0);
assert_eq!(profiles.len(), 2, "no duplicate appended");
assert_eq!(profiles[0].params.get("TOKEN").unwrap(), "new");
assert_eq!(profiles[1].name, "prod", "the others are undisturbed");
}
#[test]
fn a_profile_can_be_renamed() {
let mut profiles = vec![profile("dev", &[("HOST", "localhost")])];
let at = upsert_profile(&mut profiles, profile("local", &[("HOST", "localhost")]), Some(0))
.expect("no clash");
assert_eq!(at, 0);
assert_eq!(profiles.len(), 1);
assert_eq!(profiles[0].name, "local");
}
#[test]
fn a_name_another_profile_already_has_is_refused() {
let mut profiles = vec![profile("dev", &[]), profile("prod", &[])];
let err = upsert_profile(&mut profiles, profile("prod", &[]), None)
.expect_err("adding a second `prod`");
assert!(err.contains("prod"), "the error names the profile: {err}");
let err = upsert_profile(&mut profiles, profile("prod", &[]), Some(0))
.expect_err("renaming `dev` onto `prod`");
assert!(err.contains("prod"));
assert_eq!(profiles.len(), 2, "nothing was written on either refusal");
assert_eq!(profiles[0].name, "dev");
}
#[test]
fn an_out_of_range_edit_index_appends() {
let mut profiles = vec![profile("dev", &[])];
let at = upsert_profile(&mut profiles, profile("prod", &[]), Some(9)).expect("no clash");
assert_eq!(at, 1);
assert_eq!(profiles.len(), 2);
}
}