aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorpablu <me@pablu.de>2026-05-19 20:51:10 +0200
committerpablu <me@pablu.de>2026-05-19 20:51:10 +0200
commitafe649e0efc31fcd33a828e54bd6313cd37f1784 (patch)
treec2cec9450cc1778f62c6339dad9c8f7d43c46b16 /src
parenta6b4b29aaa222fa5c2f30cad233e97cb8125f96f (diff)
Add available package loading, update README.md, use eyre_color::Result
Diffstat (limited to 'src')
-rw-r--r--src/app.rs14
-rw-r--r--src/event.rs16
-rw-r--r--src/gentoo/mod.rs2
-rw-r--r--src/gentoo/package.rs92
-rw-r--r--src/gentoo/portage.rs281
-rw-r--r--src/gentoo/useflags.rs11
-rw-r--r--src/main.rs6
-rw-r--r--src/ui.rs64
8 files changed, 356 insertions, 130 deletions
diff --git a/src/app.rs b/src/app.rs
index 8e0de7f..6e9e8ab 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,17 +1,19 @@
use ratatui::widgets::ListState;
use ratatui_textarea::TextArea;
-use crate::gentoo::{Package, portage::Portage};
+use crate::gentoo::{InstalledPackage, portage::Portage};
#[derive(Debug)]
pub enum ViewState {
Dashboard = 0,
InstalledPackages = 1,
+ AvailablePackages = 2,
}
#[derive(Debug)]
pub struct App<'a> {
portage: Portage,
+
pub list_state: ListState,
pub showing_search_window: bool,
pub textarea: TextArea<'a>,
@@ -41,11 +43,11 @@ impl App<'_> {
self.portage.world_packages.len()
}
- pub fn installed_packages(&self) -> &[Package] {
+ pub fn installed_packages(&self) -> &[InstalledPackage] {
&self.portage.installed_packages
}
- pub fn current_package(&self) -> &Package {
+ pub fn current_package(&self) -> &InstalledPackage {
let selected_package_index = self.list_state.selected().unwrap_or_default();
&self.portage.installed_packages[selected_package_index]
}
@@ -63,7 +65,8 @@ impl App<'_> {
pub fn cycle_current_tab(&mut self) {
match self.view {
ViewState::Dashboard => self.view = ViewState::InstalledPackages,
- ViewState::InstalledPackages => self.view = ViewState::Dashboard,
+ ViewState::InstalledPackages => self.view = ViewState::AvailablePackages,
+ ViewState::AvailablePackages => self.view = ViewState::Dashboard,
}
}
@@ -74,7 +77,8 @@ impl App<'_> {
.iter()
.enumerate()
.filter(|(_, s)| {
- s.name
+ s.atom
+ .qualified_name()
.to_lowercase()
.contains(&self.textarea.lines()[0].to_lowercase())
})
diff --git a/src/event.rs b/src/event.rs
index 81cf6bd..44a4c3e 100644
--- a/src/event.rs
+++ b/src/event.rs
@@ -19,12 +19,28 @@ pub fn handle_event(app: &mut App) -> io::Result<bool> {
return Ok(true);
}
}
+ crate::app::ViewState::AvailablePackages => {
+ if handle_available_packages_keybinds(key, app) {
+ return Ok(true);
+ }
+ }
}
}
Ok(false)
}
+fn handle_available_packages_keybinds(key: KeyEvent, app: &mut App<'_>) -> bool {
+ match key.code {
+ KeyCode::Char('q') => return true,
+ KeyCode::Tab => app.cycle_current_tab(),
+
+ _ => {}
+ }
+
+ false
+}
+
fn handle_search_keybinds(key: KeyEvent, app: &mut App) {
match key.code {
KeyCode::Esc => app.toggle_search_window(),
diff --git a/src/gentoo/mod.rs b/src/gentoo/mod.rs
index ff343bc..e2cc3df 100644
--- a/src/gentoo/mod.rs
+++ b/src/gentoo/mod.rs
@@ -2,6 +2,6 @@ pub mod package;
pub mod portage;
pub mod useflags;
-pub use package::Package;
+pub use package::InstalledPackage;
pub use portage::Portage;
pub use useflags::UseFlag;
diff --git a/src/gentoo/package.rs b/src/gentoo/package.rs
index a74a09c..d1596f7 100644
--- a/src/gentoo/package.rs
+++ b/src/gentoo/package.rs
@@ -1,44 +1,70 @@
use std::collections::HashSet;
+use chrono::{DateTime, Utc};
+
use crate::gentoo::UseFlag;
-#[derive(Debug, Clone)]
-#[allow(dead_code)]
-pub struct Package {
+#[derive(Debug, Clone, Hash, Eq, PartialEq)]
+pub struct PackageKey {
+ pub category: String,
pub name: String,
- pub use_flags: HashSet<UseFlag>,
- pub version: String,
- pub repository: String,
+}
+
+impl PackageKey {
+ pub fn from_atom(s: &str) -> Option<Self> {
+ let (category, name) = s.split_once('/')?;
+ Some(Self {
+ category: category.to_string(),
+ name: name.to_string(),
+ })
+ }
+
+ pub fn qualified_name(&self) -> String {
+ format!("{}/{}", self.category, self.name)
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Metadata {
pub maintainer: Option<String>,
pub description: Option<String>,
- pub homepage: Option<Vec<String>>,
+ pub homepage: Vec<String>,
pub license: Option<String>,
+ pub repository: String,
+}
+
+#[derive(Debug, Clone)]
+#[allow(dead_code)]
+pub struct InstalledPackage {
+ pub atom: PackageKey,
+
+ pub version: String,
+
+ pub build_time: DateTime<Utc>,
+
+ pub slot: i32,
+
+ pub metadata: Metadata,
+
+ pub enabled_use_flags: HashSet<String>,
+ pub iuse: Vec<UseFlag>,
+
pub size: usize,
}
-// #[allow(dead_code)]
-// impl Package {
-// pub fn new(
-// name: String,
-// version: String,
-// repository: String,
-// size: usize,
-// homepage: Option<Vec<String>>,
-// license: Option<String>,
-// description: Option<String>,
-// maintainer: Option<String>,
-// use_flags: HashSet<UseFlag>,
-// ) -> Self {
-// Package {
-// name,
-// use_flags,
-// version,
-// repository,
-// maintainer,
-// description,
-// homepage,
-// license,
-// size,
-// }
-// }
-// }
+#[derive(Debug)]
+pub struct Package {
+ pub atom: PackageKey,
+
+ pub versions: Vec<Version>,
+}
+
+#[derive(Debug, Clone)]
+pub struct Version {
+ pub version: String,
+
+ pub metadata: Metadata,
+
+ pub keywords: Vec<String>,
+ pub iuse: Vec<UseFlag>,
+}
diff --git a/src/gentoo/portage.rs b/src/gentoo/portage.rs
index bc9bb32..51dc60a 100644
--- a/src/gentoo/portage.rs
+++ b/src/gentoo/portage.rs
@@ -1,20 +1,26 @@
use std::{
- collections::HashSet,
- fs, io,
+ collections::{HashMap, HashSet},
+ fs,
path::{Path, PathBuf},
};
+use chrono::DateTime;
+use color_eyre::eyre::Context;
use quick_xml::{Reader, events::Event};
-use crate::gentoo::{Package, UseFlag};
+use crate::gentoo::{
+ InstalledPackage, UseFlag,
+ package::{Metadata, Package, PackageKey, Version},
+};
#[derive(Debug, Default)]
pub struct Portage {
- pub installed_packages: Vec<Package>,
- pub world_packages: Vec<String>,
+ pub installed_packages: Vec<InstalledPackage>,
+ pub world_packages: HashSet<PackageKey>,
+ pub available_packages: Vec<Package>,
}
-fn split_pkg(input: &str) -> (&str, Option<&str>) {
+fn split_pkg(input: &str) -> (&str, &str) {
let bytes = input.as_bytes();
for i in (0..bytes.len()).rev() {
@@ -24,14 +30,14 @@ fn split_pkg(input: &str) -> (&str, Option<&str>) {
{
let name = &input[..i];
let version = &input[i + 1..];
- return (name, Some(version));
+ return (name, version);
}
}
- (input, None)
+ (input, "")
}
-fn extract_maintainer(path: &Path) -> io::Result<Option<String>> {
+fn extract_maintainer(path: &Path) -> color_eyre::Result<Option<String>> {
let s = fs::read_to_string(path)?;
let mut reader = Reader::from_str(s.as_str());
reader.config_mut().trim_text(true);
@@ -69,32 +75,189 @@ fn extract_maintainer(path: &Path) -> io::Result<Option<String>> {
Ok(None)
}
+fn parse_cache_file(content: String) -> HashMap<String, String> {
+ content
+ .lines()
+ .filter_map(|line| {
+ line.split_once('=')
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ })
+ .collect()
+}
+
impl Portage {
pub fn new() -> Self {
Self {
installed_packages: vec![],
- world_packages: vec![],
+ world_packages: HashSet::new(),
+ available_packages: vec![],
}
}
- pub fn load_world_packages(&mut self) -> io::Result<()> {
+ pub fn load_world_packages() -> color_eyre::Result<HashSet<PackageKey>> {
let world = fs::read_to_string("/var/lib/portage/world")?;
- self.world_packages = world
+ Ok(world
.split_whitespace()
- .map(std::string::ToString::to_string)
- .collect();
+ .filter_map(PackageKey::from_atom)
+ .collect())
+ }
+
+ pub fn load_available_packages(&mut self) -> color_eyre::Result<()> {
+ let repos = fs::read_dir("/var/db/repos/").wrap_err("failed to read /var/db/repos")?;
+
+ let mut packages: HashMap<PackageKey, Package> = HashMap::new();
+
+ for repo in repos {
+ let Ok(repo) = repo else {
+ continue;
+ };
+ let repo_name = repo.file_name();
+ let Some(repo_name) = repo_name.to_str() else {
+ continue;
+ };
+
+ let Ok(categories) = fs::read_dir(repo.path().join("metadata/md5-cache")) else {
+ // TODO: Someday parse actual ebuilds instead of md5-cache
+ continue;
+ };
+
+ for category in categories {
+ let Ok(category) = category else {
+ continue;
+ };
+
+ let Ok(cat_name) = category.file_name().into_string() else {
+ continue;
+ };
+
+ if cat_name == "Manifest.gz" {
+ continue;
+ }
+
+ let pkgs = fs::read_dir(category.path())?;
+
+ for pkg in pkgs {
+ let Ok(pkg) = pkg else {
+ continue;
+ };
+
+ let Ok(pkg_name) = pkg.file_name().into_string() else {
+ continue;
+ };
+
+ if pkg_name == "Manifest.gz" {
+ continue;
+ }
+
+ let (pkg_name, pkg_version) = split_pkg(&pkg_name);
+
+ let version_file = fs::read_to_string(pkg.path()).wrap_err_with(|| {
+ format!(
+ "failed to read version_file for {}/{}-{} in repo {}",
+ cat_name, pkg_name, pkg_version, repo_name
+ )
+ })?;
+ let data = parse_cache_file(version_file);
+
+ let homepages: Vec<String> = data
+ .get("HOMEPAGE")
+ .and_then(|s| {
+ Some(
+ s.split_whitespace()
+ .map(std::string::ToString::to_string)
+ .collect(),
+ )
+ })
+ .unwrap_or(vec![]);
+
+ let keywords: Vec<String> = data
+ .get("KEYWORDS")
+ .and_then(|s| {
+ Some(
+ s.split_whitespace()
+ .map(std::string::ToString::to_string)
+ .collect(),
+ )
+ })
+ .unwrap_or(vec![]);
+
+ let iuse: Vec<UseFlag> = data
+ .get("IUSE")
+ .and_then(|s| {
+ Some(
+ s.split_whitespace()
+ .map(|original_iuse| {
+ let iuse = original_iuse
+ .strip_prefix("+")
+ .unwrap_or(original_iuse);
+
+ UseFlag {
+ name: iuse.to_string(),
+ default: iuse != original_iuse,
+ }
+ })
+ .collect(),
+ )
+ })
+ .unwrap_or(vec![]);
+
+ let repo_path = PathBuf::from("/var/db/repos/")
+ .join(&repo_name)
+ .join(&cat_name)
+ .join(pkg_name)
+ .join("metadata.xml");
+
+ let maintainer = extract_maintainer(&repo_path).unwrap_or(None);
+
+ let version = Version {
+ version: pkg_version.to_string(),
+ metadata: Metadata {
+ maintainer: maintainer,
+ description: data.get("DESCRIPTION").cloned(),
+ homepage: homepages,
+ license: data.get("LICENSE").cloned(),
+ repository: repo_name.to_string(),
+ },
+ keywords: keywords,
+ iuse: iuse,
+ };
+
+ let pkg_key = PackageKey {
+ category: cat_name.clone(),
+ name: pkg_name.to_string(),
+ };
+
+ if let Some(package) = packages.get_mut(&pkg_key) {
+ package.versions.push(version);
+ } else {
+ let pkg = Package {
+ atom: pkg_key.clone(),
+ versions: vec![version],
+ };
+ packages.insert(pkg_key, pkg);
+ }
+ }
+ }
+ }
+
+ self.available_packages = packages.into_values().collect();
Ok(())
}
- pub fn load_installed_packages(&mut self) -> io::Result<()> {
+ pub fn load_installed_packages() -> color_eyre::Result<Vec<InstalledPackage>> {
let categories = fs::read_dir("/var/db/pkg")?;
+ let mut v = vec![];
+
for category in categories {
let Ok(category) = category else {
continue;
};
- let cat_name = category.file_name();
+ let Ok(cat_name) = category.file_name().into_string() else {
+ continue;
+ };
+
let pkgs = fs::read_dir(category.path())?;
for pkg in pkgs {
let Ok(pkg) = pkg else {
@@ -109,37 +272,36 @@ impl Portage {
let (pkg_name, pkg_version) = split_pkg(pkg_name);
let use_flags_file = fs::read_to_string(pkg.path().join("USE"))?;
- let use_flags: HashSet<&str> = use_flags_file.trim().split(' ').collect();
+ let enabled_use_flags: HashSet<String> = use_flags_file
+ .split_whitespace()
+ .map(|s| s.to_string())
+ .collect();
let iuse_flags_file = fs::read_to_string(pkg.path().join("IUSE"))?;
- let iuse_flags: Vec<&str> = iuse_flags_file.trim().split(' ').collect();
- let use_flags: HashSet<UseFlag> = iuse_flags
- .iter()
- .filter(|x| !x.trim().is_empty())
- .map(|&original_iuse| {
+ let use_flags: Vec<UseFlag> = iuse_flags_file
+ .split_whitespace()
+ .map(|original_iuse| {
let iuse = original_iuse.strip_prefix("+").unwrap_or(original_iuse);
- let is_default = iuse != original_iuse;
-
- let is_active = use_flags.contains(&iuse);
- UseFlag::new(iuse.into(), is_active, is_default)
+ UseFlag {
+ name: iuse.to_string(),
+ default: iuse != original_iuse,
+ }
})
.collect();
let repository = fs::read_to_string(pkg.path().join("repository"))?
.trim()
.to_string();
- let homepage: Option<Vec<String>> =
- match fs::read_to_string(pkg.path().join("HOMEPAGE")) {
- Ok(homepage) => Some(
- homepage
- .split_whitespace()
- .map(std::string::ToString::to_string)
- .collect(),
- ),
- Err(_) => None,
- };
+
+ let homepage: Vec<String> = match fs::read_to_string(pkg.path().join("HOMEPAGE")) {
+ Ok(homepage) => homepage
+ .split_whitespace()
+ .map(std::string::ToString::to_string)
+ .collect(),
+ Err(_) => vec![],
+ };
let license: Option<String> = match fs::read_to_string(pkg.path().join("LICENSE")) {
Ok(license) => Some(license.trim().to_string()),
@@ -152,33 +314,50 @@ impl Portage {
Err(_) => None,
};
- let size: usize = match fs::read_to_string(pkg.path().join("SIZE")) {
- Ok(size) => size.trim().parse().unwrap_or(0),
- Err(_) => 0,
- };
+ let size: usize = fs::read_to_string(pkg.path().join("SIZE"))?
+ .trim()
+ .parse()
+ .unwrap_or(0);
let repo_path = PathBuf::from("/var/db/repos/")
.join(&repository)
- .join(cat_name.to_str().unwrap_or_default())
+ .join(&cat_name)
.join(pkg_name)
.join("metadata.xml");
+ let build_time = fs::read_to_string(pkg.path().join("BUILD_TIME"))?
+ .trim()
+ .parse::<i64>()
+ .ok()
+ .and_then(DateTime::from_timestamp_secs)
+ .unwrap_or(DateTime::from_timestamp_nanos(0));
+
let maintainer = extract_maintainer(&repo_path)?;
- self.installed_packages.push(Package {
- name: format!("{}/{}", cat_name.to_str().unwrap_or_default(), pkg_name),
- use_flags,
- version: pkg_version.unwrap_or_default().into(),
- repository,
- maintainer,
- description,
- homepage,
- license,
+ let atom = PackageKey {
+ category: cat_name.clone(),
+ name: pkg_name.to_string(),
+ };
+
+ v.push(InstalledPackage {
+ atom: atom,
+ version: pkg_version.to_string(),
+ build_time,
+ slot: 0,
+ metadata: Metadata {
+ maintainer,
+ description,
+ homepage: homepage,
+ license,
+ repository: repository,
+ },
+ enabled_use_flags: enabled_use_flags,
+ iuse: use_flags,
size,
});
}
}
- Ok(())
+ Ok(v)
}
}
diff --git a/src/gentoo/useflags.rs b/src/gentoo/useflags.rs
index 1e83b32..2bcd678 100644
--- a/src/gentoo/useflags.rs
+++ b/src/gentoo/useflags.rs
@@ -1,16 +1,5 @@
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct UseFlag {
pub name: String,
- pub enabled: bool,
pub default: bool,
}
-
-impl UseFlag {
- pub fn new(name: String, enabled: bool, default: bool) -> Self {
- UseFlag {
- name,
- enabled,
- default,
- }
- }
-}
diff --git a/src/main.rs b/src/main.rs
index 56016d3..ae3ff96 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -38,8 +38,10 @@ fn main() -> color_eyre::Result<()> {
let mut tui = init_tui()?;
let mut p = Portage::new();
- p.load_world_packages()?;
- p.load_installed_packages()?;
+ p.world_packages = Portage::load_world_packages()?;
+
+ p.installed_packages = Portage::load_installed_packages()?;
+ p.load_available_packages()?;
let mut app = App::new(p);
diff --git a/src/ui.rs b/src/ui.rs
index 46615b0..fa515ce 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -12,7 +12,7 @@ use ratatui::{
use crate::{
app::{App, ViewState},
- gentoo::Package,
+ gentoo::InstalledPackage,
theme::Theme,
};
@@ -27,6 +27,9 @@ pub fn ui(frame: &mut Frame, app: &mut App) {
match app.view {
crate::app::ViewState::Dashboard => render_dashboard(frame, rest, app),
crate::app::ViewState::InstalledPackages => render_installed_packages(frame, rest, app),
+ crate::app::ViewState::AvailablePackages => {
+ frame.render_widget(Text::from("NOT IMPLEMENTED"), rest)
+ }
}
}
@@ -34,9 +37,10 @@ fn render_tab(frame: &mut Frame, area: Rect, selected_tab: &ViewState) {
let selected_index = match selected_tab {
ViewState::Dashboard => 0,
ViewState::InstalledPackages => 1,
+ ViewState::AvailablePackages => 2,
};
- let tabs = Tabs::new(vec!["Dashboard", "Installed", "Search"])
+ let tabs = Tabs::new(vec!["Dashboard", "Installed", "Browse"])
.block(Block::default().borders(Borders::ALL).style(Theme::block()))
.highlight_style(Theme::success())
.select(selected_index)
@@ -58,7 +62,7 @@ fn render_dashboard(frame: &mut Frame, area: Rect, app: &App) {
let constraints = [
Constraint::Length(9),
- Constraint::Percentage(30),
+ Constraint::Min(15),
Constraint::Fill(1),
Constraint::Length(3),
];
@@ -254,7 +258,7 @@ fn render_package_metadata(frame: &mut Frame, area: Rect, app: &App) {
let bold_style = Theme::title(); //Style::default().add_modifier(Modifier::BOLD);
let maintainer = Line::from_iter([
Span::styled("Maintainer: ", bold_style),
- Span::raw(pkg.maintainer.as_deref().unwrap_or("Unknown")),
+ Span::raw(pkg.metadata.maintainer.as_deref().unwrap_or("Unknown")),
]);
let version = Line::from_iter([
@@ -264,17 +268,17 @@ fn render_package_metadata(frame: &mut Frame, area: Rect, app: &App) {
let repository = Line::from_iter([
Span::styled("Repository: ", bold_style),
- Span::raw(&pkg.repository),
+ Span::raw(&pkg.metadata.repository),
]);
let license = Line::from_iter([
Span::styled("License: ", bold_style),
- Span::raw(pkg.license.as_deref().unwrap_or("Unknown")),
+ Span::raw(pkg.metadata.license.as_deref().unwrap_or("Unknown")),
]);
let description = Line::from_iter([
Span::styled("Description: ", bold_style),
- Span::raw(pkg.description.as_deref().unwrap_or("Unknown")),
+ Span::raw(pkg.metadata.description.as_deref().unwrap_or("Unknown")),
]);
let size = Line::from_iter([
@@ -282,7 +286,20 @@ fn render_package_metadata(frame: &mut Frame, area: Rect, app: &App) {
Span::raw(human_size(pkg.size)),
]);
- let mut lines = vec![maintainer, version, repository, license, description, size];
+ let build_time = Line::from_iter([
+ Span::styled("Build time: ", bold_style),
+ Span::raw(pkg.build_time.to_string()),
+ ]);
+
+ let mut lines = vec![
+ maintainer,
+ version,
+ repository,
+ build_time,
+ license,
+ description,
+ size,
+ ];
let mut homepage = homepage_lines(pkg, bold_style);
lines.append(&mut homepage);
@@ -299,29 +316,22 @@ fn render_package_metadata(frame: &mut Frame, area: Rect, app: &App) {
frame.render_widget(paragraph, area);
}
-fn homepage_lines(pkg: &Package, bold_style: Style) -> Vec<Line<'_>> {
+fn homepage_lines(pkg: &InstalledPackage, bold_style: Style) -> Vec<Line<'_>> {
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled("Homepage: ", bold_style),
Span::raw(""),
]));
- if let Some(homepages) = &pkg.homepage {
- for url in homepages.iter().filter(|s| !s.is_empty()) {
- lines.push(Line::from(vec![
- Span::raw(" - "), // indentation
- Span::styled(
- url,
- Style::default()
- .fg(Color::LightBlue)
- .add_modifier(Modifier::UNDERLINED),
- ),
- ]));
- }
- } else {
+ for url in pkg.metadata.homepage.iter().filter(|s| !s.is_empty()) {
lines.push(Line::from(vec![
- Span::raw(" "),
- Span::styled("Unknown", Style::default().fg(Color::DarkGray)),
+ Span::raw(" - "), // indentation
+ Span::styled(
+ url,
+ Style::default()
+ .fg(Color::LightBlue)
+ .add_modifier(Modifier::UNDERLINED),
+ ),
]));
}
@@ -350,7 +360,7 @@ fn render_packages(frame: &mut Frame, area: Rect, app: &mut App) {
let items: Vec<String> = app
.installed_packages()
.iter()
- .map(|x| x.name.clone())
+ .map(|x| x.atom.qualified_name())
.collect();
let list = List::new(items)
@@ -370,8 +380,8 @@ fn render_packages(frame: &mut Frame, area: Rect, app: &mut App) {
fn render_use_flags(frame: &mut Frame, area: Rect, app: &App) {
let selected_package = app.current_package();
- let items = selected_package.use_flags.iter().map(|x| {
- let text_style = if x.enabled {
+ let items = selected_package.iuse.iter().map(|x| {
+ let text_style = if selected_package.enabled_use_flags.contains(&x.name) {
Theme::success()
} else if x.default {
Theme::info()