use std::collections::HashMap;
use std::path::{Path, PathBuf};
use ratatui::style::Color;
use tokio::runtime::Handle;
use crate::theme::Theme;
use travelagent_core::config::CommentTypeConfig;
use travelagent_core::engine::ReviewEngine;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::forge::{ForgeBackend, PrId};
use travelagent_core::model::{
CommentType, DiffFile, DiffLine, FileStatus, ReviewSession, SessionDiffSource,
};
use travelagent_core::vcs::{CommitInfo, FileBackend, VcsBackend, VcsInfo, VcsType, detect_vcs};
use super::{
AgentActionState, AiSummaryState, App, AppMode, COMMIT_PAGE_SIZE, CommentEditState,
CommentTypeDefinition, CommitSelectionState, DiffSource, DiffState, DiffViewMode, ErrorLog,
FileListState, FocusedPanel, GapExpansionState, HelpState, InlineCommitSelectorState,
InputMode, LiveModeState, LocalState, McpListenerState, MentalModelEditState, NavigationState,
PaletteState, RemoteSessionState, TourSessionState, UiLayoutState, VISIBLE_COMMIT_COUNT,
ViewerPaneState,
};
struct RemoteStubBackend {
info: VcsInfo,
}
impl VcsBackend for RemoteStubBackend {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> travelagent_core::error::Result<Vec<DiffFile>> {
Err(TrvError::UnsupportedOperation(
"Remote PR mode does not support local diffs".into(),
))
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> travelagent_core::error::Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
impl App {
#[allow(clippy::too_many_arguments)]
pub fn new(
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
revisions: Option<&str>,
working_tree: bool,
path_filter: Option<&str>,
file_path: Option<&str>,
runtime_handle: Handle,
) -> Result<Self> {
if let Some(file_path) = file_path {
let vcs = Box::new(FileBackend::new(file_path)?);
let vcs_info = vcs.info().clone();
let highlighter = theme.syntax_highlighter();
let mut diff_files = vcs.get_working_tree_diff()?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let session = Self::load_or_create_session(&vcs_info, SessionDiffSource::WorkingTree);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None, runtime_handle,
AppMode::Local(LocalState::default()),
)?;
app.ui_layout.show_file_list = false;
app.nav.focused_panel = FocusedPanel::Diff;
return Ok(app);
}
let vcs = detect_vcs()?;
let vcs_info = vcs.info().clone();
let highlighter = theme.syntax_highlighter();
if let Some(revisions) = revisions {
let commit_ids = vcs.resolve_revisions(revisions)?;
if working_tree {
let diff_files = Self::get_working_tree_with_commits_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
path_filter,
)?;
let session = Self::load_or_create_staged_unstaged_and_commits_session(
&vcs_info,
&commit_ids,
)?;
let review_commits: Vec<CommitInfo> = vcs
.get_commits_info(&commit_ids)?
.into_iter()
.rev()
.collect();
let has_staged = Self::get_staged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)
.is_ok();
let has_unstaged = Self::get_unstaged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)
.is_ok();
let mut all_commits = Vec::new();
if has_staged {
all_commits.push(Self::staged_commit_entry());
}
if has_unstaged {
all_commits.push(Self::unstaged_commit_entry());
}
all_commits.extend(review_commits);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs.clone(),
output_to_stdout,
diff_files,
session,
DiffSource::StagedUnstagedAndCommits(commit_ids),
InputMode::Normal,
Vec::new(),
path_filter,
runtime_handle.clone(),
AppMode::Local(LocalState::default()),
)?;
app.inline_selector.range_diff_files = Some(app.diff_files.clone());
app.commit_select.list = all_commits.clone();
app.commit_select.cursor = 0;
app.commit_select.selection_range = if all_commits.is_empty() {
None
} else {
Some((0, all_commits.len() - 1))
};
app.commit_select.scroll_offset = 0;
app.commit_select.visible_count = all_commits.len();
app.commit_select.has_more = false;
app.inline_selector.visible = all_commits.len() > 1;
app.inline_selector.diff_cache.clear();
app.inline_selector.commits = all_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
return Ok(app);
}
let diff_files = Self::get_commit_range_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
path_filter,
)?;
let session = Self::load_or_create_commit_range_session(&vcs_info, &commit_ids)?;
let review_commits = vcs.get_commits_info(&commit_ids)?;
let review_commits: Vec<CommitInfo> = review_commits.into_iter().rev().collect();
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs.clone(),
output_to_stdout,
diff_files,
session,
DiffSource::CommitRange(commit_ids),
InputMode::Normal,
Vec::new(),
path_filter,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
if review_commits.len() > 1 {
app.inline_selector.range_diff_files = Some(app.diff_files.clone());
app.commit_select.list = review_commits.clone();
app.commit_select.cursor = 0;
app.commit_select.selection_range = Some((0, review_commits.len() - 1));
app.commit_select.scroll_offset = 0;
app.commit_select.visible_count = review_commits.len();
app.commit_select.has_more = false;
app.inline_selector.visible = true;
app.inline_selector.diff_cache.clear();
}
app.inline_selector.commits = review_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
Ok(app)
} else if working_tree {
let diff_files = Self::get_working_tree_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
)?;
let session =
Self::load_or_create_session(&vcs_info, SessionDiffSource::StagedAndUnstaged);
let app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::StagedAndUnstaged,
InputMode::Normal,
Vec::new(),
path_filter,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
Ok(app)
} else {
let has_staged_changes = match Self::get_staged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(_) => true,
Err(TrvError::NoChanges) => false,
Err(TrvError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let has_unstaged_changes = match Self::get_unstaged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(_) => true,
Err(TrvError::NoChanges) => false,
Err(TrvError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let working_tree_diff = if has_staged_changes || has_unstaged_changes {
match Self::get_working_tree_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
path_filter,
) {
Ok(diff_files) => Some(diff_files),
Err(TrvError::NoChanges) => None,
Err(e) => return Err(e),
}
} else {
None
};
let commits = vcs.get_recent_commits(0, VISIBLE_COMMIT_COUNT)?;
if !has_staged_changes && !has_unstaged_changes && commits.is_empty() {
return Err(TrvError::NoChanges);
}
let mut commit_list = commits.clone();
if has_staged_changes {
commit_list.insert(0, Self::staged_commit_entry());
}
if has_unstaged_changes {
commit_list.insert(0, Self::unstaged_commit_entry());
}
let diff_source = if has_staged_changes && has_unstaged_changes {
DiffSource::StagedAndUnstaged
} else if has_staged_changes {
DiffSource::Staged
} else if has_unstaged_changes {
DiffSource::Unstaged
} else {
DiffSource::WorkingTree
};
let session_source = if has_staged_changes && has_unstaged_changes {
SessionDiffSource::StagedAndUnstaged
} else if has_staged_changes {
SessionDiffSource::Staged
} else if has_unstaged_changes {
SessionDiffSource::Unstaged
} else {
SessionDiffSource::WorkingTree
};
let session = Self::load_or_create_session(&vcs_info, session_source);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
working_tree_diff.unwrap_or_default(),
session,
diff_source,
InputMode::CommitSelect,
commit_list,
path_filter,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
app.commit_select.has_more = commits.len() >= VISIBLE_COMMIT_COUNT;
app.commit_select.visible_count = app.commit_select.list.len();
Ok(app)
}
}
#[allow(clippy::too_many_arguments)]
pub fn new_resumed(
session: ReviewSession,
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
runtime_handle: Handle,
) -> Result<Self> {
let vcs = detect_vcs()?;
let vcs_info = vcs.info().clone();
let highlighter = theme.syntax_highlighter();
match session.diff_source {
SessionDiffSource::Remote => Err(TrvError::UnsupportedOperation(
"Cannot resume a remote session locally".into(),
)),
SessionDiffSource::WorkingTree
| SessionDiffSource::Staged
| SessionDiffSource::Unstaged
| SessionDiffSource::StagedAndUnstaged => {
let diff_files = Self::get_working_tree_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
None,
)?;
let app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::StagedAndUnstaged,
InputMode::Normal,
Vec::new(),
None,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
Ok(app)
}
SessionDiffSource::CommitRange => {
let commit_ids = session.commit_range.clone().unwrap_or_default();
let diff_files = Self::get_commit_range_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
None,
)?;
let review_commits: Vec<CommitInfo> = vcs
.get_commits_info(&commit_ids)?
.into_iter()
.rev()
.collect();
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::CommitRange(commit_ids.clone()),
InputMode::Normal,
Vec::new(),
None,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
if review_commits.len() > 1 {
app.inline_selector.range_diff_files = Some(app.diff_files.clone());
app.commit_select.list = review_commits.clone();
app.commit_select.cursor = 0;
app.commit_select.selection_range = Some((0, review_commits.len() - 1));
app.commit_select.scroll_offset = 0;
app.commit_select.visible_count = review_commits.len();
app.commit_select.has_more = false;
app.inline_selector.visible = true;
app.inline_selector.diff_cache.clear();
}
app.inline_selector.commits = review_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
Ok(app)
}
SessionDiffSource::WorkingTreeAndCommits
| SessionDiffSource::StagedUnstagedAndCommits => {
let commit_ids = session.commit_range.clone().unwrap_or_default();
let diff_files = Self::get_working_tree_with_commits_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
&commit_ids,
highlighter,
None,
)?;
let review_commits: Vec<CommitInfo> = vcs
.get_commits_info(&commit_ids)?
.into_iter()
.rev()
.collect();
let has_staged = Self::get_staged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
None,
)
.is_ok();
let has_unstaged = Self::get_unstaged_diff_with_ignore(
vcs.as_ref(),
&vcs_info.root_path,
highlighter,
None,
)
.is_ok();
let mut all_commits = Vec::new();
if has_staged {
all_commits.push(Self::staged_commit_entry());
}
if has_unstaged {
all_commits.push(Self::unstaged_commit_entry());
}
all_commits.extend(review_commits);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
DiffSource::StagedUnstagedAndCommits(commit_ids.clone()),
InputMode::Normal,
Vec::new(),
None,
runtime_handle,
AppMode::Local(LocalState::default()),
)?;
app.inline_selector.range_diff_files = Some(app.diff_files.clone());
app.commit_select.list = all_commits.clone();
app.commit_select.cursor = 0;
app.commit_select.selection_range = if all_commits.is_empty() {
None
} else {
Some((0, all_commits.len() - 1))
};
app.commit_select.scroll_offset = 0;
app.commit_select.visible_count = all_commits.len();
app.commit_select.has_more = false;
app.inline_selector.visible = all_commits.len() > 1;
app.inline_selector.diff_cache.clear();
app.inline_selector.commits = all_commits;
app.insert_commit_message_if_single();
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
Ok(app)
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn new_remote(
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
diff_files: Vec<DiffFile>,
pr_title: String,
pr_number: u64,
owner: &str,
repo: &str,
runtime_handle: Handle,
forge: Option<std::sync::Arc<dyn ForgeBackend>>,
pr_id: PrId,
) -> Result<Self> {
let vcs_info = VcsInfo {
root_path: PathBuf::from(format!("{owner}/{repo}")),
head_commit: format!("PR #{pr_number}"),
branch_name: Some(format!("PR-{pr_number}")),
vcs_type: VcsType::Git,
};
let vcs: Box<dyn VcsBackend> = Box::new(RemoteStubBackend {
info: vcs_info.clone(),
});
let session = Self::load_or_create_session(&vcs_info, SessionDiffSource::Remote);
let diff_source = DiffSource::Remote {
pr_title,
pr_number,
};
let remote_state = RemoteSessionState::new(forge, pr_id);
let mut app = Self::build(
vcs,
vcs_info,
theme,
comment_type_configs,
output_to_stdout,
diff_files,
session,
diff_source,
InputMode::Normal,
Vec::new(),
None,
runtime_handle,
AppMode::Remote(remote_state),
)?;
if app.diff_files.len() == 1 {
app.ui_layout.show_file_list = false;
app.nav.focused_panel = FocusedPanel::Diff;
}
Ok(app)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build(
vcs: Box<dyn VcsBackend>,
vcs_info: VcsInfo,
theme: Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
output_to_stdout: bool,
diff_files: Vec<DiffFile>,
mut session: ReviewSession,
diff_source: DiffSource,
input_mode: InputMode,
commit_list: Vec<CommitInfo>,
path_filter: Option<&str>,
runtime_handle: Handle,
mode: AppMode,
) -> Result<Self> {
session.apply_diff_files(&diff_files);
let has_more_commit = commit_list.len() >= VISIBLE_COMMIT_COUNT;
let visible_commit_count = if commit_list.is_empty() {
VISIBLE_COMMIT_COUNT
} else {
commit_list.len()
};
let comment_types = Self::resolve_comment_types(&theme, comment_type_configs);
let default_comment_type = Self::first_comment_type(&comment_types);
let mental_model_byte_limit_value =
travelagent_core::config::DEFAULT_MENTAL_MODEL_BYTE_LIMIT;
let restored_tour = session.tour.take();
let restored_tour_comment_meta = std::mem::take(&mut session.tour_comment_meta);
let restored_tour_triage = std::mem::take(&mut session.tour_triage);
let mut app = Self {
theme,
vcs,
vcs_info,
engine: ReviewEngine::new(session),
diff_files,
diff_source,
nav: NavigationState {
input_mode,
focused_panel: FocusedPanel::Diff,
diff_view_mode: DiffViewMode::Unified,
command_origin: InputMode::Normal,
},
file_list_state: FileListState::default(),
diff_state: DiffState::default(),
help_state: HelpState::default(),
palette: PaletteState::default(),
search_buffer: String::new(),
last_search_pattern: None,
comment: CommentEditState {
buffer: String::new(),
cursor: 0,
comment_type: default_comment_type,
types: comment_types,
is_review_level: false,
is_file_level: true,
line: None,
editing_id: None,
visual_anchor: None,
line_range: None,
cursor_screen_pos: None,
},
mental_model_edit: MentalModelEditState::default(),
mental_model_byte_limit: mental_model_byte_limit_value,
blind_mode: false,
blind_patterns: Vec::new(),
spar_mode: false,
spec_statuses: std::collections::HashMap::new(),
sparring_cursor: 0,
commit_select: CommitSelectionState {
list: commit_list,
cursor: 0,
scroll_offset: 0,
viewport_height: 0,
selection_range: None,
visible_count: visible_commit_count,
page_size: COMMIT_PAGE_SIZE,
has_more: has_more_commit,
},
should_quit: false,
discard_on_exit: false,
dirty: false,
quit_warned: false,
confirm_on_quit: true,
comment_templates: Vec::new(),
message: None,
error_log: ErrorLog::default(),
agent_flash: None,
viewport_pinned: false,
agent_ghost: None,
notify_queue: std::collections::VecDeque::new(),
forge_warn_queue: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::VecDeque::new(),
)),
pending_confirm: None,
supports_keyboard_enhancement: false,
ui_layout: UiLayoutState::default(),
gaps: GapExpansionState {
expanded_top: HashMap::new(),
expanded_bottom: HashMap::new(),
},
line_annotations: Vec::new(),
output_to_stdout,
pending_stdout_output: None,
update_info: None,
pending_count: None,
inline_selector: InlineCommitSelectorState {
commits: Vec::new(),
visible: false,
diff_cache: HashMap::new(),
range_diff_files: None,
saved_selection: None,
},
path_filter: path_filter.map(std::string::ToString::to_string),
export_legend: true,
mode,
runtime_handle,
auto_stage: false,
tour: TourSessionState {
plan: restored_tour,
comment_meta: restored_tour_comment_meta,
triage: restored_tour_triage,
..TourSessionState::default()
},
pending_external_edit: false,
pending_open_file_editor: None,
word_diff_enabled: true,
markdown_rendering_enabled: true,
risk_border_colors: true,
ai: AiSummaryState::default(),
reaction_picker_cursor: 0,
tab_width: 4,
risk_config: travelagent_core::risk::RiskConfig::default(),
auto_collapse_cfg: travelagent_core::auto_collapse::AutoCollapseConfig::default(),
live: LiveModeState::default(),
agent_action: AgentActionState::default(),
mcp_listener: McpListenerState::default(),
viewer: ViewerPaneState::default(),
peeked_reviewed: std::collections::HashSet::new(),
mcp_peer_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pending_tour_request: None,
pending_tour_request_poll: None,
};
if app.path_filter.is_some() && app.diff_files.len() == 1 {
app.ui_layout.show_file_list = false;
app.nav.focused_panel = FocusedPanel::Diff;
}
app.sort_files_by_directory(true);
app.expand_all_dirs();
app.rebuild_annotations();
let seed: std::collections::HashMap<std::path::PathBuf, String> = app
.diff_files
.iter()
.map(|file| {
let path = file.display_path_lossy().clone();
let abs = app.vcs_info.root_path.join(&path);
let content = std::fs::read_to_string(&abs).unwrap_or_default();
(path, content)
})
.collect();
app.live.replace_cached_contents(seed);
Ok(app)
}
fn resolve_comment_types(
theme: &Theme,
comment_type_configs: Option<Vec<CommentTypeConfig>>,
) -> Vec<CommentTypeDefinition> {
let defaults = vec![
CommentTypeDefinition {
id: "note".to_string(),
label: "note".to_string(),
definition: Some("observations".to_string()),
color: Some(theme.comment_note),
},
CommentTypeDefinition {
id: "suggestion".to_string(),
label: "suggestion".to_string(),
definition: Some("improvements".to_string()),
color: Some(theme.comment_suggestion),
},
CommentTypeDefinition {
id: "issue".to_string(),
label: "issue".to_string(),
definition: Some("problems to fix".to_string()),
color: Some(theme.comment_issue),
},
CommentTypeDefinition {
id: "praise".to_string(),
label: "praise".to_string(),
definition: Some("positive feedback".to_string()),
color: Some(theme.comment_praise),
},
CommentTypeDefinition {
id: "question".to_string(),
label: "question".to_string(),
definition: Some("requires answer".to_string()),
color: Some(theme.comment_question),
},
];
let Some(configs) = comment_type_configs else {
return defaults;
};
let mut resolved = Vec::new();
for config in configs {
let id = config.id;
let label = config.label.unwrap_or_else(|| id.clone());
let definition = config.definition;
let color = config.color.as_deref().and_then(Self::parse_config_color);
resolved.push(CommentTypeDefinition {
id,
label,
definition,
color,
});
}
if resolved.is_empty() {
defaults
} else {
resolved
}
}
pub fn set_comment_templates(
&mut self,
templates: Option<std::collections::HashMap<String, String>>,
) {
let Some(templates) = templates else {
self.comment_templates.clear();
return;
};
let mut entries: Vec<(String, String)> = templates.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
self.comment_templates = entries;
}
pub(super) fn first_comment_type(comment_types: &[CommentTypeDefinition]) -> CommentType {
comment_types
.first()
.map(|comment_type| CommentType::from_id(&comment_type.id))
.unwrap_or_default()
}
pub(crate) fn parse_config_color(value: &str) -> Option<Color> {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return None;
}
if let Some(hex) = normalized.strip_prefix('#')
&& hex.len() == 6
&& let Ok(rgb) = u32::from_str_radix(hex, 16)
{
let r = ((rgb >> 16) & 0xff) as u8;
let g = ((rgb >> 8) & 0xff) as u8;
let b = (rgb & 0xff) as u8;
return Some(Color::Rgb(r, g, b));
}
match normalized.as_str() {
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"gray" | "grey" => Some(Color::Gray),
"darkgray" | "dark_gray" | "darkgrey" | "dark_grey" => Some(Color::DarkGray),
"lightred" | "light_red" => Some(Color::LightRed),
"lightgreen" | "light_green" => Some(Color::LightGreen),
"lightyellow" | "light_yellow" => Some(Color::LightYellow),
"lightblue" | "light_blue" => Some(Color::LightBlue),
"lightmagenta" | "light_magenta" => Some(Color::LightMagenta),
"lightcyan" | "light_cyan" => Some(Color::LightCyan),
"white" => Some(Color::White),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_config_color_accepts_six_digit_hex() {
assert_eq!(
App::parse_config_color("#ff8040"),
Some(Color::Rgb(0xff, 0x80, 0x40))
);
assert_eq!(
App::parse_config_color("#FF8040"),
Some(Color::Rgb(0xff, 0x80, 0x40))
);
}
#[test]
fn parse_config_color_named_color_is_case_insensitive_and_trimmed() {
assert_eq!(App::parse_config_color("Red"), Some(Color::Red));
assert_eq!(App::parse_config_color(" blue "), Some(Color::Blue));
assert_eq!(
App::parse_config_color("light_green"),
Some(Color::LightGreen)
);
assert_eq!(App::parse_config_color("dark_gray"), Some(Color::DarkGray));
}
#[test]
fn parse_config_color_rejects_empty_and_malformed() {
assert_eq!(App::parse_config_color(""), None);
assert_eq!(App::parse_config_color(" "), None);
assert_eq!(App::parse_config_color("#fff"), None);
assert_eq!(App::parse_config_color("#ff80401"), None);
assert_eq!(App::parse_config_color("#zzzzzz"), None);
assert_eq!(App::parse_config_color("burgundy"), None);
}
}