feat(systemd): convert systemd provider from C-ABI to native Provider impl

First of 7 plugin conversions (task #6). Establishes the conversion
pattern: drop extern "C" vtable + PluginItem + opaque ProviderHandle
in favor of a regular struct that impls the Provider trait. Submenu
support comes via the new submenu_actions() trait method instead of
the old '?SUBMENU:' string-encoded query convention.

- providers/systemd.rs: new module, type_id 'uuctl' (CLI back-compat)
- Provider impl with prefix(:uuctl), icon(system-run), tab_label(Units),
  search_noun(systemd units), and submenu_actions for service controls
- ProviderManager::new_with_config registers it when config and feature both enabled
- config.providers.systemd added (alias 'uuctl' for back-compat)
- cargo feature 'systemd' (in default and full feature sets)
- 8 unit tests: parse_systemctl_output, provider_type, submenu for
  active/inactive/empty data, terminal flag on status/journal,
  clean_display_name edge cases

Issue #5 fixed locally — 'owlry -m uuctl' will return systemd units
once the binary is rebuilt and installed. 186 tests pass.
This commit is contained in:
2026-05-13 02:10:09 +02:00
parent 0a4a09037e
commit eb8a65f1fd
4 changed files with 390 additions and 3 deletions
+8 -1
View File
@@ -71,6 +71,13 @@ glib-build-tools = "0.20"
tempfile = "3"
[features]
default = []
default = ["systemd"]
# Enable verbose debug logging (for development/testing builds)
dev-logging = []
# Optional providers (compiled in when enabled).
# The AUR PKGBUILD builds with --features "full" to ship everything.
systemd = []
full = ["systemd"]
+4
View File
@@ -171,6 +171,9 @@ pub struct ProvidersConfig {
/// Enable built-in system actions (shutdown, reboot, lock, etc.)
#[serde(default = "default_true")]
pub system: bool,
/// Enable systemd user units provider (alias: uuctl)
#[serde(default = "default_true", alias = "uuctl")]
pub systemd: bool,
/// Enable frecency-based result ranking
#[serde(default = "default_true")]
pub frecency: bool,
@@ -192,6 +195,7 @@ impl Default for ProvidersConfig {
calculator: true,
converter: true,
system: true,
systemd: true,
frecency: true,
frecency_weight: 0.3,
search_engine: "duckduckgo".to_string(),
+14 -2
View File
@@ -6,6 +6,10 @@ pub(crate) mod calculator;
pub(crate) mod converter;
pub(crate) mod system;
// Optional feature-gated providers
#[cfg(feature = "systemd")]
pub(crate) mod systemd;
// Re-exports for core providers
pub use application::ApplicationProvider;
pub use command::CommandProvider;
@@ -239,17 +243,19 @@ impl ProviderManager {
/// Only built-in / compiled-in providers are registered here. Future Lua-defined
/// providers (Phase 3+) are added via [`Self::add_provider`] after construction.
pub fn new_with_config(config: Arc<RwLock<Config>>) -> Self {
let (calc_enabled, conv_enabled, sys_enabled) = match config.read() {
let (calc_enabled, conv_enabled, sys_enabled, systemd_enabled) = match config.read() {
Ok(cfg) => (
cfg.providers.calculator,
cfg.providers.converter,
cfg.providers.system,
cfg.providers.systemd,
),
Err(_) => {
log::warn!("Config lock poisoned during provider init; using defaults");
(true, true, true)
(true, true, true, true)
}
};
let _ = systemd_enabled; // referenced only behind cfg(feature)
let mut core_providers: Vec<Box<dyn Provider>> = vec![
Box::new(ApplicationProvider::new()),
@@ -261,6 +267,12 @@ impl ProviderManager {
info!("Registered built-in system provider");
}
#[cfg(feature = "systemd")]
if systemd_enabled {
core_providers.push(Box::new(systemd::SystemdProvider::new()));
info!("Registered systemd provider (type_id: uuctl)");
}
let mut builtin_dynamic: Vec<Box<dyn DynamicProvider>> = Vec::new();
if calc_enabled {
builtin_dynamic.push(Box::new(calculator::CalculatorProvider));
+364
View File
@@ -0,0 +1,364 @@
//! systemd user services provider.
//!
//! Lists user-level systemd services and exposes start/stop/restart/enable/
//! disable/status/journal as submenu actions. type_id stays `uuctl` for CLI
//! and config back-compat.
use super::{ItemSource, LaunchItem, Provider, ProviderType};
use std::process::Command;
const TYPE_ID: &str = "uuctl";
pub struct SystemdProvider {
items: Vec<LaunchItem>,
}
impl Default for SystemdProvider {
fn default() -> Self {
Self::new()
}
}
impl SystemdProvider {
pub fn new() -> Self {
Self { items: Vec::new() }
}
fn systemctl_available() -> bool {
Command::new("systemctl")
.args(["--user", "--version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_systemctl_output(output: &str) -> Vec<LaunchItem> {
let mut items = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let unit_name = match parts.next() {
Some(u) => u,
None => continue,
};
if !unit_name.ends_with(".service") {
continue;
}
let _load_state = parts.next().unwrap_or("");
let active_state = parts.next().unwrap_or("");
let sub_state = parts.next().unwrap_or("");
let description: String = parts.collect::<Vec<_>>().join(" ");
let display_name = clean_display_name(unit_name);
let is_active = active_state == "active";
let status_icon = if is_active { "" } else { "" };
let status_desc = if description.is_empty() {
format!("{} {} ({})", status_icon, sub_state, active_state)
} else {
format!("{} {} ({})", status_icon, description, sub_state)
};
// SUBMENU:<type_id>:<data> is the protocol the UI uses to route
// a selection back to this provider's submenu_actions().
let submenu_data = format!("SUBMENU:{}:{}:{}", TYPE_ID, unit_name, is_active);
let icon = if is_active {
"emblem-ok-symbolic"
} else {
"emblem-pause-symbolic"
};
items.push(LaunchItem {
id: format!("systemd:service:{}", unit_name),
name: display_name,
description: Some(status_desc),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command: submenu_data,
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
source: ItemSource::Core,
});
}
items
}
}
impl Provider for SystemdProvider {
fn name(&self) -> &str {
"User Units"
}
fn provider_type(&self) -> ProviderType {
ProviderType::Plugin(TYPE_ID.into())
}
fn refresh(&mut self) {
self.items.clear();
if !Self::systemctl_available() {
return;
}
let output = match Command::new("systemctl")
.args([
"--user",
"list-units",
"--type=service",
"--all",
"--no-legend",
"--no-pager",
])
.output()
{
Ok(o) if o.status.success() => o,
_ => return,
};
let stdout = String::from_utf8_lossy(&output.stdout);
self.items = Self::parse_systemctl_output(&stdout);
self.items
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
fn items(&self) -> &[LaunchItem] {
&self.items
}
fn prefix(&self) -> Option<&str> {
Some(":uuctl")
}
fn icon(&self) -> &str {
"system-run"
}
fn tab_label(&self) -> Option<&str> {
Some("Units")
}
fn search_noun(&self) -> Option<&str> {
Some("systemd units")
}
/// Submenu data is `<unit_name>:<is_active>` (encoded by `refresh()`).
fn submenu_actions(&self, data: &str) -> Vec<LaunchItem> {
let parts: Vec<&str> = data.splitn(2, ':').collect();
let (unit_name, is_active) = match parts.as_slice() {
[unit, active] if !unit.is_empty() => (*unit, *active == "true"),
[unit] if !unit.is_empty() => (*unit, false),
_ => return Vec::new(),
};
let display = clean_display_name(unit_name);
actions_for_service(unit_name, &display, is_active)
}
}
fn clean_display_name(unit_name: &str) -> String {
unit_name
.trim_end_matches(".service")
.replace("app-", "")
.replace("@autostart", "")
.replace("\\x2d", "-")
}
fn make_action(id: &str, name: &str, command: String, desc: String, icon: &str) -> LaunchItem {
LaunchItem {
id: id.to_string(),
name: name.to_string(),
description: Some(desc),
icon: Some(icon.to_string()),
provider: ProviderType::Plugin(TYPE_ID.into()),
command,
terminal: false,
tags: vec!["systemd".to_string(), "service".to_string()],
source: ItemSource::Core,
}
}
fn actions_for_service(unit: &str, display: &str, is_active: bool) -> Vec<LaunchItem> {
let mut actions = Vec::new();
if is_active {
actions.push(make_action(
&format!("systemd:restart:{}", unit),
"↻ Restart",
format!("systemctl --user restart {}", unit),
format!("Restart {}", display),
"view-refresh",
));
actions.push(make_action(
&format!("systemd:stop:{}", unit),
"■ Stop",
format!("systemctl --user stop {}", unit),
format!("Stop {}", display),
"process-stop",
));
actions.push(make_action(
&format!("systemd:reload:{}", unit),
"⟳ Reload",
format!("systemctl --user reload {}", unit),
format!("Reload {} configuration", display),
"view-refresh",
));
actions.push(make_action(
&format!("systemd:kill:{}", unit),
"✗ Kill",
format!("systemctl --user kill {}", unit),
format!("Force kill {}", display),
"edit-delete",
));
} else {
actions.push(make_action(
&format!("systemd:start:{}", unit),
"▶ Start",
format!("systemctl --user start {}", unit),
format!("Start {}", display),
"media-playback-start",
));
}
// Always-available actions. Status and Journal need a terminal.
let mut status = make_action(
&format!("systemd:status:{}", unit),
" Status",
format!("systemctl --user status {}", unit),
format!("Show {} status", display),
"dialog-information",
);
status.terminal = true;
actions.push(status);
let mut journal = make_action(
&format!("systemd:journal:{}", unit),
"📋 Journal",
format!("journalctl --user -u {} -f", unit),
format!("Show {} logs", display),
"utilities-system-monitor",
);
journal.terminal = true;
actions.push(journal);
actions.push(make_action(
&format!("systemd:enable:{}", unit),
"⊕ Enable",
format!("systemctl --user enable {}", unit),
format!("Enable {} on startup", display),
"emblem-default",
));
actions.push(make_action(
&format!("systemd:disable:{}", unit),
"⊖ Disable",
format!("systemctl --user disable {}", unit),
format!("Disable {} on startup", display),
"emblem-unreadable",
));
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_systemctl_output_extracts_services_with_submenu_command() {
let output = "
foo.service loaded active running Foo Service
bar.service loaded inactive dead Bar Service
baz@autostart.service loaded active running Baz App
";
let items = SystemdProvider::parse_systemctl_output(output);
assert_eq!(items.len(), 3);
// Active service: SUBMENU command with is_active=true.
assert_eq!(items[0].name, "foo");
assert!(items[0].command.contains("SUBMENU:uuctl:foo.service:true"));
// Inactive service: SUBMENU command with is_active=false.
assert_eq!(items[1].name, "bar");
assert!(items[1].command.contains("SUBMENU:uuctl:bar.service:false"));
// Display name cleaning strips @autostart suffix.
assert_eq!(items[2].name, "baz");
}
#[test]
fn parse_systemctl_output_skips_non_service_lines() {
let output = "
foo.service loaded active running Foo
bar.socket loaded active listening Bar
quux.timer loaded active waiting Quux
baz.service loaded inactive dead Baz
";
let items = SystemdProvider::parse_systemctl_output(output);
assert_eq!(items.len(), 2);
assert_eq!(items[0].name, "foo");
assert_eq!(items[1].name, "baz");
}
#[test]
fn provider_type_is_uuctl_plugin() {
// CLI back-compat: `-m uuctl` and `:uuctl` both resolve to this type_id.
let p = SystemdProvider::new();
assert_eq!(p.provider_type(), ProviderType::Plugin("uuctl".into()));
}
#[test]
fn submenu_actions_for_active_service_includes_restart_stop_not_start() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("nginx.service:true");
let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"systemd:restart:nginx.service"));
assert!(ids.contains(&"systemd:stop:nginx.service"));
assert!(ids.contains(&"systemd:status:nginx.service"));
assert!(!ids.contains(&"systemd:start:nginx.service"));
}
#[test]
fn submenu_actions_for_inactive_service_includes_start_not_stop() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("nginx.service:false");
let ids: Vec<&str> = actions.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"systemd:start:nginx.service"));
assert!(ids.contains(&"systemd:status:nginx.service"));
assert!(!ids.contains(&"systemd:stop:nginx.service"));
}
#[test]
fn submenu_actions_returns_empty_for_garbage_data() {
let p = SystemdProvider::new();
assert!(p.submenu_actions("").is_empty());
}
#[test]
fn submenu_actions_terminal_flag_set_on_status_and_journal() {
let p = SystemdProvider::new();
let actions = p.submenu_actions("test.service:true");
for action in &actions {
if action.id.contains(":status:") || action.id.contains(":journal:") {
assert!(action.terminal, "{} must have terminal=true", action.id);
}
}
}
#[test]
fn clean_display_name_strips_service_suffix_and_known_prefixes() {
assert_eq!(clean_display_name("foo.service"), "foo");
assert_eq!(clean_display_name("app-firefox.service"), "firefox");
assert_eq!(clean_display_name("bar@autostart.service"), "bar");
// The hex-escaped dash that systemd uses for some unit names.
assert_eq!(clean_display_name("foo\\x2dbar.service"), "foo-bar");
}
}