use std::path::{Path, PathBuf};
use appimg_core::install::{self, IconChoice, InstallRequest};
use appimg_core::metadata::AppImageInfo;
use appimg_core::{update, MAIN_CATEGORIES};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Name,
Comment,
Categories,
Args,
Terminal,
Icon,
UpdateSource,
}
impl Field {
pub const ALL: [Field; 7] = [
Field::Name,
Field::Comment,
Field::Categories,
Field::Args,
Field::Terminal,
Field::Icon,
Field::UpdateSource,
];
pub fn label(self) -> &'static str {
match self {
Field::Name => "Name",
Field::Comment => "Comment",
Field::Categories => "Categories",
Field::Args => "Arguments",
Field::Terminal => "Terminal",
Field::Icon => "Icon",
Field::UpdateSource => "Update from",
}
}
pub fn next(self) -> Field {
let index = Field::ALL.iter().position(|f| *f == self).unwrap_or(0);
Field::ALL[(index + 1) % Field::ALL.len()]
}
pub fn previous(self) -> Field {
let index = Field::ALL.iter().position(|f| *f == self).unwrap_or(0);
Field::ALL[(index + Field::ALL.len() - 1) % Field::ALL.len()]
}
}
pub struct InstallForm {
pub info: AppImageInfo,
pub request: InstallRequest,
pub field: Field,
pub category_cursor: usize,
pub args_text: String,
pub icon_text: String,
pub update_source_text: String,
pub suggested: bool,
}
impl InstallForm {
pub fn new(source: &Path, origin: &str, info: AppImageInfo) -> Self {
let request = InstallRequest::from_info(source, origin, &info);
let category_cursor =
request.categories.first().and_then(|first| index_of(first)).unwrap_or(0);
let suggestion = install::suggested_update_source(&request, &info);
let suggested = suggestion.is_some();
let update_source_text =
suggestion.or_else(|| request.update_source.clone()).unwrap_or_default();
Self {
info,
request,
field: Field::Name,
category_cursor,
args_text: String::new(),
icon_text: String::new(),
update_source_text,
suggested,
}
}
pub fn source(&self) -> &Path {
&self.request.source
}
pub fn is_selected(&self, category: &str) -> bool {
self.request.categories.iter().any(|c| c == category)
}
pub fn toggle_category(&mut self) {
let Some(category) = MAIN_CATEGORIES.get(self.category_cursor) else {
return;
};
match self.request.categories.iter().position(|c| c == category) {
Some(index) => {
self.request.categories.remove(index);
}
None => self.request.categories.push((*category).to_string()),
}
}
pub fn move_category(&mut self, delta: isize) {
let last = MAIN_CATEGORIES.len() - 1;
self.category_cursor = match delta {
delta if delta < 0 => self.category_cursor.saturating_sub(delta.unsigned_abs()),
delta => (self.category_cursor + delta as usize).min(last),
};
}
pub fn text_mut(&mut self) -> Option<&mut String> {
match self.field {
Field::Name => Some(&mut self.request.name),
Field::Comment => Some(self.request.comment.get_or_insert_with(String::new)),
Field::Args => Some(&mut self.args_text),
Field::Icon => Some(&mut self.icon_text),
Field::UpdateSource => Some(&mut self.update_source_text),
Field::Categories | Field::Terminal => None,
}
}
pub fn text(&self, field: Field) -> String {
match field {
Field::Name => self.request.name.clone(),
Field::Comment => self.request.comment.clone().unwrap_or_default(),
Field::Args => self.args_text.clone(),
Field::Icon => self.icon_text.clone(),
Field::UpdateSource => self.update_source_text.clone(),
Field::Terminal => {
if self.request.terminal {
"yes".to_string()
} else {
"no".to_string()
}
}
Field::Categories => self.request.categories.join(", "),
}
}
pub fn finish(&mut self) -> Result<(), String> {
if self.request.name.trim().is_empty() {
return Err("the name cannot be empty".to_string());
}
self.request.extra_args = crate::commands::install::split_args(&self.args_text);
self.request.comment = self.request.comment.take().filter(|c| !c.trim().is_empty());
let icon = self.icon_text.trim();
self.request.icon = if icon.is_empty() {
IconChoice::Embedded
} else {
let path = PathBuf::from(icon);
if !path.is_file() {
return Err(format!("{} is not a file", path.display()));
}
IconChoice::File(path)
};
let source = self.update_source_text.trim();
self.request.update_source = if source.is_empty() {
None
} else {
Some(update::parse_update_source(source).map_err(|error| error.to_string())?)
};
Ok(())
}
}
fn index_of(category: &str) -> Option<usize> {
MAIN_CATEGORIES.iter().position(|c| *c == category)
}
#[cfg(test)]
mod tests {
use super::*;
fn form_for(info: AppImageInfo) -> InstallForm {
let path = Path::new("/home/me/Downloads/Fake_App-1.0.0-x86_64.AppImage");
InstallForm::new(path, &path.to_string_lossy(), info)
}
fn linking(repository: &str) -> AppImageInfo {
AppImageInfo {
name: Some("Fake App".to_string()),
suggested_update_source: Some(repository.to_string()),
..Default::default()
}
}
#[test]
fn the_appstream_suggestion_is_prefilled_and_installed_once_confirmed() {
let mut form = form_for(linking("github:fake/app"));
assert_eq!(form.text(Field::UpdateSource), "github:fake/app");
assert!(form.suggested);
assert_eq!(form.request.update_source, None);
form.finish().unwrap();
assert_eq!(form.request.update_source.as_deref(), Some("github:fake/app"));
}
#[test]
fn embedded_update_information_leaves_the_field_empty() {
let mut info = linking("github:fake/app");
info.update_info = Some("zsync|https://example.com/Fake_App.AppImage.zsync".to_string());
let mut form = form_for(info);
assert_eq!(form.text(Field::UpdateSource), "");
assert!(!form.suggested);
form.finish().unwrap();
assert_eq!(form.request.update_source, None);
}
#[test]
fn the_field_is_checked_and_stored_in_one_spelling() {
let mut form = form_for(linking("github:fake/app"));
form.update_source_text = " ".to_string();
form.finish().unwrap();
assert_eq!(form.request.update_source, None);
form.update_source_text = "https://github.com/o/r/releases".to_string();
form.finish().unwrap();
assert_eq!(form.request.update_source.as_deref(), Some("github:o/r"));
form.update_source_text = "o/r".to_string();
let problem = form.finish().unwrap_err();
assert!(problem.contains("is not an update source"), "{problem}");
}
}