use crate::config::{CatalogItem, CustomVarDef, Entry, EnvProfile, VarKind};
use crate::db;
use crate::tui::theme::{BathConfig, Theme};
use crate::tui::view::View;
use anyhow::Result;
use ratatui::widgets::ListState;
use rusqlite::Connection;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Command,
Search,
}
#[derive(Clone)]
pub enum Holding {
Item(CatalogItem),
Part {
profile: String,
var: String,
from: usize,
entry: Entry,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlacePartOutcome {
Moved,
OriginGone,
CannotConvert,
NotHolding,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenameOutcome {
Renamed,
Unchanged,
Rejected,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EditorStyle {
Single,
PathPart,
PartsList,
}
#[derive(Clone)]
pub struct VarTypeOption {
pub name: String,
pub kind: VarKind,
pub separator: String,
pub editor: EditorStyle,
}
pub fn builtin_var_options() -> Vec<VarTypeOption> {
vec![
VarTypeOption {
name: "PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PathPart,
},
VarTypeOption {
name: "CPATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "C_INCLUDE_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "CPLUS_INCLUDE_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "OBJC_INCLUDE_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "LIBRARY_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "LD_LIBRARY_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "LD_RUN_PATH".to_string(),
kind: VarKind::List,
separator: ":".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "CPPFLAGS".to_string(),
kind: VarKind::List,
separator: " ".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "CFLAGS".to_string(),
kind: VarKind::List,
separator: " ".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "CXXFLAGS".to_string(),
kind: VarKind::List,
separator: " ".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "LDFLAGS".to_string(),
kind: VarKind::List,
separator: " ".to_string(),
editor: EditorStyle::PartsList,
},
VarTypeOption {
name: "RANLIB".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "CC".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "CXX".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "AR".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "STRIP".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "GCC_EXEC_PREFIX".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "COLLECT_GCC_OPTIONS".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
VarTypeOption {
name: "LANG".to_string(),
kind: VarKind::Scalar,
separator: "".to_string(),
editor: EditorStyle::Single,
},
]
}
pub struct AppState {
pub conn: Connection,
pub profiles: Vec<EnvProfile>,
pub active_profile_index: usize,
pub profile_list_state: ListState,
pub custom_var_defs: Vec<CustomVarDef>,
pub var_options: Vec<VarTypeOption>,
pub active_view: View,
pub input_mode: InputMode,
pub theme_preset: String,
pub theme: Theme,
pub config: BathConfig,
pub vars_list_state: ListState,
pub defs_list_state: ListState,
pub parts_list_state: ListState,
pub items_list_state: ListState,
pub profiles_filter: String,
pub vars_filter: String,
pub defs_filter: String,
pub parts_filter: String,
pub items_filter: String,
pub command_input: String,
pub command_suggestions: Vec<String>,
pub command_selected: usize,
pub search_target: View,
pub status: String,
pub holding: Option<Holding>,
pub items: Vec<CatalogItem>,
}
impl AppState {
pub fn new() -> Result<Self> {
let conn = db::establish_connection()?;
let config = crate::tui::theme::load_config().unwrap_or_default();
let (theme, theme_preset) =
crate::tui::theme::resolve_from_config(&config).unwrap_or_else(|_| {
let theme =
crate::tui::theme::resolve_theme(crate::tui::theme::default_preset(), None)
.unwrap();
(theme, crate::tui::theme::default_preset().to_string())
});
let mut profiles = db::load_all_profiles(&conn)?;
if profiles.is_empty() {
let default = EnvProfile::new("default");
db::save_profile(&conn, &default)?;
profiles.push(default);
}
let mut profile_list_state = ListState::default();
profile_list_state.select(Some(0));
let mut vars_list_state = ListState::default();
vars_list_state.select(Some(0));
let mut defs_list_state = ListState::default();
defs_list_state.select(Some(0));
let mut parts_list_state = ListState::default();
parts_list_state.select(Some(0));
let mut items_list_state = ListState::default();
items_list_state.select(Some(0));
let mut app = AppState {
conn,
profiles,
active_profile_index: 0,
profile_list_state,
custom_var_defs: Vec::new(),
var_options: Vec::new(),
active_view: View::Vars,
input_mode: InputMode::Normal,
theme_preset,
theme,
config,
vars_list_state,
defs_list_state,
parts_list_state,
items_list_state,
profiles_filter: String::new(),
vars_filter: String::new(),
defs_filter: String::new(),
parts_filter: String::new(),
items_filter: String::new(),
command_input: String::new(),
command_suggestions: Vec::new(),
command_selected: 0,
search_target: View::Vars,
status: String::new(),
holding: None,
items: Vec::new(),
};
app.refresh_var_options()?;
app.refresh_items()?;
Ok(app)
}
pub fn set_theme_preset(&mut self, preset: &str, persist: bool) -> Result<bool> {
let preset = preset.trim();
if preset.is_empty() {
return Ok(false);
}
if crate::tui::daisyui_themes::get(preset).is_none() {
return Ok(false);
}
self.theme_preset = preset.to_string();
self.config
.theme
.get_or_insert_with(Default::default)
.preset = Some(self.theme_preset.clone());
self.theme = crate::tui::theme::resolve_theme(preset, self.config.theme.as_ref())?;
if persist {
crate::tui::theme::save_config(&self.config)?;
}
Ok(true)
}
pub fn refresh_items(&mut self) -> Result<()> {
self.items = db::load_items(&self.conn)?;
Ok(())
}
pub fn selected_var_name(&self) -> Option<String> {
let rows = crate::tui::select::compute_var_rows(self);
if rows.is_empty() {
return None;
}
let i = self
.vars_list_state
.selected()
.unwrap_or(0)
.min(rows.len() - 1);
rows.get(i).map(|r| r.name.clone())
}
pub fn refresh_var_options(&mut self) -> Result<()> {
self.custom_var_defs = db::load_custom_var_defs(&self.conn)?;
let mut opts = builtin_var_options();
for d in &self.custom_var_defs {
let opt = VarTypeOption {
name: d.name.clone(),
kind: d.kind.clone(),
separator: d.separator.clone(),
editor: match d.kind {
VarKind::Scalar => EditorStyle::Single,
VarKind::List => EditorStyle::PartsList,
},
};
if let Some(existing) = opts.iter_mut().find(|o| o.name == opt.name) {
*existing = opt;
} else {
opts.push(opt);
}
}
self.var_options = opts;
Ok(())
}
pub fn add_env_var(&mut self, entry: Entry) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
profile.entries.push(entry);
db::save_profile(&self.conn, profile)?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_env_var(&mut self, index: usize) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
if index < profile.entries.len() {
profile.entries.remove(index);
db::save_profile(&self.conn, profile)?;
}
Ok(())
}
#[allow(dead_code)]
pub fn update_env_var(&mut self, index: usize, entry: Entry) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
if index < profile.entries.len() {
profile.entries[index] = entry;
db::save_profile(&self.conn, profile)?;
}
Ok(())
}
#[allow(dead_code)]
pub fn move_env_var_up(&mut self, index: usize) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
if index == 0 || index >= profile.entries.len() {
return Ok(());
}
profile.entries.swap(index - 1, index);
db::save_profile(&self.conn, profile)?;
Ok(())
}
#[allow(dead_code)]
pub fn move_env_var_down(&mut self, index: usize) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
if profile.entries.is_empty() || index >= profile.entries.len().saturating_sub(1) {
return Ok(());
}
profile.entries.swap(index, index + 1);
db::save_profile(&self.conn, profile)?;
Ok(())
}
pub fn hold_part(&mut self, var: &str, part_index: usize) -> bool {
let profile = &self.profiles[self.active_profile_index];
let entry = profile
.entries
.iter()
.filter(|e| e.var_name().as_ref() == var)
.nth(part_index)
.cloned();
if let Some(entry) = entry {
self.holding = Some(Holding::Part {
profile: profile.name.clone(),
var: var.to_string(),
from: part_index,
entry,
});
true
} else {
false
}
}
pub fn place_held_part(
&mut self,
target_var: &str,
insert_at: usize,
) -> Result<PlacePartOutcome> {
let Some(Holding::Part {
profile: origin_profile,
var: origin_var,
from,
entry,
}) = self.holding.clone()
else {
return Ok(PlacePartOutcome::NotHolding);
};
let Some(origin_idx) = self.profiles.iter().position(|p| p.name == origin_profile) else {
self.holding = None;
return Ok(PlacePartOutcome::OriginGone);
};
let var_positions: Vec<usize> = self.profiles[origin_idx]
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.var_name().as_ref() == origin_var)
.map(|(i, _)| i)
.collect();
let entries = &self.profiles[origin_idx].entries;
let held_pos = var_positions
.get(from)
.filter(|&&i| entries[i] == entry)
.map(|_| from)
.or_else(|| var_positions.iter().position(|&i| entries[i] == entry));
let Some(held_pos) = held_pos else {
self.holding = None;
return Ok(PlacePartOutcome::OriginGone);
};
let held_global = var_positions[held_pos];
let new_entry = if entry.var_name().as_ref() == target_var {
entry.clone()
} else {
let value = crate::tui::select::preview_value(&entry);
match crate::tui::select::make_part_entry(self, target_var, value) {
Some(e) => e,
None => return Ok(PlacePartOutcome::CannotConvert),
}
};
self.profiles[origin_idx].entries.remove(held_global);
let same_list = origin_idx == self.active_profile_index && origin_var == target_var;
let mut insert_at = insert_at;
if same_list && held_pos < insert_at {
insert_at -= 1;
}
{
let profile = &mut self.profiles[self.active_profile_index];
let target_positions: Vec<usize> = profile
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.var_name().as_ref() == target_var)
.map(|(i, _)| i)
.collect();
let global_at = if target_positions.is_empty() {
profile.entries.len()
} else if insert_at < target_positions.len() {
target_positions[insert_at]
} else {
target_positions.last().unwrap() + 1
};
profile.entries.insert(global_at, new_entry);
}
self.holding = None;
db::save_profile(&self.conn, &self.profiles[self.active_profile_index])?;
if origin_idx != self.active_profile_index {
db::save_profile(&self.conn, &self.profiles[origin_idx])?;
}
Ok(PlacePartOutcome::Moved)
}
pub fn replace_var_parts(&mut self, var_name: &str, new_parts: Vec<Entry>) -> Result<()> {
let profile = &mut self.profiles[self.active_profile_index];
let mut idxs: Vec<usize> = profile
.entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
if e.var_name().as_ref() == var_name {
Some(i)
} else {
None
}
})
.collect();
if idxs.is_empty() {
profile.entries.extend(new_parts);
db::save_profile(&self.conn, profile)?;
return Ok(());
}
let insert_at = *idxs.iter().min().unwrap_or(&0);
idxs.sort_unstable();
for i in idxs.into_iter().rev() {
profile.entries.remove(i);
}
for (offset, e) in new_parts.into_iter().enumerate() {
profile.entries.insert(insert_at + offset, e);
}
db::save_profile(&self.conn, profile)?;
Ok(())
}
pub fn add_profile(&mut self, profile: EnvProfile) -> Result<bool> {
if profile.name.trim().is_empty() {
return Ok(false);
}
if self.profiles.iter().any(|p| p.name == profile.name) {
return Ok(false);
}
db::save_profile(&self.conn, &profile)?;
self.profiles.push(profile);
Ok(true)
}
pub fn delete_profile(&mut self, index: usize) -> Result<bool> {
if self.profiles.len() <= 1 {
return Ok(false);
}
if index >= self.profiles.len() {
return Ok(false);
}
let profile = self.profiles.remove(index);
db::delete_profile(&self.conn, &profile.name)?;
if index < self.active_profile_index {
self.active_profile_index -= 1;
} else if self.active_profile_index >= self.profiles.len() {
self.active_profile_index = self.profiles.len() - 1;
}
Ok(true)
}
pub fn update_profile(&mut self, index: usize, new_name: String) -> Result<RenameOutcome> {
if index >= self.profiles.len() {
return Ok(RenameOutcome::Rejected);
}
if new_name.trim().is_empty() {
return Ok(RenameOutcome::Rejected);
}
if self
.profiles
.iter()
.enumerate()
.any(|(i, p)| i != index && p.name == new_name)
{
return Ok(RenameOutcome::Rejected);
}
let old_name = self.profiles[index].name.clone();
if old_name == new_name {
return Ok(RenameOutcome::Unchanged);
}
db::rename_profile(&self.conn, &old_name, &new_name)?;
self.profiles[index].name = new_name;
Ok(RenameOutcome::Renamed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn test_app(names: &[&str]) -> AppState {
let conn = Connection::open_in_memory().unwrap();
db::initialize_db(&conn).unwrap();
let mut profiles = Vec::new();
for name in names {
let p = EnvProfile::new(name);
db::save_profile(&conn, &p).unwrap();
profiles.push(p);
}
AppState {
conn,
profiles,
active_profile_index: 0,
profile_list_state: ListState::default(),
custom_var_defs: Vec::new(),
var_options: builtin_var_options(),
active_view: View::Vars,
input_mode: InputMode::Normal,
theme_preset: crate::tui::theme::default_preset().to_string(),
theme: crate::tui::theme::resolve_theme(crate::tui::theme::default_preset(), None)
.unwrap(),
config: BathConfig::default(),
vars_list_state: ListState::default(),
defs_list_state: ListState::default(),
parts_list_state: ListState::default(),
items_list_state: ListState::default(),
profiles_filter: String::new(),
vars_filter: String::new(),
defs_filter: String::new(),
parts_filter: String::new(),
items_filter: String::new(),
command_input: String::new(),
command_suggestions: Vec::new(),
command_selected: 0,
search_target: View::Vars,
status: String::new(),
holding: None,
items: Vec::new(),
}
}
fn profile_count(app: &AppState) -> i64 {
app.conn
.query_row("SELECT COUNT(*) FROM profiles", [], |row| row.get(0))
.unwrap()
}
#[test]
fn set_theme_preset_rejects_unknown_preset_without_staging_config() -> Result<()> {
let mut app = test_app(&["default"]);
let before = app.theme_preset.clone();
let applied = app.set_theme_preset("doesnotexist", false)?;
assert!(!applied, "unknown preset must be reported as not applied");
assert_eq!(
app.theme_preset, before,
"unknown preset must not be applied"
);
assert!(
app.config
.theme
.as_ref()
.and_then(|t| t.preset.as_deref())
.is_none(),
"unknown preset must not be staged into the config that gets persisted"
);
Ok(())
}
#[test]
fn set_theme_preset_applies_known_preset_and_stages_config() -> Result<()> {
let mut app = test_app(&["default"]);
let applied = app.set_theme_preset("nord", false)?;
assert!(applied);
assert_eq!(app.theme_preset, "nord");
assert_eq!(
app.config.theme.as_ref().and_then(|t| t.preset.as_deref()),
Some("nord"),
"known preset must be staged into the config"
);
Ok(())
}
#[test]
fn add_profile_with_duplicate_name_is_rejected_and_preserves_db() -> Result<()> {
let mut app = test_app(&[]);
let mut work = EnvProfile::new("work");
work.entries
.push(crate::config::Entry::CFlag("-O2".to_string()));
db::save_profile(&app.conn, &work)?;
app.profiles.push(work);
let added = app.add_profile(EnvProfile::new("work"))?;
assert!(!added, "add_profile must signal that nothing was added");
assert_eq!(
app.profiles.len(),
1,
"duplicate name must not create a second in-memory profile"
);
let loaded = db::load_profile(&app.conn, "work")?;
assert_eq!(
loaded.entries.len(),
1,
"existing profile entries must not be overwritten in the DB"
);
assert_eq!(profile_count(&app), 1);
Ok(())
}
#[test]
fn add_profile_rejects_blank_name() -> Result<()> {
let mut app = test_app(&["default"]);
let added = app.add_profile(EnvProfile::new(" "))?;
assert!(!added, "add_profile must signal that nothing was added");
assert_eq!(app.profiles.len(), 1, "blank names must be rejected");
assert_eq!(profile_count(&app), 1);
Ok(())
}
#[test]
fn delete_profile_below_active_shifts_active_index_down() -> Result<()> {
let mut app = test_app(&["a", "b", "c"]);
app.active_profile_index = 1;
app.delete_profile(0)?;
assert_eq!(app.profiles.len(), 2);
assert_eq!(
app.active_profile_index, 0,
"deleting below the active profile must shift the active index down"
);
assert_eq!(app.profiles[app.active_profile_index].name, "b");
Ok(())
}
#[test]
fn delete_profile_of_active_lands_on_next_profile() -> Result<()> {
let mut app = test_app(&["a", "b", "c"]);
app.active_profile_index = 1;
app.delete_profile(1)?;
assert_eq!(app.active_profile_index, 1);
assert_eq!(app.profiles[app.active_profile_index].name, "c");
Ok(())
}
#[test]
fn delete_profile_of_active_at_end_clamps_to_last() -> Result<()> {
let mut app = test_app(&["a", "b", "c"]);
app.active_profile_index = 2;
app.delete_profile(2)?;
assert_eq!(app.active_profile_index, 1);
assert_eq!(app.profiles[app.active_profile_index].name, "b");
Ok(())
}
#[test]
fn delete_profile_above_active_leaves_active_alone() -> Result<()> {
let mut app = test_app(&["a", "b", "c"]);
app.active_profile_index = 0;
app.delete_profile(2)?;
assert_eq!(app.active_profile_index, 0);
assert_eq!(app.profiles[app.active_profile_index].name, "a");
Ok(())
}
#[test]
fn update_profile_to_existing_name_is_rejected_without_error() -> Result<()> {
let mut app = test_app(&["a", "b"]);
let res = app.update_profile(0, "b".to_string());
assert!(
res.is_ok(),
"renaming to an existing name must not surface an error: {res:?}"
);
assert_eq!(
res?,
RenameOutcome::Rejected,
"update_profile must signal that nothing was renamed"
);
assert_eq!(
app.profiles[0].name, "a",
"rejected rename must not change state"
);
assert_eq!(profile_count(&app), 2);
assert!(db::load_profile(&app.conn, "a").is_ok());
assert!(db::load_profile(&app.conn, "b").is_ok());
Ok(())
}
#[test]
fn update_profile_same_name_is_not_reported_as_a_rename() -> Result<()> {
let mut app = test_app(&["a", "b"]);
let res = app.update_profile(0, "a".to_string())?;
assert_eq!(
res,
RenameOutcome::Unchanged,
"renaming a profile to its own unchanged name must not be reported as a rename"
);
assert_eq!(app.profiles[0].name, "a");
assert_eq!(profile_count(&app), 2);
assert!(db::load_profile(&app.conn, "a").is_ok());
Ok(())
}
#[test]
fn update_profile_reports_a_real_rename_as_renamed() -> Result<()> {
let mut app = test_app(&["a", "b"]);
let res = app.update_profile(0, "c".to_string())?;
assert_eq!(res, RenameOutcome::Renamed);
assert_eq!(app.profiles[0].name, "c");
assert!(db::load_profile(&app.conn, "c").is_ok());
assert!(
db::load_profile(&app.conn, "a").is_err(),
"the old name must be gone from the DB after a rename"
);
Ok(())
}
#[test]
fn update_profile_rejects_blank_name() -> Result<()> {
let mut app = test_app(&["a", "b"]);
let _ = app.update_profile(0, " ".to_string());
assert_eq!(app.profiles[0].name, "a", "blank rename must be rejected");
assert!(db::load_profile(&app.conn, "a").is_ok());
Ok(())
}
#[test]
fn delete_profile_signals_whether_it_deleted() -> Result<()> {
let mut app = test_app(&["a", "b"]);
assert!(app.delete_profile(1)?, "a real deletion must report true");
assert!(
!app.delete_profile(0)?,
"refusing to delete the last profile must report false"
);
assert!(
!app.delete_profile(5)?,
"an out-of-range index must report false"
);
Ok(())
}
use crate::config::PathEntry;
fn path_entry(path: &str, program: &str, version: &str) -> Entry {
Entry::Path(PathEntry {
path: path.to_string(),
program: program.to_string(),
version: version.to_string(),
})
}
fn path_values(app: &AppState, profile_idx: usize) -> Vec<String> {
app.profiles[profile_idx]
.entries
.iter()
.filter_map(|e| match e {
Entry::Path(pe) => Some(pe.path.clone()),
_ => None,
})
.collect()
}
#[test]
fn hold_part_defers_removal_and_persists_nothing() -> Result<()> {
let mut app = test_app(&["default"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
assert!(app.hold_part("PATH", 0));
assert_eq!(app.profiles[0].entries.len(), 1, "part must stay in place");
assert_eq!(
db::load_profile(&app.conn, "default")?.entries.len(),
1,
"holding must not touch the DB"
);
app.holding = None;
assert_eq!(app.profiles[0].entries.len(), 1);
assert_eq!(db::load_profile(&app.conn, "default")?.entries.len(), 1);
Ok(())
}
#[test]
fn place_held_part_keeps_entry_verbatim_within_same_var() -> Result<()> {
let mut app = test_app(&["default"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
app.add_env_var(path_entry("/a", "", ""))?;
app.add_env_var(path_entry("/b", "", ""))?;
assert!(app.hold_part("PATH", 0));
let outcome = app.place_held_part("PATH", 2)?;
assert_eq!(outcome, PlacePartOutcome::Moved);
assert_eq!(path_values(&app, 0), vec!["/a", "/opt/gcc/bin", "/b"]);
let moved = &db::load_profile(&app.conn, "default")?.entries[1];
assert_eq!(
*moved,
path_entry("/opt/gcc/bin", "gcc", "13"),
"the original entry (incl. program/version) must be reinserted verbatim"
);
assert!(app.holding.is_none());
Ok(())
}
#[test]
fn place_held_part_moves_across_profiles_using_recorded_origin() -> Result<()> {
let mut app = test_app(&["a", "b"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
assert!(app.hold_part("PATH", 0));
app.active_profile_index = 1;
let outcome = app.place_held_part("PATH", 0)?;
assert_eq!(outcome, PlacePartOutcome::Moved);
assert!(
app.profiles[0].entries.is_empty(),
"the part must be removed from its recorded origin profile"
);
assert_eq!(
app.profiles[1].entries,
vec![path_entry("/opt/gcc/bin", "gcc", "13")]
);
assert!(db::load_profile(&app.conn, "a")?.entries.is_empty());
assert_eq!(db::load_profile(&app.conn, "b")?.entries.len(), 1);
Ok(())
}
#[test]
fn place_held_part_reports_origin_gone_when_part_was_deleted() -> Result<()> {
let mut app = test_app(&["default"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
app.add_env_var(path_entry("/a", "", ""))?;
assert!(app.hold_part("PATH", 0));
app.replace_var_parts("PATH", vec![path_entry("/a", "", "")])?;
let outcome = app.place_held_part("PATH", 1)?;
assert_eq!(outcome, PlacePartOutcome::OriginGone);
assert!(app.holding.is_none(), "a stale hold must be cancelled");
assert_eq!(
path_values(&app, 0),
vec!["/a"],
"nothing may be resurrected from a stale hold"
);
Ok(())
}
#[test]
fn place_held_part_relocates_entry_after_reorder_while_holding() -> Result<()> {
let mut app = test_app(&["default"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
app.add_env_var(path_entry("/a", "", ""))?;
app.add_env_var(path_entry("/b", "", ""))?;
assert!(app.hold_part("PATH", 0));
app.replace_var_parts(
"PATH",
vec![
path_entry("/a", "", ""),
path_entry("/b", "", ""),
path_entry("/opt/gcc/bin", "gcc", "13"),
],
)?;
let outcome = app.place_held_part("PATH", 0)?;
assert_eq!(outcome, PlacePartOutcome::Moved);
assert_eq!(path_values(&app, 0), vec!["/opt/gcc/bin", "/a", "/b"]);
Ok(())
}
#[test]
fn place_held_part_converts_only_when_target_var_differs() -> Result<()> {
let mut app = test_app(&["default"]);
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))?;
assert!(app.hold_part("PATH", 0));
let outcome = app.place_held_part("CPATH", 0)?;
assert_eq!(outcome, PlacePartOutcome::Moved);
assert_eq!(
app.profiles[0].entries,
vec![Entry::CPath("/opt/gcc/bin".to_string())]
);
Ok(())
}
#[test]
fn selected_var_is_derived_from_highlighted_row() {
let mut app = test_app(&["default"]);
let rows = crate::tui::select::compute_var_rows(&app);
app.vars_list_state.select(Some(rows.len() - 1));
assert_eq!(
app.selected_var_name().as_deref(),
Some(rows.last().unwrap().name.as_str()),
"jumping the highlight must move the derived var with it"
);
app.vars_filter = "cpath".to_string();
let filtered = crate::tui::select::compute_var_rows(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(
app.selected_var_name().as_deref(),
Some(filtered[0].name.as_str()),
"filtering must move the derived var to the highlighted row"
);
app.vars_filter = "no-such-var".to_string();
assert_eq!(app.selected_var_name(), None);
}
#[test]
fn custom_def_shadows_builtin_instead_of_duplicating_it() -> Result<()> {
let mut app = test_app(&["default"]);
db::save_custom_var_def(
&app.conn,
&CustomVarDef {
name: "PATH".to_string(),
kind: VarKind::List,
separator: ";".to_string(),
},
)?;
app.refresh_var_options()?;
let path_opts: Vec<_> = app
.var_options
.iter()
.filter(|o| o.name == "PATH")
.collect();
assert_eq!(
path_opts.len(),
1,
"a custom def must shadow the builtin of the same name, not duplicate it"
);
assert_eq!(
path_opts[0].separator, ";",
"the custom def must take precedence over the builtin"
);
let rows = crate::tui::select::compute_var_rows(&app);
assert_eq!(
rows.iter().filter(|r| r.name == "PATH").count(),
1,
"the Vars list must show one row per var name"
);
Ok(())
}
#[test]
fn delete_profile_does_not_remove_last_profile() -> Result<()> {
let mut app = test_app(&["default"]);
app.delete_profile(0)?;
assert_eq!(app.profiles.len(), 1);
assert_eq!(app.profiles[0].name, "default");
assert_eq!(app.active_profile_index, 0);
let count: i64 = app
.conn
.query_row("SELECT COUNT(*) FROM profiles", [], |row| row.get(0))?;
assert_eq!(count, 1);
Ok(())
}
}