From d5f64f6aa8388354f27366b9467ec54a72d78b93 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 01/65] Keep active history limit independent Stop clamping the active notification limit to the saved history retention limit. This lets users disable saved history while still allowing active notifications, and updates the sanitizer regression test to lock that behavior in. --- .../src/config/runtime/sanitize/pipeline.rs | 1 - crates/unixnotis-core/src/config/tests/runtime/layout.rs | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs index 1b086807..250bed59 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs @@ -110,7 +110,6 @@ fn sanitize_history(config: &mut Config) { // Active notifications are bounded tighter than history to protect panel layout and memory config.history.max_active = config.history.max_active.min(MAX_HISTORY_ACTIVE); config.history.max_entries = config.history.max_entries.min(MAX_HISTORY_ENTRIES); - config.history.max_active = config.history.max_active.min(config.history.max_entries); } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/tests/runtime/layout.rs b/crates/unixnotis-core/src/config/tests/runtime/layout.rs index 6eb62ec9..212b7388 100644 --- a/crates/unixnotis-core/src/config/tests/runtime/layout.rs +++ b/crates/unixnotis-core/src/config/tests/runtime/layout.rs @@ -220,16 +220,15 @@ fn sanitize_clamps_history_limits() { } #[test] -fn sanitize_keeps_active_history_within_total_history() { - // Active rows should never outgrow the total history budget +fn sanitize_keeps_active_limit_independent_from_history_retention() { let mut config = Config::default(); config.history.max_active = 12; - config.history.max_entries = 1; + config.history.max_entries = 0; sanitize_config(&mut config); - assert_eq!(config.history.max_entries, 1); - assert_eq!(config.history.max_active, 1); + assert_eq!(config.history.max_entries, 0); + assert_eq!(config.history.max_active, 12); } #[test] From 6de6a1f1e3f48032d24fd85b30a2da347a8e6f6b Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 02/65] Preserve inline notification markup adjacency Treat inline markup as formatting instead of forced word separation when rendering notification views. The new tests cover adjacent inline tags and mixed inline/block spacing so bodies do not gain artificial spaces. --- .../unixnotis-core/src/model/notification.rs | 3 --- .../src/model/tests/notification.rs | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 0fceda9a..a09b38f4 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -160,9 +160,6 @@ fn push_tag_spacing(output: &mut String, tag: &str) { if matches!(tag_name.as_str(), "br" | "p" | "div" | "li" | "tr") { // These tags normally separate chunks of text output.push('\n'); - } else { - // Inline tags still need a small break so words do not run together - output.push(' '); } } diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index 4858d250..c9ab0f5a 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -121,6 +121,26 @@ fn notification_view_treats_self_closing_break_as_newline() { assert_eq!(view.body, "Line one\nLine two"); } +#[test] +fn notification_view_preserves_inline_markup_adjacency() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "foobar and baz".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "foobar and baz"); +} + +#[test] +fn notification_view_collapses_inline_spaces_without_leaking_after_blocks() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Alpha Beta
Gamma".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Alpha Beta\nGamma"); +} + #[test] fn notification_view_collapses_repeated_block_tag_newlines() { let mut notification = notification_with_image(image_with_raw_bytes()); From 8229069d018620356b018eae35e592ca68f51b99 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 03/65] Bound notification image metadata Normalize and bound image paths and icon names derived from untrusted notification hints. This adds UTF-8-safe truncation, localhost file URI normalization, remote file URI rejection for app icons, and regression coverage for each behavior. --- crates/unixnotis-core/src/model/image.rs | 2 + .../unixnotis-core/src/model/image/hints.rs | 101 ++++++++++++++++-- .../src/model/tests/image/hints.rs | 50 ++++++++- 3 files changed, 143 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-core/src/model/image.rs b/crates/unixnotis-core/src/model/image.rs index b3ab3ebc..d92c2f71 100644 --- a/crates/unixnotis-core/src/model/image.rs +++ b/crates/unixnotis-core/src/model/image.rs @@ -40,6 +40,8 @@ pub struct NotificationImage { // Bound untrusted image payloads to keep daemon/UI memory predictable under floods pub(super) const MAX_IMAGE_BYTES: usize = 256 * 1024; pub(super) const MAX_IMAGE_DIMENSION: i32 = 256; +pub(super) const MAX_IMAGE_PATH_BYTES: usize = 1024; +pub(super) const MAX_ICON_NAME_BYTES: usize = 256; #[cfg(test)] use hints::{owned_to_string, strip_desktop_suffix}; diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 952678df..80fa7d8d 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -4,7 +4,11 @@ use std::collections::HashMap; use zbus::zvariant::{Array, OwnedValue, Structure, Value}; -use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; +use crate::util; + +use super::{ + ImageData, NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_PATH_BYTES, +}; impl NotificationImage { pub fn from_hints(app_name: &str, app_icon: &str, hints: &HashMap) -> Self { @@ -20,6 +24,7 @@ impl NotificationImage { .get("image-path") .and_then(owned_to_string) .or_else(|| hints.get("image_path").and_then(owned_to_string)) + .map(|path| normalize_image_path(&path)) .unwrap_or_default(); // Desktop-entry values map to icon theme names after the suffix is removed @@ -27,18 +32,18 @@ impl NotificationImage { .get("desktop-entry") .and_then(owned_to_string) .map(|entry| strip_desktop_suffix(&entry)); - let app_icon_path = if app_icon.starts_with('/') || app_icon.starts_with("file://") { - Some(app_icon.to_string()) - } else { - None - }; + let app_icon_path = normalize_app_icon_path(app_icon); if image_path.is_empty() { if let Some(path) = app_icon_path.as_ref() { image_path = path.clone(); } } - let icon_name = - resolve_icon_name(app_name, app_icon, app_icon_path.as_ref(), desktop_entry); + let icon_name = bound_icon_name(&resolve_icon_name( + app_name, + app_icon, + app_icon_path.as_ref(), + desktop_entry, + )); Self { has_image_data: image_data.is_some(), @@ -99,7 +104,7 @@ fn resolve_icon_name( if app_icon_path.is_some() { return String::new(); } - if !app_icon.is_empty() { + if !app_icon.is_empty() && !app_icon.starts_with("file://") { return strip_desktop_suffix(app_icon); } if let Some(desktop_entry) = desktop_entry { @@ -111,7 +116,85 @@ fn resolve_icon_name( String::new() } +fn normalize_app_icon_path(app_icon: &str) -> Option { + // Normalize the incoming icon path first so later checks operate on a cleaned, + // bounded value rather than raw metadata input. + let path = normalize_image_path(app_icon); + + // Only accept paths that are already absolute filesystem paths or valid file URIs. + // Relative paths are rejected because app icons need to resolve unambiguously. + if path.starts_with('/') || path.starts_with("file://") { + Some(path) + } else { + None + } +} + +fn normalize_image_path(value: &str) -> String { + // Sanitize display-facing metadata and enforce the maximum byte length before + // doing any URI-specific normalization. + let bounded = sanitize_metadata_string(value, MAX_IMAGE_PATH_BYTES); + + // File URIs get normalized into the accepted form when possible. Invalid or + // unsupported file URI shapes fall back to an empty string. + if bounded.starts_with("file://") { + return normalize_file_uri(&bounded).unwrap_or_default(); + } + + // Non-file URI values are returned after sanitization/truncation only. + bounded +} + +fn normalize_file_uri(value: &str) -> Option { + // This function only handles file:// URIs; anything else is rejected immediately. + let stripped = value.strip_prefix("file://")?; + + // A file URI with an absolute path is already in the expected form. + if stripped.starts_with('/') { + return Some(value.to_string()); + } + + // Convert localhost-based file URIs into the canonical absolute-path form. + stripped + .strip_prefix("localhost/") + .map(|path| format!("file:///{path}")) +} + +fn bound_icon_name(value: &str) -> String { + // Icon names use the same metadata sanitization path, but with the icon-name + // byte limit instead of the image-path byte limit. + sanitize_metadata_string(value, MAX_ICON_NAME_BYTES) +} + +fn sanitize_metadata_string(value: &str, max_bytes: usize) -> String { + // Remove inline display control/problematic characters before trimming and + // applying the final UTF-8-safe byte limit. + let cleaned = util::sanitize_inline_display_text(value); + truncate_utf8_bytes(cleaned.trim(), max_bytes) +} + +fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + // Fast path: avoid allocation/truncation work when the value already fits. + if value.len() <= max_bytes { + return value.to_string(); + } + + // Find the last valid UTF-8 character boundary that does not exceed max_bytes, + // so slicing never cuts through the middle of a multi-byte character. + let end = value + .char_indices() + .map(|(index, _)| index) + .take_while(|index| *index <= max_bytes) + .last() + .unwrap_or(0); + + // Return only the byte-safe prefix. + value[..end].to_string() +} + pub(super) fn owned_to_string(value: &OwnedValue) -> Option { + // Clone the owned D-Bus value first, then attempt to extract it as a String. + // Any clone or conversion failure is represented as None. value .try_clone() .ok() diff --git a/crates/unixnotis-core/src/model/tests/image/hints.rs b/crates/unixnotis-core/src/model/tests/image/hints.rs index 891e6298..b64f3f74 100644 --- a/crates/unixnotis-core/src/model/tests/image/hints.rs +++ b/crates/unixnotis-core/src/model/tests/image/hints.rs @@ -1,4 +1,7 @@ -use super::super::{owned_to_string, strip_desktop_suffix, NotificationImage}; +use super::super::{ + owned_to_string, strip_desktop_suffix, NotificationImage, MAX_ICON_NAME_BYTES, + MAX_IMAGE_PATH_BYTES, +}; use super::{image_data_value, string_value}; use std::collections::HashMap; use zbus::zvariant::{OwnedValue, Structure, Value}; @@ -59,6 +62,51 @@ fn from_hints_uses_app_name_when_no_icon_hints_exist() { assert!(!image.has_image_data); } +#[test] +fn from_hints_bounds_image_path_and_icon_name_without_splitting_utf8() { + let mut hints = HashMap::new(); + let long_path = format!("/tmp/{}{}", "a".repeat(MAX_IMAGE_PATH_BYTES), "é"); + let long_icon = format!("{}{}", "b".repeat(MAX_ICON_NAME_BYTES), "é"); + hints.insert("image-path".to_string(), string_value(&long_path)); + + let image = NotificationImage::from_hints("App", &long_icon, &hints); + + assert!(image.image_path.len() <= MAX_IMAGE_PATH_BYTES); + assert!(image.image_path.is_char_boundary(image.image_path.len())); + assert!(image.icon_name.len() <= MAX_ICON_NAME_BYTES); + assert!(image.icon_name.is_char_boundary(image.icon_name.len())); +} + +#[test] +fn from_hints_truncates_image_path_at_previous_utf8_boundary() { + let mut hints = HashMap::new(); + let prefix = format!("/{}", "a".repeat(MAX_IMAGE_PATH_BYTES - 2)); + hints.insert( + "image-path".to_string(), + string_value(&format!("{prefix}é-tail")), + ); + + let image = NotificationImage::from_hints("App", "", &hints); + + assert_eq!(image.image_path, prefix); + assert_eq!(image.image_path.len(), MAX_IMAGE_PATH_BYTES - 1); + assert!(image.image_path.is_char_boundary(image.image_path.len())); +} + +#[test] +fn from_hints_normalizes_localhost_file_uri_and_ignores_remote_file_uri_path() { + let mut hints = HashMap::new(); + hints.insert( + "image-path".to_string(), + string_value("file://localhost/tmp/icon%20name.png"), + ); + + let image = NotificationImage::from_hints("App", "file://example.com/tmp/app.png", &hints); + + assert_eq!(image.image_path, "file:///tmp/icon%20name.png"); + assert_eq!(image.icon_name, "App"); +} + #[test] fn parse_image_data_accepts_legacy_hint_aliases_and_rejects_wrong_field_count() { let parsed = NotificationImage::parse_image_data(&image_data_value( From d1aaded1be1071e33aa06e9fcef3c549aa9491ce Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 04/65] Expose split clear commands on control proxy Add clear_active and clear_history to the generated control proxy contract alongside clear_all. This keeps client tooling aligned with the daemon methods that distinguish active rows from saved history. --- crates/unixnotis-core/src/control.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/unixnotis-core/src/control.rs b/crates/unixnotis-core/src/control.rs index 8eee3c45..4b47d3fe 100644 --- a/crates/unixnotis-core/src/control.rs +++ b/crates/unixnotis-core/src/control.rs @@ -213,9 +213,15 @@ trait Control { /// Invoke an action key for a notification. fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; - /// Clear all notifications from history and popups. + /// Clear active notifications and saved history. fn clear_all(&self) -> zbus::Result<()>; + /// Clear active notifications without deleting saved history. + fn clear_active(&self) -> zbus::Result<()>; + + /// Clear saved history without closing active notifications. + fn clear_history(&self) -> zbus::Result<()>; + /// Mark the panel UI ready after signal subscriptions are active. fn mark_panel_ready(&self) -> zbus::Result<()>; From 3c050a3e7c844af2dc3926c2cf568fe4dda273aa Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 05/65] Split noticenterctl CLI parsing modules Move the former flat cli_args implementation into a focused cli module tree. Argument structs, command enums, DND/debug/inhibit/preset mapping, and CLI tests now live under crates/noticenterctl/src/cli with clearer ownership. --- crates/noticenterctl/src/cli/args.rs | 11 + crates/noticenterctl/src/cli/command.rs | 69 ++++ crates/noticenterctl/src/cli/debug.rs | 25 ++ crates/noticenterctl/src/cli/dnd.rs | 11 + crates/noticenterctl/src/cli/inhibit.rs | 20 ++ crates/noticenterctl/src/cli/mod.rs | 18 ++ crates/noticenterctl/src/cli/preset.rs | 27 ++ .../noticenterctl/src/cli/tests/local_only.rs | 24 ++ crates/noticenterctl/src/cli/tests/mapping.rs | 24 ++ crates/noticenterctl/src/cli/tests/mod.rs | 3 + crates/noticenterctl/src/cli/tests/parsing.rs | 160 ++++++++++ crates/noticenterctl/src/cli_args.rs | 302 ------------------ crates/noticenterctl/src/preset/mod.rs | 2 +- 13 files changed, 393 insertions(+), 303 deletions(-) create mode 100644 crates/noticenterctl/src/cli/args.rs create mode 100644 crates/noticenterctl/src/cli/command.rs create mode 100644 crates/noticenterctl/src/cli/debug.rs create mode 100644 crates/noticenterctl/src/cli/dnd.rs create mode 100644 crates/noticenterctl/src/cli/inhibit.rs create mode 100644 crates/noticenterctl/src/cli/mod.rs create mode 100644 crates/noticenterctl/src/cli/preset.rs create mode 100644 crates/noticenterctl/src/cli/tests/local_only.rs create mode 100644 crates/noticenterctl/src/cli/tests/mapping.rs create mode 100644 crates/noticenterctl/src/cli/tests/mod.rs create mode 100644 crates/noticenterctl/src/cli/tests/parsing.rs delete mode 100644 crates/noticenterctl/src/cli_args.rs diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs new file mode 100644 index 00000000..19dd4c80 --- /dev/null +++ b/crates/noticenterctl/src/cli/args.rs @@ -0,0 +1,11 @@ +use clap::Parser; + +use super::Command; + +#[derive(Parser, Debug)] +#[command(author, version, about)] +pub(crate) struct Args { + // Subcommands map 1:1 to the daemon control surface + #[command(subcommand)] + pub(crate) command: Command, +} diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs new file mode 100644 index 00000000..a9bf5bf4 --- /dev/null +++ b/crates/noticenterctl/src/cli/command.rs @@ -0,0 +1,69 @@ +use clap::Subcommand; + +use super::{DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + // Toggle the panel visibility without changing other state + TogglePanel, + // Open the panel, optionally enabling debug logging for live diagnostics + OpenPanel { + #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "info")] + debug: Option, + }, + // Close the panel if it is visible + ClosePanel, + // Set or toggle Do Not Disturb mode + Dnd { + #[arg(value_enum)] + state: DndState, + }, + // Clear active notifications and saved history + Clear, + // Clear active notifications and saved history + ClearAll, + // Clear active notifications without deleting saved history + ClearActive, + // Clear saved history without closing active notifications + ClearHistory, + // Dismiss a single notification by identifier + Dismiss { + id: u32, + }, + // List active notifications; full output requires diagnostic mode + ListActive { + #[arg(long)] + full: bool, + }, + // List notification history; full output requires diagnostic mode + ListHistory { + #[arg(long)] + full: bool, + }, + // Create a new inhibitor token + Inhibit { + reason: String, + #[arg(long, value_enum, default_value = "all")] + scope: InhibitScopeArg, + }, + // Remove an inhibitor by token + Uninhibit { + id: u64, + }, + // Print current inhibitors to stdout + ListInhibitors, + // Validate theme CSS files without touching D-Bus + CssCheck, + // Export, inspect, or import a shareable preset bundle + Preset { + #[command(subcommand)] + command: PresetCommand, + }, +} + +impl Command { + pub(crate) fn is_local_only(&self) -> bool { + // Local-only commands should not fail just because D-Bus is unavailable + matches!(self, Command::CssCheck | Command::Preset { .. }) + } +} diff --git a/crates/noticenterctl/src/cli/debug.rs b/crates/noticenterctl/src/cli/debug.rs new file mode 100644 index 00000000..03322f0b --- /dev/null +++ b/crates/noticenterctl/src/cli/debug.rs @@ -0,0 +1,25 @@ +use clap::ValueEnum; +use unixnotis_core::PanelDebugLevel; + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum DebugLevelArg { + // Only critical diagnostic output + Critical, + // Warnings and above + Warn, + // Informational output + Info, + // Verbose diagnostics + Verbose, +} + +impl From for PanelDebugLevel { + fn from(value: DebugLevelArg) -> Self { + match value { + DebugLevelArg::Critical => PanelDebugLevel::Critical, + DebugLevelArg::Warn => PanelDebugLevel::Warn, + DebugLevelArg::Info => PanelDebugLevel::Info, + DebugLevelArg::Verbose => PanelDebugLevel::Verbose, + } + } +} diff --git a/crates/noticenterctl/src/cli/dnd.rs b/crates/noticenterctl/src/cli/dnd.rs new file mode 100644 index 00000000..1f818e60 --- /dev/null +++ b/crates/noticenterctl/src/cli/dnd.rs @@ -0,0 +1,11 @@ +use clap::ValueEnum; + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum DndState { + // Explicitly enable DND + On, + // Explicitly disable DND + Off, + // Toggle based on current daemon state + Toggle, +} diff --git a/crates/noticenterctl/src/cli/inhibit.rs b/crates/noticenterctl/src/cli/inhibit.rs new file mode 100644 index 00000000..c5408c0e --- /dev/null +++ b/crates/noticenterctl/src/cli/inhibit.rs @@ -0,0 +1,20 @@ +use clap::ValueEnum; +use unixnotis_core::{INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum InhibitScopeArg { + // Suppress both panel and popup updates + All, + // Suppress popup updates only + Popups, +} + +impl InhibitScopeArg { + pub(crate) fn as_scope(self) -> u32 { + // Map CLI scope to the daemon bitmask value + match self { + Self::All => INHIBIT_SCOPE_ALL, + Self::Popups => INHIBIT_SCOPE_POPUPS, + } + } +} diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs new file mode 100644 index 00000000..99d982c5 --- /dev/null +++ b/crates/noticenterctl/src/cli/mod.rs @@ -0,0 +1,18 @@ +//! Command-line argument types for noticenterctl + +mod args; +mod command; +mod debug; +mod dnd; +mod inhibit; +mod preset; + +pub(crate) use args::Args; +pub(crate) use command::Command; +pub(crate) use debug::DebugLevelArg; +pub(crate) use dnd::DndState; +pub(crate) use inhibit::InhibitScopeArg; +pub(crate) use preset::PresetCommand; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/cli/preset.rs b/crates/noticenterctl/src/cli/preset.rs new file mode 100644 index 00000000..77bf7632 --- /dev/null +++ b/crates/noticenterctl/src/cli/preset.rs @@ -0,0 +1,27 @@ +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub(crate) enum PresetCommand { + // Export the current config tree into one shareable bundle file + Export { + output: String, + #[arg(long = "except", value_name = "PATH")] + except: Vec, + #[arg(long)] + force: bool, + }, + // Import a bundle into the current config tree + Import { + input: String, + #[arg(long = "except", value_name = "PATH")] + except: Vec, + #[arg(long)] + dry_run: bool, + #[arg(long)] + allow_exec: bool, + }, + // Print bundle metadata and included files without writing anything + Inspect { + input: String, + }, +} diff --git a/crates/noticenterctl/src/cli/tests/local_only.rs b/crates/noticenterctl/src/cli/tests/local_only.rs new file mode 100644 index 00000000..b3a4e112 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/local_only.rs @@ -0,0 +1,24 @@ +use clap::Parser; + +use super::super::{Args, Command, PresetCommand}; + +#[test] +fn local_only_classification_distinguishes_local_and_control_commands() { + assert!(Command::CssCheck.is_local_only()); + assert!(Command::Preset { + command: PresetCommand::Inspect { + input: "bundle.unixnotis".to_string() + } + } + .is_local_only()); + + assert!(!Command::ClearActive.is_local_only()); +} + +#[test] +fn preset_commands_are_local_only() { + // Preset commands should bypass D-Bus setup like css-check does + let args = Args::try_parse_from(["noticenterctl", "preset", "inspect", "bundle.unixnotis"]) + .expect("parse args"); + assert!(args.command.is_local_only()); +} diff --git a/crates/noticenterctl/src/cli/tests/mapping.rs b/crates/noticenterctl/src/cli/tests/mapping.rs new file mode 100644 index 00000000..a6718a34 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/mapping.rs @@ -0,0 +1,24 @@ +use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; + +use super::super::{DebugLevelArg, InhibitScopeArg}; + +#[test] +fn debug_level_arg_into_panel_level() { + // Validates CLI debug levels map to the matching control plane enum + let table = [ + (DebugLevelArg::Critical, PanelDebugLevel::Critical), + (DebugLevelArg::Warn, PanelDebugLevel::Warn), + (DebugLevelArg::Info, PanelDebugLevel::Info), + (DebugLevelArg::Verbose, PanelDebugLevel::Verbose), + ]; + for (arg, expected) in table { + let mapped: PanelDebugLevel = arg.into(); + assert_eq!(mapped, expected); + } +} + +#[test] +fn inhibit_scope_arg_maps_to_control_bitmasks() { + assert_eq!(InhibitScopeArg::All.as_scope(), INHIBIT_SCOPE_ALL); + assert_eq!(InhibitScopeArg::Popups.as_scope(), INHIBIT_SCOPE_POPUPS); +} diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs new file mode 100644 index 00000000..77686497 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -0,0 +1,3 @@ +mod local_only; +mod mapping; +mod parsing; diff --git a/crates/noticenterctl/src/cli/tests/parsing.rs b/crates/noticenterctl/src/cli/tests/parsing.rs new file mode 100644 index 00000000..02896747 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/parsing.rs @@ -0,0 +1,160 @@ +use clap::Parser; + +use super::super::{Args, Command, DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; + +#[test] +fn parses_open_panel_debug_default() { + // Ensures clap default_missing_value maps --debug to the Info level + let args = + Args::try_parse_from(["noticenterctl", "open-panel", "--debug"]).expect("parse args"); + match args.command { + Command::OpenPanel { debug } => { + assert!(matches!(debug, Some(DebugLevelArg::Info))); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_open_panel_debug_value() { + // Verifies explicit debug values map to the requested verbosity + let args = Args::try_parse_from(["noticenterctl", "open-panel", "--debug", "verbose"]) + .expect("parse args"); + match args.command { + Command::OpenPanel { debug } => { + assert!(matches!(debug, Some(DebugLevelArg::Verbose))); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_dnd_toggle() { + // Confirms the value enum accepts the toggle state for DND commands + let args = Args::try_parse_from(["noticenterctl", "dnd", "toggle"]).expect("parse args"); + match args.command { + Command::Dnd { state } => { + assert!(matches!(state, DndState::Toggle)); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_explicit_clear_variants() { + for (name, expected) in [ + ("clear", "clear"), + ("clear-all", "clear-all"), + ("clear-active", "clear-active"), + ("clear-history", "clear-history"), + ] { + let args = Args::try_parse_from(["noticenterctl", name]).expect("parse args"); + match (args.command, expected) { + (Command::Clear, "clear") + | (Command::ClearAll, "clear-all") + | (Command::ClearActive, "clear-active") + | (Command::ClearHistory, "clear-history") => {} + (other, _) => panic!("unexpected command: {other:?}"), + } + } +} + +#[test] +fn parses_inhibit_default_scope() { + // Ensures inhibit defaults to the "all" scope when omitted + let args = Args::try_parse_from(["noticenterctl", "inhibit", "focus"]).expect("parse args"); + match args.command { + Command::Inhibit { scope, .. } => { + assert!(matches!(scope, InhibitScopeArg::All)); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_inhibit_popups_scope() { + // Confirms popups scope is accepted for inhibit calls + let args = Args::try_parse_from(["noticenterctl", "inhibit", "focus", "--scope", "popups"]) + .expect("parse args"); + match args.command { + Command::Inhibit { scope, .. } => { + assert!(matches!(scope, InhibitScopeArg::Popups)); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_preset_export_with_repeated_except() { + // Repeated --except flags should preserve order for later filtering + let args = Args::try_parse_from([ + "noticenterctl", + "preset", + "export", + "bundle.unixnotis", + "--except", + "installer.toml", + "--except", + "assets/bg.png", + ]) + .expect("parse args"); + match args.command { + Command::Preset { + command: + PresetCommand::Export { + output, + except, + force, + }, + } => { + assert_eq!(output, "bundle.unixnotis"); + assert_eq!(except, vec!["installer.toml", "assets/bg.png"]); + assert!(!force); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_preset_import_dry_run() { + // Dry-run import should parse without touching D-Bus + let args = Args::try_parse_from([ + "noticenterctl", + "preset", + "import", + "bundle.unixnotis", + "--dry-run", + ]) + .expect("parse args"); + match args.command { + Command::Preset { + command: + PresetCommand::Import { + input, + except, + dry_run, + allow_exec, + }, + } => { + assert_eq!(input, "bundle.unixnotis"); + assert!(except.is_empty()); + assert!(dry_run); + assert!(!allow_exec); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_preset_inspect() { + let args = Args::try_parse_from(["noticenterctl", "preset", "inspect", "bundle.unixnotis"]) + .expect("parse args"); + match args.command { + Command::Preset { + command: PresetCommand::Inspect { input }, + } => { + assert_eq!(input, "bundle.unixnotis"); + } + other => panic!("unexpected command: {other:?}"), + } +} diff --git a/crates/noticenterctl/src/cli_args.rs b/crates/noticenterctl/src/cli_args.rs deleted file mode 100644 index 64ab1a34..00000000 --- a/crates/noticenterctl/src/cli_args.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! CLI argument and command definitions for noticenterctl. - -use clap::{Parser, Subcommand, ValueEnum}; -use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; - -#[derive(Parser, Debug)] -#[command(author, version, about)] -pub(crate) struct Args { - // Subcommands map 1:1 to the daemon control surface. - #[command(subcommand)] - pub(crate) command: Command, -} - -#[derive(Subcommand, Debug)] -pub(crate) enum Command { - // Toggle the panel visibility without changing other state. - TogglePanel, - // Open the panel, optionally enabling debug logging for live diagnostics. - OpenPanel { - #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "info")] - debug: Option, - }, - // Close the panel if it is visible. - ClosePanel, - // Set or toggle Do Not Disturb mode. - Dnd { - #[arg(value_enum)] - state: DndState, - }, - // Clear active notifications. - Clear, - // Dismiss a single notification by identifier. - Dismiss { - id: u32, - }, - // List active notifications; full output requires diagnostic mode. - ListActive { - #[arg(long)] - full: bool, - }, - // List notification history; full output requires diagnostic mode. - ListHistory { - #[arg(long)] - full: bool, - }, - // Create a new inhibitor token. - Inhibit { - reason: String, - #[arg(long, value_enum, default_value = "all")] - scope: InhibitScopeArg, - }, - // Remove an inhibitor by token. - Uninhibit { - id: u64, - }, - // Print current inhibitors to stdout. - ListInhibitors, - // Validate theme CSS files without touching D-Bus. - CssCheck, - // Export, inspect, or import a shareable preset bundle. - Preset { - #[command(subcommand)] - command: PresetCommand, - }, -} - -impl Command { - pub(crate) fn is_local_only(&self) -> bool { - // Local-only commands should not fail just because D-Bus is unavailable. - matches!(self, Command::CssCheck | Command::Preset { .. }) - } -} - -#[derive(Subcommand, Debug)] -pub(crate) enum PresetCommand { - // Export the current config tree into one shareable bundle file. - Export { - output: String, - #[arg(long = "except", value_name = "PATH")] - except: Vec, - #[arg(long)] - force: bool, - }, - // Import a bundle into the current config tree. - Import { - input: String, - #[arg(long = "except", value_name = "PATH")] - except: Vec, - #[arg(long)] - dry_run: bool, - #[arg(long)] - allow_exec: bool, - }, - // Print bundle metadata and included files without writing anything. - Inspect { - input: String, - }, -} - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum DndState { - // Explicitly enable DND. - On, - // Explicitly disable DND. - Off, - // Toggle based on current daemon state. - Toggle, -} - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum DebugLevelArg { - // Only critical diagnostic output. - Critical, - // Warnings and above. - Warn, - // Informational output (default). - Info, - // Verbose diagnostics. - Verbose, -} - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum InhibitScopeArg { - // Suppress both panel and popup updates. - All, - // Suppress popup updates only. - Popups, -} - -impl InhibitScopeArg { - pub(crate) fn as_scope(self) -> u32 { - // Map CLI scope to the daemon bitmask value. - match self { - Self::All => INHIBIT_SCOPE_ALL, - Self::Popups => INHIBIT_SCOPE_POPUPS, - } - } -} - -impl From for PanelDebugLevel { - fn from(value: DebugLevelArg) -> Self { - match value { - DebugLevelArg::Critical => PanelDebugLevel::Critical, - DebugLevelArg::Warn => PanelDebugLevel::Warn, - DebugLevelArg::Info => PanelDebugLevel::Info, - DebugLevelArg::Verbose => PanelDebugLevel::Verbose, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - #[test] - fn parses_open_panel_debug_default() { - // Ensures clap default_missing_value maps --debug to the Info level. - let args = - Args::try_parse_from(["noticenterctl", "open-panel", "--debug"]).expect("parse args"); - match args.command { - Command::OpenPanel { debug } => { - assert!(matches!(debug, Some(DebugLevelArg::Info))); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn parses_open_panel_debug_value() { - // Verifies explicit debug values map to the requested verbosity. - let args = Args::try_parse_from(["noticenterctl", "open-panel", "--debug", "verbose"]) - .expect("parse args"); - match args.command { - Command::OpenPanel { debug } => { - assert!(matches!(debug, Some(DebugLevelArg::Verbose))); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn parses_dnd_toggle() { - // Confirms the value enum accepts the toggle state for DND commands. - let args = Args::try_parse_from(["noticenterctl", "dnd", "toggle"]).expect("parse args"); - match args.command { - Command::Dnd { state } => { - assert!(matches!(state, DndState::Toggle)); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn debug_level_arg_into_panel_level() { - // Validates CLI debug levels map to the matching control plane enum. - let table = [ - (DebugLevelArg::Critical, PanelDebugLevel::Critical), - (DebugLevelArg::Warn, PanelDebugLevel::Warn), - (DebugLevelArg::Info, PanelDebugLevel::Info), - (DebugLevelArg::Verbose, PanelDebugLevel::Verbose), - ]; - for (arg, expected) in table { - let mapped: PanelDebugLevel = arg.into(); - assert_eq!(mapped, expected); - } - } - - #[test] - fn parses_inhibit_default_scope() { - // Ensures inhibit defaults to the "all" scope when omitted. - let args = Args::try_parse_from(["noticenterctl", "inhibit", "focus"]).expect("parse args"); - match args.command { - Command::Inhibit { scope, .. } => { - assert!(matches!(scope, InhibitScopeArg::All)); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn parses_inhibit_popups_scope() { - // Confirms popups scope is accepted for inhibit calls. - let args = Args::try_parse_from(["noticenterctl", "inhibit", "focus", "--scope", "popups"]) - .expect("parse args"); - match args.command { - Command::Inhibit { scope, .. } => { - assert!(matches!(scope, InhibitScopeArg::Popups)); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn parses_preset_export_with_repeated_except() { - // Repeated --except flags should preserve order for later filtering. - let args = Args::try_parse_from([ - "noticenterctl", - "preset", - "export", - "bundle.unixnotis", - "--except", - "installer.toml", - "--except", - "assets/bg.png", - ]) - .expect("parse args"); - match args.command { - Command::Preset { - command: - PresetCommand::Export { - output, - except, - force, - }, - } => { - assert_eq!(output, "bundle.unixnotis"); - assert_eq!(except, vec!["installer.toml", "assets/bg.png"]); - assert!(!force); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn parses_preset_import_dry_run() { - // Dry-run import should parse without touching D-Bus. - let args = Args::try_parse_from([ - "noticenterctl", - "preset", - "import", - "bundle.unixnotis", - "--dry-run", - ]) - .expect("parse args"); - match args.command { - Command::Preset { - command: - PresetCommand::Import { - input, - except, - dry_run, - allow_exec, - }, - } => { - assert_eq!(input, "bundle.unixnotis"); - assert!(except.is_empty()); - assert!(dry_run); - assert!(!allow_exec); - } - other => panic!("unexpected command: {other:?}"), - } - } - - #[test] - fn preset_commands_are_local_only() { - // Preset commands should bypass D-Bus setup like css-check does. - let args = Args::try_parse_from(["noticenterctl", "preset", "inspect", "bundle.unixnotis"]) - .expect("parse args"); - assert!(args.command.is_local_only()); - } -} diff --git a/crates/noticenterctl/src/preset/mod.rs b/crates/noticenterctl/src/preset/mod.rs index 0db4b437..28163829 100644 --- a/crates/noticenterctl/src/preset/mod.rs +++ b/crates/noticenterctl/src/preset/mod.rs @@ -17,7 +17,7 @@ mod pathing; use anyhow::Result; use std::path::Path; -use crate::cli_args::PresetCommand; +use crate::cli::PresetCommand; pub(crate) fn run_preset(command: PresetCommand) -> Result<()> { // Preset commands stay local so sharing configs does not depend on a running daemon From 2131e36ee30d07c4be3c2e17637b818b86ef7a1e Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 06/65] Split noticenterctl D-Bus dispatch Replace the dbus_ops catch-all with client, dispatch, and output gate modules under the dbus folder. The tests move with the D-Bus layer and verify command dispatch, local command bypasses, and diagnostic output gating. --- crates/noticenterctl/src/dbus/client.rs | 157 ++++++++++++++++++ crates/noticenterctl/src/dbus/dbus_ops.rs | 114 ------------- crates/noticenterctl/src/dbus/dispatch.rs | 97 +++++++++++ crates/noticenterctl/src/dbus/mod.rs | 10 ++ crates/noticenterctl/src/dbus/output_gate.rs | 7 + .../noticenterctl/src/dbus/tests/dispatch.rs | 140 ++++++++++++++++ crates/noticenterctl/src/dbus/tests/mod.rs | 3 + .../src/dbus/tests/output_gate.rs | 17 ++ .../noticenterctl/src/dbus/tests/support.rs | 110 ++++++++++++ crates/noticenterctl/src/main.rs | 15 +- 10 files changed, 548 insertions(+), 122 deletions(-) create mode 100644 crates/noticenterctl/src/dbus/client.rs delete mode 100644 crates/noticenterctl/src/dbus/dbus_ops.rs create mode 100644 crates/noticenterctl/src/dbus/dispatch.rs create mode 100644 crates/noticenterctl/src/dbus/mod.rs create mode 100644 crates/noticenterctl/src/dbus/output_gate.rs create mode 100644 crates/noticenterctl/src/dbus/tests/dispatch.rs create mode 100644 crates/noticenterctl/src/dbus/tests/mod.rs create mode 100644 crates/noticenterctl/src/dbus/tests/output_gate.rs create mode 100644 crates/noticenterctl/src/dbus/tests/support.rs diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs new file mode 100644 index 00000000..f6409321 --- /dev/null +++ b/crates/noticenterctl/src/dbus/client.rs @@ -0,0 +1,157 @@ +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use unixnotis_core::{ControlProxy, InhibitorInfo, NotificationView, PanelDebugLevel}; + +const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); + +// A boxed future lets every control command return the same kind of async wrapper +pub(crate) type ControlFuture<'a, T> = Pin> + 'a>>; + +// This trait lists every command a control client knows how to ask the daemon to do +pub(crate) trait ControlClient { + // Ask the panel to switch between open and closed + fn toggle_panel(&self) -> ControlFuture<'_, ()>; + + // Ask the panel to open normally + fn open_panel(&self) -> ControlFuture<'_, ()>; + + // Ask the panel to open with extra debug information + fn open_panel_debug(&self, level: PanelDebugLevel) -> ControlFuture<'_, ()>; + + // Ask the panel to close + fn close_panel(&self) -> ControlFuture<'_, ()>; + + // Remove every notification from both active and history areas + fn clear_all(&self) -> ControlFuture<'_, ()>; + + // Remove only the notifications that are currently active + fn clear_active(&self) -> ControlFuture<'_, ()>; + + // Remove only the notifications saved in history + fn clear_history(&self) -> ControlFuture<'_, ()>; + + // Remove one notification by its id + fn dismiss(&self, id: u32) -> ControlFuture<'_, ()>; + + // Fetch the notifications that are active right now + fn list_active(&self) -> ControlFuture<'_, Vec>; + + // Fetch the notifications that are stored in history + fn list_history(&self) -> ControlFuture<'_, Vec>; + + // Turn do-not-disturb on or off directly + fn set_dnd(&self, enabled: bool) -> ControlFuture<'_, ()>; + + // Flip do-not-disturb to the opposite of what it is now + fn toggle_dnd(&self) -> ControlFuture<'_, ()>; + + // Add a temporary blocker that can stop notifications for a reason and scope + fn inhibit<'a>(&'a self, reason: &'a str, scope: u32) -> ControlFuture<'a, u64>; + + // Remove an existing blocker by its id + fn uninhibit(&self, id: u64) -> ControlFuture<'_, ()>; + + // Fetch the list of blockers that are currently active + fn list_inhibitors(&self) -> ControlFuture<'_, Vec>; +} + +// This makes the real D-Bus proxy usable through the simpler ControlClient trait +impl ControlClient for ControlProxy<'_> { + fn toggle_panel(&self) -> ControlFuture<'_, ()> { + // Start the proxy call and wrap it in the shared timeout and error handling helper + Box::pin(run_control_call(ControlProxy::toggle_panel(self))) + } + + fn open_panel(&self) -> ControlFuture<'_, ()> { + // Send the open request through the proxy using the common call wrapper + Box::pin(run_control_call(ControlProxy::open_panel(self))) + } + + fn open_panel_debug(&self, level: PanelDebugLevel) -> ControlFuture<'_, ()> { + // Pass the debug level along so the daemon knows how much extra info to show + Box::pin(run_control_call(ControlProxy::open_panel_debug( + self, level, + ))) + } + + fn close_panel(&self) -> ControlFuture<'_, ()> { + // Send the close request and let the helper deal with timeout and errors + Box::pin(run_control_call(ControlProxy::close_panel(self))) + } + + fn clear_all(&self) -> ControlFuture<'_, ()> { + // Ask the daemon to clear everything it is holding + Box::pin(run_control_call(ControlProxy::clear_all(self))) + } + + fn clear_active(&self) -> ControlFuture<'_, ()> { + // Ask the daemon to clear only the notifications that are still live + Box::pin(run_control_call(ControlProxy::clear_active(self))) + } + + fn clear_history(&self) -> ControlFuture<'_, ()> { + // Ask the daemon to clear only the older saved notifications + Box::pin(run_control_call(ControlProxy::clear_history(self))) + } + + fn dismiss(&self, id: u32) -> ControlFuture<'_, ()> { + // Send the id so the daemon knows exactly which notification to remove + Box::pin(run_control_call(ControlProxy::dismiss(self, id))) + } + + fn list_active(&self) -> ControlFuture<'_, Vec> { + // Ask for the current active notifications and return them as view-friendly data + Box::pin(run_control_call(ControlProxy::list_active(self))) + } + + fn list_history(&self) -> ControlFuture<'_, Vec> { + // Ask for the saved notification history and return it as view-friendly data + Box::pin(run_control_call(ControlProxy::list_history(self))) + } + + fn set_dnd(&self, enabled: bool) -> ControlFuture<'_, ()> { + // Send the exact do-not-disturb value the caller wants + Box::pin(run_control_call(ControlProxy::set_dnd(self, enabled))) + } + + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { + // Ask the daemon to flip do-not-disturb without the caller needing to know its current value + Box::pin(run_control_call(ControlProxy::toggle_dnd(self))) + } + + fn inhibit<'a>(&'a self, reason: &'a str, scope: u32) -> ControlFuture<'a, u64> { + // Send the reason and scope, then get back an id that can be used to undo it later + Box::pin(run_control_call(ControlProxy::inhibit(self, reason, scope))) + } + + fn uninhibit(&self, id: u64) -> ControlFuture<'_, ()> { + // Tell the daemon which blocker id should be removed + Box::pin(run_control_call(ControlProxy::uninhibit(self, id))) + } + + fn list_inhibitors(&self) -> ControlFuture<'_, Vec> { + // Ask the daemon for all current blockers and their details + Box::pin(run_control_call(ControlProxy::list_inhibitors(self))) + } +} + +// Runs one daemon call while making sure it cannot hang forever +async fn run_control_call(call: impl Future>) -> Result { + // Race the real D-Bus call against the maximum allowed wait time + match tokio::time::timeout(CONTROL_CALL_TIMEOUT, call).await { + // The daemon answered in time and the call itself worked + Ok(Ok(value)) => Ok(value), + + // The daemon answered in time, but reported a D-Bus error + Ok(Err(err)) => Err(err.into()), + + // The daemon did not answer before the timeout finished + Err(_) => Err(anyhow!( + "timed out waiting for unixnotis daemon response after {}s", + CONTROL_CALL_TIMEOUT.as_secs() + )), + } +} diff --git a/crates/noticenterctl/src/dbus/dbus_ops.rs b/crates/noticenterctl/src/dbus/dbus_ops.rs deleted file mode 100644 index 96efe4e3..00000000 --- a/crates/noticenterctl/src/dbus/dbus_ops.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! D-Bus command execution for noticenterctl. - -use std::future::Future; -use std::time::Duration; - -use anyhow::{anyhow, Result}; -use unixnotis_core::{util, ControlProxy}; - -use crate::cli_args::{Command, DndState}; -use crate::main_log_follow::follow_debug_logs; -use crate::main_output::{print_inhibitors, print_notifications}; - -const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); - -pub(crate) async fn handle_command(proxy: &ControlProxy<'_>, command: Command) -> Result<()> { - // CLI forwards work to the daemon - match command { - Command::TogglePanel => { - // Simple toggle keeps the daemon in control of its own visibility rules. - run_control_call(proxy.toggle_panel()).await?; - } - Command::OpenPanel { debug } => { - // Debug mode opens the panel and streams daemon logs for real-time triage. - if let Some(level) = debug { - run_control_call(proxy.open_panel_debug(level.into())).await?; - // Panel open should still succeed when journal follow is unavailable. - if let Err(err) = follow_debug_logs() { - eprintln!("debug log follow unavailable: {err}"); - } - } else { - run_control_call(proxy.open_panel()).await?; - } - } - Command::ClosePanel => { - // Explicit close avoids accidental toggles when the panel is hidden. - run_control_call(proxy.close_panel()).await?; - } - Command::Clear => { - // Clear removes both active notifications and history entries. - run_control_call(proxy.clear_all()).await?; - } - Command::Dismiss { id } => { - // Dismiss targets a single notification by id. - run_control_call(proxy.dismiss(id)).await?; - } - Command::ListActive { full } => { - // Full output needs the debug gate - let allow_full = full && util::diagnostic_mode(); - if full && !util::diagnostic_mode() { - // Fall back to the safe view - eprintln!("--full requires UNIXNOTIS_DIAGNOSTIC=1; using redacted output"); - } - let notifications = run_control_call(proxy.list_active()).await?; - // Shared output helper - print_notifications("active", ¬ifications, allow_full); - } - Command::ListHistory { full } => { - // Same gate for history - let allow_full = full && util::diagnostic_mode(); - if full && !util::diagnostic_mode() { - eprintln!("--full requires UNIXNOTIS_DIAGNOSTIC=1; using redacted output"); - } - let notifications = run_control_call(proxy.list_history()).await?; - print_notifications("history", ¬ifications, allow_full); - } - Command::Dnd { state } => match state { - DndState::On => { - // Explicit enable avoids ambiguous scripts. - run_control_call(proxy.set_dnd(true)).await?; - } - DndState::Off => { - // Explicit disable avoids ambiguous scripts. - run_control_call(proxy.set_dnd(false)).await?; - } - DndState::Toggle => { - // Toggle must happen atomically in the daemon to avoid read-modify-write races. - run_control_call(proxy.toggle_dnd()).await?; - } - }, - Command::Inhibit { reason, scope } => { - // Print the token only - let token = run_control_call(proxy.inhibit(&reason, scope.as_scope())).await?; - println!("{token}"); - } - Command::Uninhibit { id } => { - // Token removal is safe to repeat if a previous call already released it. - run_control_call(proxy.uninhibit(id)).await?; - } - Command::ListInhibitors => { - let inhibitors = run_control_call(proxy.list_inhibitors()).await?; - // Shared output helper - print_inhibitors(&inhibitors); - } - Command::CssCheck => { - // CSS validation is handled before D-Bus connection setup. - } - Command::Preset { .. } => { - // Preset commands are handled before D-Bus connection setup. - } - } - - Ok(()) -} - -async fn run_control_call(call: impl Future>) -> Result { - match tokio::time::timeout(CONTROL_CALL_TIMEOUT, call).await { - Ok(Ok(value)) => Ok(value), - Ok(Err(err)) => Err(err.into()), - Err(_) => Err(anyhow!( - "timed out waiting for unixnotis daemon response after {}s", - CONTROL_CALL_TIMEOUT.as_secs() - )), - } -} diff --git a/crates/noticenterctl/src/dbus/dispatch.rs b/crates/noticenterctl/src/dbus/dispatch.rs new file mode 100644 index 00000000..b82c4228 --- /dev/null +++ b/crates/noticenterctl/src/dbus/dispatch.rs @@ -0,0 +1,97 @@ +use anyhow::Result; +use unixnotis_core::util; + +use crate::cli::{Command, DndState}; +use crate::main_log_follow::follow_debug_logs; +use crate::main_output::{print_inhibitors, print_notifications}; + +use super::client::ControlClient; +use super::output_gate::{allow_full_output, warn_full_requires_diagnostic}; + +pub(crate) async fn handle_command(client: &impl ControlClient, command: Command) -> Result<()> { + // CLI forwards work to the daemon + match command { + Command::TogglePanel => { + // Simple toggle keeps the daemon in control of its own visibility rules + client.toggle_panel().await?; + } + Command::OpenPanel { debug } => { + // Debug mode opens the panel and streams daemon logs for real-time triage + if let Some(level) = debug { + client.open_panel_debug(level.into()).await?; + // Panel open should still succeed when journal follow is unavailable + if let Err(err) = follow_debug_logs() { + eprintln!("debug log follow unavailable: {err}"); + } + } else { + client.open_panel().await?; + } + } + Command::ClosePanel => { + // Explicit close avoids accidental toggles when the panel is hidden + client.close_panel().await?; + } + Command::Clear | Command::ClearAll => { + // Clear keeps legacy behavior: remove active notifications and saved history + client.clear_all().await?; + } + Command::ClearActive => { + client.clear_active().await?; + } + Command::ClearHistory => { + client.clear_history().await?; + } + Command::Dismiss { id } => { + // Dismiss targets a single notification by id + client.dismiss(id).await?; + } + Command::ListActive { full } => { + let diagnostic_mode = util::diagnostic_mode(); + let allow_full = allow_full_output(full, diagnostic_mode); + if warn_full_requires_diagnostic(full, diagnostic_mode) { + // Fall back to the safe view + eprintln!("--full requires UNIXNOTIS_DIAGNOSTIC=1; using redacted output"); + } + let notifications = client.list_active().await?; + print_notifications("active", ¬ifications, allow_full); + } + Command::ListHistory { full } => { + let diagnostic_mode = util::diagnostic_mode(); + let allow_full = allow_full_output(full, diagnostic_mode); + if warn_full_requires_diagnostic(full, diagnostic_mode) { + eprintln!("--full requires UNIXNOTIS_DIAGNOSTIC=1; using redacted output"); + } + let notifications = client.list_history().await?; + print_notifications("history", ¬ifications, allow_full); + } + Command::Dnd { state } => match state { + DndState::On => { + // Explicit enable avoids ambiguous scripts + client.set_dnd(true).await?; + } + DndState::Off => { + // Explicit disable avoids ambiguous scripts + client.set_dnd(false).await?; + } + DndState::Toggle => { + // Toggle must happen atomically in the daemon to avoid read-modify-write races + client.toggle_dnd().await?; + } + }, + Command::Inhibit { reason, scope } => { + let token = client.inhibit(&reason, scope.as_scope()).await?; + println!("{token}"); + } + Command::Uninhibit { id } => { + // Token removal is safe to repeat if a previous call already released it + client.uninhibit(id).await?; + } + Command::ListInhibitors => { + let inhibitors = client.list_inhibitors().await?; + print_inhibitors(&inhibitors); + } + Command::CssCheck | Command::Preset { .. } => {} + } + + Ok(()) +} diff --git a/crates/noticenterctl/src/dbus/mod.rs b/crates/noticenterctl/src/dbus/mod.rs new file mode 100644 index 00000000..e494faa0 --- /dev/null +++ b/crates/noticenterctl/src/dbus/mod.rs @@ -0,0 +1,10 @@ +//! Control-plane D-Bus command execution + +mod client; +mod dispatch; +mod output_gate; + +pub(crate) use dispatch::handle_command; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/dbus/output_gate.rs b/crates/noticenterctl/src/dbus/output_gate.rs new file mode 100644 index 00000000..7ed11303 --- /dev/null +++ b/crates/noticenterctl/src/dbus/output_gate.rs @@ -0,0 +1,7 @@ +pub(crate) fn allow_full_output(requested: bool, diagnostic_mode: bool) -> bool { + requested && diagnostic_mode +} + +pub(crate) fn warn_full_requires_diagnostic(requested: bool, diagnostic_mode: bool) -> bool { + requested && !diagnostic_mode +} diff --git a/crates/noticenterctl/src/dbus/tests/dispatch.rs b/crates/noticenterctl/src/dbus/tests/dispatch.rs new file mode 100644 index 00000000..f473eb10 --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/dispatch.rs @@ -0,0 +1,140 @@ +use crate::cli::{Command, DndState, InhibitScopeArg, PresetCommand}; + +use super::super::handle_command; +use super::support::{RecordedCall, RecordingControlClient}; + +#[tokio::test] +async fn clear_commands_dispatch_to_matching_control_calls() { + let cases = [ + (Command::Clear, RecordedCall::ClearAll), + (Command::ClearAll, RecordedCall::ClearAll), + (Command::ClearActive, RecordedCall::ClearActive), + (Command::ClearHistory, RecordedCall::ClearHistory), + ]; + + for (command, expected) in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert_eq!(client.take_calls(), vec![expected]); + } +} + +#[tokio::test] +async fn panel_commands_dispatch_to_matching_control_calls() { + let cases = [ + (Command::TogglePanel, RecordedCall::TogglePanel), + (Command::OpenPanel { debug: None }, RecordedCall::OpenPanel), + (Command::ClosePanel, RecordedCall::ClosePanel), + ]; + + for (command, expected) in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert_eq!(client.take_calls(), vec![expected]); + } +} + +#[tokio::test] +async fn dnd_commands_dispatch_to_matching_control_calls() { + let cases = [ + ( + Command::Dnd { + state: DndState::On, + }, + RecordedCall::SetDnd(true), + ), + ( + Command::Dnd { + state: DndState::Off, + }, + RecordedCall::SetDnd(false), + ), + ( + Command::Dnd { + state: DndState::Toggle, + }, + RecordedCall::ToggleDnd, + ), + ]; + + for (command, expected) in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert_eq!(client.take_calls(), vec![expected]); + } +} + +#[tokio::test] +async fn notification_commands_dispatch_to_matching_control_calls() { + let cases = [ + (Command::Dismiss { id: 7 }, RecordedCall::Dismiss(7)), + ( + Command::ListActive { full: false }, + RecordedCall::ListActive, + ), + ( + Command::ListHistory { full: false }, + RecordedCall::ListHistory, + ), + ]; + + for (command, expected) in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert_eq!(client.take_calls(), vec![expected]); + } +} + +#[tokio::test] +async fn inhibitor_commands_dispatch_to_matching_control_calls() { + let cases = [ + ( + Command::Inhibit { + reason: "focus".to_string(), + scope: InhibitScopeArg::Popups, + }, + RecordedCall::Inhibit { + reason: "focus".to_string(), + scope: unixnotis_core::INHIBIT_SCOPE_POPUPS, + }, + ), + (Command::Uninhibit { id: 9 }, RecordedCall::Uninhibit(9)), + (Command::ListInhibitors, RecordedCall::ListInhibitors), + ]; + + for (command, expected) in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert_eq!(client.take_calls(), vec![expected]); + } +} + +#[tokio::test] +async fn local_commands_do_not_touch_control_client() { + let cases = [ + Command::CssCheck, + Command::Preset { + command: PresetCommand::Inspect { + input: "bundle.unixnotis".to_string(), + }, + }, + ]; + + for command in cases { + let client = RecordingControlClient::default(); + handle_command(&client, command) + .await + .expect("dispatch command"); + assert!(client.take_calls().is_empty()); + } +} diff --git a/crates/noticenterctl/src/dbus/tests/mod.rs b/crates/noticenterctl/src/dbus/tests/mod.rs new file mode 100644 index 00000000..4f951768 --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/mod.rs @@ -0,0 +1,3 @@ +mod dispatch; +mod output_gate; +mod support; diff --git a/crates/noticenterctl/src/dbus/tests/output_gate.rs b/crates/noticenterctl/src/dbus/tests/output_gate.rs new file mode 100644 index 00000000..5f863b2a --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/output_gate.rs @@ -0,0 +1,17 @@ +use super::super::output_gate::{allow_full_output, warn_full_requires_diagnostic}; + +#[test] +fn full_output_gate_requires_request_and_diagnostic_mode() { + assert!(allow_full_output(true, true)); + assert!(!allow_full_output(true, false)); + assert!(!allow_full_output(false, true)); + assert!(!allow_full_output(false, false)); +} + +#[test] +fn full_output_warning_only_when_full_was_requested_without_diagnostic_mode() { + assert!(warn_full_requires_diagnostic(true, false)); + assert!(!warn_full_requires_diagnostic(true, true)); + assert!(!warn_full_requires_diagnostic(false, false)); + assert!(!warn_full_requires_diagnostic(false, true)); +} diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs new file mode 100644 index 00000000..60f71c6c --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -0,0 +1,110 @@ +use std::cell::RefCell; + +use unixnotis_core::{InhibitorInfo, NotificationView, PanelDebugLevel}; + +use super::super::client::{ControlClient, ControlFuture}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RecordedCall { + TogglePanel, + OpenPanel, + OpenPanelDebug(PanelDebugLevel), + ClosePanel, + ClearAll, + ClearActive, + ClearHistory, + Dismiss(u32), + ListActive, + ListHistory, + SetDnd(bool), + ToggleDnd, + Inhibit { reason: String, scope: u32 }, + Uninhibit(u64), + ListInhibitors, +} + +#[derive(Default)] +pub(super) struct RecordingControlClient { + calls: RefCell>, +} + +impl RecordingControlClient { + fn record<'a, T: 'a>(&'a self, call: RecordedCall, value: T) -> ControlFuture<'a, T> { + Box::pin(async move { + self.calls.borrow_mut().push(call); + Ok(value) + }) + } + + pub(super) fn take_calls(&self) -> Vec { + self.calls.replace(Vec::new()) + } +} + +impl ControlClient for RecordingControlClient { + fn toggle_panel(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::TogglePanel, ()) + } + + fn open_panel(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::OpenPanel, ()) + } + + fn open_panel_debug(&self, level: PanelDebugLevel) -> ControlFuture<'_, ()> { + self.record(RecordedCall::OpenPanelDebug(level), ()) + } + + fn close_panel(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::ClosePanel, ()) + } + + fn clear_all(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::ClearAll, ()) + } + + fn clear_active(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::ClearActive, ()) + } + + fn clear_history(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::ClearHistory, ()) + } + + fn dismiss(&self, id: u32) -> ControlFuture<'_, ()> { + self.record(RecordedCall::Dismiss(id), ()) + } + + fn list_active(&self) -> ControlFuture<'_, Vec> { + self.record(RecordedCall::ListActive, Vec::new()) + } + + fn list_history(&self) -> ControlFuture<'_, Vec> { + self.record(RecordedCall::ListHistory, Vec::new()) + } + + fn set_dnd(&self, enabled: bool) -> ControlFuture<'_, ()> { + self.record(RecordedCall::SetDnd(enabled), ()) + } + + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::ToggleDnd, ()) + } + + fn inhibit<'a>(&'a self, reason: &'a str, scope: u32) -> ControlFuture<'a, u64> { + Box::pin(async move { + self.calls.borrow_mut().push(RecordedCall::Inhibit { + reason: reason.to_owned(), + scope, + }); + Ok(42) + }) + } + + fn uninhibit(&self, id: u64) -> ControlFuture<'_, ()> { + self.record(RecordedCall::Uninhibit(id), ()) + } + + fn list_inhibitors(&self) -> ControlFuture<'_, Vec> { + self.record(RecordedCall::ListInhibitors, Vec::new()) + } +} diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index dd0cf4b8..9d3f4d89 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -8,9 +8,8 @@ //! Command-line control surface for the UnixNotis D-Bus interface -mod cli_args; -#[path = "dbus/dbus_ops.rs"] -mod dbus_ops; +mod cli; +mod dbus; #[path = "main/main_css_check.rs"] mod main_css_check; #[path = "main/main_log_follow.rs"] @@ -21,7 +20,7 @@ mod preset; use anyhow::{Context, Result}; use clap::Parser; -use cli_args::Args; +use cli::Args; use unixnotis_core::ControlProxy; use zbus::Connection; @@ -33,10 +32,10 @@ async fn main() -> Result<()> { if args.command.is_local_only() { // Local-only commands skip D-Bus setup on purpose match args.command { - cli_args::Command::CssCheck => { + cli::Command::CssCheck => { main_css_check::run_css_check()?; } - cli_args::Command::Preset { command } => { + cli::Command::Preset { command } => { preset::run_preset(command).context("preset command failed")?; } _ => {} @@ -52,7 +51,7 @@ async fn main() -> Result<()> { .await .context("connect to unixnotis control interface")?; - // Hand command execution to the D-Bus operation layer - dbus_ops::handle_command(&proxy, args.command).await?; + // Hand command execution to the D-Bus command layer + dbus::handle_command(&proxy, args.command).await?; Ok(()) } From 2422c563d7ff5792c69369b0b1cd35dbea066d42 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 07/65] Clean noticenterctl comments Remove trailing periods from touched noticenterctl comments and keep nearby explanatory comments consistent with the repository comment style. No runtime behavior changes are included in this cleanup. --- crates/noticenterctl/src/main/main_css_check_files.rs | 2 +- crates/noticenterctl/src/main/main_log_follow.rs | 6 +++--- crates/noticenterctl/src/main/main_output.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/noticenterctl/src/main/main_css_check_files.rs b/crates/noticenterctl/src/main/main_css_check_files.rs index 503a7596..d9a14ac1 100644 --- a/crates/noticenterctl/src/main/main_css_check_files.rs +++ b/crates/noticenterctl/src/main/main_css_check_files.rs @@ -1,4 +1,4 @@ -//! File walking and display-path helpers for css-check. +//! File walking and display-path helpers for css-check use anyhow::{Context, Result}; use std::collections::HashSet; diff --git a/crates/noticenterctl/src/main/main_log_follow.rs b/crates/noticenterctl/src/main/main_log_follow.rs index 3a50af70..b5d322a0 100644 --- a/crates/noticenterctl/src/main/main_log_follow.rs +++ b/crates/noticenterctl/src/main/main_log_follow.rs @@ -1,4 +1,4 @@ -//! Journalctl follower used when the panel opens in debug mode. +//! Journalctl follower used when the panel opens in debug mode use anyhow::{anyhow, Context, Result}; use std::env; @@ -22,7 +22,7 @@ pub(crate) fn follow_debug_logs() -> Result<()> { )); } - // Follow the user-level systemd unit so the output matches the active session. + // Follow the user-level systemd unit so the output matches the active session let status = ProcCommand::new("journalctl") .args(["--user", "-f", "-u", &unit, "-o", "cat"]) .status() @@ -31,7 +31,7 @@ pub(crate) fn follow_debug_logs() -> Result<()> { if status.success() { Ok(()) } else { - // Propagate a clear failure when the subprocess exits non-zero. + // Propagate a clear failure when the subprocess exits non-zero Err(anyhow!("journalctl exited with status {}", status)) } } diff --git a/crates/noticenterctl/src/main/main_output.rs b/crates/noticenterctl/src/main/main_output.rs index ea8d9a6a..08b2e5f9 100644 --- a/crates/noticenterctl/src/main/main_output.rs +++ b/crates/noticenterctl/src/main/main_output.rs @@ -1,4 +1,4 @@ -//! Output formatting helpers for noticenterctl. +//! Output formatting helpers for noticenterctl use unixnotis_core::{util, NotificationView}; From a990ee10b1d86b13ac128b54ee78fb8314f0b000 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 08/65] Move daemon control server into folder Remove the flat daemon control module and make daemon/control the owner of the control server surface. The server wrapper now sits beside the existing clear, DND, inhibit, query, sanitize, watch, and test modules. --- .../src/daemon/control/dnd.rs | 2 +- .../src/daemon/control/mod.rs | 23 ++++ .../daemon/{control.rs => control/server.rs} | 102 ++++++++---------- .../src/daemon/control/tests/clear.rs | 2 +- .../src/daemon/control/tests/mod.rs | 2 + .../src/daemon/control/tests/sanitize.rs | 2 +- 6 files changed, 71 insertions(+), 62 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/control/mod.rs rename crates/unixnotis-daemon/src/daemon/{control.rs => control/server.rs} (75%) create mode 100644 crates/unixnotis-daemon/src/daemon/control/tests/mod.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/dnd.rs b/crates/unixnotis-daemon/src/daemon/control/dnd.rs index b5519163..96e5694a 100644 --- a/crates/unixnotis-daemon/src/daemon/control/dnd.rs +++ b/crates/unixnotis-daemon/src/daemon/control/dnd.rs @@ -54,7 +54,7 @@ impl ControlServer { } } if write.changed { - // Mutation is already committed; signal fanout is best-effort. + // Mutation is already committed; signal fanout is best-effort if let Err(err) = self.state.emit_state_changed().await { warn!( ?err, diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs new file mode 100644 index 00000000..3fd1a1cf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -0,0 +1,23 @@ +//! D-Bus server for com.unixnotis.Control + +mod clear; +mod dnd; +mod inhibit; +mod panel; +mod query; +mod sanitize; +mod server; +mod watch; + +pub use server::ControlServer; +pub async fn spawn_inhibitor_owner_watch( + state: std::sync::Arc, +) -> zbus::Result<()> { + watch::spawn_inhibitor_owner_watch(state).await +} + +// Cap inhibitor count so memory use stays bounded even under abusive clients +const MAX_ACTIVE_INHIBITORS: u32 = 128; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/control.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs similarity index 75% rename from crates/unixnotis-daemon/src/daemon/control.rs rename to crates/unixnotis-daemon/src/daemon/control/server.rs index 7a2a470c..368ba51a 100644 --- a/crates/unixnotis-daemon/src/daemon/control.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -1,6 +1,4 @@ -//! D-Bus server for com.unixnotis.Control. -//! -//! Provides panel control, state queries, and inhibitor management. +//! Control D-Bus interface implementation use std::sync::Arc; @@ -11,38 +9,18 @@ use unixnotis_core::{ use zbus::message::Header; use zbus::{interface, SignalContext}; -use super::{auth, to_fdo_error, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; - -// Clear-all fanout stays separate so signal planning does not crowd interface methods -#[path = "control/clear.rs"] -mod clear; -// Input normalization is shared by inhibit methods and focused unit tests -#[path = "control/sanitize.rs"] -mod sanitize; -// Owner-watch cleanup runs in background tasks, so it stays isolated -#[path = "control/watch.rs"] -mod watch; -// DND mutation and persistence flow stays out of the interface declaration -#[path = "control/dnd.rs"] -mod dnd; -// Query methods are read-heavy and do not need to sit beside mutating calls -#[path = "control/query.rs"] -mod query; -// Panel request and readiness checks have their own lifecycle rules -#[path = "control/panel.rs"] -mod panel; -// Inhibitor mutation includes async owner cleanup and signal fanout -#[path = "control/inhibit.rs"] -mod inhibit; - -/// D-Bus server for com.unixnotis.Control. +use crate::daemon::{ + auth, to_fdo_error, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, +}; + +use super::clear; + +/// D-Bus server for com.unixnotis.Control pub struct ControlServer { // Shared daemon state used by all control methods // The server stays thin - state: Arc, + pub(super) state: Arc, } -// Cap inhibitor count so memory use stays bounded even under abusive clients -const MAX_ACTIVE_INHIBITORS: u32 = 128; impl ControlServer { pub fn new(state: Arc) -> Self { @@ -50,7 +28,7 @@ impl ControlServer { Self { state } } - async fn authorize_control_call( + pub(super) async fn authorize_control_call( &self, header: &Header<'_>, method: &'static str, @@ -59,7 +37,7 @@ impl ControlServer { auth::authorize_control_call(&self.state, header, method).await } - async fn authorize_panel_readiness_call( + pub(super) async fn authorize_panel_readiness_call( &self, header: &Header<'_>, method: &'static str, @@ -68,7 +46,7 @@ impl ControlServer { auth::authorize_panel_readiness_call(&self.state, header, method).await } - fn ensure_panel_available(&self) -> zbus::fdo::Result<()> { + pub(super) fn ensure_panel_available(&self) -> zbus::fdo::Result<()> { // Rejecting here makes panel outages visible instead of silent if self.state.panel_ready() { return Ok(()); @@ -77,6 +55,20 @@ impl ControlServer { "unixnotis-center is unavailable".to_string(), )) } + + pub(super) async fn drain_active_notifications(&self) -> Vec { + let ids = { + let mut store = self.state.store.lock().await; + store.drain_active_ids() + }; + self.state.cancel_expirations(&ids); + ids + } + + pub(super) async fn clear_saved_history(&self) { + let mut store = self.state.store.lock().await; + store.clear_history(); + } } #[interface(name = "com.unixnotis.Control")] @@ -191,7 +183,6 @@ impl ControlServer { // Reuse the freedesktop action signal path for compatibility with listeners let ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) .map_err(to_fdo_error)?; - // Action signals re-use the freedesktop notification interface path. NotificationServer::action_invoked(&ctx, id, action_key) .await .map_err(to_fdo_error) @@ -199,21 +190,26 @@ impl ControlServer { async fn clear_all(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearAll").await?; - // Drain active notifications in one lock to avoid quadratic scans. - let ids = { - let mut store = self.state.store.lock().await; - let ids = store.drain_active_ids(); - // Clear live and saved items - store.clear_history(); - ids - }; - // Active timers no longer matter once the list has been wiped - self.state.cancel_expirations(&ids).await; - // Signal fanout lives in a focused helper so the D-Bus method stays readable + let ids = self.drain_active_notifications().await; + self.clear_saved_history().await; + clear::emit_clear_all_signals(&self.state, ids).await; + Ok(()) + } + + async fn clear_active(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "ClearActive").await?; + let ids = self.drain_active_notifications().await; clear::emit_clear_all_signals(&self.state, ids).await; Ok(()) } + async fn clear_history(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "ClearHistory").await?; + self.clear_saved_history().await; + clear::emit_clear_all_signals(&self.state, Vec::new()).await; + Ok(()) + } + async fn mark_panel_ready(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.set_panel_ready_state(&header, "MarkPanelReady", true) .await @@ -263,7 +259,7 @@ impl ControlServer { #[zbus(signal)] pub(crate) async fn snapshot_invalidated(ctx: &SignalContext<'_>) -> zbus::Result<()>; - /// Emitted when inhibitor state toggles or count changes. + /// Emitted when inhibitor state toggles or count changes #[zbus(signal)] pub(crate) async fn inhibitors_changed( ctx: &SignalContext<'_>, @@ -277,15 +273,3 @@ impl ControlServer { request: PanelRequest, ) -> zbus::Result<()>; } - -pub async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Result<()> { - // Delegate to a focused module so the interface file stays small and readable. - watch::spawn_inhibitor_owner_watch(state).await -} - -#[cfg(test)] -#[path = "control/tests/clear.rs"] -mod clear_tests; -#[cfg(test)] -#[path = "control/tests/sanitize.rs"] -mod sanitize_tests; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs b/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs index 277e8cea..838119d7 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs @@ -1,4 +1,4 @@ -use super::clear::{clear_all_signal_plan, emit_clear_all_signals}; +use super::super::clear::{clear_all_signal_plan, emit_clear_all_signals}; use crate::test_support::daemon_state_for_test; #[test] diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs new file mode 100644 index 00000000..93800e13 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -0,0 +1,2 @@ +mod clear; +mod sanitize; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/sanitize.rs b/crates/unixnotis-daemon/src/daemon/control/tests/sanitize.rs index ee879a3f..1141155e 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/sanitize.rs @@ -1,6 +1,6 @@ use unixnotis_core::{INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; -use super::sanitize::{normalize_inhibit_scope, sanitize_inhibit_reason}; +use super::super::sanitize::{normalize_inhibit_scope, sanitize_inhibit_reason}; #[test] fn sanitize_inhibit_reason_trims_empty_reason_to_manual() { From 7e667d75e67e13df2814a0af36d72f4965c1c028 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 09/65] Organize daemon notification server Move the notification server out of the flat notifications.rs file and split server responsibilities into capabilities, close, and flow modules. Notification tests now live under daemon/notifications/tests, including the payload.rs move requested for the old payload_tests file. --- .../src/daemon/notifications.rs | 294 ----------------- .../src/daemon/notifications/mod.rs | 8 + .../src/daemon/notifications/payload.rs | 20 +- .../src/daemon/notifications/server.rs | 98 ++++++ .../notifications/server/capabilities.rs | 17 + .../src/daemon/notifications/server/close.rs | 49 +++ .../src/daemon/notifications/server/flow.rs | 268 +++++++++++++++ .../notifications/tests/capabilities.rs | 18 ++ .../src/daemon/notifications/tests/flow.rs | 306 ++++++++++++++++++ .../{payload_tests.rs => tests/payload.rs} | 0 10 files changed, 774 insertions(+), 304 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/notifications.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/close.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs rename crates/unixnotis-daemon/src/daemon/notifications/{payload_tests.rs => tests/payload.rs} (100%) diff --git a/crates/unixnotis-daemon/src/daemon/notifications.rs b/crates/unixnotis-daemon/src/daemon/notifications.rs deleted file mode 100644 index 90cef9be..00000000 --- a/crates/unixnotis-daemon/src/daemon/notifications.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! D-Bus server for org.freedesktop.Notifications -//! -//! The interface methods live here while parsing/sanitizing helpers are split into -//! focused submodules under `notifications/` - -use std::collections::HashMap; -use std::sync::Arc; - -use tracing::debug; -use unixnotis_core::{CloseReason, CONTROL_OBJECT_PATH}; -use zbus::message::Header; -use zbus::zvariant::OwnedValue; -use zbus::{interface, SignalContext}; - -use crate::expire::ExpirationScheduler; - -use super::{ - to_fdo_error, ControlServer, DaemonState, NotificationSignalMode, NOTIFICATIONS_OBJECT_PATH, -}; - -// Split hard limits into a dedicated file so security bounds are easy to review -#[path = "notifications/limits.rs"] -mod limits; -// Split payload construction into a dedicated file to keep interface code compact -#[path = "notifications/payload.rs"] -mod payload; -// Split sender metadata helpers so ownership logic is reused consistently -#[path = "notifications/sender.rs"] -mod sender; - -use payload::{build_notification, resolve_expiration, NotificationInput}; -use sender::{app_name_matches_sender, resolve_sender_metadata}; - -/// D-Bus server for org.freedesktop.Notifications -pub struct NotificationServer { - // Shared daemon state for store access, sounds, and signal emission - state: Arc, - // Scheduler handles expiration deadlines without blocking D-Bus handlers - scheduler: ExpirationScheduler, -} - -impl NotificationServer { - pub fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { - // Keep constructor minimal and explicit - Self { state, scheduler } - } -} - -#[interface(name = "org.freedesktop.Notifications")] -impl NotificationServer { - async fn get_capabilities(&self) -> Vec { - // Capabilities are static except for optional sound support - let mut caps = vec![ - "actions".to_string(), - "body".to_string(), - "body-markup".to_string(), - "icon-static".to_string(), - ]; - if self.state.sound.supports_sound() { - caps.push("sound".to_string()); - } - caps - } - - #[allow(clippy::too_many_arguments)] - async fn notify( - &self, - app_name: String, - replaces_id: u32, - app_icon: String, - summary: String, - body: String, - actions: Vec, - hints: HashMap, - #[zbus(header)] header: Header<'_>, - expire_timeout: i32, - ) -> zbus::fdo::Result { - // Debug logging is guarded so normal operation keeps log volume small - if tracing::enabled!(tracing::Level::DEBUG) { - let summary_snip = unixnotis_core::util::log_snippet(&summary); - debug!( - app = %app_name, - summary = %summary_snip, - summary_len = summary.len(), - body_len = body.len(), - replaces_id, - expire_timeout, - "received notification" - ); - if unixnotis_core::util::diagnostic_mode() { - let body_snip = unixnotis_core::util::log_snippet(&body); - debug!(body = %body_snip, "notification body snippet"); - } - } - - // Sender metadata helps with ownership checks and diagnostics - let sender = resolve_sender_metadata(self.state.connection(), &header).await; - if sender - .sender_executable - .as_deref() - .is_some_and(|exe| !app_name_matches_sender(&app_name, exe)) - { - debug!( - app_name = %app_name, - sender = sender.sender_name.as_deref().unwrap_or("unknown"), - sender_executable = sender.sender_executable.as_deref().unwrap_or("unknown"), - "notification app_name does not match sender executable" - ); - } - - // Build a safe notification record from untrusted wire data - let notification = build_notification(NotificationInput { - app_name, - app_icon, - summary, - body, - actions, - hints, - sender, - expire_timeout, - }); - - // Store mutation and expiration scheduling happen under one lock scope - let (outcome, expiration) = { - let mut store = self.state.store.lock().await; - let outcome = store.insert(notification, replaces_id); - let expiration = if outcome.dropped { - None - } else { - // Resolve timeout after insertion so rule-mapped fields are already final - let expiration = resolve_expiration(store.config(), &outcome.notification); - store.set_expiration(outcome.notification.id, expiration); - expiration - }; - (outcome, expiration) - }; - - // Drop-all inhibition path intentionally skips signals, sound, and scheduler - if outcome.dropped { - debug!( - id = outcome.notification.id, - app = %outcome.notification.app_name, - "notification dropped due to active inhibitor" - ); - return Ok(outcome.notification.id); - } - - self.scheduler - .schedule(outcome.notification.id, expiration) - .await; - - // Sound is best-effort and decided by rules and per-notification hints - self.state - .sound - .play_from_hints(&outcome.notification.hints, outcome.allow_sound); - - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; - match self - .state - .notification_signal_mode(outcome.notification.sender_name.as_deref()) - { - NotificationSignalMode::Direct => { - if outcome.replaced { - // Only the id crosses the broadcast signal - // Trusted UIs fetch the live payload through the authorized control API - ControlServer::notification_updated( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } else { - // New notification broadcasts only the id for the same confidentiality reason - ControlServer::notification_added( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } - } - NotificationSignalMode::SnapshotOnly => { - debug!( - id = outcome.notification.id, - sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), - "notification burst detected; using snapshot invalidation instead of per-row signal" - ); - self.state - .emit_snapshot_invalidated() - .await - .map_err(to_fdo_error)?; - } - NotificationSignalMode::Suppress => {} - } - - // Evicted items are announced so UIs can remove stale rows - self.handle_evicted(outcome.evicted).await?; - self.state - .emit_state_changed() - .await - .map_err(to_fdo_error)?; - - Ok(outcome.notification.id) - } - - async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { - if evicted.is_empty() { - // Fast path avoids context allocation when no eviction happened - return Ok(()); - } - - let notif_ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; - - for id in evicted { - // Emit both freedesktop and control close signals for consistent subscribers - NotificationServer::notification_closed(¬if_ctx, id, CloseReason::Undefined as u32) - .await - .map_err(to_fdo_error)?; - ControlServer::notification_closed(&control_ctx, id, CloseReason::Undefined) - .await - .map_err(to_fdo_error)?; - } - Ok(()) - } - - async fn close_notification( - &self, - id: u32, - #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result<()> { - debug!(id, "close notification requested"); - - // Close requests are ownership checked and become no-op when unauthorized - let sender = resolve_sender_metadata(self.state.connection(), &header).await; - let Some(sender_name) = sender.sender_name.as_deref() else { - return Ok(()); - }; - - let owned = { - let store = self.state.store.lock().await; - // Ownership check allows reconnect-safe close by same sender pid - store.is_notification_owned_by( - id, - sender_name, - sender.sender_pid, - sender.sender_start_time, - ) - }; - if !owned { - debug!( - id, - sender = sender_name, - sender_pid = sender.sender_pid, - "ignoring close for unowned notification" - ); - return Ok(()); - } - - self.state - .close_notification(id, CloseReason::ClosedByCall) - .await - .map_err(to_fdo_error) - } - - async fn get_server_information(&self) -> (String, String, String, String) { - // Keep server information stable for freedesktop client compatibility - ( - "UnixNotis".to_string(), - "UnixNotis".to_string(), - env!("CARGO_PKG_VERSION").to_string(), - "1.2".to_string(), - ) - } - - #[zbus(signal)] - pub(crate) async fn notification_closed( - ctx: &SignalContext<'_>, - id: u32, - reason: u32, - ) -> zbus::Result<()>; - - #[zbus(signal)] - pub(crate) async fn action_invoked( - ctx: &SignalContext<'_>, - id: u32, - action_key: &str, - ) -> zbus::Result<()>; -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs new file mode 100644 index 00000000..b67ddddf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -0,0 +1,8 @@ +//! D-Bus server for org.freedesktop.Notifications + +mod limits; +mod payload; +mod sender; +mod server; + +pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs index f0e0bbf2..a6c0f8fc 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs @@ -17,7 +17,7 @@ use super::limits::{ }; use super::sender::SenderMetadata; -// Unbroken tokens longer than this are folded with an ellipsis to avoid UI overflow spikes. +// Unbroken tokens longer than this are folded with an ellipsis to avoid UI overflow spikes const MAX_CONTIGUOUS_TOKEN_CHARS: usize = 96; pub(super) struct NotificationInput { @@ -79,13 +79,13 @@ pub(super) fn build_notification(input: NotificationInput) -> Notification { }, app_icon: truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid - // Fold very long unbroken runs so renderer width remains bounded. + // Fold very long unbroken runs so renderer width remains bounded summary: normalize_text_for_layout( &truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), MAX_CONTIGUOUS_TOKEN_CHARS, ), // Apply the same order for body so renderer sees consistent text constraints - // Body can be much larger, so apply the same run-folding protection here. + // Body can be much larger, so apply the same run-folding protection here body: normalize_text_for_layout( &truncate_utf8_bytes(&body, MAX_BODY_BYTES), MAX_CONTIGUOUS_TOKEN_CHARS, @@ -248,8 +248,8 @@ fn normalize_text_for_layout(value: &str, max_contiguous: usize) -> String { let mut run_width = 0usize; let mut folded_run = false; - // Walk characters so non-ASCII content remains valid after normalization. - // Width is tracked in display columns instead of char count to handle wide glyphs. + // Walk characters so non-ASCII content remains valid after normalization + // Width is tracked in display columns instead of char count to handle wide glyphs for ch in value.chars() { if ch.is_whitespace() { // Whitespace resets contiguous-run accounting @@ -267,10 +267,10 @@ fn normalize_text_for_layout(value: &str, max_contiguous: usize) -> String { continue; } - // Add one ellipsis when a contiguous token crosses the safety threshold. + // Add one ellipsis when a contiguous token crosses the safety threshold if !folded_run { let ellipsis_width = display_width('…'); - // Keep final run width bounded by trimming the current run tail first. + // Keep final run width bounded by trimming the current run tail first while run_width.saturating_add(ellipsis_width) > max_contiguous { // Pop one char at a time let Some(last) = out.pop() else { @@ -291,8 +291,8 @@ fn normalize_text_for_layout(value: &str, max_contiguous: usize) -> String { } fn display_width(ch: char) -> usize { - // Width estimators in downstream UI surfaces often treat joiners/selectors as visible slots. - // Counting them here keeps folded output safely within those stricter layouts. + // Width estimators in downstream UI surfaces often treat joiners/selectors as visible slots + // Counting them here keeps folded output safely within those stricter layouts if matches!( ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FE0E}' | '\u{FE0F}' @@ -303,5 +303,5 @@ fn display_width(ch: char) -> usize { } #[cfg(test)] -#[path = "payload_tests.rs"] +#[path = "tests/payload.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server.rs b/crates/unixnotis-daemon/src/daemon/notifications/server.rs new file mode 100644 index 00000000..95b8decf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server.rs @@ -0,0 +1,98 @@ +//! Notification D-Bus interface implementation + +use std::collections::HashMap; +use std::sync::Arc; + +use zbus::message::Header; +use zbus::zvariant::OwnedValue; +use zbus::{interface, SignalContext}; + +use crate::expire::ExpirationScheduler; + +use crate::daemon::DaemonState; +use capabilities::notification_capabilities; + +mod capabilities; +mod close; +mod flow; + +/// D-Bus server for org.freedesktop.Notifications +pub struct NotificationServer { + // Shared daemon state for store access, sounds, and signal emission + state: Arc, + // Scheduler handles expiration deadlines without blocking D-Bus handlers + scheduler: ExpirationScheduler, +} + +impl NotificationServer { + pub fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { + // Keep constructor minimal and explicit + Self { state, scheduler } + } +} + +#[interface(name = "org.freedesktop.Notifications")] +impl NotificationServer { + async fn get_capabilities(&self) -> Vec { + notification_capabilities(self.state.sound.supports_sound()) + } + + #[allow(clippy::too_many_arguments)] + async fn notify( + &self, + app_name: String, + replaces_id: u32, + app_icon: String, + summary: String, + body: String, + actions: Vec, + hints: HashMap, + #[zbus(header)] header: Header<'_>, + expire_timeout: i32, + ) -> zbus::fdo::Result { + self.ingest_notify( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + &header, + expire_timeout, + ) + .await + } + + async fn close_notification( + &self, + id: u32, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.close_notification_if_owned(id, &header).await + } + + async fn get_server_information(&self) -> (String, String, String, String) { + // Keep server information stable for freedesktop client compatibility + ( + "UnixNotis".to_string(), + "UnixNotis".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + "1.2".to_string(), + ) + } + + #[zbus(signal)] + pub(crate) async fn notification_closed( + ctx: &SignalContext<'_>, + id: u32, + reason: u32, + ) -> zbus::Result<()>; + + #[zbus(signal)] + pub(crate) async fn action_invoked( + ctx: &SignalContext<'_>, + id: u32, + action_key: &str, + ) -> zbus::Result<()>; +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs new file mode 100644 index 00000000..5da4b3a7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs @@ -0,0 +1,17 @@ +pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { + // Capabilities are static except for optional sound support + let mut caps = vec![ + "actions".to_string(), + "body".to_string(), + "body-markup".to_string(), + "icon-static".to_string(), + ]; + if supports_sound { + caps.push("sound".to_string()); + } + caps +} + +#[cfg(test)] +#[path = "../tests/capabilities.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs new file mode 100644 index 00000000..10cc0bbb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -0,0 +1,49 @@ +use tracing::debug; +use unixnotis_core::CloseReason; +use zbus::message::Header; + +use crate::daemon::to_fdo_error; + +use super::NotificationServer; +use crate::daemon::notifications::sender::resolve_sender_metadata; + +impl NotificationServer { + pub(super) async fn close_notification_if_owned( + &self, + id: u32, + header: &Header<'_>, + ) -> zbus::fdo::Result<()> { + debug!(id, "close notification requested"); + + // Close requests are ownership checked and become no-op when unauthorized + let sender = resolve_sender_metadata(self.state.connection(), header).await; + let Some(sender_name) = sender.sender_name.as_deref() else { + return Ok(()); + }; + + let owned = { + let store = self.state.store.lock().await; + // Ownership check allows reconnect-safe close by same sender pid + store.is_notification_owned_by( + id, + sender_name, + sender.sender_pid, + sender.sender_start_time, + ) + }; + if !owned { + debug!( + id, + sender = sender_name, + sender_pid = sender.sender_pid, + "ignoring close for unowned notification" + ); + return Ok(()); + } + + self.state + .close_notification(id, CloseReason::ClosedByCall) + .await + .map_err(to_fdo_error) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs new file mode 100644 index 00000000..d8421c42 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; +use std::time::Instant; + +use tracing::debug; +use unixnotis_core::{CloseReason, Notification, CONTROL_OBJECT_PATH}; +use zbus::message::Header; +use zbus::zvariant::OwnedValue; +use zbus::SignalContext; + +use crate::daemon::notifications::payload::{ + build_notification, resolve_expiration, NotificationInput, +}; +use crate::daemon::notifications::sender::{app_name_matches_sender, resolve_sender_metadata}; +use crate::daemon::{ + to_fdo_error, ControlServer, NotificationSignalMode, NOTIFICATIONS_OBJECT_PATH, +}; +use crate::store::InsertOutcome; + +use super::NotificationServer; + +struct StoredNotification { + outcome: InsertOutcome, + expiration: Option, +} + +struct WireNotification { + app_name: String, + app_icon: String, + summary: String, + body: String, + actions: Vec, + hints: HashMap, + expire_timeout: i32, +} + +impl NotificationServer { + #[allow(clippy::too_many_arguments)] + pub(super) async fn ingest_notify( + &self, + app_name: String, + replaces_id: u32, + app_icon: String, + summary: String, + body: String, + actions: Vec, + hints: HashMap, + header: &Header<'_>, + expire_timeout: i32, + ) -> zbus::fdo::Result { + Self::log_received_notification(&app_name, &summary, &body, replaces_id, expire_timeout); + let notification = self + .notification_from_wire( + WireNotification { + app_name, + app_icon, + summary, + body, + actions, + hints, + expire_timeout, + }, + header, + ) + .await; + let stored = self.store_notification(notification, replaces_id).await; + self.finish_notification_change(stored.outcome, stored.expiration) + .await + } + + fn log_received_notification( + app_name: &str, + summary: &str, + body: &str, + replaces_id: u32, + expire_timeout: i32, + ) { + // Debug logging is guarded so normal operation keeps log volume small + if !tracing::enabled!(tracing::Level::DEBUG) { + return; + } + let summary_snip = unixnotis_core::util::log_snippet(summary); + debug!( + app = %app_name, + summary = %summary_snip, + summary_len = summary.len(), + body_len = body.len(), + replaces_id, + expire_timeout, + "received notification" + ); + if unixnotis_core::util::diagnostic_mode() { + let body_snip = unixnotis_core::util::log_snippet(body); + debug!(body = %body_snip, "notification body snippet"); + } + } + + async fn notification_from_wire( + &self, + input: WireNotification, + header: &Header<'_>, + ) -> Notification { + // Sender metadata helps with ownership checks and diagnostics + let sender = resolve_sender_metadata(self.state.connection(), header).await; + if sender_app_name_mismatch(&input.app_name, sender.sender_executable.as_deref()) { + debug!( + app_name = %input.app_name, + sender = sender.sender_name.as_deref().unwrap_or("unknown"), + sender_executable = sender.sender_executable.as_deref().unwrap_or("unknown"), + "notification app_name does not match sender executable" + ); + } + + // Build a safe notification record from untrusted wire data + build_notification(NotificationInput { + app_name: input.app_name, + app_icon: input.app_icon, + summary: input.summary, + body: input.body, + actions: input.actions, + hints: input.hints, + sender, + expire_timeout: input.expire_timeout, + }) + } + + async fn store_notification( + &self, + notification: Notification, + replaces_id: u32, + ) -> StoredNotification { + // Store mutation and expiration scheduling happen under one lock scope + let (outcome, expiration) = { + let mut store = self.state.store.lock().await; + let outcome = store.insert(notification, replaces_id); + let expiration = if outcome.dropped { + None + } else { + // Resolve timeout after insertion so rule-mapped fields are already final + let expiration = resolve_expiration(store.config(), &outcome.notification); + store.set_expiration(outcome.notification.id, expiration); + expiration + }; + (outcome, expiration) + }; + StoredNotification { + outcome, + expiration, + } + } + + fn handle_dropped_notification(outcome: &InsertOutcome) -> Option { + if !outcome.dropped { + return None; + } + debug!( + id = outcome.notification.id, + app = %outcome.notification.app_name, + "notification dropped due to active inhibitor" + ); + Some(outcome.notification.id) + } + + fn schedule_and_play(&self, outcome: &InsertOutcome, expiration: Option) { + self.scheduler.schedule(outcome.notification.id, expiration); + // Sound is best-effort and decided by rules and per-notification hints + self.state + .sound + .play_from_hints(&outcome.notification.hints, outcome.allow_sound); + } + + async fn emit_notification_change(&self, outcome: &InsertOutcome) -> zbus::fdo::Result<()> { + let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) + .map_err(to_fdo_error)?; + match self + .state + .notification_signal_mode(outcome.notification.sender_name.as_deref()) + { + NotificationSignalMode::Direct => { + if outcome.replaced { + // Only the id crosses the broadcast signal + // Trusted UIs fetch the live payload through the authorized control API + ControlServer::notification_updated( + &control_ctx, + outcome.notification.id, + outcome.show_popup, + ) + .await + .map_err(to_fdo_error)?; + } else { + // New notification broadcasts only the id for the same confidentiality reason + ControlServer::notification_added( + &control_ctx, + outcome.notification.id, + outcome.show_popup, + ) + .await + .map_err(to_fdo_error)?; + } + } + NotificationSignalMode::SnapshotOnly => { + debug!( + id = outcome.notification.id, + sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), + "notification burst detected; using snapshot invalidation instead of per-row signal" + ); + self.state + .emit_snapshot_invalidated() + .await + .map_err(to_fdo_error)?; + } + NotificationSignalMode::Suppress => {} + } + Ok(()) + } + + async fn finish_notification_change( + &self, + outcome: InsertOutcome, + expiration: Option, + ) -> zbus::fdo::Result { + if let Some(id) = Self::handle_dropped_notification(&outcome) { + return Ok(id); + } + + self.schedule_and_play(&outcome, expiration); + self.emit_notification_change(&outcome).await?; + // Evicted items are announced so UIs can remove stale rows + self.handle_evicted(outcome.evicted).await?; + self.state + .emit_state_changed() + .await + .map_err(to_fdo_error)?; + + Ok(outcome.notification.id) + } + + async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { + if evicted.is_empty() { + // Fast path avoids context allocation when no eviction happened + return Ok(()); + } + self.state.cancel_expirations(&evicted); + + let notif_ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) + .map_err(to_fdo_error)?; + let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) + .map_err(to_fdo_error)?; + + for id in evicted { + // Emit both freedesktop and control close signals for consistent subscribers + NotificationServer::notification_closed(¬if_ctx, id, CloseReason::Undefined as u32) + .await + .map_err(to_fdo_error)?; + ControlServer::notification_closed(&control_ctx, id, CloseReason::Undefined) + .await + .map_err(to_fdo_error)?; + } + Ok(()) + } +} + +fn sender_app_name_mismatch(app_name: &str, sender_executable: Option<&str>) -> bool { + sender_executable.is_some_and(|exe| !app_name_matches_sender(app_name, exe)) +} + +#[cfg(test)] +#[path = "../tests/flow.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs new file mode 100644 index 00000000..b762cb23 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs @@ -0,0 +1,18 @@ +use super::notification_capabilities; + +#[test] +fn notification_capabilities_without_sound_keeps_static_contract() { + let caps = notification_capabilities(false); + + assert_eq!(caps, ["actions", "body", "body-markup", "icon-static"]); +} + +#[test] +fn notification_capabilities_adds_sound_only_when_backend_supports_it() { + let caps = notification_capabilities(true); + + assert_eq!( + caps, + ["actions", "body", "body-markup", "icon-static", "sound"] + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs new file mode 100644 index 00000000..331bd509 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use futures_util::TryStreamExt; +use unixnotis_core::{ + CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, +}; +use zbus::message::Type; +use zbus::{Connection, MatchRule, Message, MessageStream}; + +use crate::daemon::{DaemonState, NotificationServer}; +use crate::expire::ExpirationScheduler; +use crate::sound::SoundSettings; +use crate::store::InsertOutcome; +use crate::test_support::daemon_state_for_test; + +fn notification_with_id(id: u32) -> Arc { + Arc::new(Notification { + id, + app_name: "app".to_string(), + app_icon: String::new(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: -1, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + }) +} + +fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { + InsertOutcome { + notification: notification_with_id(id), + replaced: false, + show_popup: !dropped, + allow_sound: !dropped, + evicted: Vec::new(), + dropped, + } +} + +#[test] +fn sender_app_name_mismatch_is_false_without_executable_metadata() { + assert!(!super::sender_app_name_mismatch("Calendar", None)); +} + +#[test] +fn sender_app_name_mismatch_is_false_when_app_matches_executable() { + assert!(!super::sender_app_name_mismatch( + "UnixNotis Center", + Some("/usr/bin/unixnotis-center"), + )); +} + +#[test] +fn sender_app_name_mismatch_is_true_when_app_does_not_match_executable() { + assert!(super::sender_app_name_mismatch( + "Calendar", + Some("/usr/bin/firefox"), + )); +} + +fn notify_header_message() -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("interface") + .sender(":1.42") + .expect("sender") + .build(&()) + .expect("message") +} + +async fn daemon_state_with_config(config: Config) -> Arc { + let connection = Connection::session().await.expect("session bus"); + let sound = SoundSettings::from_config(&config); + DaemonState::new(connection, config, sound, false) +} + +async fn control_signal_stream(state: &DaemonState, member: &str) -> MessageStream { + let receiver = Connection::session().await.expect("receiver session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("sender") + .path(CONTROL_OBJECT_PATH) + .expect("path") + .interface("com.unixnotis.Control") + .expect("interface") + .member(member) + .expect("member") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("signal stream") +} + +async fn next_signal(stream: &mut MessageStream) -> Message { + tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("signal should arrive before timeout") + .expect("signal stream should stay open") + .expect("signal message") +} + +#[test] +fn handle_dropped_notification_returns_id_for_dropped_payload() { + let outcome = insert_outcome(9, true); + + let id = NotificationServer::handle_dropped_notification(&outcome); + + assert_eq!(id, Some(9)); +} + +#[test] +fn handle_dropped_notification_returns_none_for_stored_payload() { + let outcome = insert_outcome(9, false); + + let id = NotificationServer::handle_dropped_notification(&outcome); + + assert_eq!(id, None); +} + +#[tokio::test] +async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 0, + ) + .await + .expect("notify should store"); + let second_id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "next".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 0, + ) + .await + .expect("second notify should store"); + + let store = state.store.lock().await; + let active = store.active_notification_view(id).expect("active view"); + assert_eq!(id, 1); + assert_eq!(second_id, 2); + assert_eq!(active.id, id); + assert_eq!(active.summary, "summary"); +} + +#[tokio::test] +async fn ingest_notify_schedules_expiration_for_positive_timeout() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "expires".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 25, + ) + .await + .expect("notify should store"); + + for _ in 0..30 { + if state + .store + .lock() + .await + .active_notification_view(id) + .is_none() + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + panic!("notification should expire after scheduled timeout"); +} + +#[tokio::test] +async fn ingest_notify_emits_notification_added_signal() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationAdded").await; + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 0, + ) + .await + .expect("notify should store"); + + let signal = next_signal(&mut stream).await; + let (signal_id, show_popup) = signal + .body() + .deserialize::<(u32, bool)>() + .expect("notification added body"); + assert_eq!(signal_id, id); + assert!(show_popup); +} + +#[tokio::test] +async fn ingest_notify_emits_control_close_for_evicted_active_notification() { + let mut config = Config::default(); + config.history.max_active = 1; + let state = daemon_state_with_config(config).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationClosed").await; + + let first_id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "first".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 0, + ) + .await + .expect("first notify should store"); + server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "second".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + &header, + 0, + ) + .await + .expect("second notify should store"); + + let signal = next_signal(&mut stream).await; + let (signal_id, reason) = signal + .body() + .deserialize::<(u32, CloseReason)>() + .expect("notification closed body"); + assert_eq!(signal_id, first_id); + assert_eq!(reason as u32, CloseReason::Undefined as u32); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload_tests.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/notifications/payload_tests.rs rename to crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs From dd796f656c09e7a549275b70be9ed5d3c5c476cd Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 10/65] Split daemon state into focused modules Replace the long daemon/state.rs file with state modules for the model, scheduler wiring, notification lifecycle helpers, runtime flags, cache logic, and signal fanout. Focused tests now live under daemon/state/tests instead of daemon/tests. --- crates/unixnotis-daemon/src/daemon/state.rs | 273 ------------------ .../src/daemon/state/cache.rs | 22 ++ .../unixnotis-daemon/src/daemon/state/mod.rs | 13 + .../src/daemon/state/model.rs | 66 +++++ .../src/daemon/state/notifications.rs | 51 ++++ .../src/daemon/state/runtime.rs | 41 +++ .../src/daemon/state/scheduler.rs | 46 +++ .../src/daemon/state/signals.rs | 208 +++++++++++++ .../src/daemon/state/tests/cache.rs | 75 +++++ .../src/daemon/state/tests/mod.rs | 3 + .../src/daemon/state/tests/runtime.rs | 38 +++ .../src/daemon/state/tests/signals.rs | 243 ++++++++++++++++ .../src/daemon/tests/state.rs | 65 ----- 13 files changed, 806 insertions(+), 338 deletions(-) delete mode 100644 crates/unixnotis-daemon/src/daemon/state.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/cache.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/model.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/notifications.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/runtime.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/scheduler.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/signals.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/cache.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/mod.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/signals.rs delete mode 100644 crates/unixnotis-daemon/src/daemon/tests/state.rs diff --git a/crates/unixnotis-daemon/src/daemon/state.rs b/crates/unixnotis-daemon/src/daemon/state.rs deleted file mode 100644 index d923cdbb..00000000 --- a/crates/unixnotis-daemon/src/daemon/state.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Shared daemon state and signal fanout coordination - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; - -use tokio::sync::Mutex; -use tracing::warn; -use unixnotis_core::{CloseReason, Config, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; -use zbus::{Connection, SignalContext}; - -use crate::expire::ExpirationScheduler; -use crate::sound::SoundSettings; -use crate::store::NotificationStore; - -use super::signal_burst::{ - notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, -}; -use super::{ControlServer, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; - -/// Shared daemon state guarded behind an async mutex. -pub struct DaemonState { - pub store: Mutex, - /// Immutable sound settings resolved at startup. - pub sound: SoundSettings, - connection: Connection, - // Panel control should only succeed once the center has subscribed - // This avoids accepting requests that no live listener can receive - panel_ready: AtomicBool, - popups_running: AtomicBool, - // Scheduler is installed after state startup so close paths can cancel timers - scheduler: OnceLock, - // Warn once if scheduler-backed operations happen before install - scheduler_missing_warned: AtomicBool, - // Cache the last control-state snapshot so no-op signals can be skipped - pub(in crate::daemon) last_emitted_state: StdMutex>, - // Popup UIs only care about the gate, not panel history counters - pub(in crate::daemon) last_emitted_popup_gate: StdMutex>, - // Burst tracking lets one noisy sender fall back to snapshot invalidation - // instead of forcing a storm of full add/update fanout - notification_signal_bursts: StdMutex>, - // Trial mode allows local rebuild loops without forcing daemon restarts for control auth - trial_mode: bool, -} - -impl DaemonState { - pub fn new( - connection: Connection, - config: Config, - sound: SoundSettings, - trial_mode: bool, - ) -> Arc { - let store = NotificationStore::new(config); - Arc::new(Self { - store: Mutex::new(store), - sound, - connection, - panel_ready: AtomicBool::new(false), - popups_running: AtomicBool::new(false), - scheduler: OnceLock::new(), - scheduler_missing_warned: AtomicBool::new(false), - last_emitted_state: StdMutex::new(None), - last_emitted_popup_gate: StdMutex::new(None), - notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), - trial_mode, - }) - } - - pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { - // Scheduler is wired once during daemon startup - if self.scheduler.set(scheduler).is_err() { - warn!("expiration scheduler was already installed; ignoring duplicate initialization"); - return; - } - self.scheduler_missing_warned.store(false, Ordering::SeqCst); - } - - fn scheduler(&self) -> Option { - // Cloning the sender handle is cheap and keeps await points simple - let scheduler = self.scheduler.get().cloned(); - if scheduler.is_none() && !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) { - warn!("expiration scheduler is unavailable during live daemon operation"); - } - scheduler - } - - async fn cancel_expiration(&self, id: u32) { - // Missing scheduler means startup is still incomplete, so skip quietly - let Some(scheduler) = self.scheduler() else { - return; - }; - scheduler.schedule(id, None).await; - } - - pub async fn cancel_expirations(&self, ids: &[u32]) { - // Cancel timers for every removed active id so stale wakeups do not build up - // Per-id cancel keeps the existing lazy heap design simple and predictable - let Some(scheduler) = self.scheduler() else { - return; - }; - for id in ids { - scheduler.schedule(*id, None).await; - } - } - - pub async fn close_notification(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { - let removed = { - let mut store = self.store.lock().await; - store.close(id, reason) - }; - if removed.is_none() { - return Ok(()); - } - // Timer cancel happens before signal fanout so stale wakeups stop right away - self.cancel_expiration(id).await; - - if let Err(err) = self.emit_close_fanout(id, reason).await { - warn!( - ?err, - id, - reason = reason as u32, - "notification close committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } - - pub async fn dismiss_from_panel(&self, id: u32) -> zbus::Result<()> { - let outcome = { - let mut store = self.store.lock().await; - store.dismiss_from_panel(id) - }; - - if !outcome.removed_any() { - return Ok(()); - } - - if outcome.removed_active { - // Panel dismiss removes the active entry, so its timer must go too - self.cancel_expiration(id).await; - } - if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { - warn!( - ?err, - id, "panel dismiss committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } - - async fn emit_close_fanout(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { - let notif_ctx = SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH)?; - NotificationServer::notification_closed(¬if_ctx, id, reason as u32).await?; - - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - ControlServer::notification_closed(&control_ctx, id, reason).await?; - self.emit_state_changed().await - } - - async fn emit_dismiss_fanout(&self, id: u32, removed_active: bool) -> zbus::Result<()> { - if removed_active { - let notif_ctx = SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH)?; - NotificationServer::notification_closed( - ¬if_ctx, - id, - CloseReason::DismissedByUser as u32, - ) - .await?; - } - - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - ControlServer::notification_closed(&control_ctx, id, CloseReason::DismissedByUser).await?; - self.emit_state_changed().await - } - - pub(in crate::daemon) async fn emit_state_changed(&self) -> zbus::Result<()> { - let state = { - let store = self.store.lock().await; - let history_count = store.history_len() as u32; - // Panel consumers still need history and inhibitor counters in one snapshot - ControlState { - dnd_enabled: store.dnd_enabled(), - history_count, - inhibited: store.inhibited(), - inhibitor_count: store.inhibitor_count(), - } - }; - // Popup policy only depends on the gate, so history churn should not wake it up - let popup_gate = PopupGateState { - dnd_enabled: state.dnd_enabled, - inhibited: state.inhibited, - }; - // Duplicate broadcasts add D-Bus churn without changing UI behavior - let should_emit_state = should_emit_cached(&self.last_emitted_state, &state); - let should_emit_popup_gate = should_emit_cached(&self.last_emitted_popup_gate, &popup_gate); - if !should_emit_state && !should_emit_popup_gate { - return Ok(()); - } - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - if should_emit_state { - ControlServer::state_changed(&control_ctx, state).await?; - } - if should_emit_popup_gate { - ControlServer::popup_gate_changed(&control_ctx, popup_gate).await?; - } - Ok(()) - } - - pub async fn emit_snapshot_invalidated(&self) -> zbus::Result<()> { - // This signal tells clients their local materialized view may be stale - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - ControlServer::snapshot_invalidated(&control_ctx).await - } - - pub(crate) fn connection(&self) -> &Connection { - &self.connection - } - - pub(crate) fn set_panel_ready(&self, ready: bool) { - // SeqCst keeps state changes easy to follow during crash recovery - self.panel_ready.store(ready, Ordering::SeqCst); - } - - pub(crate) fn set_popups_running(&self, running: bool) { - // Popup health is tracked for supervision and diagnostics - self.popups_running.store(running, Ordering::SeqCst); - } - - #[cfg(test)] - pub(crate) fn popups_running(&self) -> bool { - // Read path is used by supervision tests and diagnostics without mutating daemon state - self.popups_running.load(Ordering::SeqCst) - } - - pub(crate) fn panel_ready(&self) -> bool { - self.panel_ready.load(Ordering::SeqCst) - } - - pub(crate) fn notification_signal_mode( - &self, - sender_name: Option<&str>, - ) -> NotificationSignalMode { - notification_signal_mode_for_sender( - &self.notification_signal_bursts, - sender_name.unwrap_or(""), - ) - } - - pub(crate) fn trial_mode(&self) -> bool { - self.trial_mode - } -} - -fn should_emit_cached(cache: &StdMutex>, value: &T) -> bool { - // Sync mutex is enough here because this cache is tiny and never held across await points - let mut last_value = match cache.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - if last_value - .as_ref() - .is_some_and(|previous| previous == value) - { - // Identical state would only burn CPU in zbus and the listeners - return false; - } - // Clone once on change so later comparisons stay allocation-free for equal values - *last_value = Some(value.clone()); - true -} - -#[cfg(test)] -#[path = "tests/state.rs"] -mod state_tests; diff --git a/crates/unixnotis-daemon/src/daemon/state/cache.rs b/crates/unixnotis-daemon/src/daemon/state/cache.rs new file mode 100644 index 00000000..08f108c8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/cache.rs @@ -0,0 +1,22 @@ +use std::sync::Mutex as StdMutex; + +pub(in crate::daemon::state) fn should_emit_cached( + cache: &StdMutex>, + value: &T, +) -> bool { + // Sync mutex is enough here because this cache is tiny and never held across await points + let mut last_value = match cache.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if last_value + .as_ref() + .is_some_and(|previous| previous == value) + { + // Identical state would only burn CPU in zbus and the listeners + return false; + } + // Clone once on change so later comparisons stay allocation-free for equal values + *last_value = Some(value.clone()); + true +} diff --git a/crates/unixnotis-daemon/src/daemon/state/mod.rs b/crates/unixnotis-daemon/src/daemon/state/mod.rs new file mode 100644 index 00000000..90143ea8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/mod.rs @@ -0,0 +1,13 @@ +//! Shared daemon state and signal fanout coordination + +mod cache; +mod model; +mod notifications; +mod runtime; +mod scheduler; +mod signals; + +pub use model::DaemonState; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs new file mode 100644 index 00000000..a447cfff --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -0,0 +1,66 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; + +use tokio::sync::Mutex; +use unixnotis_core::{Config, ControlState, PopupGateState}; +use zbus::Connection; + +use crate::expire::ExpirationScheduler; +use crate::sound::SoundSettings; +use crate::store::NotificationStore; + +use crate::daemon::signal_burst::NotificationBurstState; + +/// Shared daemon state guarded behind an async mutex +pub struct DaemonState { + pub store: Mutex, + /// Immutable sound settings resolved at startup + pub sound: SoundSettings, + pub(in crate::daemon::state) connection: Connection, + // Panel control should only succeed once the center has subscribed + // This avoids accepting requests that no live listener can receive + pub(in crate::daemon::state) panel_ready: AtomicBool, + pub(in crate::daemon::state) popups_running: AtomicBool, + // Scheduler is installed after state startup so close paths can cancel timers + pub(in crate::daemon::state) scheduler: OnceLock, + // Warn once if scheduler-backed operations happen before install + pub(in crate::daemon::state) scheduler_missing_warned: AtomicBool, + // Cache the last control-state snapshot so no-op signals can be skipped + pub(in crate::daemon) last_emitted_state: StdMutex>, + // Popup UIs only care about the gate, not panel history counters + pub(in crate::daemon) last_emitted_popup_gate: StdMutex>, + // Burst tracking lets one noisy sender fall back to snapshot invalidation + // instead of forcing a storm of full add/update fanout + pub(in crate::daemon::state) notification_signal_bursts: + StdMutex>, + // Trial mode allows local rebuild loops without forcing daemon restarts for control auth + pub(in crate::daemon::state) trial_mode: bool, +} + +impl DaemonState { + pub fn new( + connection: Connection, + config: Config, + sound: SoundSettings, + trial_mode: bool, + ) -> Arc { + let store = NotificationStore::new(config); + Arc::new(Self { + store: Mutex::new(store), + sound, + connection, + panel_ready: AtomicBool::new(false), + popups_running: AtomicBool::new(false), + scheduler: OnceLock::new(), + scheduler_missing_warned: AtomicBool::new(false), + last_emitted_state: StdMutex::new(None), + last_emitted_popup_gate: StdMutex::new(None), + notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), + trial_mode, + }) + } + + pub(crate) fn connection(&self) -> &Connection { + &self.connection + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/notifications.rs new file mode 100644 index 00000000..baf9bd76 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/notifications.rs @@ -0,0 +1,51 @@ +use tracing::warn; +use unixnotis_core::CloseReason; + +use super::DaemonState; + +impl DaemonState { + pub async fn close_notification(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { + let removed = { + let mut store = self.store.lock().await; + store.close(id, reason) + }; + if removed.is_none() { + return Ok(()); + } + // Timer cancel happens before signal fanout so stale wakeups stop right away + self.cancel_expiration(id); + + if let Err(err) = self.emit_close_fanout(id, reason).await { + warn!( + ?err, + id, + reason = reason as u32, + "notification close committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } + + pub async fn dismiss_from_panel(&self, id: u32) -> zbus::Result<()> { + let outcome = { + let mut store = self.store.lock().await; + store.dismiss_from_panel(id) + }; + + if !outcome.removed_any() { + return Ok(()); + } + + if outcome.removed_active { + // Panel dismiss removes the active entry, so its timer must go too + self.cancel_expiration(id); + } + if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { + warn!( + ?err, + id, "panel dismiss committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/runtime.rs new file mode 100644 index 00000000..b6a7089d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/runtime.rs @@ -0,0 +1,41 @@ +use std::sync::atomic::Ordering; + +use crate::daemon::signal_burst::{notification_signal_mode_for_sender, NotificationSignalMode}; + +use super::DaemonState; + +impl DaemonState { + pub(crate) fn set_panel_ready(&self, ready: bool) { + // SeqCst keeps state changes easy to follow during crash recovery + self.panel_ready.store(ready, Ordering::SeqCst); + } + + pub(crate) fn set_popups_running(&self, running: bool) { + // Popup health is tracked for supervision and diagnostics + self.popups_running.store(running, Ordering::SeqCst); + } + + #[cfg(test)] + pub(crate) fn popups_running(&self) -> bool { + // Read path is used by supervision tests and diagnostics without mutating daemon state + self.popups_running.load(Ordering::SeqCst) + } + + pub(crate) fn panel_ready(&self) -> bool { + self.panel_ready.load(Ordering::SeqCst) + } + + pub(crate) fn notification_signal_mode( + &self, + sender_name: Option<&str>, + ) -> NotificationSignalMode { + notification_signal_mode_for_sender( + &self.notification_signal_bursts, + sender_name.unwrap_or(""), + ) + } + + pub(crate) fn trial_mode(&self) -> bool { + self.trial_mode + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs new file mode 100644 index 00000000..07ce6760 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs @@ -0,0 +1,46 @@ +use std::sync::atomic::Ordering; + +use tracing::warn; + +use crate::expire::ExpirationScheduler; + +use super::DaemonState; + +impl DaemonState { + pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { + // Scheduler is wired once during daemon startup + if self.scheduler.set(scheduler).is_err() { + warn!("expiration scheduler was already installed; ignoring duplicate initialization"); + return; + } + self.scheduler_missing_warned.store(false, Ordering::SeqCst); + } + + fn scheduler(&self) -> Option { + // Cloning the sender handle is cheap and keeps await points simple + let scheduler = self.scheduler.get().cloned(); + if scheduler.is_none() && !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) { + warn!("expiration scheduler is unavailable during live daemon operation"); + } + scheduler + } + + pub(in crate::daemon) fn cancel_expiration(&self, id: u32) { + // Missing scheduler means startup is still incomplete, so skip quietly + let Some(scheduler) = self.scheduler() else { + return; + }; + scheduler.schedule(id, None); + } + + pub fn cancel_expirations(&self, ids: &[u32]) { + // Cancel timers for every removed active id so stale wakeups do not build up + // Per-id cancel keeps the existing lazy heap design simple and predictable + let Some(scheduler) = self.scheduler() else { + return; + }; + for id in ids { + scheduler.schedule(*id, None); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/signals.rs b/crates/unixnotis-daemon/src/daemon/state/signals.rs new file mode 100644 index 00000000..51115f7a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/signals.rs @@ -0,0 +1,208 @@ +#[cfg(test)] +use std::sync::Mutex as StdMutex; + +use unixnotis_core::{CloseReason, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; +use zbus::SignalContext; + +use crate::daemon::{ControlServer, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; +use crate::store::NotificationStore; + +use super::cache::should_emit_cached; +use super::DaemonState; + +impl DaemonState { + // Sends all the "this notification closed" messages that different listeners expect + pub(in crate::daemon) async fn emit_close_fanout( + &self, + id: u32, + reason: CloseReason, + ) -> zbus::Result<()> { + // Keep the first thing that goes wrong, but still try to send every signal + let mut first_error = None; + + // Tell the standard notification interface that this notification closed + self.emit_freedesktop_close(id, reason as u32, &mut first_error) + .await; + + // Tell this daemon's control interface that the same notification closed + self.emit_control_close(id, reason, &mut first_error).await; + + // After a close, the stored state may look different, so tell clients about that too + if let Err(err) = self.emit_state_changed().await { + record_signal_error(&mut first_error, err); + } + + // Return success only if every attempted signal avoided errors + first_error.map_or(Ok(()), Err) + } + + // Sends the signals needed when a notification is dismissed by the user + pub(in crate::daemon) async fn emit_dismiss_fanout( + &self, + id: u32, + removed_active: bool, + ) -> zbus::Result<()> { + // Save the first error, while still giving the other signals a chance to run + let mut first_error = None; + + // Only the active notification needs the freedesktop close signal here + if removed_active { + self.emit_freedesktop_close(id, CloseReason::DismissedByUser as u32, &mut first_error) + .await; + } + + // The control side is always told that the notification was dismissed + self.emit_control_close(id, CloseReason::DismissedByUser, &mut first_error) + .await; + + // Let clients know the visible daemon state may have changed after dismissal + if let Err(err) = self.emit_state_changed().await { + record_signal_error(&mut first_error, err); + } + + // Give back the first error if any signal failed + first_error.map_or(Ok(()), Err) + } + + // Sends the close signal on the standard desktop notifications interface + async fn emit_freedesktop_close( + &self, + id: u32, + reason: u32, + first_error: &mut Option, + ) { + // Build the D-Bus signal context for the normal notifications object path + match SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH) { + Ok(notif_ctx) => { + // Send the actual "notification closed" signal to desktop clients + if let Err(err) = + NotificationServer::notification_closed(¬if_ctx, id, reason).await + { + // Remember this error only if no earlier signal already failed + record_signal_error(first_error, err); + } + } + // If the signal context cannot be made, remember that as the signal error + Err(err) => record_signal_error(first_error, err), + } + } + + // Sends the close signal on this daemon's control interface + async fn emit_control_close( + &self, + id: u32, + reason: CloseReason, + first_error: &mut Option, + ) { + // Build the D-Bus signal context for the control object path + match SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) { + Ok(control_ctx) => { + // Send the control-layer close event with the richer CloseReason enum + if let Err(err) = ControlServer::notification_closed(&control_ctx, id, reason).await + { + // Store the first failure so callers can still hear about a problem + record_signal_error(first_error, err); + } + } + // If the control signal context fails, treat it like any other signal failure + Err(err) => record_signal_error(first_error, err), + } + } + + // Rebuilds the current public state and tells clients only if something changed + pub(in crate::daemon) async fn emit_state_changed(&self) -> zbus::Result<()> { + // Lock the store briefly so we can take a clean snapshot of the current state + let state = { + let store = self.store.lock().await; + control_state_from_store(&store) + }; + + // Work out whether popups should currently be allowed from that state + let popup_gate = popup_gate_from_state(&state); + + // Duplicate broadcasts add D-Bus churn without changing UI behavior + let should_emit_state = should_emit_cached(&self.last_emitted_state, &state); + + // Avoid sending the popup gate signal if clients already know this value + let should_emit_popup_gate = should_emit_cached(&self.last_emitted_popup_gate, &popup_gate); + + // If neither value changed, there is nothing useful to send + if !should_emit_any_state_signal(should_emit_state, should_emit_popup_gate) { + return Ok(()); + } + + // Create one control context and reuse it for whichever state signals are needed + let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; + + // Keep the first send error while still trying the other state signal + let mut first_error = None; + + // Send the full state update only when the cached state says it is new + if should_emit_state { + if let Err(err) = ControlServer::state_changed(&control_ctx, state).await { + record_signal_error(&mut first_error, err); + } + } + + // Send the popup gate update only when that specific value changed + if should_emit_popup_gate { + if let Err(err) = ControlServer::popup_gate_changed(&control_ctx, popup_gate).await { + record_signal_error(&mut first_error, err); + } + } + + // Report the first signal error, or success if both needed signals worked + first_error.map_or(Ok(()), Err) + } + + // Tells clients to throw away their cached snapshot and fetch a fresh one + pub async fn emit_snapshot_invalidated(&self) -> zbus::Result<()> { + // This signal tells clients their local materialized view may be stale + let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; + ControlServer::snapshot_invalidated(&control_ctx).await + } +} + +pub(in crate::daemon::state) fn control_state_from_store( + store: &NotificationStore, +) -> ControlState { + // Panel consumers still need history and inhibitor counters in one snapshot + ControlState { + dnd_enabled: store.dnd_enabled(), + history_count: store.history_len() as u32, + inhibited: store.inhibited(), + inhibitor_count: store.inhibitor_count(), + } +} + +pub(in crate::daemon::state) fn popup_gate_from_state(state: &ControlState) -> PopupGateState { + // Popup policy only depends on the gate, so history churn should not wake it up + PopupGateState { + dnd_enabled: state.dnd_enabled, + inhibited: state.inhibited, + } +} + +pub(in crate::daemon::state) const fn should_emit_any_state_signal( + should_emit_state: bool, + should_emit_popup_gate: bool, +) -> bool { + should_emit_state || should_emit_popup_gate +} + +pub(in crate::daemon::state) fn record_signal_error( + first_error: &mut Option, + err: zbus::Error, +) { + if first_error.is_none() { + *first_error = Some(err); + } +} + +#[cfg(test)] +pub(in crate::daemon::state) fn cached_state_would_emit( + cache: &StdMutex>, + value: &T, +) -> bool { + should_emit_cached(cache, value) +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs new file mode 100644 index 00000000..84290935 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs @@ -0,0 +1,75 @@ +use std::sync::Mutex; + +use unixnotis_core::{ControlState, PopupGateState}; + +use super::super::signals::cached_state_would_emit; + +#[test] +fn cached_state_emits_first_value_then_suppresses_duplicates() { + let cache = Mutex::new(None); + let state = ControlState { + dnd_enabled: false, + history_count: 1, + inhibited: false, + inhibitor_count: 0, + }; + + // First value must be emitted because clients have no previous state + assert!(cached_state_would_emit(&cache, &state)); + // Identical values should not wake D-Bus subscribers again + assert!(!cached_state_would_emit(&cache, &state)); +} + +#[test] +fn cached_state_emits_when_any_gate_field_changes() { + let cache = Mutex::new(None); + let open = PopupGateState { + dnd_enabled: false, + inhibited: false, + }; + let dnd = PopupGateState { + dnd_enabled: true, + inhibited: false, + }; + + assert!(cached_state_would_emit(&cache, &open)); + // A changed popup gate affects visibility policy, so it must emit + assert!(cached_state_would_emit(&cache, &dnd)); + assert!(!cached_state_would_emit(&cache, &dnd)); +} + +#[test] +fn cached_state_emits_after_counter_change() { + let cache = Mutex::new(None); + let first = ControlState { + dnd_enabled: false, + history_count: 0, + inhibited: false, + inhibitor_count: 0, + }; + let changed = ControlState { + history_count: 1, + ..first.clone() + }; + + assert!(cached_state_would_emit(&cache, &first)); + assert!(cached_state_would_emit(&cache, &changed)); + assert!(!cached_state_would_emit(&cache, &changed)); +} + +#[test] +fn cached_state_recovers_from_poisoned_mutex() { + let cache = Mutex::new(None); + let _ = std::panic::catch_unwind(|| { + let _guard = cache.lock().expect("lock before poison"); + panic!("poison cache"); + }); + + let state = PopupGateState { + dnd_enabled: false, + inhibited: true, + }; + + assert!(cached_state_would_emit(&cache, &state)); + assert!(!cached_state_would_emit(&cache, &state)); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs new file mode 100644 index 00000000..a391b21e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -0,0 +1,3 @@ +mod cache; +mod runtime; +mod signals; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs new file mode 100644 index 00000000..8194fe0d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs @@ -0,0 +1,38 @@ +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn daemon_state_boolean_flags_reflect_runtime_updates() { + let state = daemon_state_for_test(true).await; + + assert!(state.trial_mode()); + assert!(!state.panel_ready()); + assert!(!state.popups_running()); + + // These atomics gate user-visible command handling, so getters must reflect writes exactly + state.set_panel_ready(true); + state.set_popups_running(true); + + assert!(state.panel_ready()); + assert!(state.popups_running()); +} + +#[tokio::test] +async fn daemon_state_boolean_flags_can_return_to_false() { + let state = daemon_state_for_test(true).await; + + state.set_panel_ready(true); + state.set_popups_running(true); + state.set_panel_ready(false); + state.set_popups_running(false); + + assert!(!state.panel_ready()); + assert!(!state.popups_running()); +} + +#[tokio::test] +async fn daemon_state_trial_mode_can_be_disabled() { + let state = daemon_state_for_test(false).await; + + // Trial mode changes control authorization, so false must stay observable + assert!(!state.trial_mode()); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs b/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs new file mode 100644 index 00000000..a4e28fb9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs @@ -0,0 +1,243 @@ +use std::time::Duration; + +use futures_util::TryStreamExt; +use unixnotis_core::{CloseReason, Config, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; +use zbus::message::Type; +use zbus::{Connection, MatchRule, Message, MessageStream}; + +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::store::NotificationStore; +use crate::test_support::daemon_state_for_test; + +use super::super::signals::{ + control_state_from_store, popup_gate_from_state, record_signal_error, + should_emit_any_state_signal, +}; + +async fn signal_stream( + state: &super::super::DaemonState, + path: &str, + interface: &str, + member: &str, +) -> MessageStream { + let receiver = Connection::session().await.expect("receiver session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("sender") + .path(path) + .expect("path") + .interface(interface) + .expect("interface") + .member(member) + .expect("member") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("signal stream") +} + +async fn control_signal_stream(state: &super::super::DaemonState, member: &str) -> MessageStream { + signal_stream(state, CONTROL_OBJECT_PATH, "com.unixnotis.Control", member).await +} + +async fn notifications_signal_stream( + state: &super::super::DaemonState, + member: &str, +) -> MessageStream { + signal_stream( + state, + NOTIFICATIONS_OBJECT_PATH, + "org.freedesktop.Notifications", + member, + ) + .await +} + +async fn next_signal(stream: &mut MessageStream) -> Message { + tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("signal should arrive before timeout") + .expect("signal stream should stay open") + .expect("signal message") +} + +async fn assert_no_signal(stream: &mut MessageStream) { + assert!( + tokio::time::timeout(Duration::from_millis(100), stream.try_next()) + .await + .is_err(), + "signal should not be emitted" + ); +} + +#[test] +fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { + let state = ControlState { + dnd_enabled: true, + history_count: 99, + inhibited: false, + inhibitor_count: 12, + }; + + let gate = popup_gate_from_state(&state); + + assert!(gate.dnd_enabled); + assert!(!gate.inhibited); +} + +#[test] +fn control_state_from_store_reads_dnd_history_and_inhibitors() { + let mut store = NotificationStore::new(Config::default()); + + store.set_dnd(true); + store.add_inhibitor(":1.test".to_string(), "focus".to_string(), 0); + + let state = control_state_from_store(&store); + + assert!(state.dnd_enabled); + assert!(state.inhibited); + assert_eq!(state.inhibitor_count, 1); + assert_eq!(state.history_count, 0); +} + +#[test] +fn should_emit_any_state_signal_is_false_when_both_cached_values_match() { + assert!(!should_emit_any_state_signal(false, false)); +} + +#[test] +fn should_emit_any_state_signal_is_true_when_control_state_changed() { + assert!(should_emit_any_state_signal(true, false)); +} + +#[test] +fn should_emit_any_state_signal_is_true_when_popup_gate_changed() { + assert!(should_emit_any_state_signal(false, true)); +} + +#[test] +fn should_emit_any_state_signal_is_true_when_both_values_changed() { + assert!(should_emit_any_state_signal(true, true)); +} + +#[test] +fn record_signal_error_stores_first_error() { + let mut first_error = None; + + record_signal_error(&mut first_error, zbus::Error::Failure("first".to_string())); + + assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); +} + +#[test] +fn record_signal_error_keeps_existing_error() { + let mut first_error = Some(zbus::Error::Failure("first".to_string())); + + record_signal_error(&mut first_error, zbus::Error::Failure("second".to_string())); + + assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); +} + +#[tokio::test] +async fn emit_close_fanout_sends_freedesktop_and_control_close_signals() { + let state = daemon_state_for_test(false).await; + let mut freedesktop_stream = notifications_signal_stream(&state, "NotificationClosed").await; + let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; + + state + .emit_close_fanout(7, CloseReason::ClosedByCall) + .await + .expect("close fanout should emit"); + + let freedesktop_signal = next_signal(&mut freedesktop_stream).await; + let (freedesktop_id, freedesktop_reason) = freedesktop_signal + .body() + .deserialize::<(u32, u32)>() + .expect("freedesktop close body"); + assert_eq!(freedesktop_id, 7); + assert_eq!(freedesktop_reason, CloseReason::ClosedByCall as u32); + + let control_signal = next_signal(&mut control_stream).await; + let (control_id, control_reason) = control_signal + .body() + .deserialize::<(u32, CloseReason)>() + .expect("control close body"); + assert_eq!(control_id, 7); + assert_eq!(control_reason as u32, CloseReason::ClosedByCall as u32); +} + +#[tokio::test] +async fn emit_dismiss_fanout_sends_control_close_signal() { + let state = daemon_state_for_test(false).await; + let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; + + state + .emit_dismiss_fanout(8, false) + .await + .expect("dismiss fanout should emit"); + + let control_signal = next_signal(&mut control_stream).await; + let (control_id, control_reason) = control_signal + .body() + .deserialize::<(u32, CloseReason)>() + .expect("control close body"); + assert_eq!(control_id, 8); + assert_eq!(control_reason as u32, CloseReason::DismissedByUser as u32); +} + +#[tokio::test] +async fn emit_state_changed_sends_initial_state_and_suppresses_duplicate() { + let state = daemon_state_for_test(false).await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; + + state + .emit_state_changed() + .await + .expect("state changed should emit"); + + let state_signal = next_signal(&mut state_stream).await; + let emitted_state = state_signal + .body() + .deserialize::() + .expect("state body"); + assert!(!emitted_state.dnd_enabled); + assert!(!emitted_state.inhibited); + assert_eq!(emitted_state.history_count, 0); + assert_eq!(emitted_state.inhibitor_count, 0); + + let gate_signal = next_signal(&mut gate_stream).await; + let emitted_gate = gate_signal + .body() + .deserialize::() + .expect("popup gate body"); + assert!(!emitted_gate.dnd_enabled); + assert!(!emitted_gate.inhibited); + + state + .emit_state_changed() + .await + .expect("duplicate state should not fail"); + assert_no_signal(&mut state_stream).await; + assert_no_signal(&mut gate_stream).await; +} + +#[tokio::test] +async fn emit_snapshot_invalidated_sends_snapshot_signal() { + let state = daemon_state_for_test(false).await; + let mut stream = control_signal_stream(&state, "SnapshotInvalidated").await; + + state + .emit_snapshot_invalidated() + .await + .expect("snapshot invalidation should emit"); + + let signal = next_signal(&mut stream).await; + signal.body().deserialize::<()>().expect("empty body"); +} diff --git a/crates/unixnotis-daemon/src/daemon/tests/state.rs b/crates/unixnotis-daemon/src/daemon/tests/state.rs deleted file mode 100644 index dfcfee13..00000000 --- a/crates/unixnotis-daemon/src/daemon/tests/state.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::sync::Mutex; - -use unixnotis_core::{ControlState, PopupGateState}; - -use crate::test_support::daemon_state_for_test; - -use super::should_emit_cached; - -#[test] -fn cached_state_emits_first_value_then_suppresses_duplicates() { - let cache = Mutex::new(None); - let state = ControlState { - dnd_enabled: false, - history_count: 1, - inhibited: false, - inhibitor_count: 0, - }; - - // First value must be emitted because clients have no previous state - assert!(should_emit_cached(&cache, &state)); - // Identical values should not wake D-Bus subscribers again - assert!(!should_emit_cached(&cache, &state)); -} - -#[test] -fn cached_state_emits_when_any_gate_field_changes() { - let cache = Mutex::new(None); - let open = PopupGateState { - dnd_enabled: false, - inhibited: false, - }; - let dnd = PopupGateState { - dnd_enabled: true, - inhibited: false, - }; - - assert!(should_emit_cached(&cache, &open)); - // A changed popup gate affects visibility policy, so it must emit - assert!(should_emit_cached(&cache, &dnd)); - assert!(!should_emit_cached(&cache, &dnd)); -} - -#[tokio::test] -async fn daemon_state_boolean_flags_reflect_runtime_updates() { - let state = daemon_state_for_test(true).await; - - assert!(state.trial_mode()); - assert!(!state.panel_ready()); - assert!(!state.popups_running()); - - // These atomics gate user-visible command handling, so getters must reflect writes exactly - state.set_panel_ready(true); - state.set_popups_running(true); - - assert!(state.panel_ready()); - assert!(state.popups_running()); -} - -#[tokio::test] -async fn daemon_state_trial_mode_can_be_disabled() { - let state = daemon_state_for_test(false).await; - - // Trial mode changes control authorization, so false must stay observable - assert!(!state.trial_mode()); -} From a75ea0dfabe57b29ad7f9642e3bc299cd4fe3cde Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 11/65] Make daemon root module paths explicit Add explicit path attributes for daemon, child process, sound, and trial mode root modules. This keeps nested module discovery stable after the folder refactors and makes cargo-mutants see the moved files reliably. --- crates/unixnotis-daemon/src/child_process/root.rs | 2 ++ crates/unixnotis-daemon/src/daemon/root.rs | 7 +++++++ crates/unixnotis-daemon/src/sound/root.rs | 3 +++ crates/unixnotis-daemon/src/trial_mode/root.rs | 3 +++ 4 files changed, 15 insertions(+) diff --git a/crates/unixnotis-daemon/src/child_process/root.rs b/crates/unixnotis-daemon/src/child_process/root.rs index 9545b4f4..d242f4c3 100644 --- a/crates/unixnotis-daemon/src/child_process/root.rs +++ b/crates/unixnotis-daemon/src/child_process/root.rs @@ -15,7 +15,9 @@ use unixnotis_core::util::CONFIG_PATH_ENV; use super::Args; use crate::daemon::DaemonState; +#[path = "paths.rs"] mod paths; +#[path = "supervisor.rs"] mod supervisor; use paths::{apply_parent_death_signal, resolve_center_path, resolve_popups_path}; diff --git a/crates/unixnotis-daemon/src/daemon/root.rs b/crates/unixnotis-daemon/src/daemon/root.rs index 70d57f67..c45339d1 100644 --- a/crates/unixnotis-daemon/src/daemon/root.rs +++ b/crates/unixnotis-daemon/src/daemon/root.rs @@ -1,11 +1,18 @@ //! D-Bus server implementation and daemon state coordination +#[path = "auth.rs"] mod auth; +#[path = "bus_names.rs"] mod bus_names; +#[path = "control/mod.rs"] mod control; +#[path = "errors.rs"] mod errors; +#[path = "notifications/mod.rs"] mod notifications; +#[path = "signal_burst.rs"] mod signal_burst; +#[path = "state/mod.rs"] mod state; pub use bus_names::{log_name_reply, request_control_name, request_well_known_name}; diff --git a/crates/unixnotis-daemon/src/sound/root.rs b/crates/unixnotis-daemon/src/sound/root.rs index a55e84f5..660acef0 100644 --- a/crates/unixnotis-daemon/src/sound/root.rs +++ b/crates/unixnotis-daemon/src/sound/root.rs @@ -9,8 +9,11 @@ use tracing::{debug, warn}; use unixnotis_core::Config; use zbus::zvariant::OwnedValue; +#[path = "backend.rs"] mod backend; +#[path = "command.rs"] mod command; +#[path = "resolve.rs"] mod resolve; use backend::{detect_backend, SoundBackend}; diff --git a/crates/unixnotis-daemon/src/trial_mode/root.rs b/crates/unixnotis-daemon/src/trial_mode/root.rs index 62c09b30..eda07bf7 100644 --- a/crates/unixnotis-daemon/src/trial_mode/root.rs +++ b/crates/unixnotis-daemon/src/trial_mode/root.rs @@ -11,8 +11,11 @@ use zbus::fdo::DBusProxy; use super::dbus_owner::wait_for_owner_state; use super::{Args, RestoreStrategy}; +#[path = "control.rs"] mod control; +#[path = "owner.rs"] mod owner; +#[path = "prompt.rs"] mod prompt; // Re-export keeps main.rs call sites unchanged after the split From f935b197fbf6682a6bacab1e38a2a2848f030d37 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 12/65] Use an unbounded expiration scheduler channel Make expiration scheduling synchronous from callers by switching the scheduler command channel to an unbounded sender. This removes unnecessary await points from close/cancel paths while preserving stale deadline filtering and heap compaction. --- crates/unixnotis-daemon/src/expire.rs | 45 +++++++++++++++++---------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/unixnotis-daemon/src/expire.rs b/crates/unixnotis-daemon/src/expire.rs index f3c4841d..486d3ec7 100644 --- a/crates/unixnotis-daemon/src/expire.rs +++ b/crates/unixnotis-daemon/src/expire.rs @@ -1,4 +1,4 @@ -//! Notification expiration scheduling and timeouts. +//! Notification expiration scheduling and timeouts use std::cmp::Ordering; use std::collections::{BinaryHeap, HashMap}; @@ -11,26 +11,24 @@ use tracing::warn; use crate::daemon::DaemonState; use unixnotis_core::CloseReason; -/// Commands sent to the expiration scheduler. +/// Commands sent to the expiration scheduler pub enum ExpirationCommand { Schedule { id: u32, deadline: Instant }, Cancel { id: u32 }, } -/// Asynchronous expiration manager backed by a priority queue. +/// Asynchronous expiration manager backed by a priority queue #[derive(Clone)] pub struct ExpirationScheduler { - sender: mpsc::Sender, + sender: mpsc::UnboundedSender, } -const EXPIRATION_QUEUE_CAPACITY: usize = 256; - impl ExpirationScheduler { pub fn start(state: Arc) -> Self { - let (sender, mut receiver) = mpsc::channel(EXPIRATION_QUEUE_CAPACITY); + let (sender, mut receiver) = mpsc::unbounded_channel(); tokio::spawn(async move { let mut heap: BinaryHeap = BinaryHeap::new(); - // Tracks the latest deadline per notification to discard stale heap entries. + // Tracks the latest deadline per notification to discard stale heap entries let mut scheduled: HashMap = HashMap::new(); loop { let next_deadline = heap.peek().map(|item| item.deadline); @@ -66,7 +64,7 @@ impl ExpirationScheduler { if !is_current { continue; } - // Verify the deadline is still current before closing the notification. + // Verify the deadline is still current before closing the notification let expiration = { let store = state.store.lock().await; store.expiration_for(item.id) @@ -77,12 +75,12 @@ impl ExpirationScheduler { if is_still_current { // Remove the scheduled entry only once the deadline is confirmed // to still be active. This avoids dropping new schedules created - // while the expiration task was waiting on the store lock. + // while the expiration task was waiting on the store lock if scheduled.get(&item.id) == Some(&item.deadline) { scheduled.remove(&item.id); } // Expiration closes must be observable so signal/state failures - // are visible in logs instead of being silently ignored. + // are visible in logs instead of being silently ignored if let Err(err) = state.close_notification(item.id, CloseReason::Expired).await { @@ -94,7 +92,7 @@ impl ExpirationScheduler { } } else if scheduled.get(&item.id) == Some(&item.deadline) { // The store no longer expects this deadline (dismissed or updated), - // so drop the stale schedule to avoid repeated checks. + // so drop the stale schedule to avoid repeated checks scheduled.remove(&item.id); } } @@ -108,12 +106,12 @@ impl ExpirationScheduler { Self { sender } } - pub async fn schedule(&self, id: u32, deadline: Option) { + pub fn schedule(&self, id: u32, deadline: Option) { let command = match deadline { Some(deadline) => ExpirationCommand::Schedule { id, deadline }, None => ExpirationCommand::Cancel { id }, }; - if let Err(err) = self.sender.send(command).await { + if let Err(err) = self.sender.send(command) { warn!(?err, "expiration schedule request dropped"); } } @@ -141,7 +139,7 @@ impl PartialOrd for ExpirationItem { impl Ord for ExpirationItem { fn cmp(&self, other: &Self) -> Ordering { - // Reverse ordering to make BinaryHeap a min-heap on deadline. + // Reverse ordering to make BinaryHeap a min-heap on deadline other.deadline.cmp(&self.deadline) } } @@ -153,34 +151,47 @@ fn apply_command( ) { match cmd { ExpirationCommand::Schedule { id, deadline } => { - // Keep the newest deadline and push to the heap for ordering. + // Keep the newest deadline and push to the heap for ordering scheduled.insert(id, deadline); heap.push(ExpirationItem { id, deadline }); } ExpirationCommand::Cancel { id } => { - // Cancel only updates the tracking map; stale heap entries are ignored. + // Cancel only updates the tracking map; stale heap entries are ignored scheduled.remove(&id); } } } fn maybe_compact(heap: &mut BinaryHeap, scheduled: &HashMap) { + // Count how many expiration entries are still real and expected to happen let live = scheduled.len(); + + // If nothing is scheduled anymore, the heap has no useful work left to keep if live == 0 { heap.clear(); return; } + + // Allow the heap to be bigger than the live set, but not wildly bigger let threshold = live.saturating_mul(4).max(128); + + // If the heap is still small enough, rebuilding it would just waste work if heap.len() <= threshold { return; } + + // Make a fresh heap sized for the entries that are still actually scheduled let mut rebuilt = BinaryHeap::with_capacity(live); + + // Copy each real scheduled expiration into the new clean heap for (id, deadline) in scheduled { rebuilt.push(ExpirationItem { id: *id, deadline: *deadline, }); } + + // Swap out the old messy heap for the rebuilt one with only live entries *heap = rebuilt; } From 7153e4bf2aaa82cb5c66b048f7c89e56dfe1eed6 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 13/65] Test scheduled notification expiration Add a regression test that stores an active notification, schedules a near-term deadline, and verifies the scheduler closes it and clears expiration bookkeeping. This covers the live scheduler path rather than only heap helpers. --- crates/unixnotis-daemon/src/expire_tests.rs | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/unixnotis-daemon/src/expire_tests.rs b/crates/unixnotis-daemon/src/expire_tests.rs index 7641e0e5..f8fbc614 100644 --- a/crates/unixnotis-daemon/src/expire_tests.rs +++ b/crates/unixnotis-daemon/src/expire_tests.rs @@ -1,5 +1,9 @@ use super::*; +use chrono::Utc; +use std::collections::HashMap; use std::time::Duration; +use unixnotis_core::{Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; #[test] fn expiration_heap_orders_by_deadline() { @@ -88,3 +92,64 @@ fn maybe_compact_rebuilds_from_scheduled() { let item = heap.pop().expect("rebuilt item"); assert_eq!(item.id, 1); } + +#[tokio::test] +async fn scheduler_closes_notification_at_scheduled_deadline() { + let state = crate::test_support::daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + state.set_scheduler(scheduler.clone()); + let deadline = Instant::now() + Duration::from_millis(20); + + let id = { + let mut store = state.store.lock().await; + let outcome = store.insert(make_notification("expires"), 0); + let id = outcome.notification.id; + store.set_expiration(id, Some(deadline)); + id + }; + + scheduler.schedule(id, Some(deadline)); + + let expired = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let is_active = { + let store = state.store.lock().await; + store.active_notification_view(id).is_some() + }; + if !is_active { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await; + + assert!(expired.is_ok()); + let store = state.store.lock().await; + assert_eq!(store.expiration_for(id), None); +} + +fn make_notification(summary: &str) -> Notification { + Notification { + id: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} From 99c461729e99d1ce0b9298becf39a94c7396a08e Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 14/65] Keep active notifications when history is disabled Preserve active notification capacity even when saved history retention is set to zero. The new store regression test proves active rows remain visible when max_active allows them while history stays disabled. --- .../src/store/store_lifecycle.rs | 8 ++++---- .../src/store/store_tests/lifecycle.rs | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/unixnotis-daemon/src/store/store_lifecycle.rs b/crates/unixnotis-daemon/src/store/store_lifecycle.rs index 09600668..77c4c829 100644 --- a/crates/unixnotis-daemon/src/store/store_lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/store_lifecycle.rs @@ -8,7 +8,7 @@ use unixnotis_core::{ use super::{DismissOutcome, InsertOutcome, NotificationStore}; -// Hard ceiling for concurrently active notifications to protect panel/popups stability. +// Hard ceiling for concurrently active notifications to protect panel/popups stability const ACTIVE_HARD_CAP: usize = 12; impl NotificationStore { @@ -119,7 +119,7 @@ impl NotificationStore { } fn enforce_active_limit(&mut self) -> Vec { - // Config limit still applies, but active list never exceeds the global safety cap. + // Config limit still applies, but active list never exceeds the global safety cap let max_active = self.config.history.max_active.min(ACTIVE_HARD_CAP); if max_active == 0 { // max_active=0 means archive everything immediately @@ -193,11 +193,11 @@ impl NotificationStore { if notification.suppress_sound { return false; } - // Inhibitors should suppress sound too so focus/presentation mode stays quiet. + // Inhibitors should suppress sound too so focus/presentation mode stays quiet if self.inhibited { return false; } - // DND still keeps critical notification sounds enabled. + // DND still keeps critical notification sounds enabled if self.dnd_enabled { return notification.urgency == Urgency::Critical; } diff --git a/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs index c2a9e2da..f507ae4d 100644 --- a/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs @@ -41,7 +41,7 @@ fn max_active_evicts_oldest_to_history() { #[test] fn max_active_hard_cap_limits_even_when_config_is_higher() { - // Config may request a larger active window, but runtime hard-cap protects UI stability. + // Config may request a larger active window, but runtime hard-cap protects UI stability let mut store = make_store_with_limits(32, 64); for idx in 0..18 { @@ -70,6 +70,18 @@ fn max_entries_zero_drops_history_on_close() { assert_eq!(store.history_len(), 0); } +#[test] +fn max_entries_zero_keeps_active_notifications_when_active_limit_allows() { + let mut store = make_store_with_limits(2, 0); + + store.insert(make_notification("first"), 0); + let outcome = store.insert(make_notification("second"), 0); + + assert!(outcome.evicted.is_empty()); + assert_eq!(store.list_active().len(), 2); + assert_eq!(store.history_len(), 0); +} + #[test] fn history_eviction_keeps_most_recent_entries() { let mut store = make_store_with_limits(0, 2); @@ -78,7 +90,7 @@ fn history_eviction_keeps_most_recent_entries() { store.insert(make_notification("second"), 0); store.insert(make_notification("third"), 0); - // History listing returns most-recent-first order. + // History listing returns most-recent-first order let history = store.list_history(); assert_eq!(history.len(), 2); assert_eq!(history[0].summary, "third"); @@ -108,7 +120,7 @@ fn max_entries_zero_drops_history_on_insert() { let outcome = store.insert(make_notification("first"), 0); - // Eviction should archive the active entry, then drop it due to the zero history limit. + // Eviction should archive the active entry, then drop it due to the zero history limit assert_eq!(outcome.evicted.len(), 1); assert!(store.list_active().is_empty()); assert_eq!(store.history_len(), 0); From 0cb9b3746c73402f54a76c7600dd01d2c692fa38 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 15/65] Clean daemon comments after refactor Normalize touched daemon comments so they avoid trailing periods and remain short, direct explanations. This keeps the expanded module tree consistent with the requested comment style without changing daemon behavior. --- crates/unixnotis-daemon/src/dbus_owner.rs | 6 +++--- crates/unixnotis-daemon/src/main.rs | 4 ++-- crates/unixnotis-daemon/src/runtime_config.rs | 10 +++++----- crates/unixnotis-daemon/src/shutdown_signal.rs | 6 +++--- crates/unixnotis-daemon/src/store/store_history.rs | 10 +++++----- crates/unixnotis-daemon/src/store/store_inhibit.rs | 6 +++--- crates/unixnotis-daemon/src/store/store_state.rs | 10 +++++----- crates/unixnotis-daemon/src/store/store_tests/dnd.rs | 4 ++-- .../src/store/store_tests/ownership.rs | 10 +++++----- crates/unixnotis-daemon/src/trial_mode/owner.rs | 4 ++-- 10 files changed, 35 insertions(+), 35 deletions(-) diff --git a/crates/unixnotis-daemon/src/dbus_owner.rs b/crates/unixnotis-daemon/src/dbus_owner.rs index 1e6b2709..4ff28128 100644 --- a/crates/unixnotis-daemon/src/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/dbus_owner.rs @@ -1,6 +1,6 @@ -//! D-Bus owner tracking helpers. +//! D-Bus owner tracking helpers //! -//! Provides reusable helpers for name ownership checks during startup and trial mode. +//! Provides reusable helpers for name ownership checks during startup and trial mode use std::time::Duration; @@ -20,7 +20,7 @@ pub(super) async fn wait_for_owner_state( let mut stream = dbus_proxy .receive_name_owner_changed_with_args(&[(0, name_str.as_str())]) .await?; - // Re-check after subscribing to avoid missing a transition between the initial query and stream setup. + // Re-check after subscribing to avoid missing a transition between the initial query and stream setup let has_owner = match dbus_proxy.name_has_owner(name.clone()).await { Ok(value) => value, Err(err) => { diff --git a/crates/unixnotis-daemon/src/main.rs b/crates/unixnotis-daemon/src/main.rs index e199dc65..c90ea7a7 100644 --- a/crates/unixnotis-daemon/src/main.rs +++ b/crates/unixnotis-daemon/src/main.rs @@ -1,4 +1,4 @@ -//! Daemon entrypoint and service bootstrap. +//! Daemon entrypoint and service bootstrap #![allow( clippy::blanket_clippy_restriction_lints, @@ -142,7 +142,7 @@ async fn main() -> Result<()> { TrialState::default() }; - // Resolve sound settings once to avoid repeated filesystem work. + // Resolve sound settings once to avoid repeated filesystem work let sound_settings = SoundSettings::from_config(&config); let state = DaemonState::new(connection.clone(), config, sound_settings, args.trial); let scheduler = ExpirationScheduler::start(state.clone()); diff --git a/crates/unixnotis-daemon/src/runtime_config.rs b/crates/unixnotis-daemon/src/runtime_config.rs index e493c00e..3d1d82c4 100644 --- a/crates/unixnotis-daemon/src/runtime_config.rs +++ b/crates/unixnotis-daemon/src/runtime_config.rs @@ -1,6 +1,6 @@ -//! Configuration loading and tracing setup. +//! Configuration loading and tracing setup //! -//! Keeps environment handling and logging setup out of the main control flow. +//! Keeps environment handling and logging setup out of the main control flow use std::env; use std::fs; @@ -26,7 +26,7 @@ pub(super) fn init_tracing(config: &Config) { let (filter, warning) = match EnvFilter::try_from_default_env() { Ok(filter) => (filter, None), Err(err) => { - // Only warn if RUST_LOG was set but invalid; missing env should remain quiet. + // Only warn if RUST_LOG was set but invalid; missing env should remain quiet let env_warning = if env::var("RUST_LOG").is_ok() { Some(format!( "invalid RUST_LOG value: {err}; falling back to config log_level" @@ -106,7 +106,7 @@ fn detect_wayland_display() -> Option { } } - // Fallback scan: prefer wayland-0 when WAYLAND_DISPLAY is unset, otherwise accept any socket. + // Fallback scan: prefer wayland-0 when WAYLAND_DISPLAY is unset, otherwise accept any socket let runtime_dir = env::var("XDG_RUNTIME_DIR").ok()?; let entries = fs::read_dir(&runtime_dir).ok()?; let mut candidates = Vec::new(); @@ -123,7 +123,7 @@ fn detect_wayland_display() -> Option { let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(_) => { - // If file type cannot be inspected, skip the entry to avoid false positives. + // If file type cannot be inspected, skip the entry to avoid false positives continue; } }; diff --git a/crates/unixnotis-daemon/src/shutdown_signal.rs b/crates/unixnotis-daemon/src/shutdown_signal.rs index 2910a684..101682ed 100644 --- a/crates/unixnotis-daemon/src/shutdown_signal.rs +++ b/crates/unixnotis-daemon/src/shutdown_signal.rs @@ -1,6 +1,6 @@ -//! Signal handling for graceful shutdown. +//! Signal handling for graceful shutdown //! -//! Centralizes signal waiting logic used by the daemon runtime. +//! Centralizes signal waiting logic used by the daemon runtime use tokio::signal; use tracing::warn; @@ -16,7 +16,7 @@ pub(super) async fn shutdown_signal() { } Err(err) => { warn!(?err, "failed to register SIGTERM handler"); - // Keep the future pending so startup does not abort on registration failure. + // Keep the future pending so startup does not abort on registration failure std::future::pending::<()>().await; } } diff --git a/crates/unixnotis-daemon/src/store/store_history.rs b/crates/unixnotis-daemon/src/store/store_history.rs index 54a7a8a8..6848c8bd 100644 --- a/crates/unixnotis-daemon/src/store/store_history.rs +++ b/crates/unixnotis-daemon/src/store/store_history.rs @@ -1,7 +1,7 @@ -//! Notification history storage with ordering. +//! Notification history storage with ordering //! //! Kept in a dedicated module so store.rs can focus on active notifications -//! and cross-cutting policy decisions. +//! and cross-cutting policy decisions use std::collections::{HashMap, VecDeque}; use std::sync::Arc; @@ -51,7 +51,7 @@ impl HistoryStore { pub(super) fn remove(&mut self, id: &u32) -> Option> { let removed = self.entries.remove(id); if removed.is_some() { - // Removal is infrequent compared to insertion; pay the cost here to keep order clean. + // Removal is infrequent compared to insertion; pay the cost here to keep order clean self.order.retain(|entry| entry != id); } removed @@ -60,7 +60,7 @@ impl HistoryStore { pub(super) fn insert(&mut self, notification: Arc) { let id = notification.id; if self.entries.contains_key(&id) { - // Avoid duplicate IDs in order when a notification is replaced. + // Avoid duplicate IDs in order when a notification is replaced self.order.retain(|entry| *entry != id); } self.entries.insert(id, notification); @@ -75,7 +75,7 @@ impl HistoryStore { while self.entries.len() > max_entries { let Some(id) = self.order.pop_front() else { - // Recover ordering when entries outlive the recorded order. + // Recover ordering when entries outlive the recorded order self.order.extend(self.entries.keys().copied()); if self.order.is_empty() { break; diff --git a/crates/unixnotis-daemon/src/store/store_inhibit.rs b/crates/unixnotis-daemon/src/store/store_inhibit.rs index be7e7bfa..f6dea848 100644 --- a/crates/unixnotis-daemon/src/store/store_inhibit.rs +++ b/crates/unixnotis-daemon/src/store/store_inhibit.rs @@ -1,6 +1,6 @@ -//! Inhibitor bookkeeping and scope evaluation. +//! Inhibitor bookkeeping and scope evaluation //! -//! Split out to keep the main store focused on notification lifecycle logic. +//! Split out to keep the main store focused on notification lifecycle logic use unixnotis_core::INHIBIT_SCOPE_POPUPS; @@ -35,6 +35,6 @@ impl InhibitorOwnerMismatch { } pub(super) fn inhibits_popups(scope: u32) -> bool { - // Scope 0 is the legacy "all output" value; bit 0 specifically targets popups. + // Scope 0 is the legacy "all output" value; bit 0 specifically targets popups scope == 0 || scope & INHIBIT_SCOPE_POPUPS != 0 } diff --git a/crates/unixnotis-daemon/src/store/store_state.rs b/crates/unixnotis-daemon/src/store/store_state.rs index c53afe1e..2b352cc3 100644 --- a/crates/unixnotis-daemon/src/store/store_state.rs +++ b/crates/unixnotis-daemon/src/store/store_state.rs @@ -1,6 +1,6 @@ -//! DND persistence helpers. +//! DND persistence helpers //! -//! Encapsulates on-disk state to keep filesystem I/O isolated from the store core. +//! Encapsulates on-disk state to keep filesystem I/O isolated from the store core use std::fs; use std::io; @@ -62,12 +62,12 @@ impl DndStateStore { fs::create_dir_all(parent)?; let temp_path = self.temp_path(parent); let mut file = fs::File::create(&temp_path)?; - // Write and flush the file before renaming to avoid partially written state files. + // Write and flush the file before renaming to avoid partially written state files io::Write::write_all(&mut file, &body)?; - // Ensure temp contents are durable before the atomic rename. + // Ensure temp contents are durable before the atomic rename file.sync_all()?; fs::rename(&temp_path, &self.path)?; - // Sync the directory entry so the rename survives sudden power loss. + // Sync the directory entry so the rename survives sudden power loss sync_parent_dir(parent)?; Ok(()) } diff --git a/crates/unixnotis-daemon/src/store/store_tests/dnd.rs b/crates/unixnotis-daemon/src/store/store_tests/dnd.rs index 74ddd79e..b4ace410 100644 --- a/crates/unixnotis-daemon/src/store/store_tests/dnd.rs +++ b/crates/unixnotis-daemon/src/store/store_tests/dnd.rs @@ -123,7 +123,7 @@ fn stale_dnd_rollback_cannot_overwrite_newer_write() { assert!(write_b.changed); assert!(!store.dnd_enabled()); - // Simulate late failure from write_a and verify guarded rollback is rejected. + // Simulate late failure from write_a and verify guarded rollback is rejected let rolled_back = store.rollback_dnd_write_if_current(&write_a); assert!(!rolled_back); assert!(!store.dnd_enabled()); @@ -159,7 +159,7 @@ fn dnd_rollback_restores_state_when_write_is_still_current() { let write = store.set_dnd(true); assert!(store.dnd_enabled()); - // Simulate persistence failure with no newer writes in between. + // Simulate persistence failure with no newer writes in between let rolled_back = store.rollback_dnd_write_if_current(&write); assert!(rolled_back); assert!(!store.dnd_enabled()); diff --git a/crates/unixnotis-daemon/src/store/store_tests/ownership.rs b/crates/unixnotis-daemon/src/store/store_tests/ownership.rs index 3af99be3..bf84f7ba 100644 --- a/crates/unixnotis-daemon/src/store/store_tests/ownership.rs +++ b/crates/unixnotis-daemon/src/store/store_tests/ownership.rs @@ -8,7 +8,7 @@ fn replace_id_in_history_reuses_id_and_clears_entry() { store.close(first.notification.id, CloseReason::Expired); assert_eq!(store.history_len(), 1); - // Replacement should reuse the original ID and remove the history entry. + // Replacement should reuse the original ID and remove the history entry let replaced = store.insert(make_notification("replacement"), first.notification.id); assert!(replaced.replaced); assert_eq!(replaced.notification.id, first.notification.id); @@ -18,7 +18,7 @@ fn replace_id_in_history_reuses_id_and_clears_entry() { assert_eq!(active.len(), 1); assert_eq!(active[0].summary, "replacement"); - // Closing the replacement should re-add a single history entry for the updated notification. + // Closing the replacement should re-add a single history entry for the updated notification store.close(replaced.notification.id, CloseReason::Expired); let history = store.list_history(); assert_eq!(history.len(), 1); @@ -36,7 +36,7 @@ fn replace_id_rejected_for_different_sender() { store.close(first.notification.id, CloseReason::Expired); assert_eq!(store.history_len(), 1); - // Cross-sender replacement must allocate a fresh id and keep prior history intact. + // Cross-sender replacement must allocate a fresh id and keep prior history intact let replaced = store.insert( make_notification_with_sender("replacement", ":1.sender-b", 202, 2), first.notification.id, @@ -101,7 +101,7 @@ fn is_notification_owned_by_accepts_same_process_after_reconnect() { make_notification_with_sender("owned", ":1.owner-a", 1234, 55), 0, ); - // A new bus name from the same process lifetime should still be treated as owner. + // A new bus name from the same process lifetime should still be treated as owner assert!(store.is_notification_owned_by( outcome.notification.id, ":1.owner-b", @@ -117,7 +117,7 @@ fn is_notification_owned_by_rejects_reused_pid_with_new_start_time() { make_notification_with_sender("owned", ":1.owner-a", 1234, 55), 0, ); - // Same pid is not enough once the original process lifetime has ended. + // Same pid is not enough once the original process lifetime has ended assert!(!store.is_notification_owned_by( outcome.notification.id, ":1.owner-b", diff --git a/crates/unixnotis-daemon/src/trial_mode/owner.rs b/crates/unixnotis-daemon/src/trial_mode/owner.rs index c0d8cc07..453ace34 100644 --- a/crates/unixnotis-daemon/src/trial_mode/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/owner.rs @@ -185,7 +185,7 @@ async fn pgrep_exact(name: &str) -> Vec { } async fn read_comm(pid: u32) -> Option { - // Prefer /proc to avoid spawning a process for a single field. + // Prefer /proc to avoid spawning a process for a single field let path = format!("/proc/{}/comm", pid); if let Ok(contents) = fs::read_to_string(path).await { let comm = contents.trim().to_string(); @@ -209,7 +209,7 @@ async fn read_comm(pid: u32) -> Option { } async fn read_args(pid: u32) -> Option> { - // Use /proc/cmdline to preserve argument boundaries and quoting. + // Use /proc/cmdline to preserve argument boundaries and quoting let path = format!("/proc/{}/cmdline", pid); if let Ok(contents) = fs::read(path).await { let parts = contents From 3c79e73aa27243adccad677992cb25aad3f5c966 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 16/65] Add installer shell PATH cleanup Add helpers to remove installer-owned PATH blocks from shell startup files during cleanup. The tests cover startup file selection, idempotent insertion, safe removal, missing files, and preserving unrelated user-managed lines. --- .../src/actions/environment/mod.rs | 6 +- .../src/actions/environment/shell_path.rs | 129 ++++++++++- .../actions/environment/tests/shell_path.rs | 212 +++++++++++++++++- 3 files changed, 337 insertions(+), 10 deletions(-) diff --git a/crates/unixnotis-installer/src/actions/environment/mod.rs b/crates/unixnotis-installer/src/actions/environment/mod.rs index 53b8aee9..45e0a1ab 100644 --- a/crates/unixnotis-installer/src/actions/environment/mod.rs +++ b/crates/unixnotis-installer/src/actions/environment/mod.rs @@ -3,14 +3,14 @@ mod shell_path; mod sync; -pub(crate) use shell_path::ensure_shell_path_entry; +pub(crate) use shell_path::{ensure_shell_path_entry, remove_shell_path_entry}; pub(crate) use sync::sync_user_environment; pub(crate) use sync::HYPR_IMPORT_VARS; #[cfg(test)] pub(in crate::actions::environment) use shell_path::{ - ensure_path_entry_in_file, format_path_for_shell_line, shell_path_entry_exists, - shell_startup_files, + ensure_path_entry_in_file, format_path_for_shell_line, remove_path_entry_from_file, + shell_path_entry_exists, shell_startup_files, }; #[cfg(test)] diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index 7d5ffc9e..ea707d3f 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -46,6 +46,49 @@ pub(crate) fn ensure_shell_path_entry(ctx: &mut ActionContext) -> Result<()> { Ok(()) } +pub(crate) fn remove_shell_path_entry(ctx: &mut ActionContext) -> Result<()> { + // Resolve the user's home directory so shell startup files and path entries + // can be located relative to the current user. + let home = crate::paths::home_dir()?; + + // Read the active shell when available so the cleanup can target the startup + // files that are most likely to contain the installer-added PATH entry. + let shell = env::var("SHELL").ok(); + + // Build the list of shell startup files that should be checked for PATH entries. + let startup_files = shell_startup_files(&home, shell.as_deref()); + + // Track which files were actually modified so the final log output is accurate. + let mut removed_files = Vec::new(); + + for startup_file in startup_files { + // Remove only installer-owned references to the configured bin directory. + // Files that do not contain such an entry are left untouched. + if remove_path_entry_from_file(&startup_file, &home, &ctx.paths.bin_dir)? { + removed_files.push(startup_file); + } + } + + if removed_files.is_empty() { + // Nothing matched the installer-managed PATH entry, so no startup file changed. + log_line(ctx, "No installer-owned shell PATH entries found."); + } else { + for startup_file in removed_files { + // Report each modified startup file using a home-relative display path + // to keep the message readable and user-specific. + log_line( + ctx, + format!( + "Removed installer-owned PATH entry from {}", + format_with_home(&startup_file) + ), + ); + } + } + + Ok(()) +} + pub(in crate::actions::environment) fn shell_startup_files( home: &Path, shell: Option<&str>, @@ -106,19 +149,91 @@ pub(in crate::actions::environment) fn ensure_path_entry_in_file( Ok(true) } +pub(in crate::actions::environment) fn remove_path_entry_from_file( + file: &Path, + home: &Path, + bin_dir: &Path, +) -> Result { + // Read the startup file contents before attempting any removal + // Missing files are not an error because not every shell uses every startup file + let existing = match fs::read_to_string(file) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(anyhow!("failed to read {}: {}", file.display(), err)), + }; + + // If the installer marker is not present, this file was not modified by this installer. + if !existing.contains(PATH_BLOCK_MARKER) { + return Ok(false); + } + + // Track whether anything was removed while collecting the lines that should remain. + let mut changed = false; + let mut kept = Vec::new(); + + // Use a peekable iterator so a marker line can inspect the following PATH line + // before deciding whether both should be removed. + let mut lines = existing.lines().peekable(); + + while let Some(line) = lines.next() { + // Installer-owned PATH entries are stored as a marker line followed by + // the actual shell export line + if line.trim() == PATH_BLOCK_MARKER { + if let Some(next) = lines.peek() { + // Remove the marker and the following PATH entry only when the next + // line points to the installer-managed bin directory. + if is_shell_path_entry_line(next.trim(), home, bin_dir) { + lines.next(); + changed = true; + continue; + } + } + } + + // Lines that are not part of an installer-owned PATH block are preserved + kept.push(line); + } + + // Avoid rewriting the file when no matching PATH block was removed + if !changed { + return Ok(false); + } + + // Rebuild the file using newline separators and preserve a trailing newline + // when the resulting file is not empty + let mut updated = kept.join("\n"); + if !updated.is_empty() { + updated.push('\n'); + } + + // Write the cleaned startup file back to disk. + fs::write(file, updated) + .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; + Ok(true) +} + pub(in crate::actions::environment) fn shell_path_entry_exists( contents: &str, home: &Path, bin_dir: &Path, ) -> bool { + // Check each line independently after trimming surrounding whitespace so + // indentation does not prevent detecting the PATH export + contents + .lines() + .any(|line| is_shell_path_entry_line(line.trim(), home, bin_dir)) +} + +fn is_shell_path_entry_line(trimmed: &str, home: &Path, bin_dir: &Path) -> bool { + // Build both the shell-friendly form and the absolute form because existing + // startup files may contain either representation let shell_path = format_path_for_shell_line(home, bin_dir); let absolute_path = bin_dir.display().to_string(); - contents.lines().any(|line| { - let trimmed = line.trim(); - trimmed.starts_with("export PATH=") - && (trimmed.contains(&shell_path) || trimmed.contains(&absolute_path)) - }) + // Only consider export PATH lines, and only when they reference the installer + // bin directory in either supported path format. + trimmed.starts_with("export PATH=") + && (trimmed.contains(&shell_path) || trimmed.contains(&absolute_path)) } pub(in crate::actions::environment) fn format_path_for_shell_line( @@ -128,12 +243,16 @@ pub(in crate::actions::environment) fn format_path_for_shell_line( // Prefer `$HOME` when possible so startup files stay portable across usernames if let Ok(stripped) = bin_dir.strip_prefix(home) { let tail = stripped.to_string_lossy(); + + // If the bin directory is exactly the home directory, `$HOME` alone is enough if tail.is_empty() { "$HOME".to_string() } else { + // Convert the home-relative suffix into a shell-friendly `$HOME/...` path format!("$HOME/{}", tail.trim_start_matches('/')) } } else { + // Fall back to the absolute path when the bin directory is outside home bin_dir.display().to_string() } } diff --git a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs index 1484d0d1..9a0ae5f4 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs @@ -2,8 +2,8 @@ use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; use super::super::{ - ensure_path_entry_in_file, format_path_for_shell_line, shell_path_entry_exists, - shell_startup_files, + ensure_path_entry_in_file, format_path_for_shell_line, remove_path_entry_from_file, + shell_path_entry_exists, shell_startup_files, }; #[test] @@ -13,6 +13,20 @@ fn shell_startup_files_prefers_zsh_and_profile() { assert_eq!(files, vec![home.join(".zshrc"), home.join(".profile")]); } +#[test] +fn shell_startup_files_prefers_bash_and_profile() { + let home = std::path::PathBuf::from("/tmp/unixnotis-home"); + let files = shell_startup_files(&home, Some("/bin/bash")); + assert_eq!(files, vec![home.join(".bashrc"), home.join(".profile")]); +} + +#[test] +fn shell_startup_files_uses_only_profile_for_unknown_shell() { + let home = std::path::PathBuf::from("/tmp/unixnotis-home"); + let files = shell_startup_files(&home, Some("/bin/fish")); + assert_eq!(files, vec![home.join(".profile")]); +} + #[test] fn ensure_path_entry_in_file_is_idempotent() { let root = test_root("path-entry-idempotent"); @@ -31,6 +45,85 @@ fn ensure_path_entry_in_file_is_idempotent() { let _ = fs::remove_dir_all(&root); } +#[test] +fn ensure_path_entry_in_file_separates_existing_content_without_trailing_newline() { + let root = test_root("path-entry-existing-no-newline"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&home).expect("create home"); + fs::write(&startup, "existing line").expect("write startup"); + + let changed = ensure_path_entry_in_file(&startup, &home, &bin_dir).expect("path write"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(changed); + assert!(contents.starts_with("existing line\n# unixnotis-installer path entry\n")); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn ensure_path_entry_in_file_starts_empty_file_with_managed_block() { + let root = test_root("path-entry-empty-start"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&home).expect("create home"); + + let changed = ensure_path_entry_in_file(&startup, &home, &bin_dir).expect("path write"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(changed); + assert_eq!( + contents, + "# unixnotis-installer path entry\nexport PATH=\"$HOME/.local/bin:$PATH\"\n" + ); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn ensure_path_entry_in_file_uses_existing_trailing_newline_without_blank_line() { + let root = test_root("path-entry-existing-newline"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&home).expect("create home"); + fs::write(&startup, "existing line\n").expect("write startup"); + + let changed = ensure_path_entry_in_file(&startup, &home, &bin_dir).expect("path write"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(changed); + assert_eq!( + contents, + "existing line\n# unixnotis-installer path entry\nexport PATH=\"$HOME/.local/bin:$PATH\"\n" + ); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn ensure_path_entry_in_file_reports_directory_read_errors() { + let root = test_root("path-entry-directory-read-error"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&startup).expect("startup directory"); + + let err = ensure_path_entry_in_file(&startup, &home, &bin_dir) + .expect_err("directory should not be treated as missing"); + + assert!(err.to_string().contains("failed to read")); + + let _ = fs::remove_dir_all(&root); +} + #[test] fn ensure_path_entry_in_file_detects_existing_absolute_path_entry() { let root = test_root("path-entry-existing-absolute"); @@ -56,6 +149,121 @@ fn ensure_path_entry_in_file_detects_existing_absolute_path_entry() { let _ = fs::remove_dir_all(&root); } +#[test] +fn remove_path_entry_from_file_removes_only_managed_block() { + let root = test_root("path-entry-remove-managed"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&home).expect("create home"); + ensure_path_entry_in_file(&startup, &home, &bin_dir).expect("managed block"); + let changed = remove_path_entry_from_file(&startup, &home, &bin_dir).expect("remove block"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(changed); + assert!(!contents.contains("# unixnotis-installer path entry")); + assert!(contents.is_empty()); + assert!(!shell_path_entry_exists(&contents, &home, &bin_dir)); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn remove_path_entry_from_file_preserves_surrounding_content_and_trailing_newline() { + let root = test_root("path-entry-remove-preserve-content"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&home).expect("create home"); + fs::write( + &startup, + "before\n# unixnotis-installer path entry\nexport PATH=\"$HOME/.local/bin:$PATH\"\nafter\n", + ) + .expect("startup contents"); + + let changed = remove_path_entry_from_file(&startup, &home, &bin_dir).expect("remove block"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(changed); + assert_eq!(contents, "before\nafter\n"); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn remove_path_entry_from_file_keeps_manual_path_entry() { + let root = test_root("path-entry-keep-manual"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".zshrc"); + + fs::create_dir_all(&home).expect("create home"); + fs::write( + &startup, + format!("export PATH=\"{}:$PATH\"\n", bin_dir.display()), + ) + .expect("manual path"); + + let changed = remove_path_entry_from_file(&startup, &home, &bin_dir).expect("remove block"); + let contents = fs::read_to_string(&startup).expect("read startup"); + + assert!(!changed); + assert!(contents.contains(&bin_dir.display().to_string())); + assert!(shell_path_entry_exists(&contents, &home, &bin_dir)); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn remove_path_entry_from_file_treats_missing_file_as_noop() { + let root = test_root("path-entry-remove-missing"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + let changed = remove_path_entry_from_file(&startup, &home, &bin_dir).expect("missing noop"); + + assert!(!changed); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn remove_path_entry_from_file_reports_directory_read_errors() { + let root = test_root("path-entry-remove-directory-read-error"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".profile"); + + fs::create_dir_all(&startup).expect("startup directory"); + + let err = remove_path_entry_from_file(&startup, &home, &bin_dir) + .expect_err("directory should not be treated as missing"); + + assert!(err.to_string().contains("failed to read")); + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn shell_path_entry_exists_requires_export_line_with_target_path() { + let home = std::path::PathBuf::from("/tmp/unixnotis-home"); + let bin_dir = home.join(".local").join("bin"); + + assert!(!shell_path_entry_exists( + "PATH=\"$HOME/.local/bin:$PATH\"", + &home, + &bin_dir + )); + assert!(!shell_path_entry_exists( + "export PATH=\"/opt/other:$PATH\"", + &home, + &bin_dir + )); +} + #[test] fn format_path_for_shell_line_uses_home_prefix_when_possible() { let home = std::path::PathBuf::from("/tmp/unixnotis-home"); From 33f571761371aad05a514fc6c4b02429e6635185 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 17/65] Stop daemon before uninstall cleanup Update the uninstall plan so the daemon is stopped before service and file removal. Uninstall now also removes installer-managed Hyprland autostart and shell PATH entries on a best-effort basis, with UI copy updated to describe that scope. --- crates/unixnotis-installer/src/actions.rs | 4 +++- .../src/actions/actions_state/plan.rs | 3 ++- .../src/actions/actions_state/tests/plan.rs | 3 ++- .../src/actions/install/service.rs | 23 ++++++++++++++++++- crates/unixnotis-installer/src/ui/confirm.rs | 3 +-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/unixnotis-installer/src/actions.rs b/crates/unixnotis-installer/src/actions.rs index 43a604c7..49341a08 100644 --- a/crates/unixnotis-installer/src/actions.rs +++ b/crates/unixnotis-installer/src/actions.rs @@ -44,7 +44,9 @@ pub(super) use build::run_build; pub(crate) use config::backup::{list_backup_dirs_for_ui, restore_config}; pub(super) use config::{ensure_config, remove_state, reset_config}; pub(super) use daemon::stop_active_daemon; -pub(super) use environment::{ensure_shell_path_entry, sync_user_environment, HYPR_IMPORT_VARS}; +pub(super) use environment::{ + ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment, HYPR_IMPORT_VARS, +}; pub(super) use install::{ enable_service, install_binaries, install_service, remove_binaries, uninstall_service, }; diff --git a/crates/unixnotis-installer/src/actions/actions_state/plan.rs b/crates/unixnotis-installer/src/actions/actions_state/plan.rs index 4d08061f..2ed671d1 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/plan.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/plan.rs @@ -46,7 +46,8 @@ pub fn build_plan(mode: ActionMode) -> Vec { steps } ActionMode::Uninstall => vec![ - // Service is removed before deleting binaries and state files + // Stop before deleting artifacts so a live daemon cannot survive removal + StepKind::StopDaemon, StepKind::UninstallService, StepKind::RemoveBinaries, StepKind::RemoveState, diff --git a/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs b/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs index a787a11d..a597fda5 100644 --- a/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs +++ b/crates/unixnotis-installer/src/actions/actions_state/tests/plan.rs @@ -20,11 +20,12 @@ fn install_plan_stays_focused_on_build_and_install() { } #[test] -fn uninstall_plan_removes_service_before_binaries_and_state() { +fn uninstall_plan_stops_daemon_before_removing_files() { let plan = build_plan(ActionMode::Uninstall); assert_eq!( plan, vec![ + StepKind::StopDaemon, StepKind::UninstallService, StepKind::RemoveBinaries, StepKind::RemoveState, diff --git a/crates/unixnotis-installer/src/actions/install/service.rs b/crates/unixnotis-installer/src/actions/install/service.rs index b8d1ed6f..b15007be 100644 --- a/crates/unixnotis-installer/src/actions/install/service.rs +++ b/crates/unixnotis-installer/src/actions/install/service.rs @@ -9,7 +9,7 @@ use crate::paths::format_with_home; use super::super::{ ensure_shell_path_entry, hyprland::{ensure_hyprland_autostart, remove_hyprland_autostart}, - log_line, sync_user_environment, ActionContext, + log_line, remove_shell_path_entry, sync_user_environment, ActionContext, }; mod artifacts; @@ -155,7 +155,19 @@ pub(crate) fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { ); } + // Remove any Hyprland autostart entry managed by the installer before + // cleaning up shell startup files. remove_hyprland_autostart(ctx); + + // Shell PATH cleanup is non-fatal: uninstall should continue even if one + // startup file cannot be read or updated. + if let Err(err) = remove_shell_path_entry(ctx) { + log_line( + ctx, + format!("Warning: failed to remove shell PATH entries ({err})"), + ); + } + Ok(()) } @@ -163,10 +175,18 @@ fn log_unsafe_service_artifacts( ctx: &mut ActionContext, artifacts: &[crate::service_manager::ServiceArtifact], ) -> bool { + // Track whether any unsafe artifact path was detected so the caller can + // decide whether cleanup should proceed. let mut found = false; + for artifact in artifacts { + // Only warn about service artifacts whose paths are considered unsafe + // to remove automatically if service_artifact_path_conflicts(artifact) { found = true; + + // Log the unsafe path in a user-friendly form and leave the file in + // place rather than risking deletion of something not owned by us log_line( ctx, format!( @@ -176,5 +196,6 @@ fn log_unsafe_service_artifacts( ); } } + found } diff --git a/crates/unixnotis-installer/src/ui/confirm.rs b/crates/unixnotis-installer/src/ui/confirm.rs index e7ffb320..9f8be9db 100644 --- a/crates/unixnotis-installer/src/ui/confirm.rs +++ b/crates/unixnotis-installer/src/ui/confirm.rs @@ -88,10 +88,9 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { Style::default().fg(Color::Yellow), ))); } else { - // Uninstall removes the managed binaries from the same user bin dir lines.push(Line::from(Span::styled( format!( - "Uninstall removes unixnotis-daemon, unixnotis-popups, unixnotis-center, and noticenterctl from {}", + "Uninstall stops UnixNotis, removes managed startup entries, and removes unixnotis-daemon, unixnotis-popups, unixnotis-center, and noticenterctl from {}; config files are kept", bin_dir ), Style::default().fg(Color::Yellow), From 9712293d772d63e1230f88ef274c0db9d9438fe1 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 18/65] Add installer version flag Support -V and --version as early-exit installer CLI options alongside help. Tests cover both help aliases, version parsing, version output, and usage text that documents the new flags. --- crates/unixnotis-installer/src/cli.rs | 11 ++++++++++- crates/unixnotis-installer/src/main.rs | 4 ++++ .../unixnotis-installer/src/tests/cli/help.rs | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/unixnotis-installer/src/cli.rs b/crates/unixnotis-installer/src/cli.rs index 05fae487..a6911ac1 100644 --- a/crates/unixnotis-installer/src/cli.rs +++ b/crates/unixnotis-installer/src/cli.rs @@ -37,6 +37,9 @@ pub(crate) enum CliAction { /// Print usage and exit before starting the TUI or doing detection work Help, + + /// Print version and exit before starting the TUI or doing detection work + Version, } /// Parse arguments from the current process environment @@ -54,7 +57,11 @@ pub(crate) fn parse_env_args() -> Result { /// /// Keep this as a plain static string so help output is cheap and cannot fail pub(crate) fn usage() -> &'static str { - "Usage: unixnotis-installer [--service-manager systemd|dinit|runit|s6]\n" + "Usage: unixnotis-installer [--service-manager systemd|dinit|runit|s6] [-h|--help] [-V|--version]\n" +} + +pub(crate) fn version() -> &'static str { + env!("CARGO_PKG_VERSION") } /// Parse a sequence of OS-native argument strings @@ -90,6 +97,8 @@ where // only wants usage text "-h" | "--help" => return Ok(CliAction::Help), + "-V" | "--version" => return Ok(CliAction::Version), + // Support the split form: // // --service-manager runit diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 039958d9..fe99c6ef 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -52,6 +52,10 @@ fn main() -> Result<()> { print!("{}", cli::usage()); return Ok(()); } + CliAction::Version => { + println!("{}", cli::version()); + return Ok(()); + } }; let mut app = app::App::new(cli.service_manager); let mut terminal_guard = TerminalGuard::new()?; diff --git a/crates/unixnotis-installer/src/tests/cli/help.rs b/crates/unixnotis-installer/src/tests/cli/help.rs index 37adb74a..5db8bf6a 100644 --- a/crates/unixnotis-installer/src/tests/cli/help.rs +++ b/crates/unixnotis-installer/src/tests/cli/help.rs @@ -1,4 +1,4 @@ -use super::{parse_args, test_support, usage, CliAction}; +use super::{parse_args, test_support, usage, version, CliAction}; #[test] fn help_short_circuits_tui_startup() { @@ -9,6 +9,21 @@ fn help_short_circuits_tui_startup() { assert!(matches!(parsed, CliAction::Help)); } +#[test] +fn short_help_short_circuits_tui_startup() { + let parsed = parse_args(test_support::args(&["-h"])).expect("valid args"); + + assert!(matches!(parsed, CliAction::Help)); +} + +#[test] +fn version_short_circuits_tui_startup() { + let parsed = parse_args(test_support::args(&["--version"])).expect("valid args"); + + assert!(matches!(parsed, CliAction::Version)); + assert_eq!(version(), env!("CARGO_PKG_VERSION")); +} + #[test] fn usage_mentions_every_supported_service_manager() { let text = usage(); @@ -21,4 +36,6 @@ fn usage_mentions_every_supported_service_manager() { "usage text should mention {expected}" ); } + assert!(text.contains("-h|--help")); + assert!(text.contains("-V|--version")); } From 5dcbafc70b6fba80237e8280e227b375fabbc296 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 19/65] Validate release archive binaries from manifest Use the release manifest binary list as the source of truth when detecting release archives. The validation now rejects empty or unsafe binary names and checks that every declared runtime binary exists inside the release bin directory. --- .../src/paths/discovery.rs | 44 ++++++++++++++----- .../src/paths/tests/general.rs | 42 ++++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/crates/unixnotis-installer/src/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index 4b601e32..5b0171e1 100644 --- a/crates/unixnotis-installer/src/paths/discovery.rs +++ b/crates/unixnotis-installer/src/paths/discovery.rs @@ -198,36 +198,58 @@ fn find_release_root_from_current_exe() -> Option { } pub(in crate::paths) fn is_unixnotis_release_archive(root: &Path) -> bool { + // A valid release archive must include the manifest file at the expected root location. let manifest = root.join(RELEASE_MANIFEST_FILE); + + // If the manifest cannot be read, the directory cannot be treated as a release archive. let Ok(contents) = fs::read_to_string(manifest) else { return false; }; + + // The manifest must also match the expected JSON shape before any archive contents are trusted. let Ok(manifest) = serde_json::from_str::(&contents) else { return false; }; + + // Runtime binaries are expected to live under the archive's bin directory. let release_bin_dir = root.join(RELEASE_BIN_DIR); + // The archive layout is intentionally simple: installer at root, runtime tools in bin if !release_bin_dir.is_dir() { return false; } - // All managed tools must be present so a downloaded installer fails before any file changes - let names = manifest.binaries.into_iter().collect::>(); - release_archive_binaries() + // Normalize manifest binary names by trimming whitespace and collecting into a set. + // The set removes duplicates so each listed binary only needs to be checked once. + let names = manifest + .binaries + .into_iter() + .map(|name| name.trim().to_string()) + .collect::>(); + + // An archive with no declared binaries is incomplete, even if the bin directory exists. + if names.is_empty() { + return false; + } + + // Every declared binary must have a safe filename and exist as a regular file in bin. + names .iter() - .all(|binary| names.contains(*binary) && release_bin_dir.join(binary).is_file()) + .all(|binary| is_release_binary_name(binary) && release_bin_dir.join(binary).is_file()) } -fn release_archive_binaries() -> [&'static str; 4] { - [ - "unixnotis-daemon", - "unixnotis-popups", - "unixnotis-center", - "noticenterctl", - ] +fn is_release_binary_name(binary: &str) -> bool { + // Only allow plain file names. Empty names, current/parent directory references, + // and path separators are rejected to prevent escaping the release bin directory. + !binary.is_empty() + && binary != "." + && binary != ".." + && !binary.contains('/') + && !binary.contains('\\') } #[derive(serde::Deserialize)] struct ReleaseArchiveManifest { + // The manifest declares the runtime binary filenames expected inside RELEASE_BIN_DIR. binaries: Vec, } diff --git a/crates/unixnotis-installer/src/paths/tests/general.rs b/crates/unixnotis-installer/src/paths/tests/general.rs index ba14b2af..5c82c41b 100644 --- a/crates/unixnotis-installer/src/paths/tests/general.rs +++ b/crates/unixnotis-installer/src/paths/tests/general.rs @@ -101,6 +101,48 @@ fn release_archive_detection_requires_manifest_and_bundled_binaries() { let _ = fs::remove_dir_all(root); } +#[test] +fn release_archive_detection_uses_manifest_binary_list_without_hardcoded_names() { + let root = env::temp_dir().join(format!( + "unixnotis-release-archive-custom-{}", + std::process::id() + )); + let bin_dir = root.join(RELEASE_BIN_DIR); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join(RELEASE_MANIFEST_FILE), + r#"{"version":"1.0.0","binaries":["unixnotis-daemon"]}"#, + ) + .expect("release manifest"); + fs::write(bin_dir.join("unixnotis-daemon"), "binary").expect("release binary"); + + assert!(is_unixnotis_release_archive(&root)); + + fs::remove_file(bin_dir.join("unixnotis-daemon")).expect("remove manifest binary"); + assert!(!is_unixnotis_release_archive(&root)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn release_archive_detection_rejects_unsafe_manifest_binary_names() { + let root = env::temp_dir().join(format!( + "unixnotis-release-archive-unsafe-{}", + std::process::id() + )); + let bin_dir = root.join(RELEASE_BIN_DIR); + fs::create_dir_all(&bin_dir).expect("release bin dir"); + fs::write( + root.join(RELEASE_MANIFEST_FILE), + r#"{"version":"1.0.0","binaries":["../unixnotis-daemon"]}"#, + ) + .expect("release manifest"); + + assert!(!is_unixnotis_release_archive(&root)); + + let _ = fs::remove_dir_all(root); +} + #[test] fn release_root_discovery_prefers_explicit_archive_override() { let _guard = env_lock(); From b17d07fb45d7a07fe5daf190061324cdafb002da Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 14:14:26 -0500 Subject: [PATCH 20/65] Package release binaries from workspace metadata Teach the release packaging script to read managed binary names from workspace metadata, build that set, install it into the archive, and write the same list into the release manifest. Shell quoting stays checked by shellcheck and shellharden. --- scripts/package-release.sh | 73 ++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 3321c0db..139b5273 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -18,13 +18,15 @@ main() { local version="${tag#v}" local target="x86_64-unknown-linux-gnu" local root + local binaries=() root="$(repo_root)" cd "$root" + readarray -t binaries < <(managed_binaries) assert_workspace_version "$version" # Build first so packaging never creates an archive around stale target artifacts - build_release_binaries - assemble_archive "$tag" "$version" "$target" + build_release_binaries "${binaries[@]}" + assemble_archive "$tag" "$version" "$target" "${binaries[@]}" } repo_root() { @@ -48,19 +50,23 @@ assert_workspace_version() { } build_release_binaries() { + local binaries=("$@") + local args=(build --release -p unixnotis-installer) + + for binary in "${binaries[@]}"; do + args+=(-p "$binary") + done + # Build only the programs the installer deploys plus the installer itself - cargo build --release \ - -p unixnotis-installer \ - -p unixnotis-daemon \ - -p unixnotis-popups \ - -p unixnotis-center \ - -p noticenterctl + cargo "${args[@]}" } assemble_archive() { local tag="${1}" local version="${2}" local target="${3}" + shift 3 + local binaries=("$@") local package_root="unixnotis-${tag}-${target}" local dist_root="dist/${package_root}" local archive="dist/${package_root}.tar.zst" @@ -74,12 +80,11 @@ assemble_archive() { install -m 0755 target/release/unixnotis-installer "${dist_root}/unixnotis-installer" # Runtime tools stay under bin so the installer can validate and copy them as a group - install -m 0755 target/release/unixnotis-daemon "${dist_root}/bin/unixnotis-daemon" - install -m 0755 target/release/unixnotis-popups "${dist_root}/bin/unixnotis-popups" - install -m 0755 target/release/unixnotis-center "${dist_root}/bin/unixnotis-center" - install -m 0755 target/release/noticenterctl "${dist_root}/bin/noticenterctl" + for binary in "${binaries[@]}"; do + install -m 0755 "target/release/${binary}" "${dist_root}/bin/${binary}" + done - write_manifest "${dist_root}/unixnotis-release.json" "$tag" "$version" "$target" + write_manifest "${dist_root}/unixnotis-release.json" "$tag" "$version" "$target" "${binaries[@]}" write_readme "${dist_root}/README.txt" "$tag" # zstd keeps the release small while still being standard on modern Linux systems @@ -96,15 +101,55 @@ write_manifest() { local tag="${2}" local version="${3}" local target="${4}" + shift 4 + local binaries=("$@") + local json_binaries="[" + local separator="" + + for binary in "${binaries[@]}"; do + json_binaries+="${separator}\"${binary}\"" + separator="," + done + json_binaries+="]" # Keep this schema tiny because the installer trusts it to find bundled binaries - printf '{"version":"%s","tag":"%s","target":"%s","binaries":["unixnotis-daemon","unixnotis-popups","unixnotis-center","noticenterctl"]}\n' \ + printf '{"version":"%s","tag":"%s","target":"%s","binaries":%s}\n' \ "$version" \ "$tag" \ "$target" \ + "$json_binaries" \ > "$path" } +managed_binaries() { + cargo metadata --no-deps --format-version 1 | + python3 -c ' +import json +import sys + +metadata = json.load(sys.stdin) +binaries = ( + metadata.get("workspace_metadata", {}) + .get("unixnotis", {}) + .get("installer", {}) + .get("binaries", []) +) +if not isinstance(binaries, list) or not binaries: + raise SystemExit("workspace metadata must define unixnotis.installer.binaries") + +seen = set() +for raw in binaries: + if not isinstance(raw, str): + raise SystemExit("installer binary names must be strings") + name = raw.strip() + if not name or name in {".", ".."} or "/" in name or "\\" in name or "\"" in name: + raise SystemExit(f"unsafe installer binary name: {raw!r}") + if name not in seen: + seen.add(name) + print(name) +' +} + write_readme() { local path="${1}" local tag="${2}" From cf8d5204a25386fac9195ef71fd549166a731b63 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:31:34 -0500 Subject: [PATCH 21/65] refactor: move notification tooling into domain modules Move noticenterctl out of flat main/dbus path redirects and into app, cli, css_check, dbus, debug_logs, output, and preset domain folders. Rename daemon store files away from store_* while preserving the existing logic as a pure layout step. --- crates/noticenterctl/src/app.rs | 37 ++ crates/noticenterctl/src/cli/args.rs | 82 +++- crates/noticenterctl/src/cli/debug.rs | 25 - crates/noticenterctl/src/cli/dnd.rs | 11 - crates/noticenterctl/src/cli/inhibit.rs | 20 - crates/noticenterctl/src/cli/mod.rs | 16 +- crates/noticenterctl/src/cli/preset.rs | 27 -- .../src/cli/{command.rs => route.rs} | 2 +- .../src/cli/tests/{parsing.rs => args.rs} | 22 + crates/noticenterctl/src/cli/tests/mapping.rs | 24 - crates/noticenterctl/src/cli/tests/mod.rs | 5 +- .../src/cli/tests/{local_only.rs => route.rs} | 0 .../cache}/dependencies.rs | 0 .../cache}/mod.rs | 0 .../cache}/model.rs | 0 .../cache}/parse.rs | 0 .../cache}/session.rs | 0 .../cache}/store.rs | 0 .../cache}/tests/helpers.rs | 0 .../cache}/tests/import_invalidation.rs | 0 .../cache}/tests/invalidation.rs | 0 .../cache}/tests/mod.rs | 0 .../cache}/tests/reuse.rs | 0 .../files.rs} | 0 .../geometry.rs} | 0 .../geometry}/model.rs | 0 .../geometry}/model/box_metrics.rs | 0 .../geometry}/model/constants.rs | 0 .../geometry}/model/fixed_grid.rs | 0 .../geometry/model}/media/height.rs | 0 .../geometry/model}/media/helpers.rs | 0 .../geometry/model}/media/mod.rs | 0 .../geometry/model}/media/shell.rs | 0 .../geometry/model/media/tests/mod.rs} | 0 .../geometry/model}/media/vertical.rs | 0 .../geometry/model}/media/width.rs | 0 .../geometry}/model/tracking.rs | 0 .../geometry}/parse.rs | 0 .../geometry}/parse/custom_properties.rs | 0 .../geometry}/parse/lengths/mod.rs | 0 .../geometry}/parse/lengths/resolve_calc.rs | 0 .../parse/lengths/resolve_compare.rs | 0 .../geometry}/parse/lengths/resolve_var.rs | 0 .../geometry}/parse/lengths/tokenize.rs | 0 .../geometry}/parse/lengths/units.rs | 0 .../geometry}/parse/selectors.rs | 0 .../geometry}/stock/baselines.rs | 0 .../geometry}/stock/classes.rs | 0 .../geometry}/stock/mod.rs | 0 .../geometry}/stock/size_rules.rs | 0 .../geometry}/tests/custom_properties.rs | 0 .../geometry}/tests/height_budget.rs | 0 .../geometry}/tests/mod.rs | 0 .../geometry}/tests/stock_and_selectors.rs | 0 .../geometry}/tests/width_budget.rs | 0 .../lint}/mod.rs | 0 .../lint}/scan.rs | 0 .../lint}/values.rs | 0 .../main_css_check.rs => css_check/mod.rs} | 0 .../parse}/blocks.rs | 0 .../parse}/declarations.rs | 0 .../parse}/mod.rs | 0 .../parse}/selectors.rs | 0 .../parse}/types.rs | 0 .../policy.rs} | 0 .../report}/mod.rs | 0 .../report}/model.rs | 0 .../report}/render.rs | 0 .../report}/style.rs | 0 .../report/tests/mod.rs} | 0 .../runtime.rs} | 0 .../tests/mod.rs} | 0 .../theme}/collect.rs | 0 .../theme}/mod.rs | 0 .../theme}/model.rs | 0 .../theme}/paths.rs | 0 .../theme}/tests/assets.rs | 0 .../theme}/tests/commands.rs | 0 .../theme}/tests/helpers.rs | 0 .../theme}/tests/mod.rs | 0 .../theme}/tests/paths.rs | 0 .../src/dbus/{dispatch.rs => commands.rs} | 0 .../dbus/tests/{dispatch.rs => commands.rs} | 0 .../main_log_follow.rs => debug_logs/mod.rs} | 0 crates/noticenterctl/src/main.rs | 49 +- .../src/main/main_css_check_cache.rs | 432 ------------------ .../src/main/main_css_check_cache_tests.rs | 402 ---------------- .../src/main/main_css_check_parse.rs | 328 ------------- .../src/main/main_css_check_theme.rs | 319 ------------- .../src/main/main_css_check_theme_tests.rs | 379 --------------- .../{dbus/output_gate.rs => output/gate.rs} | 0 .../{main/main_output.rs => output/mod.rs} | 0 .../tests/output_gate.rs => output/tests.rs} | 0 .../preset/archive/{tests.rs => tests/mod.rs} | 0 .../command_rules/{tests.rs => tests/mod.rs} | 0 .../css_asset_refs/{tests.rs => tests/mod.rs} | 0 .../preset/export/{tests.rs => tests/mod.rs} | 0 .../store/{store_history.rs => history.rs} | 0 .../store/{store_identity.rs => identity.rs} | 0 .../store/{store_inhibit.rs => inhibit.rs} | 0 ...tore_inhibitor_api.rs => inhibitor_api.rs} | 0 .../{store_lifecycle.rs => lifecycle.rs} | 0 .../src/{store.rs => store/mod.rs} | 0 .../src/store/{store_rules.rs => rules.rs} | 0 .../src/store/{store_state.rs => state.rs} | 0 .../src/store/{store_tests => tests}/dnd.rs | 0 .../store/{store_tests => tests}/inhibit.rs | 0 .../store/{store_tests => tests}/lifecycle.rs | 0 .../store/{store_tests => tests}/ownership.rs | 0 .../src/store/{store_tests => tests}/rules.rs | 0 .../store/{store_tests => tests}/support.rs | 0 111 files changed, 153 insertions(+), 2027 deletions(-) create mode 100644 crates/noticenterctl/src/app.rs delete mode 100644 crates/noticenterctl/src/cli/debug.rs delete mode 100644 crates/noticenterctl/src/cli/dnd.rs delete mode 100644 crates/noticenterctl/src/cli/inhibit.rs delete mode 100644 crates/noticenterctl/src/cli/preset.rs rename crates/noticenterctl/src/cli/{command.rs => route.rs} (96%) rename crates/noticenterctl/src/cli/tests/{parsing.rs => args.rs} (86%) delete mode 100644 crates/noticenterctl/src/cli/tests/mapping.rs rename crates/noticenterctl/src/cli/tests/{local_only.rs => route.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/dependencies.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/model.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/parse.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/session.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/store.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/tests/helpers.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/tests/import_invalidation.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/tests/invalidation.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/tests/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_cache => css_check/cache}/tests/reuse.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_files.rs => css_check/files.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry.rs => css_check/geometry.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/model.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/model/box_metrics.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/model/constants.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/model/fixed_grid.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/height.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/helpers.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/shell.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry/media/tests.rs => css_check/geometry/model/media/tests/mod.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/vertical.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry/model}/media/width.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/model/tracking.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/custom_properties.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/resolve_calc.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/resolve_compare.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/resolve_var.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/tokenize.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/lengths/units.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/parse/selectors.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/stock/baselines.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/stock/classes.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/stock/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/stock/size_rules.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/tests/custom_properties.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/tests/height_budget.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/tests/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/tests/stock_and_selectors.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_geometry => css_check/geometry}/tests/width_budget.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_lint => css_check/lint}/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_lint => css_check/lint}/scan.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_lint => css_check/lint}/values.rs (100%) rename crates/noticenterctl/src/{main/main_css_check.rs => css_check/mod.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_parse => css_check/parse}/blocks.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_parse => css_check/parse}/declarations.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_parse => css_check/parse}/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_parse => css_check/parse}/selectors.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_parse => css_check/parse}/types.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_policy.rs => css_check/policy.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_report => css_check/report}/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_report => css_check/report}/model.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_report => css_check/report}/render.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_report => css_check/report}/style.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_report/tests.rs => css_check/report/tests/mod.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_runtime.rs => css_check/runtime.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_tests.rs => css_check/tests/mod.rs} (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/collect.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/model.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/paths.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/tests/assets.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/tests/commands.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/tests/helpers.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/tests/mod.rs (100%) rename crates/noticenterctl/src/{main/main_css_check_theme => css_check/theme}/tests/paths.rs (100%) rename crates/noticenterctl/src/dbus/{dispatch.rs => commands.rs} (100%) rename crates/noticenterctl/src/dbus/tests/{dispatch.rs => commands.rs} (100%) rename crates/noticenterctl/src/{main/main_log_follow.rs => debug_logs/mod.rs} (100%) delete mode 100644 crates/noticenterctl/src/main/main_css_check_cache.rs delete mode 100644 crates/noticenterctl/src/main/main_css_check_cache_tests.rs delete mode 100644 crates/noticenterctl/src/main/main_css_check_parse.rs delete mode 100644 crates/noticenterctl/src/main/main_css_check_theme.rs delete mode 100644 crates/noticenterctl/src/main/main_css_check_theme_tests.rs rename crates/noticenterctl/src/{dbus/output_gate.rs => output/gate.rs} (100%) rename crates/noticenterctl/src/{main/main_output.rs => output/mod.rs} (100%) rename crates/noticenterctl/src/{dbus/tests/output_gate.rs => output/tests.rs} (100%) rename crates/noticenterctl/src/preset/archive/{tests.rs => tests/mod.rs} (100%) rename crates/noticenterctl/src/preset/command_rules/{tests.rs => tests/mod.rs} (100%) rename crates/noticenterctl/src/preset/css_asset_refs/{tests.rs => tests/mod.rs} (100%) rename crates/noticenterctl/src/preset/export/{tests.rs => tests/mod.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_history.rs => history.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_identity.rs => identity.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_inhibit.rs => inhibit.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_inhibitor_api.rs => inhibitor_api.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_lifecycle.rs => lifecycle.rs} (100%) rename crates/unixnotis-daemon/src/{store.rs => store/mod.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_rules.rs => rules.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_state.rs => state.rs} (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/dnd.rs (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/inhibit.rs (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/lifecycle.rs (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/ownership.rs (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/rules.rs (100%) rename crates/unixnotis-daemon/src/store/{store_tests => tests}/support.rs (100%) diff --git a/crates/noticenterctl/src/app.rs b/crates/noticenterctl/src/app.rs new file mode 100644 index 00000000..79ce083a --- /dev/null +++ b/crates/noticenterctl/src/app.rs @@ -0,0 +1,37 @@ +//! Application runner for noticenterctl + +use anyhow::{Context, Result}; +use clap::Parser; +use unixnotis_core::ControlProxy; +use zbus::Connection; + +use crate::cli::{Args, Command}; + +pub(crate) async fn run() -> Result<()> { + // Parse CLI arguments before any daemon work starts + let args = Args::parse(); + + if args.command.is_local_only() { + // Local commands must work even when the daemon is not running + match args.command { + Command::CssCheck => { + crate::css_check::run_css_check()?; + } + Command::Preset { command } => { + crate::preset::run_preset(command).context("preset command failed")?; + } + _ => {} + } + return Ok(()); + } + + // Control commands need the session bus and the daemon proxy + let connection = Connection::session() + .await + .context("connect to session bus")?; + let proxy = ControlProxy::new(&connection) + .await + .context("connect to unixnotis control interface")?; + + crate::dbus::handle_command(&proxy, args.command).await +} diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index 19dd4c80..b5ca1ae1 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -1,6 +1,7 @@ -use clap::Parser; +use clap::{Parser, Subcommand, ValueEnum}; +use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; -use super::Command; +use super::route::Command; #[derive(Parser, Debug)] #[command(author, version, about)] @@ -9,3 +10,80 @@ pub(crate) struct Args { #[command(subcommand)] pub(crate) command: Command, } + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum DebugLevelArg { + // Only critical diagnostic output + Critical, + // Warnings and above + Warn, + // Informational output + Info, + // Verbose diagnostics + Verbose, +} + +impl From for PanelDebugLevel { + fn from(value: DebugLevelArg) -> Self { + match value { + DebugLevelArg::Critical => PanelDebugLevel::Critical, + DebugLevelArg::Warn => PanelDebugLevel::Warn, + DebugLevelArg::Info => PanelDebugLevel::Info, + DebugLevelArg::Verbose => PanelDebugLevel::Verbose, + } + } +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum DndState { + // Explicitly enable DND + On, + // Explicitly disable DND + Off, + // Toggle based on current daemon state + Toggle, +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(crate) enum InhibitScopeArg { + // Suppress both panel and popup updates + All, + // Suppress popup updates only + Popups, +} + +impl InhibitScopeArg { + pub(crate) fn as_scope(self) -> u32 { + // Map CLI scope to the daemon bitmask value + match self { + Self::All => INHIBIT_SCOPE_ALL, + Self::Popups => INHIBIT_SCOPE_POPUPS, + } + } +} + +#[derive(Subcommand, Debug)] +pub(crate) enum PresetCommand { + // Export the current config tree into one shareable bundle file + Export { + output: String, + #[arg(long = "except", value_name = "PATH")] + except: Vec, + #[arg(long)] + force: bool, + }, + // Import a bundle into the current config tree + Import { + input: String, + #[arg(long = "except", value_name = "PATH")] + except: Vec, + #[arg(long)] + dry_run: bool, + #[arg(long)] + allow_exec: bool, + }, + // Print bundle metadata and included files without writing anything + Inspect { + input: String, + }, +} diff --git a/crates/noticenterctl/src/cli/debug.rs b/crates/noticenterctl/src/cli/debug.rs deleted file mode 100644 index 03322f0b..00000000 --- a/crates/noticenterctl/src/cli/debug.rs +++ /dev/null @@ -1,25 +0,0 @@ -use clap::ValueEnum; -use unixnotis_core::PanelDebugLevel; - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum DebugLevelArg { - // Only critical diagnostic output - Critical, - // Warnings and above - Warn, - // Informational output - Info, - // Verbose diagnostics - Verbose, -} - -impl From for PanelDebugLevel { - fn from(value: DebugLevelArg) -> Self { - match value { - DebugLevelArg::Critical => PanelDebugLevel::Critical, - DebugLevelArg::Warn => PanelDebugLevel::Warn, - DebugLevelArg::Info => PanelDebugLevel::Info, - DebugLevelArg::Verbose => PanelDebugLevel::Verbose, - } - } -} diff --git a/crates/noticenterctl/src/cli/dnd.rs b/crates/noticenterctl/src/cli/dnd.rs deleted file mode 100644 index 1f818e60..00000000 --- a/crates/noticenterctl/src/cli/dnd.rs +++ /dev/null @@ -1,11 +0,0 @@ -use clap::ValueEnum; - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum DndState { - // Explicitly enable DND - On, - // Explicitly disable DND - Off, - // Toggle based on current daemon state - Toggle, -} diff --git a/crates/noticenterctl/src/cli/inhibit.rs b/crates/noticenterctl/src/cli/inhibit.rs deleted file mode 100644 index c5408c0e..00000000 --- a/crates/noticenterctl/src/cli/inhibit.rs +++ /dev/null @@ -1,20 +0,0 @@ -use clap::ValueEnum; -use unixnotis_core::{INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; - -#[derive(ValueEnum, Debug, Clone, Copy)] -pub(crate) enum InhibitScopeArg { - // Suppress both panel and popup updates - All, - // Suppress popup updates only - Popups, -} - -impl InhibitScopeArg { - pub(crate) fn as_scope(self) -> u32 { - // Map CLI scope to the daemon bitmask value - match self { - Self::All => INHIBIT_SCOPE_ALL, - Self::Popups => INHIBIT_SCOPE_POPUPS, - } - } -} diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs index 99d982c5..fd8b66c3 100644 --- a/crates/noticenterctl/src/cli/mod.rs +++ b/crates/noticenterctl/src/cli/mod.rs @@ -1,18 +1,12 @@ //! Command-line argument types for noticenterctl mod args; -mod command; -mod debug; -mod dnd; -mod inhibit; -mod preset; +mod route; -pub(crate) use args::Args; -pub(crate) use command::Command; -pub(crate) use debug::DebugLevelArg; -pub(crate) use dnd::DndState; -pub(crate) use inhibit::InhibitScopeArg; -pub(crate) use preset::PresetCommand; +pub(crate) use args::{Args, DndState, PresetCommand}; +#[cfg(test)] +pub(crate) use args::{DebugLevelArg, InhibitScopeArg}; +pub(crate) use route::Command; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/cli/preset.rs b/crates/noticenterctl/src/cli/preset.rs deleted file mode 100644 index 77bf7632..00000000 --- a/crates/noticenterctl/src/cli/preset.rs +++ /dev/null @@ -1,27 +0,0 @@ -use clap::Subcommand; - -#[derive(Subcommand, Debug)] -pub(crate) enum PresetCommand { - // Export the current config tree into one shareable bundle file - Export { - output: String, - #[arg(long = "except", value_name = "PATH")] - except: Vec, - #[arg(long)] - force: bool, - }, - // Import a bundle into the current config tree - Import { - input: String, - #[arg(long = "except", value_name = "PATH")] - except: Vec, - #[arg(long)] - dry_run: bool, - #[arg(long)] - allow_exec: bool, - }, - // Print bundle metadata and included files without writing anything - Inspect { - input: String, - }, -} diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/route.rs similarity index 96% rename from crates/noticenterctl/src/cli/command.rs rename to crates/noticenterctl/src/cli/route.rs index a9bf5bf4..1f1be61c 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/route.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -use super::{DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; +use super::args::{DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; #[derive(Subcommand, Debug)] pub(crate) enum Command { diff --git a/crates/noticenterctl/src/cli/tests/parsing.rs b/crates/noticenterctl/src/cli/tests/args.rs similarity index 86% rename from crates/noticenterctl/src/cli/tests/parsing.rs rename to crates/noticenterctl/src/cli/tests/args.rs index 02896747..e75ef51d 100644 --- a/crates/noticenterctl/src/cli/tests/parsing.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -1,4 +1,5 @@ use clap::Parser; +use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; use super::super::{Args, Command, DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; @@ -158,3 +159,24 @@ fn parses_preset_inspect() { other => panic!("unexpected command: {other:?}"), } } + +#[test] +fn debug_level_arg_into_panel_level() { + // Validates CLI debug levels map to the matching control plane enum + let table = [ + (DebugLevelArg::Critical, PanelDebugLevel::Critical), + (DebugLevelArg::Warn, PanelDebugLevel::Warn), + (DebugLevelArg::Info, PanelDebugLevel::Info), + (DebugLevelArg::Verbose, PanelDebugLevel::Verbose), + ]; + for (arg, expected) in table { + let mapped: PanelDebugLevel = arg.into(); + assert_eq!(mapped, expected); + } +} + +#[test] +fn inhibit_scope_arg_maps_to_control_bitmasks() { + assert_eq!(InhibitScopeArg::All.as_scope(), INHIBIT_SCOPE_ALL); + assert_eq!(InhibitScopeArg::Popups.as_scope(), INHIBIT_SCOPE_POPUPS); +} diff --git a/crates/noticenterctl/src/cli/tests/mapping.rs b/crates/noticenterctl/src/cli/tests/mapping.rs deleted file mode 100644 index a6718a34..00000000 --- a/crates/noticenterctl/src/cli/tests/mapping.rs +++ /dev/null @@ -1,24 +0,0 @@ -use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; - -use super::super::{DebugLevelArg, InhibitScopeArg}; - -#[test] -fn debug_level_arg_into_panel_level() { - // Validates CLI debug levels map to the matching control plane enum - let table = [ - (DebugLevelArg::Critical, PanelDebugLevel::Critical), - (DebugLevelArg::Warn, PanelDebugLevel::Warn), - (DebugLevelArg::Info, PanelDebugLevel::Info), - (DebugLevelArg::Verbose, PanelDebugLevel::Verbose), - ]; - for (arg, expected) in table { - let mapped: PanelDebugLevel = arg.into(); - assert_eq!(mapped, expected); - } -} - -#[test] -fn inhibit_scope_arg_maps_to_control_bitmasks() { - assert_eq!(InhibitScopeArg::All.as_scope(), INHIBIT_SCOPE_ALL); - assert_eq!(InhibitScopeArg::Popups.as_scope(), INHIBIT_SCOPE_POPUPS); -} diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs index 77686497..cfe2741d 100644 --- a/crates/noticenterctl/src/cli/tests/mod.rs +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -1,3 +1,2 @@ -mod local_only; -mod mapping; -mod parsing; +mod args; +mod route; diff --git a/crates/noticenterctl/src/cli/tests/local_only.rs b/crates/noticenterctl/src/cli/tests/route.rs similarity index 100% rename from crates/noticenterctl/src/cli/tests/local_only.rs rename to crates/noticenterctl/src/cli/tests/route.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/dependencies.rs b/crates/noticenterctl/src/css_check/cache/dependencies.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/dependencies.rs rename to crates/noticenterctl/src/css_check/cache/dependencies.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/mod.rs b/crates/noticenterctl/src/css_check/cache/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/mod.rs rename to crates/noticenterctl/src/css_check/cache/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/model.rs b/crates/noticenterctl/src/css_check/cache/model.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/model.rs rename to crates/noticenterctl/src/css_check/cache/model.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/parse.rs b/crates/noticenterctl/src/css_check/cache/parse.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/parse.rs rename to crates/noticenterctl/src/css_check/cache/parse.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/session.rs b/crates/noticenterctl/src/css_check/cache/session.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/session.rs rename to crates/noticenterctl/src/css_check/cache/session.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/store.rs b/crates/noticenterctl/src/css_check/cache/store.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/store.rs rename to crates/noticenterctl/src/css_check/cache/store.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/tests/helpers.rs b/crates/noticenterctl/src/css_check/cache/tests/helpers.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/tests/helpers.rs rename to crates/noticenterctl/src/css_check/cache/tests/helpers.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/tests/import_invalidation.rs b/crates/noticenterctl/src/css_check/cache/tests/import_invalidation.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/tests/import_invalidation.rs rename to crates/noticenterctl/src/css_check/cache/tests/import_invalidation.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/tests/invalidation.rs b/crates/noticenterctl/src/css_check/cache/tests/invalidation.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/tests/invalidation.rs rename to crates/noticenterctl/src/css_check/cache/tests/invalidation.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/tests/mod.rs b/crates/noticenterctl/src/css_check/cache/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/tests/mod.rs rename to crates/noticenterctl/src/css_check/cache/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_cache/tests/reuse.rs b/crates/noticenterctl/src/css_check/cache/tests/reuse.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_cache/tests/reuse.rs rename to crates/noticenterctl/src/css_check/cache/tests/reuse.rs diff --git a/crates/noticenterctl/src/main/main_css_check_files.rs b/crates/noticenterctl/src/css_check/files.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_files.rs rename to crates/noticenterctl/src/css_check/files.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry.rs b/crates/noticenterctl/src/css_check/geometry.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry.rs rename to crates/noticenterctl/src/css_check/geometry.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/model.rs b/crates/noticenterctl/src/css_check/geometry/model.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/model.rs rename to crates/noticenterctl/src/css_check/geometry/model.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/model/box_metrics.rs b/crates/noticenterctl/src/css_check/geometry/model/box_metrics.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/model/box_metrics.rs rename to crates/noticenterctl/src/css_check/geometry/model/box_metrics.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/model/constants.rs b/crates/noticenterctl/src/css_check/geometry/model/constants.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/model/constants.rs rename to crates/noticenterctl/src/css_check/geometry/model/constants.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/model/fixed_grid.rs b/crates/noticenterctl/src/css_check/geometry/model/fixed_grid.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/model/fixed_grid.rs rename to crates/noticenterctl/src/css_check/geometry/model/fixed_grid.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/height.rs b/crates/noticenterctl/src/css_check/geometry/model/media/height.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/height.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/height.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/helpers.rs b/crates/noticenterctl/src/css_check/geometry/model/media/helpers.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/helpers.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/helpers.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/mod.rs b/crates/noticenterctl/src/css_check/geometry/model/media/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/mod.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/shell.rs b/crates/noticenterctl/src/css_check/geometry/model/media/shell.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/shell.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/shell.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/tests.rs b/crates/noticenterctl/src/css_check/geometry/model/media/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/tests.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/vertical.rs b/crates/noticenterctl/src/css_check/geometry/model/media/vertical.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/vertical.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/vertical.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/media/width.rs b/crates/noticenterctl/src/css_check/geometry/model/media/width.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/media/width.rs rename to crates/noticenterctl/src/css_check/geometry/model/media/width.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/model/tracking.rs b/crates/noticenterctl/src/css_check/geometry/model/tracking.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/model/tracking.rs rename to crates/noticenterctl/src/css_check/geometry/model/tracking.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse.rs b/crates/noticenterctl/src/css_check/geometry/parse.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse.rs rename to crates/noticenterctl/src/css_check/geometry/parse.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/custom_properties.rs rename to crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/mod.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_calc.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_calc.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_calc.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_calc.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_compare.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_compare.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_var.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/resolve_var.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/tokenize.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/tokenize.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/units.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/units.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/lengths/units.rs rename to crates/noticenterctl/src/css_check/geometry/parse/lengths/units.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/parse/selectors.rs b/crates/noticenterctl/src/css_check/geometry/parse/selectors.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse/selectors.rs rename to crates/noticenterctl/src/css_check/geometry/parse/selectors.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/stock/baselines.rs b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/baselines.rs rename to crates/noticenterctl/src/css_check/geometry/stock/baselines.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/classes.rs rename to crates/noticenterctl/src/css_check/geometry/stock/classes.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/stock/mod.rs b/crates/noticenterctl/src/css_check/geometry/stock/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/mod.rs rename to crates/noticenterctl/src/css_check/geometry/stock/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/stock/size_rules.rs b/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/size_rules.rs rename to crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/tests/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/custom_properties.rs rename to crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/tests/height_budget.rs b/crates/noticenterctl/src/css_check/geometry/tests/height_budget.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/height_budget.rs rename to crates/noticenterctl/src/css_check/geometry/tests/height_budget.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/mod.rs rename to crates/noticenterctl/src/css_check/geometry/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/tests/stock_and_selectors.rs b/crates/noticenterctl/src/css_check/geometry/tests/stock_and_selectors.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/stock_and_selectors.rs rename to crates/noticenterctl/src/css_check/geometry/tests/stock_and_selectors.rs diff --git a/crates/noticenterctl/src/main/main_css_check_geometry/tests/width_budget.rs b/crates/noticenterctl/src/css_check/geometry/tests/width_budget.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/width_budget.rs rename to crates/noticenterctl/src/css_check/geometry/tests/width_budget.rs diff --git a/crates/noticenterctl/src/main/main_css_check_lint/mod.rs b/crates/noticenterctl/src/css_check/lint/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_lint/mod.rs rename to crates/noticenterctl/src/css_check/lint/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_lint/scan.rs rename to crates/noticenterctl/src/css_check/lint/scan.rs diff --git a/crates/noticenterctl/src/main/main_css_check_lint/values.rs b/crates/noticenterctl/src/css_check/lint/values.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_lint/values.rs rename to crates/noticenterctl/src/css_check/lint/values.rs diff --git a/crates/noticenterctl/src/main/main_css_check.rs b/crates/noticenterctl/src/css_check/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check.rs rename to crates/noticenterctl/src/css_check/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_parse/blocks.rs b/crates/noticenterctl/src/css_check/parse/blocks.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_parse/blocks.rs rename to crates/noticenterctl/src/css_check/parse/blocks.rs diff --git a/crates/noticenterctl/src/main/main_css_check_parse/declarations.rs b/crates/noticenterctl/src/css_check/parse/declarations.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_parse/declarations.rs rename to crates/noticenterctl/src/css_check/parse/declarations.rs diff --git a/crates/noticenterctl/src/main/main_css_check_parse/mod.rs b/crates/noticenterctl/src/css_check/parse/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_parse/mod.rs rename to crates/noticenterctl/src/css_check/parse/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_parse/selectors.rs b/crates/noticenterctl/src/css_check/parse/selectors.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_parse/selectors.rs rename to crates/noticenterctl/src/css_check/parse/selectors.rs diff --git a/crates/noticenterctl/src/main/main_css_check_parse/types.rs b/crates/noticenterctl/src/css_check/parse/types.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_parse/types.rs rename to crates/noticenterctl/src/css_check/parse/types.rs diff --git a/crates/noticenterctl/src/main/main_css_check_policy.rs b/crates/noticenterctl/src/css_check/policy.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_policy.rs rename to crates/noticenterctl/src/css_check/policy.rs diff --git a/crates/noticenterctl/src/main/main_css_check_report/mod.rs b/crates/noticenterctl/src/css_check/report/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_report/mod.rs rename to crates/noticenterctl/src/css_check/report/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_report/model.rs b/crates/noticenterctl/src/css_check/report/model.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_report/model.rs rename to crates/noticenterctl/src/css_check/report/model.rs diff --git a/crates/noticenterctl/src/main/main_css_check_report/render.rs b/crates/noticenterctl/src/css_check/report/render.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_report/render.rs rename to crates/noticenterctl/src/css_check/report/render.rs diff --git a/crates/noticenterctl/src/main/main_css_check_report/style.rs b/crates/noticenterctl/src/css_check/report/style.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_report/style.rs rename to crates/noticenterctl/src/css_check/report/style.rs diff --git a/crates/noticenterctl/src/main/main_css_check_report/tests.rs b/crates/noticenterctl/src/css_check/report/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_report/tests.rs rename to crates/noticenterctl/src/css_check/report/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_runtime.rs b/crates/noticenterctl/src/css_check/runtime.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_runtime.rs rename to crates/noticenterctl/src/css_check/runtime.rs diff --git a/crates/noticenterctl/src/main/main_css_check_tests.rs b/crates/noticenterctl/src/css_check/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_tests.rs rename to crates/noticenterctl/src/css_check/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/collect.rs b/crates/noticenterctl/src/css_check/theme/collect.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/collect.rs rename to crates/noticenterctl/src/css_check/theme/collect.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/mod.rs b/crates/noticenterctl/src/css_check/theme/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/mod.rs rename to crates/noticenterctl/src/css_check/theme/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/model.rs b/crates/noticenterctl/src/css_check/theme/model.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/model.rs rename to crates/noticenterctl/src/css_check/theme/model.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/paths.rs b/crates/noticenterctl/src/css_check/theme/paths.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/paths.rs rename to crates/noticenterctl/src/css_check/theme/paths.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/tests/assets.rs b/crates/noticenterctl/src/css_check/theme/tests/assets.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/tests/assets.rs rename to crates/noticenterctl/src/css_check/theme/tests/assets.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/tests/commands.rs b/crates/noticenterctl/src/css_check/theme/tests/commands.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/tests/commands.rs rename to crates/noticenterctl/src/css_check/theme/tests/commands.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/tests/helpers.rs b/crates/noticenterctl/src/css_check/theme/tests/helpers.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/tests/helpers.rs rename to crates/noticenterctl/src/css_check/theme/tests/helpers.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/tests/mod.rs b/crates/noticenterctl/src/css_check/theme/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/tests/mod.rs rename to crates/noticenterctl/src/css_check/theme/tests/mod.rs diff --git a/crates/noticenterctl/src/main/main_css_check_theme/tests/paths.rs b/crates/noticenterctl/src/css_check/theme/tests/paths.rs similarity index 100% rename from crates/noticenterctl/src/main/main_css_check_theme/tests/paths.rs rename to crates/noticenterctl/src/css_check/theme/tests/paths.rs diff --git a/crates/noticenterctl/src/dbus/dispatch.rs b/crates/noticenterctl/src/dbus/commands.rs similarity index 100% rename from crates/noticenterctl/src/dbus/dispatch.rs rename to crates/noticenterctl/src/dbus/commands.rs diff --git a/crates/noticenterctl/src/dbus/tests/dispatch.rs b/crates/noticenterctl/src/dbus/tests/commands.rs similarity index 100% rename from crates/noticenterctl/src/dbus/tests/dispatch.rs rename to crates/noticenterctl/src/dbus/tests/commands.rs diff --git a/crates/noticenterctl/src/main/main_log_follow.rs b/crates/noticenterctl/src/debug_logs/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_log_follow.rs rename to crates/noticenterctl/src/debug_logs/mod.rs diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index 9d3f4d89..3d7e7736 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -6,52 +6,15 @@ reason = "workspace clippy runs use these groups as review signals, not as zero-tolerance policy gates" )] -//! Command-line control surface for the UnixNotis D-Bus interface - +mod app; mod cli; +mod css_check; mod dbus; -#[path = "main/main_css_check.rs"] -mod main_css_check; -#[path = "main/main_log_follow.rs"] -mod main_log_follow; -#[path = "main/main_output.rs"] -mod main_output; +mod debug_logs; +mod output; mod preset; -use anyhow::{Context, Result}; -use clap::Parser; -use cli::Args; -use unixnotis_core::ControlProxy; -use zbus::Connection; - #[tokio::main] -async fn main() -> Result<()> { - // Parse CLI arguments first so local-only commands can stop here - let args = Args::parse(); - - if args.command.is_local_only() { - // Local-only commands skip D-Bus setup on purpose - match args.command { - cli::Command::CssCheck => { - main_css_check::run_css_check()?; - } - cli::Command::Preset { command } => { - preset::run_preset(command).context("preset command failed")?; - } - _ => {} - } - return Ok(()); - } - - // Connect to the session bus before proxying control commands - let connection = Connection::session() - .await - .context("connect to session bus")?; - let proxy = ControlProxy::new(&connection) - .await - .context("connect to unixnotis control interface")?; - - // Hand command execution to the D-Bus command layer - dbus::handle_command(&proxy, args.command).await?; - Ok(()) +async fn main() -> anyhow::Result<()> { + app::run().await } diff --git a/crates/noticenterctl/src/main/main_css_check_cache.rs b/crates/noticenterctl/src/main/main_css_check_cache.rs deleted file mode 100644 index c701ba49..00000000 --- a/crates/noticenterctl/src/main/main_css_check_cache.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! Cache-aware GTK parse stage for css-check - -use anyhow::{Context, Result}; -use gtk::prelude::*; -use gtk::CssProvider; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; - -use super::main_css_check_files::format_display_path; -use super::main_css_check_policy::parsing_error_hint; -use super::main_css_check_report::{CssCheckCategory, CssCheckDiagnostic}; -use super::source_line_text; - -const CSS_PARSE_CACHE_VERSION: u32 = 2; -const CSS_PARSE_CACHE_FILE: &str = "css-check-parse-cache-v2.json"; - -pub(super) struct CssParseReport { - pub(super) diagnostics: Vec, - pub(super) error_count: usize, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct CssParseWorkItem { - // The configured path stays as the visible path in fresh and cached reports - load_path: PathBuf, - // Canonical paths keep aliases and symlinks on one cache key - canonical_path: PathBuf, - // Stable identity keeps stale success and stale failure entries out - identity: CssFileIdentity, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct CssFileIdentity { - size: u64, - modified_nanos: u128, - #[cfg(unix)] - device: u64, - #[cfg(unix)] - inode: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -enum CachedDiagnosticSource { - // Top-level findings should always render against the current logical input path - TopLevel, - // Imported files need their own stable path in the report - Path(PathBuf), - // GTK can occasionally report inline data instead of a file path - Data, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct CachedParseDiagnostic { - source: CachedDiagnosticSource, - line: Option, - column: Option, - message: String, - hint: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -struct CssParseCacheFile { - version: u32, - entries: BTreeMap, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -struct CssParseCacheEntry { - identity: CssFileIdentity, - // Hashing cached hits closes the coarse-mtime false-hit edge - content_hash: String, - diagnostics: Vec, -} - -struct CssParseCacheState { - path: PathBuf, - file: CssParseCacheFile, - dirty: bool, -} - -impl CssParseCacheState { - fn load(path: PathBuf) -> Self { - // Broken cache files should never block validation - let file = fs::read_to_string(&path) - .ok() - .and_then(|contents| serde_json::from_str::(&contents).ok()) - .filter(|cache| cache.version == CSS_PARSE_CACHE_VERSION) - .unwrap_or_else(|| CssParseCacheFile { - version: CSS_PARSE_CACHE_VERSION, - entries: BTreeMap::new(), - }); - - Self { - path, - file, - dirty: false, - } - } - - fn lookup(&self, work_item: &CssParseWorkItem) -> Result> { - let key = cache_key_for_path(&work_item.canonical_path); - let Some(entry) = self.file.entries.get(&key) else { - return Ok(None); - }; - if entry.identity != work_item.identity { - return Ok(None); - } - - // A would-be hit still proves the current bytes before reuse - let current_hash = hash_css_file_bytes(&work_item.load_path)?; - if current_hash == entry.content_hash { - return Ok(Some(entry)); - } - - Ok(None) - } - - fn store( - &mut self, - work_item: &CssParseWorkItem, - diagnostics: Vec, - ) -> Result<()> { - let key = cache_key_for_path(&work_item.canonical_path); - let entry = CssParseCacheEntry { - identity: work_item.identity.clone(), - content_hash: hash_css_file_bytes(&work_item.load_path)?, - diagnostics, - }; - if self.file.entries.get(&key) == Some(&entry) { - return Ok(()); - } - self.file.entries.insert(key, entry); - self.dirty = true; - Ok(()) - } - - fn save(self) { - if !self.dirty { - return; - } - - let Some(parent) = self.path.parent() else { - return; - }; - - if fs::create_dir_all(parent).is_err() { - return; - } - - let Ok(contents) = serde_json::to_vec_pretty(&self.file) else { - return; - }; - - // Write-then-rename keeps partial cache files out of later runs - let temp_path = parent.join(format!( - ".{}.tmp-{}", - CSS_PARSE_CACHE_FILE, - std::process::id() - )); - if fs::write(&temp_path, contents).is_err() { - return; - } - if fs::rename(&temp_path, &self.path).is_err() { - let _ = fs::remove_file(&temp_path); - } - } -} - -pub(super) fn validate_css_parse_files( - files: &[PathBuf], - config_dir: &Path, - display_root: &str, -) -> Result { - let work_items = build_parse_work_items(files)?; - let cache_path = default_css_parse_cache_path(); - run_cached_parse_session( - &work_items, - config_dir, - display_root, - cache_path.as_deref(), - parse_css_file_with_gtk, - ) -} - -fn run_cached_parse_session( - work_items: &[CssParseWorkItem], - config_dir: &Path, - display_root: &str, - cache_path: Option<&Path>, - mut parse_file: F, -) -> Result -where - F: FnMut(&CssParseWorkItem) -> Result>, -{ - let mut cache = cache_path.map(|path| CssParseCacheState::load(path.to_path_buf())); - let mut diagnostics = Vec::new(); - let mut error_count = 0usize; - - for work_item in work_items { - if let Some(entry) = cache - .as_ref() - .map(|cache| cache.lookup(work_item)) - .transpose()? - .flatten() - { - let cached_diagnostics = - render_cached_diagnostics(&entry.diagnostics, work_item, config_dir, display_root); - error_count += cached_diagnostics.len(); - diagnostics.extend(cached_diagnostics); - continue; - } - - let fresh_diagnostics = parse_file(work_item)?; - error_count += fresh_diagnostics.len(); - diagnostics.extend(render_cached_diagnostics( - &fresh_diagnostics, - work_item, - config_dir, - display_root, - )); - if let Some(cache) = cache.as_mut() { - cache.store(work_item, fresh_diagnostics)?; - } - } - - if let Some(cache) = cache { - cache.save(); - } - - Ok(CssParseReport { - diagnostics, - error_count, - }) -} - -fn build_parse_work_items(files: &[PathBuf]) -> Result> { - let mut work_items = Vec::with_capacity(files.len()); - for path in files { - // Metadata should come from the real target, not the symlink shell - let metadata = - fs::metadata(path).with_context(|| format!("read css metadata {}", path.display()))?; - let canonical_path = fs::canonicalize(path) - .with_context(|| format!("resolve css file {}", path.display()))?; - work_items.push(CssParseWorkItem { - load_path: path.clone(), - canonical_path, - identity: CssFileIdentity::from_metadata(&metadata)?, - }); - } - Ok(work_items) -} - -fn parse_css_file_with_gtk(work_item: &CssParseWorkItem) -> Result> { - let provider = CssProvider::new(); - let current_file = work_item.canonical_path.clone(); - let findings = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); - let findings_for_signal = findings.clone(); - - provider.connect_parsing_error(move |_provider, section, error| { - let location = section.start_location(); - let line = location.lines() + 1; - let source_path = section.file().and_then(|file| file.path()); - - // Line hints stay tied to the exact file GTK blamed - let hint = source_line_text(source_path.as_deref(), line) - .and_then(|line_text| parsing_error_hint(&line_text)); - - let source = classify_cached_source_path(source_path.as_deref(), ¤t_file); - findings_for_signal - .borrow_mut() - .push(CachedParseDiagnostic { - source, - line: Some(line), - column: Some(location.line_chars() + 1), - message: error.message().to_string(), - hint, - }); - }); - - // Gtk clears prior provider state on every load_from_path call - provider.load_from_path(&work_item.load_path); - let diagnostics = findings.borrow().clone(); - Ok(diagnostics) -} - -fn classify_cached_source_path( - source_path: Option<&Path>, - current_file: &Path, -) -> CachedDiagnosticSource { - let Some(source_path) = source_path else { - return CachedDiagnosticSource::Data; - }; - - let normalized_source = - fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf()); - if normalized_source == current_file { - return CachedDiagnosticSource::TopLevel; - } - - CachedDiagnosticSource::Path(source_path.to_path_buf()) -} - -fn render_cached_diagnostics( - diagnostics: &[CachedParseDiagnostic], - work_item: &CssParseWorkItem, - config_dir: &Path, - display_root: &str, -) -> Vec { - let top_level_display = format_display_path(config_dir, display_root, &work_item.load_path); - diagnostics - .iter() - .map(|diagnostic| { - let display_path = match &diagnostic.source { - CachedDiagnosticSource::TopLevel => top_level_display.clone(), - CachedDiagnosticSource::Path(path) => { - format_display_path(config_dir, display_root, path) - } - CachedDiagnosticSource::Data => "".to_string(), - }; - - CssCheckDiagnostic::error( - CssCheckCategory::Parse, - display_path, - diagnostic.line, - diagnostic.column, - diagnostic.message.clone(), - diagnostic.hint.clone(), - ) - }) - .collect() -} - -fn default_css_parse_cache_path() -> Option { - // Cache storage should follow the usual XDG rules first - if let Ok(cache_home) = env::var("XDG_CACHE_HOME") { - let trimmed = cache_home.trim(); - if !trimmed.is_empty() { - return Some( - PathBuf::from(trimmed) - .join("unixnotis") - .join(CSS_PARSE_CACHE_FILE), - ); - } - } - - let home = env::var("HOME").ok()?; - Some( - PathBuf::from(home) - .join(".cache") - .join("unixnotis") - .join(CSS_PARSE_CACHE_FILE), - ) -} - -fn cache_key_for_path(path: &Path) -> String { - path.to_string_lossy().into_owned() -} - -fn hash_css_file_bytes(path: &Path) -> Result { - // Hash the exact bytes GTK would read so cached hits stay honest - let bytes = fs::read(path).with_context(|| format!("read css file {}", path.display()))?; - Ok(blake3::hash(&bytes).to_hex().to_string()) -} - -impl CssFileIdentity { - fn from_metadata(metadata: &fs::Metadata) -> Result { - let modified = metadata - .modified() - .context("read css file modification time")?; - let modified_nanos = modified - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - - return Ok(Self { - size: metadata.len(), - modified_nanos, - device: metadata.dev(), - inode: metadata.ino(), - }); - } - - #[cfg(not(unix))] - { - Ok(Self { - size: metadata.len(), - modified_nanos, - }) - } - } -} - -#[cfg(test)] -fn validate_css_parse_files_with( - files: &[PathBuf], - config_dir: &Path, - display_root: &str, - cache_path: &Path, - parse_file: impl FnMut(&CssParseWorkItem) -> Result>, -) -> Result { - let work_items = build_parse_work_items(files)?; - run_cached_parse_session( - &work_items, - config_dir, - display_root, - Some(cache_path), - parse_file, - ) -} - -#[cfg(test)] -fn parse_diagnostic_for_test(message: impl Into) -> Vec { - vec![CachedParseDiagnostic { - source: CachedDiagnosticSource::TopLevel, - line: Some(1), - column: Some(1), - message: message.into(), - hint: None, - }] -} - -#[cfg(test)] -#[path = "main_css_check_cache_tests.rs"] -mod tests; diff --git a/crates/noticenterctl/src/main/main_css_check_cache_tests.rs b/crates/noticenterctl/src/main/main_css_check_cache_tests.rs deleted file mode 100644 index 5da673b3..00000000 --- a/crates/noticenterctl/src/main/main_css_check_cache_tests.rs +++ /dev/null @@ -1,402 +0,0 @@ -use std::cell::Cell; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use super::{parse_diagnostic_for_test, validate_css_parse_files_with, CssParseWorkItem}; - -static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - -struct TempDirGuard { - path: PathBuf, -} - -impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("unixnotis-css-cache-{}-{}-{}", name, stamp, serial)); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &str) -> PathBuf { - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(&path, contents).expect("write file"); - path - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -fn pause_for_metadata_tick() { - // Fast file swaps can land in the same timestamp slot on some filesystems - std::thread::sleep(Duration::from_millis(5)); -} - -#[cfg(unix)] -fn set_file_mtime(path: &Path, modified: SystemTime) { - use rustix::fs::{utimensat, AtFlags, Timespec, Timestamps, CWD}; - - let duration = modified - .duration_since(UNIX_EPOCH) - .expect("mtime before unix epoch"); - let timestamp = Timespec { - tv_sec: duration.as_secs().try_into().expect("mtime seconds fit"), - tv_nsec: duration.subsec_nanos().into(), - }; - let times = Timestamps { - last_access: timestamp, - last_modification: timestamp, - }; - - // Keeping mtime identical forces the old fast identity to collide - utimensat(CWD, path, ×, AtFlags::empty()).expect("set file mtime"); -} - -fn parse_with_counter( - invocations: &Cell, - work_item: &CssParseWorkItem, -) -> anyhow::Result> { - invocations.set(invocations.get() + 1); - let contents = fs::read_to_string(&work_item.load_path)?; - if contents == "clean" { - return Ok(Vec::new()); - } - Ok(parse_diagnostic_for_test(contents)) -} - -#[test] -fn unchanged_files_reuse_cached_parse_diagnostics() { - let root = TempDirGuard::new("unchanged-hit"); - let css_path = root.write("config/base.css", "broken-one"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - let first = validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 1); - assert_eq!(first.error_count, 1); - assert_eq!(second.error_count, 1); - assert_eq!(first.diagnostics, second.diagnostics); -} - -#[test] -fn unchanged_clean_files_do_not_reparse() { - let root = TempDirGuard::new("unchanged-clean"); - let css_path = root.write("config/base.css", "clean"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 1); - assert_eq!(second.error_count, 0); - assert!(second.diagnostics.is_empty()); -} - -#[test] -fn edited_files_always_revalidate() { - let root = TempDirGuard::new("edited-file"); - let css_path = root.write("config/base.css", "broken-one"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - fs::write(&css_path, "broken-two").expect("rewrite file"); - - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("broken-two")); -} - -#[test] -fn deleting_and_recreating_a_file_misses_the_cache() { - let root = TempDirGuard::new("delete-recreate"); - let css_path = root.write("config/base.css", "broken-one"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - fs::remove_file(&css_path).expect("remove file"); - fs::write(&css_path, "broken-two").expect("recreate file"); - - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("broken-two")); -} - -#[test] -fn replacing_a_path_with_a_new_inode_misses_the_cache() { - let root = TempDirGuard::new("replace-path"); - let css_path = root.write("config/base.css", "broken-aa"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - let old_path = root.path().join("config/base.old.css"); - fs::rename(&css_path, &old_path).expect("move old file"); - fs::write(&css_path, "broken-bb").expect("write replacement file"); - - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("broken-bb")); -} - -#[cfg(unix)] -#[test] -fn symlink_target_changes_miss_the_cache() { - use std::os::unix::fs::symlink; - - let root = TempDirGuard::new("symlink-retarget"); - let target_a = root.write("config/shared/base-a.css", "broken-one"); - let _target_b = root.write("config/shared/base-b.css", "broken-two"); - let symlink_path = root.path().join("config/base.css"); - symlink("shared/base-a.css", &symlink_path).expect("create symlink"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[symlink_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - let _ = target_a; - fs::remove_file(&symlink_path).expect("remove symlink"); - symlink("shared/base-b.css", &symlink_path).expect("retarget symlink"); - - let second = validate_css_parse_files_with( - &[symlink_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("broken-two")); -} - -#[test] -fn renaming_the_real_path_creates_a_new_cache_key() { - let root = TempDirGuard::new("path-change"); - let old_path = root.write("config/base-a.css", "broken-one"); - let new_path = root.path().join("config/base-b.css"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[old_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - fs::rename(&old_path, &new_path).expect("rename file"); - - let second = validate_css_parse_files_with( - &[new_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("broken-one")); -} - -#[test] -fn mixed_runs_only_reparse_the_changed_file() { - let root = TempDirGuard::new("mixed-run"); - let first_path = root.write("config/base.css", "broken-one"); - let second_path = root.write("config/panel.css", "broken-two"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[first_path.clone(), second_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - pause_for_metadata_tick(); - fs::write(&second_path, "broken-three").expect("rewrite second file"); - - let second = validate_css_parse_files_with( - &[first_path, second_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 3); - assert_eq!(second.error_count, 2); - assert!(second - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains("broken-one"))); - assert!(second - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains("broken-three"))); -} - -#[cfg(unix)] -#[test] -fn same_size_same_inode_same_mtime_still_misses_when_contents_change() { - use std::os::unix::fs::MetadataExt; - - let root = TempDirGuard::new("coarse-mtime-edge"); - let css_path = root.write("config/base.css", "aaaaa"); - let cache_path = root.path().join("cache.json"); - let invocations = Cell::new(0usize); - - validate_css_parse_files_with( - &[css_path.clone()], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("first parse"); - - let before = fs::metadata(&css_path).expect("metadata before rewrite"); - let original_mtime = before.modified().expect("mtime before rewrite"); - - fs::write(&css_path, "bbbbb").expect("rewrite same-sized file"); - set_file_mtime(&css_path, original_mtime); - - let after = fs::metadata(&css_path).expect("metadata after rewrite"); - assert_eq!(before.len(), after.len()); - assert_eq!(before.ino(), after.ino()); - assert_eq!(before.dev(), after.dev()); - assert_eq!( - after.modified().expect("mtime after rewrite"), - original_mtime - ); - - let second = validate_css_parse_files_with( - &[css_path], - root.path(), - "$TMP/unixnotis", - &cache_path, - |work_item| parse_with_counter(&invocations, work_item), - ) - .expect("second parse"); - - assert_eq!(invocations.get(), 2); - assert!(second.diagnostics[0].message.contains("bbbbb")); -} diff --git a/crates/noticenterctl/src/main/main_css_check_parse.rs b/crates/noticenterctl/src/main/main_css_check_parse.rs deleted file mode 100644 index 909d84f1..00000000 --- a/crates/noticenterctl/src/main/main_css_check_parse.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Small CSS scanner helpers for css-check - -pub(super) struct CssBlock { - // Selector text before normalization - pub(super) selector: String, - // Raw block body without the outer braces - pub(super) block: String, - // Cursor position for the next scan step - pub(super) next: usize, - // Absolute offsets let lint map warnings back to line and column - pub(super) selector_start: usize, - pub(super) block_start: usize, -} - -pub(super) struct CssDeclaration { - pub(super) name: String, - pub(super) value: String, - // Declaration offsets are relative to the block slice - pub(super) start: usize, -} - -pub(super) fn strip_css_comments(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - let mut in_comment = false; - while let Some(ch) = chars.next() { - if in_comment { - // Stay in comment mode until the closing marker is found - if ch == '*' && matches!(chars.peek(), Some('/')) { - output.push(' '); - output.push(' '); - chars.next(); - in_comment = false; - continue; - } - if ch == '\n' || ch == '\r' { - // Newlines need to stay in place so line numbers still match - output.push(ch); - } else { - output.push(' '); - } - continue; - } - if ch == '/' && matches!(chars.peek(), Some('*')) { - // Replace the opening marker too so offsets stay aligned with the source - output.push(' '); - output.push(' '); - chars.next(); - in_comment = true; - continue; - } - output.push(ch); - } - output -} - -pub(super) fn next_css_block(bytes: &[u8], start: usize) -> Option<(String, String, usize)> { - // Older callers only need the text, so this stays as a small adapter - next_css_block_with_offsets(bytes, start).map(|block| (block.selector, block.block, block.next)) -} - -pub(super) fn next_css_block_with_offsets(bytes: &[u8], start: usize) -> Option { - // This scanner only needs to find balanced blocks - let mut selector_start = start; - while selector_start < bytes.len() && bytes[selector_start].is_ascii_whitespace() { - selector_start += 1; - } - - let mut index = selector_start; - let mut in_string: Option = None; - while index < bytes.len() { - let byte = bytes[index]; - if let Some(quote) = in_string { - if byte == quote { - in_string = None; - } - index += 1; - continue; - } - if byte == b'"' || byte == b'\'' { - in_string = Some(byte); - index += 1; - continue; - } - if byte == b'{' { - let selector = String::from_utf8_lossy(&bytes[selector_start..index]).to_string(); - let mut depth = 1usize; - index += 1; - let block_start = index; - while index < bytes.len() { - let byte = bytes[index]; - if let Some(quote) = in_string { - if byte == quote { - in_string = None; - } - index += 1; - continue; - } - if byte == b'"' || byte == b'\'' { - in_string = Some(byte); - index += 1; - continue; - } - if byte == b'{' { - depth += 1; - } else if byte == b'}' { - depth -= 1; - if depth == 0 { - let block = String::from_utf8_lossy(&bytes[block_start..index]).to_string(); - return Some(CssBlock { - selector, - block, - next: index + 1, - selector_start, - block_start, - }); - } - } - index += 1; - } - break; - } - index += 1; - } - None -} - -pub(super) fn normalize_selector(selector: &str) -> String { - // Whitespace-only changes should not make selectors look different - selector - .split_whitespace() - .collect::>() - .join(" ") - .trim() - .to_string() -} - -pub(super) fn split_selectors(selector: &str) -> Vec { - // Split grouped selectors, but not commas inside nested parts - let mut parts = Vec::new(); - let mut current = String::new(); - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string: Option = None; - let mut chars = selector.chars().peekable(); - - while let Some(ch) = chars.next() { - if let Some(quote) = in_string { - if ch == '\\' { - current.push(ch); - if let Some(next_char) = chars.next() { - current.push(next_char); - } - continue; - } - if ch == quote { - in_string = None; - } - current.push(ch); - continue; - } - - match ch { - '"' | '\'' => { - in_string = Some(ch); - current.push(ch); - } - '(' => { - paren_depth = paren_depth.saturating_add(1); - current.push(ch); - } - ')' => { - paren_depth = paren_depth.saturating_sub(1); - current.push(ch); - } - '[' => { - bracket_depth = bracket_depth.saturating_add(1); - current.push(ch); - } - ']' => { - bracket_depth = bracket_depth.saturating_sub(1); - current.push(ch); - } - ',' if paren_depth == 0 && bracket_depth == 0 => { - let trimmed = current.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - } - current.clear(); - } - _ => current.push(ch), - } - } - - let trimmed = current.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - } - - parts -} - -pub(super) fn should_recurse_at_rule(selector: &str) -> bool { - // Only recurse into at-rules that can hold nested selector blocks - let name = selector - .trim_start_matches('@') - .split_whitespace() - .next() - .unwrap_or(""); - matches!( - name, - "media" | "supports" | "layer" | "container" | "document" - ) -} - -pub(super) fn parse_css_declarations(block: &str) -> Vec<(String, String)> { - // Older callers only need declaration text, not source offsets - parse_css_declarations_with_offsets(block) - .into_iter() - .map(|declaration| (declaration.name, declaration.value)) - .collect() -} - -pub(super) fn parse_css_declarations_with_offsets(block: &str) -> Vec { - // Keep ';' inside quoted text and function args - let mut declarations = Vec::new(); - let mut start = 0usize; - let mut paren_depth = 0usize; - let mut bracket_depth = 0usize; - let mut in_string: Option = None; - let mut escaped = false; - - for (index, ch) in block.char_indices() { - if let Some(quote) = in_string { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == quote { - in_string = None; - } - continue; - } - - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth += 1, - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth += 1, - ']' => bracket_depth = bracket_depth.saturating_sub(1), - ';' if paren_depth == 0 && bracket_depth == 0 => { - push_declaration(&mut declarations, &block[start..index], start); - start = index + ch.len_utf8(); - } - _ => {} - } - } - - push_declaration(&mut declarations, &block[start..], start); - declarations -} - -fn push_declaration(declarations: &mut Vec, raw: &str, raw_start: usize) { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return; - } - - // Use the first top-level ':' only - let Some(colon_index) = find_top_level_colon(trimmed) else { - return; - }; - - let name = trimmed[..colon_index].trim(); - let value = trimmed[colon_index + 1..].trim(); - if name.is_empty() || value.is_empty() { - return; - } - - // Leading space is kept in the offset so later line math points at the property name - let leading_trim = raw.find(trimmed).unwrap_or(0); - declarations.push(CssDeclaration { - name: name.to_string(), - value: value.to_string(), - start: raw_start + leading_trim, - }); -} - -fn find_top_level_colon(input: &str) -> Option { - // Ignore ':' inside quotes and nested groups - let mut paren_depth = 0usize; - let mut bracket_depth = 0usize; - let mut in_string: Option = None; - let mut escaped = false; - - for (index, ch) in input.char_indices() { - if let Some(quote) = in_string { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == quote { - in_string = None; - } - continue; - } - - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth += 1, - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth += 1, - ']' => bracket_depth = bracket_depth.saturating_sub(1), - ':' if paren_depth == 0 && bracket_depth == 0 => return Some(index), - _ => {} - } - } - - None -} diff --git a/crates/noticenterctl/src/main/main_css_check_theme.rs b/crates/noticenterctl/src/main/main_css_check_theme.rs deleted file mode 100644 index f0fec920..00000000 --- a/crates/noticenterctl/src/main/main_css_check_theme.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Active theme target discovery and path sanity checks for css-check - -use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use unixnotis_core::{Config, ThemePaths}; - -use crate::preset::command_rules::{ - collect_host_specific_command_paths, collect_outside_command_paths, -}; -use crate::preset::css_asset_refs::collect_external_css_asset_refs_from_paths; - -use super::main_css_check_files::{collect_css_files, format_display_path}; -use super::main_css_check_report::{CssCheckActiveFile, CssCheckCategory, CssCheckDiagnostic}; - -pub(super) struct CssCheckInputs { - pub(super) files: Vec, - pub(super) active_files: Vec, - pub(super) notes: Vec, - pub(super) diagnostics: Vec, -} - -struct ThemeTarget { - slot_name: &'static str, - config_key: &'static str, - path: PathBuf, -} - -impl ThemeTarget { - fn display_path(&self, config_dir: &Path, display_root: &str) -> String { - format_display_path(config_dir, display_root, &self.path) - } -} - -pub(super) fn collect_css_check_inputs( - config_dir: &Path, - display_root: &str, -) -> Result { - let config_path = Config::default_config_path().context("resolve config path")?; - let config = Config::load_default().context("load config for active theme paths")?; - collect_css_check_inputs_from(config_dir, display_root, &config_path, &config) -} - -fn collect_css_check_inputs_from( - config_dir: &Path, - display_root: &str, - config_path: &Path, - config: &Config, -) -> Result { - let config_display = format_display_path(config_dir, display_root, config_path); - - // css-check should follow the same theme path resolution as the UI - let theme_paths = config - .resolve_theme_paths_from(config_dir) - .context("resolve theme paths for css-check")?; - - // Extra root css files are tracked so the report can explain what was skipped - let root_css_files = collect_css_files(config_dir)?; - let root_css_set: HashSet = root_css_files.iter().cloned().collect(); - - let targets = theme_targets(theme_paths); - let mut diagnostics = Vec::new(); - let mut notes = Vec::new(); - let mut files = Vec::new(); - let mut active_files = Vec::new(); - let mut seen_files = HashSet::new(); - let mut duplicate_slots: BTreeMap> = BTreeMap::new(); - let mut outside_root = 0usize; - let mut non_css_targets = 0usize; - - for target in &targets { - active_files.push(CssCheckActiveFile { - slot_name: target.config_key, - display_path: target.display_path(config_dir, display_root), - }); - if !target.path.starts_with(config_dir) { - // Outside theme files work, but they make the setup less portable - outside_root += 1; - } - if !has_css_extension(&target.path) { - // The UI still loads these files, so css-check should not skip them - non_css_targets += 1; - } - - duplicate_slots - .entry(normalize_target_key(&target.path)) - .or_default() - .push(target.config_key); - - if !target.path.exists() { - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - target.display_path(config_dir, display_root), - format!( - "configured {} target is missing; UnixNotis will create a default theme file there on startup, so css-check is validating less than the live UI expects", - target.slot_name - ), - )); - continue; - } - - if !target.path.is_file() { - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - target.display_path(config_dir, display_root), - format!( - "configured {} target is not a regular file", - target.slot_name - ), - )); - continue; - } - - // One parse and one lint pass per real file is enough - // - // Canonical paths collapse symlink aliases and repeated logical paths - // back onto the same on-disk file before GTK sees them - let dedupe_key = dedupe_key_for_theme_file(&target.path); - if seen_files.insert(dedupe_key) { - files.push(target.path.clone()); - } - } - - let outside_commands = collect_outside_command_paths(config_dir, config); - let outside_command_paths = outside_commands.len(); - for command in outside_commands { - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - config_display.clone(), - format!( - "{} points outside {display_root}; shared presets should keep explicit command paths inside the UnixNotis config directory ({})", - command.slot, command.command - ), - )); - } - // Command paths can still leak host-local values even when config.toml stays inside the root - let host_specific_commands = collect_host_specific_command_paths(config_dir, config); - let host_specific_command_paths = host_specific_commands.len(); - for command in host_specific_commands { - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - config_display.clone(), - format!( - "{} uses a host-local command path inside {display_root}; export should rewrite it before sharing: {}", - command.slot, command.command - ), - )); - } - // CSS can still pull assets from outside the config root - let external_css_refs = collect_external_css_asset_refs_from_paths(config_dir, &files)?; - let external_css_asset_refs = external_css_refs.len(); - let remote_css_asset_refs = external_css_refs - .iter() - .filter(|asset_ref| asset_ref.reason == "remote url") - .count(); - let local_external_css_asset_refs = external_css_asset_refs - remote_css_asset_refs; - for asset_ref in external_css_refs { - let message = format_external_css_asset_ref_message(display_root, &asset_ref); - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - format_display_path(config_dir, display_root, &asset_ref.css_file), - message, - )); - } - - for slots in duplicate_slots.values() { - if slots.len() < 2 { - continue; - } - - // One file loaded into multiple theme slots can look much stronger than expected - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - config_display.clone(), - format!( - "{} all resolve to the same file; that stylesheet will be loaded into multiple UnixNotis theme slots", - join_config_keys(slots) - ), - )); - } - - let configured_existing: HashSet = files.iter().cloned().collect(); - let skipped_extra_css = root_css_set - .iter() - .filter(|path| !configured_existing.contains(*path)) - .count(); - - if outside_root > 0 { - notes.push(format!( - "{outside_root} configured theme file(s) live outside {display_root} and were checked directly" - )); - diagnostics.push(CssCheckDiagnostic::warning( - CssCheckCategory::Theme, - config_display.clone(), - format!( - "{outside_root} configured theme file(s) point outside {display_root}; that makes the setup less portable and means those files are loaded from outside the UnixNotis config directory" - ), - )); - } - if outside_command_paths > 0 { - notes.push(format!( - "{outside_command_paths} configured command path(s) point outside {display_root}" - )); - } - if host_specific_command_paths > 0 { - notes.push(format!( - "{host_specific_command_paths} configured command path(s) use host-local config-root paths" - )); - } - // Keep the live css-check notes aligned with the preset safety prompts - if local_external_css_asset_refs > 0 { - notes.push(format!( - "{local_external_css_asset_refs} css asset reference(s) leave {display_root}" - )); - } - if remote_css_asset_refs > 0 { - notes.push(format!( - "{remote_css_asset_refs} css asset reference(s) use remote URLs" - )); - } - if non_css_targets > 0 { - notes.push(format!( - "{non_css_targets} configured theme file(s) do not end in .css and were checked because config.toml points to them" - )); - } - if skipped_extra_css > 0 { - notes.push(format!( - "{skipped_extra_css} extra css file(s) under {display_root} were skipped because config.toml does not reference them" - )); - } - - files.sort(); - Ok(CssCheckInputs { - files, - active_files, - notes, - diagnostics, - }) -} - -fn format_external_css_asset_ref_message( - display_root: &str, - asset_ref: &crate::preset::css_asset_refs::ExternalCssAssetRef, -) -> String { - if asset_ref.reason == "remote url" { - return format!( - "css asset reference uses a remote URL: {}", - asset_ref.asset_ref - ); - } - - format!( - "css asset reference leaves {display_root}: {} ({})", - asset_ref.asset_ref, asset_ref.reason - ) -} - -fn theme_targets(theme_paths: ThemePaths) -> [ThemeTarget; 5] { - [ - ThemeTarget { - slot_name: "base css", - config_key: "[theme].base_css", - path: theme_paths.base_css, - }, - ThemeTarget { - slot_name: "panel css", - config_key: "[theme].panel_css", - path: theme_paths.panel_css, - }, - ThemeTarget { - slot_name: "popup css", - config_key: "[theme].popup_css", - path: theme_paths.popup_css, - }, - ThemeTarget { - slot_name: "widgets css", - config_key: "[theme].widgets_css", - path: theme_paths.widgets_css, - }, - ThemeTarget { - slot_name: "media css", - config_key: "[theme].media_css", - path: theme_paths.media_css, - }, - ] -} - -fn has_css_extension(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("css")) - .unwrap_or(false) -} - -fn normalize_target_key(path: &Path) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - -fn dedupe_key_for_theme_file(path: &Path) -> PathBuf { - // Real-file dedupe should follow symlinks when that works - // - // Falling back to the configured path keeps css-check usable even when - // canonicalize cannot read every directory in the path yet - std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - -fn join_config_keys(keys: &[&'static str]) -> String { - keys.iter() - .copied() - .collect::>() - .into_iter() - .collect::>() - .join(", ") -} - -#[cfg(test)] -#[path = "main_css_check_theme_tests.rs"] -mod tests; diff --git a/crates/noticenterctl/src/main/main_css_check_theme_tests.rs b/crates/noticenterctl/src/main/main_css_check_theme_tests.rs deleted file mode 100644 index 4532dc8e..00000000 --- a/crates/noticenterctl/src/main/main_css_check_theme_tests.rs +++ /dev/null @@ -1,379 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use unixnotis_core::Config; - -use super::collect_css_check_inputs_from; - -static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - -struct TempDirGuard { - path: PathBuf, -} - -impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("unixnotis-css-check-{}-{}-{}", name, stamp, serial)); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &str) { - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(path, contents).expect("write file"); - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -#[test] -fn includes_active_targets_outside_config_root_and_skips_unused_css() { - let root = TempDirGuard::new("external"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - let external_panel = root.path().join("shared").join("panel.theme"); - fs::create_dir_all(external_panel.parent().expect("panel parent")) - .expect("create panel parent"); - - fs::write( - config_dir.join("base.css"), - ".unixnotis-panel { color: red; }", - ) - .expect("write base.css"); - fs::write( - config_dir.join("popup.css"), - ".unixnotis-popup { color: red; }", - ) - .expect("write popup.css"); - fs::write( - config_dir.join("widgets.css"), - ".unixnotis-toggle { color: red; }", - ) - .expect("write widgets.css"); - fs::write( - config_dir.join("media.css"), - ".unixnotis-media-card { color: red; }", - ) - .expect("write media.css"); - fs::write(&external_panel, ".unixnotis-panel { color: blue; }").expect("write external panel"); - fs::write(config_dir.join("unused.css"), ".unused { color: red; }").expect("write unused.css"); - root.write( - "xdg/unixnotis/config.toml", - &format!("[theme]\npanel_css = \"{}\"\n", external_panel.display()), - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert_eq!(inputs.files.len(), 5); - assert!(inputs.files.iter().any(|path| path == &external_panel)); - assert!(inputs - .notes - .iter() - .any(|line| line.contains("live outside $XDG_CONFIG_HOME/unixnotis"))); - assert!(inputs.diagnostics.iter().any(|warning| warning - .message - .contains("point outside $XDG_CONFIG_HOME/unixnotis"))); - assert!(inputs - .notes - .iter() - .any(|line| line.contains("extra css file(s)"))); -} - -#[test] -fn warns_when_theme_slots_share_one_file() { - let root = TempDirGuard::new("duplicate"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write("xdg/unixnotis/base.css", ".unixnotis-panel { color: red; }"); - root.write( - "xdg/unixnotis/popup.css", - ".unixnotis-popup { color: red; }", - ); - root.write( - "xdg/unixnotis/widgets.css", - ".unixnotis-toggle { color: red; }", - ); - root.write( - "xdg/unixnotis/media.css", - ".unixnotis-media-card { color: red; }", - ); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"shared.css\"\npanel_css = \"shared.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n", - ); - root.write( - "xdg/unixnotis/shared.css", - ".unixnotis-panel { color: blue; }", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs.diagnostics.iter().any(|warning| warning - .message - .contains("[theme].base_css, [theme].panel_css"))); -} - -#[cfg(unix)] -#[test] -fn dedupes_theme_slots_that_resolve_to_the_same_real_file() { - use std::os::unix::fs::symlink; - - let root = TempDirGuard::new("canonical-dedupe"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write("xdg/unixnotis/base.css", ".unixnotis-panel { color: red; }"); - root.write( - "xdg/unixnotis/widgets.css", - ".unixnotis-toggle { color: red; }", - ); - root.write( - "xdg/unixnotis/media.css", - ".unixnotis-media-card { color: red; }", - ); - root.write( - "xdg/unixnotis/shared/popup.css", - ".unixnotis-popup { color: red; }", - ); - fs::create_dir_all(config_dir.join("aliases")).expect("create alias dir"); - symlink( - "../shared/popup.css", - config_dir.join("aliases/popup-link.css"), - ) - .expect("create symlink"); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\npanel_css = \"shared/popup.css\"\npopup_css = \"aliases/popup-link.css\"\n", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - let popup_paths = inputs - .files - .iter() - .filter(|path| path.ends_with("popup.css") || path.ends_with("popup-link.css")) - .collect::>(); - assert_eq!(popup_paths.len(), 1); - assert_eq!(inputs.files.len(), 4); -} - -#[test] -fn warns_when_configured_theme_target_is_missing() { - let root = TempDirGuard::new("missing"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write("xdg/unixnotis/base.css", ".unixnotis-panel { color: red; }"); - root.write( - "xdg/unixnotis/popup.css", - ".unixnotis-popup { color: red; }", - ); - root.write( - "xdg/unixnotis/widgets.css", - ".unixnotis-toggle { color: red; }", - ); - root.write( - "xdg/unixnotis/media.css", - ".unixnotis-media-card { color: red; }", - ); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\npanel_css = \"missing/panel.css\"\n", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs.diagnostics.iter().any(|warning| warning - .message - .contains("configured panel css target is missing"))); -} - -#[test] -fn warns_when_command_path_points_outside_config_root() { - let root = TempDirGuard::new("outside-command"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write("xdg/unixnotis/base.css", ".unixnotis-panel { color: red; }"); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs - .notes - .iter() - .any(|line| line.contains("configured command path(s) point outside"))); - assert!(inputs.diagnostics.iter().any(|warning| warning - .message - .contains("shared presets should keep explicit command paths inside"))); -} - -#[test] -fn warns_when_css_asset_ref_leaves_config_root() { - let root = TempDirGuard::new("outside-css-asset"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write( - "xdg/unixnotis/base.css", - ".unixnotis-panel { background-image: url(\"../outside.png\"); }", - ); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"base.css\"\n", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs - .notes - .iter() - .any(|line| line.contains("css asset reference(s) leave"))); - assert!(inputs - .diagnostics - .iter() - .any(|warning| warning.message.contains("css asset reference leaves"))); -} - -#[test] -fn warns_when_css_asset_ref_uses_remote_url() { - let root = TempDirGuard::new("remote-css-asset"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - root.write( - "xdg/unixnotis/base.css", - ".unixnotis-panel { background-image: url(\"https://example.com/panel.png\"); }", - ); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"base.css\"\n", - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs - .notes - .iter() - .any(|line| line.contains("use remote URLs"))); - assert!(inputs - .diagnostics - .iter() - .any(|warning| warning.message.contains("uses a remote URL"))); -} - -#[test] -fn warns_when_command_path_is_host_specific_under_config_root() { - let root = TempDirGuard::new("host-specific-command"); - let config_dir = root.path().join("xdg").join("unixnotis"); - fs::create_dir_all(config_dir.join("scripts")).expect("create config dir"); - let script_path = config_dir.join("scripts/unixnotis-thermal-stat"); - - root.write("xdg/unixnotis/base.css", ".unixnotis-panel { color: red; }"); - fs::write(&script_path, "#!/bin/sh\necho 42\n").expect("write script"); - root.write( - "xdg/unixnotis/config.toml", - &format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = {:?}\n", - script_path.display().to_string() - ), - ); - - let config_path = config_dir.join("config.toml"); - let config = Config::load_from_path(&config_path).expect("load config"); - let inputs = collect_css_check_inputs_from( - &config_dir, - "$XDG_CONFIG_HOME/unixnotis", - &config_path, - &config, - ) - .expect("inputs"); - - assert!(inputs - .notes - .iter() - .any(|line| line.contains("host-local config-root paths"))); - assert!(inputs.diagnostics.iter().any(|warning| warning - .message - .contains("export should rewrite it before sharing"))); -} diff --git a/crates/noticenterctl/src/dbus/output_gate.rs b/crates/noticenterctl/src/output/gate.rs similarity index 100% rename from crates/noticenterctl/src/dbus/output_gate.rs rename to crates/noticenterctl/src/output/gate.rs diff --git a/crates/noticenterctl/src/main/main_output.rs b/crates/noticenterctl/src/output/mod.rs similarity index 100% rename from crates/noticenterctl/src/main/main_output.rs rename to crates/noticenterctl/src/output/mod.rs diff --git a/crates/noticenterctl/src/dbus/tests/output_gate.rs b/crates/noticenterctl/src/output/tests.rs similarity index 100% rename from crates/noticenterctl/src/dbus/tests/output_gate.rs rename to crates/noticenterctl/src/output/tests.rs diff --git a/crates/noticenterctl/src/preset/archive/tests.rs b/crates/noticenterctl/src/preset/archive/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/preset/archive/tests.rs rename to crates/noticenterctl/src/preset/archive/tests/mod.rs diff --git a/crates/noticenterctl/src/preset/command_rules/tests.rs b/crates/noticenterctl/src/preset/command_rules/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/preset/command_rules/tests.rs rename to crates/noticenterctl/src/preset/command_rules/tests/mod.rs diff --git a/crates/noticenterctl/src/preset/css_asset_refs/tests.rs b/crates/noticenterctl/src/preset/css_asset_refs/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/preset/css_asset_refs/tests.rs rename to crates/noticenterctl/src/preset/css_asset_refs/tests/mod.rs diff --git a/crates/noticenterctl/src/preset/export/tests.rs b/crates/noticenterctl/src/preset/export/tests/mod.rs similarity index 100% rename from crates/noticenterctl/src/preset/export/tests.rs rename to crates/noticenterctl/src/preset/export/tests/mod.rs diff --git a/crates/unixnotis-daemon/src/store/store_history.rs b/crates/unixnotis-daemon/src/store/history.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_history.rs rename to crates/unixnotis-daemon/src/store/history.rs diff --git a/crates/unixnotis-daemon/src/store/store_identity.rs b/crates/unixnotis-daemon/src/store/identity.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_identity.rs rename to crates/unixnotis-daemon/src/store/identity.rs diff --git a/crates/unixnotis-daemon/src/store/store_inhibit.rs b/crates/unixnotis-daemon/src/store/inhibit.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibit.rs diff --git a/crates/unixnotis-daemon/src/store/store_inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitor_api.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_inhibitor_api.rs rename to crates/unixnotis-daemon/src/store/inhibitor_api.rs diff --git a/crates/unixnotis-daemon/src/store/store_lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_lifecycle.rs rename to crates/unixnotis-daemon/src/store/lifecycle.rs diff --git a/crates/unixnotis-daemon/src/store.rs b/crates/unixnotis-daemon/src/store/mod.rs similarity index 100% rename from crates/unixnotis-daemon/src/store.rs rename to crates/unixnotis-daemon/src/store/mod.rs diff --git a/crates/unixnotis-daemon/src/store/store_rules.rs b/crates/unixnotis-daemon/src/store/rules.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_rules.rs rename to crates/unixnotis-daemon/src/store/rules.rs diff --git a/crates/unixnotis-daemon/src/store/store_state.rs b/crates/unixnotis-daemon/src/store/state.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_state.rs rename to crates/unixnotis-daemon/src/store/state.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/dnd.rs b/crates/unixnotis-daemon/src/store/tests/dnd.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/dnd.rs rename to crates/unixnotis-daemon/src/store/tests/dnd.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/inhibit.rs b/crates/unixnotis-daemon/src/store/tests/inhibit.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/inhibit.rs rename to crates/unixnotis-daemon/src/store/tests/inhibit.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/lifecycle.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs rename to crates/unixnotis-daemon/src/store/tests/lifecycle.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/ownership.rs b/crates/unixnotis-daemon/src/store/tests/ownership.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/ownership.rs rename to crates/unixnotis-daemon/src/store/tests/ownership.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/rules.rs b/crates/unixnotis-daemon/src/store/tests/rules.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/rules.rs rename to crates/unixnotis-daemon/src/store/tests/rules.rs diff --git a/crates/unixnotis-daemon/src/store/store_tests/support.rs b/crates/unixnotis-daemon/src/store/tests/support.rs similarity index 100% rename from crates/unixnotis-daemon/src/store/store_tests/support.rs rename to crates/unixnotis-daemon/src/store/tests/support.rs From 6e7d8e72d3394ebc3ae703e963b8f84d8cf17a18 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:31:59 -0500 Subject: [PATCH 22/65] noticenterctl: tighten css cache and theme modules Update css-check cache and theme modules after the domain move so imports and comments match their new locations. Keep existing behavior while preserving the organized cache and theme test folders. --- crates/noticenterctl/src/css_check/cache/dependencies.rs | 2 +- crates/noticenterctl/src/css_check/cache/model.rs | 2 +- crates/noticenterctl/src/css_check/cache/parse.rs | 6 +++--- crates/noticenterctl/src/css_check/theme/collect.rs | 6 ++---- crates/noticenterctl/src/css_check/theme/model.rs | 4 ++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/noticenterctl/src/css_check/cache/dependencies.rs b/crates/noticenterctl/src/css_check/cache/dependencies.rs index c53e866a..39587061 100644 --- a/crates/noticenterctl/src/css_check/cache/dependencies.rs +++ b/crates/noticenterctl/src/css_check/cache/dependencies.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use super::super::main_css_check_parse::strip_css_comments; +use super::super::parse::strip_css_comments; use super::model::{CssDependencyState, CssFileIdentity}; pub(in super::super) fn collect_import_dependency_states( diff --git a/crates/noticenterctl/src/css_check/cache/model.rs b/crates/noticenterctl/src/css_check/cache/model.rs index e75a93fd..c444c31a 100644 --- a/crates/noticenterctl/src/css_check/cache/model.rs +++ b/crates/noticenterctl/src/css_check/cache/model.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::PathBuf; use std::time::UNIX_EPOCH; -use super::super::main_css_check_report::CssCheckDiagnostic; +use super::super::report::CssCheckDiagnostic; pub(in super::super) struct CssParseReport { // Fresh and cached parser findings end up in one flat report diff --git a/crates/noticenterctl/src/css_check/cache/parse.rs b/crates/noticenterctl/src/css_check/cache/parse.rs index c70920e5..60e0f553 100644 --- a/crates/noticenterctl/src/css_check/cache/parse.rs +++ b/crates/noticenterctl/src/css_check/cache/parse.rs @@ -4,9 +4,9 @@ use gtk::CssProvider; use std::fs; use std::path::Path; -use super::super::main_css_check_files::format_display_path; -use super::super::main_css_check_policy::parsing_error_hint; -use super::super::main_css_check_report::{CssCheckCategory, CssCheckDiagnostic}; +use super::super::files::format_display_path; +use super::super::policy::parsing_error_hint; +use super::super::report::{CssCheckCategory, CssCheckDiagnostic}; use super::super::source_line_text; use super::model::{CachedDiagnosticSource, CachedParseDiagnostic, CssParseWorkItem}; diff --git a/crates/noticenterctl/src/css_check/theme/collect.rs b/crates/noticenterctl/src/css_check/theme/collect.rs index c901d469..85a1fd16 100644 --- a/crates/noticenterctl/src/css_check/theme/collect.rs +++ b/crates/noticenterctl/src/css_check/theme/collect.rs @@ -9,10 +9,8 @@ use crate::preset::command_rules::{ }; use crate::preset::css_asset_refs::collect_external_css_asset_refs_from_paths; -use super::super::main_css_check_files::{collect_css_files, format_display_path}; -use super::super::main_css_check_report::{ - CssCheckActiveFile, CssCheckCategory, CssCheckDiagnostic, -}; +use super::super::files::{collect_css_files, format_display_path}; +use super::super::report::{CssCheckActiveFile, CssCheckCategory, CssCheckDiagnostic}; use super::model::CssCheckInputs; use super::paths::{ dedupe_key_for_theme_file, has_css_extension, join_config_keys, normalize_target_key, diff --git a/crates/noticenterctl/src/css_check/theme/model.rs b/crates/noticenterctl/src/css_check/theme/model.rs index 483e48a2..6f522c62 100644 --- a/crates/noticenterctl/src/css_check/theme/model.rs +++ b/crates/noticenterctl/src/css_check/theme/model.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; -use super::super::main_css_check_files::format_display_path; -use super::super::main_css_check_report::{CssCheckActiveFile, CssCheckDiagnostic}; +use super::super::files::format_display_path; +use super::super::report::{CssCheckActiveFile, CssCheckDiagnostic}; pub(in super::super) struct CssCheckInputs { // These are the real files that move into parse and lint later From b1482c7371ccae94cfbc76f73253ef2fa7865acd Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:04 -0500 Subject: [PATCH 23/65] noticenterctl: expand css-check parser and lint coverage Add local tests for CSS block scanning, declaration parsing, selector splitting, lint duplicate detection, runtime warnings, file walking, and policy hints. Keep root css-check helpers covered through organized tests under css_check/tests. --- .../noticenterctl/src/css_check/lint/mod.rs | 11 +- .../noticenterctl/src/css_check/lint/scan.rs | 2 +- .../src/css_check/lint/tests/mod.rs | 91 +++++++++++++++ .../src/css_check/lint/values.rs | 6 +- crates/noticenterctl/src/css_check/mod.rs | 42 +++---- .../noticenterctl/src/css_check/parse/mod.rs | 3 + .../src/css_check/parse/tests/mod.rs | 80 +++++++++++++ crates/noticenterctl/src/css_check/runtime.rs | 2 +- .../src/css_check/tests/files.rs | 105 ++++++++++++++++++ .../noticenterctl/src/css_check/tests/mod.rs | 10 +- .../src/css_check/tests/policy.rs | 33 ++++++ .../src/css_check/tests/runtime.rs | 45 ++++++++ 12 files changed, 390 insertions(+), 40 deletions(-) create mode 100644 crates/noticenterctl/src/css_check/lint/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/parse/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/tests/files.rs create mode 100644 crates/noticenterctl/src/css_check/tests/policy.rs create mode 100644 crates/noticenterctl/src/css_check/tests/runtime.rs diff --git a/crates/noticenterctl/src/css_check/lint/mod.rs b/crates/noticenterctl/src/css_check/lint/mod.rs index 64abe87c..ac12502e 100644 --- a/crates/noticenterctl/src/css_check/lint/mod.rs +++ b/crates/noticenterctl/src/css_check/lint/mod.rs @@ -5,15 +5,16 @@ use std::fs; use std::path::{Path, PathBuf}; use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; -use super::main_css_check_files::format_display_path; -use super::main_css_check_geometry::{collect_custom_property_scopes, CssCustomPropertyScopes}; -use super::main_css_check_report::{CssCheckCategory, CssCheckDiagnostic}; +use super::files::format_display_path; +use super::geometry::{collect_custom_property_scopes, CssCustomPropertyScopes}; +use super::report::{CssCheckCategory, CssCheckDiagnostic}; -#[path = "scan.rs"] mod scan; -#[path = "values.rs"] mod values; +#[cfg(test)] +mod tests; + #[derive(Debug)] pub(super) struct CssCheckLintFinding { // Lint can point at the source when the scanner has a stable offset diff --git a/crates/noticenterctl/src/css_check/lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs index 2dd445a6..5362ec77 100644 --- a/crates/noticenterctl/src/css_check/lint/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/scan.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use super::super::main_css_check_parse::{ +use super::super::parse::{ next_css_block_with_offsets, normalize_selector, parse_css_declarations_with_offsets, should_recurse_at_rule, split_selectors, strip_css_comments, }; diff --git a/crates/noticenterctl/src/css_check/lint/tests/mod.rs b/crates/noticenterctl/src/css_check/lint/tests/mod.rs new file mode 100644 index 00000000..3b03e608 --- /dev/null +++ b/crates/noticenterctl/src/css_check/lint/tests/mod.rs @@ -0,0 +1,91 @@ +use super::{lint_css_contents, lint_css_contents_with_properties}; +use crate::css_check::geometry::collect_custom_property_scopes; + +#[test] +fn duplicate_selector_warns_inside_same_at_rule_context() { + let css = "@media (min-width: 1px) { .a { color: red; } .a { color: blue; } }"; + + let warnings = lint_css_contents(css); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("duplicate selector '.a'")); + assert!(warnings[0] + .message + .contains("within @media (min-width: 1px)")); +} + +#[test] +fn duplicate_selector_in_different_at_rule_contexts_stays_quiet() { + let css = r#" + @media (min-width: 1px) { .a { color: red; } } + @media (min-width: 2px) { .a { color: blue; } } + "#; + + let warnings = lint_css_contents(css); + + assert!(warnings + .iter() + .all(|warning| !warning.message.contains("duplicate selector"))); +} + +#[test] +fn duplicate_property_suppresses_identical_value_and_resolved_modern_fallback() { + let css = r#" + :root { --wide: 144px; } + .unixnotis-toggle { + color: red; + color: red; + min-width: 120px; + min-width: var(--wide); + } + "#; + let custom_properties = collect_custom_property_scopes(css); + + let warnings = lint_css_contents_with_properties(css, &custom_properties); + + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn duplicate_property_warns_when_modern_width_fallback_cannot_resolve() { + let css = r#" + .unixnotis-toggle { + min-width: 120px; + min-width: var(--missing-width); + } + "#; + let custom_properties = collect_custom_property_scopes(css); + + let warnings = lint_css_contents_with_properties(css, &custom_properties); + + assert!(warnings + .iter() + .any(|warning| warning.message.contains("duplicate property 'min-width'"))); + assert!(warnings + .iter() + .any(|warning| warning.message.contains("uses var() in a layout value"))); +} + +#[test] +fn width_values_warn_for_percentage_non_px_and_unresolved_compare_math() { + let css = r#" + .unixnotis-panel { min-width: 80%; } + .unixnotis-toggle { min-width: 12rem; } + .unixnotis-stat-card { min-width: max(10px, var(--missing)); } + "#; + + let messages = lint_css_contents(css) + .into_iter() + .map(|warning| warning.message) + .collect::>(); + + assert!(messages + .iter() + .any(|message| message.contains("percentage lengths"))); + assert!(messages + .iter() + .any(|message| message.contains("non-px length units"))); + assert!(messages + .iter() + .any(|message| message.contains("min(), max(), or clamp()"))); +} diff --git a/crates/noticenterctl/src/css_check/lint/values.rs b/crates/noticenterctl/src/css_check/lint/values.rs index 077f9ecf..7ccd9cd5 100644 --- a/crates/noticenterctl/src/css_check/lint/values.rs +++ b/crates/noticenterctl/src/css_check/lint/values.rs @@ -1,7 +1,5 @@ -use super::super::main_css_check_geometry::{ - can_model_horizontal_size_value, CssCustomPropertyScopes, -}; -use super::super::main_css_check_policy::is_horizontal_size_property; +use super::super::geometry::{can_model_horizontal_size_value, CssCustomPropertyScopes}; +use super::super::policy::is_horizontal_size_property; pub(super) fn should_suppress_duplicate_property_warning( property: &str, diff --git a/crates/noticenterctl/src/css_check/mod.rs b/crates/noticenterctl/src/css_check/mod.rs index 861dce76..e0be9de8 100644 --- a/crates/noticenterctl/src/css_check/mod.rs +++ b/crates/noticenterctl/src/css_check/mod.rs @@ -1,38 +1,29 @@ //! CSS validation and lint helpers for UnixNotis themes -#[path = "main_css_check_cache/mod.rs"] -mod main_css_check_cache; -#[path = "main_css_check_files.rs"] -mod main_css_check_files; -#[path = "main_css_check_geometry.rs"] -mod main_css_check_geometry; -#[path = "main_css_check_lint/mod.rs"] -mod main_css_check_lint; -#[path = "main_css_check_parse/mod.rs"] -mod main_css_check_parse; -#[path = "main_css_check_policy.rs"] -mod main_css_check_policy; -#[path = "main_css_check_report/mod.rs"] -mod main_css_check_report; -#[path = "main_css_check_runtime.rs"] -mod main_css_check_runtime; -#[path = "main_css_check_theme/mod.rs"] -mod main_css_check_theme; +mod cache; +mod files; +mod geometry; +mod lint; +mod parse; +mod policy; +mod report; +mod runtime; +mod theme; use anyhow::{anyhow, Context, Result}; use std::fs; use std::path::Path; use unixnotis_core::Config; -use self::main_css_check_cache::validate_css_parse_files; -use self::main_css_check_files::{display_config_root, format_display_path}; -use self::main_css_check_geometry::lint_geometry_css_files; -use self::main_css_check_lint::lint_css_files; -use self::main_css_check_report::{ +use self::cache::validate_css_parse_files; +use self::files::{display_config_root, format_display_path}; +use self::geometry::lint_geometry_css_files; +use self::lint::lint_css_files; +use self::report::{ render_css_check_report_for_stdout, CssCheckCategory, CssCheckDiagnostic, CssCheckReport, }; -use self::main_css_check_runtime::lint_runtime_config; -use self::main_css_check_theme::collect_css_check_inputs; +use self::runtime::lint_runtime_config; +use self::theme::collect_css_check_inputs; pub(crate) fn run_css_check() -> Result<()> { // GTK needs to be ready before CSS parsing starts @@ -130,5 +121,4 @@ fn source_line_text(path: Option<&Path>, line_number: usize) -> Option { .map(str::to_string) } #[cfg(test)] -#[path = "main_css_check_tests.rs"] mod tests; diff --git a/crates/noticenterctl/src/css_check/parse/mod.rs b/crates/noticenterctl/src/css_check/parse/mod.rs index 12bbf199..52c4a78b 100644 --- a/crates/noticenterctl/src/css_check/parse/mod.rs +++ b/crates/noticenterctl/src/css_check/parse/mod.rs @@ -5,6 +5,9 @@ mod declarations; mod selectors; mod types; +#[cfg(test)] +mod tests; + pub(super) use blocks::{next_css_block, next_css_block_with_offsets, strip_css_comments}; pub(super) use declarations::{parse_css_declarations, parse_css_declarations_with_offsets}; pub(super) use selectors::{normalize_selector, should_recurse_at_rule, split_selectors}; diff --git a/crates/noticenterctl/src/css_check/parse/tests/mod.rs b/crates/noticenterctl/src/css_check/parse/tests/mod.rs new file mode 100644 index 00000000..ce3e1f12 --- /dev/null +++ b/crates/noticenterctl/src/css_check/parse/tests/mod.rs @@ -0,0 +1,80 @@ +use super::{ + next_css_block_with_offsets, parse_css_declarations_with_offsets, should_recurse_at_rule, + split_selectors, strip_css_comments, +}; + +#[test] +fn strip_css_comments_preserves_byte_count_and_line_count() { + let css = ".a { color: red; }\n/* hidden\nrule */\n.b { color: blue; }"; + + let stripped = strip_css_comments(css); + + assert_eq!(stripped.len(), css.len()); + assert_eq!(stripped.matches('\n').count(), css.matches('\n').count()); + assert!(!stripped.contains("hidden")); + assert!(stripped.contains(".b { color: blue; }")); +} + +#[test] +fn next_css_block_with_offsets_ignores_braces_inside_strings() { + let css = ".a { content: \"{\"; } .b { color: red; }"; + + let block = next_css_block_with_offsets(css.as_bytes(), 0).expect("first block"); + + assert_eq!(block.selector.trim(), ".a"); + assert_eq!(block.block.trim(), "content: \"{\";"); + assert_eq!(&css[block.selector_start..block.selector_start + 2], ".a"); + + let second = next_css_block_with_offsets(css.as_bytes(), block.next).expect("second block"); + assert_eq!(second.selector.trim(), ".b"); + assert_eq!(second.block.trim(), "color: red;"); + assert_eq!( + second.block_start + second.block.find("color").expect("color offset"), + css.find("color").expect("source color offset") + ); +} + +#[test] +fn parse_css_declarations_keeps_nested_colons_and_semicolons_inside_values() { + let block = r#"background: url("data:image/svg+xml;utf8,"); content: "a:b;c"; padding: calc(4px + 2px);"#; + + let declarations = parse_css_declarations_with_offsets(block); + + assert_eq!(declarations.len(), 3); + assert_eq!(declarations[0].name, "background"); + assert!(declarations[0].value.contains("data:image/svg+xml;utf8")); + assert_eq!(declarations[1].value, r#""a:b;c""#); + assert_eq!(declarations[2].value, "calc(4px + 2px)"); + assert_eq!( + declarations[2].start, + block.find("padding").expect("padding offset") + ); +} + +#[test] +fn split_selectors_only_splits_top_level_commas() { + let selector = r#".a:is(.b, .c), .d[data-label="x,y"], .e:not(.f, .g)"#; + + let selectors = split_selectors(selector); + + assert_eq!( + selectors, + vec![ + ".a:is(.b, .c)".to_string(), + r#".d[data-label="x,y"]"#.to_string(), + ".e:not(.f, .g)".to_string(), + ] + ); +} + +#[test] +fn should_recurse_at_rule_accepts_selector_bearing_at_rules_only() { + assert!(should_recurse_at_rule("@media (min-width: 1px)")); + assert!(should_recurse_at_rule("@supports (color: red)")); + assert!(should_recurse_at_rule("@layer theme")); + assert!(should_recurse_at_rule("@container panel (width > 1px)")); + assert!(should_recurse_at_rule("@document url-prefix(\"app\")")); + + assert!(!should_recurse_at_rule("@keyframes pulse")); + assert!(!should_recurse_at_rule("@font-face")); +} diff --git a/crates/noticenterctl/src/css_check/runtime.rs b/crates/noticenterctl/src/css_check/runtime.rs index 0d82843c..1f792062 100644 --- a/crates/noticenterctl/src/css_check/runtime.rs +++ b/crates/noticenterctl/src/css_check/runtime.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; use unixnotis_core::{Config, PANEL_RUNTIME_WIDTH_MIN}; -use super::main_css_check_report::{CssCheckCategory, CssCheckDiagnostic}; +use super::report::{CssCheckCategory, CssCheckDiagnostic}; pub(super) fn lint_runtime_config( config_dir: &Path, diff --git a/crates/noticenterctl/src/css_check/tests/files.rs b/crates/noticenterctl/src/css_check/tests/files.rs new file mode 100644 index 00000000..4b551b0d --- /dev/null +++ b/crates/noticenterctl/src/css_check/tests/files.rs @@ -0,0 +1,105 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::super::files::{collect_css_files, display_config_root, format_display_path}; + +static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempTree { + root: PathBuf, +} + +impl TempTree { + fn new(name: &str) -> Self { + let id = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-css-check-{name}-{}-{id}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create temp tree"); + Self { root } + } + + fn path(&self) -> &Path { + &self.root + } +} + +impl Drop for TempTree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn collect_css_files_recurses_skips_backup_dirs_and_sorts_results() { + let temp = TempTree::new("collect"); + fs::create_dir_all(temp.path().join("theme/nested")).expect("create nested"); + fs::create_dir_all(temp.path().join("Backup-2026-01-01")).expect("create backup"); + fs::write(temp.path().join("theme/b.css"), "").expect("write b css"); + fs::write(temp.path().join("theme/nested/a.CSS"), "").expect("write a css"); + fs::write(temp.path().join("theme/nested/readme.txt"), "").expect("write txt"); + fs::write(temp.path().join("Backup-2026-01-01/ignored.css"), "").expect("write backup"); + + let files = collect_css_files(temp.path()).expect("collect css files"); + let names = files + .iter() + .map(|path| { + path.strip_prefix(temp.path()) + .expect("relative path") + .display() + .to_string() + }) + .collect::>(); + + assert_eq!(names, vec!["theme/b.css", "theme/nested/a.CSS"]); +} + +#[test] +fn collect_css_files_rejects_missing_root_with_context() { + let temp = TempTree::new("missing"); + let missing = temp.path().join("missing"); + + let error = collect_css_files(&missing).expect_err("missing root should fail"); + + assert!(error.to_string().contains("resolve config directory")); +} + +#[test] +fn format_display_path_uses_config_root_for_nested_files_only() { + let config_dir = PathBuf::from("/tmp/unixnotis-test-config"); + let display_root = "$TEST_CONFIG/unixnotis"; + + assert_eq!( + format_display_path( + &config_dir, + display_root, + &config_dir.join("style/main.css") + ), + "$TEST_CONFIG/unixnotis/style/main.css" + ); + assert_eq!( + format_display_path(&config_dir, display_root, &config_dir), + "$TEST_CONFIG/unixnotis" + ); + assert_eq!( + format_display_path( + &config_dir, + display_root, + Path::new("/tmp/other/style/main.css") + ), + "/tmp/other/style/main.css" + ); +} + +#[test] +fn display_config_root_falls_back_to_literal_path_for_nonstandard_roots() { + let root = PathBuf::from("/tmp/unixnotis-nonstandard-root"); + + assert_eq!( + display_config_root(&root), + "/tmp/unixnotis-nonstandard-root" + ); +} diff --git a/crates/noticenterctl/src/css_check/tests/mod.rs b/crates/noticenterctl/src/css_check/tests/mod.rs index ae28679c..db373642 100644 --- a/crates/noticenterctl/src/css_check/tests/mod.rs +++ b/crates/noticenterctl/src/css_check/tests/mod.rs @@ -1,11 +1,15 @@ -use super::main_css_check_lint::lint_css_contents; -use super::main_css_check_parse::{parse_css_declarations, split_selectors}; -use super::main_css_check_runtime::panel_width_floor_warning; +use super::lint::lint_css_contents; +use super::parse::{parse_css_declarations, split_selectors}; +use super::runtime::panel_width_floor_warning; use unixnotis_core::{ build_modern_theme_custom_properties, gtk_css_features_for_version, Config, ThemeConfig, PANEL_RUNTIME_WIDTH_MIN, }; +mod files; +mod policy; +mod runtime; + #[test] fn split_selectors_handles_is_commas() { // Commas inside :is() stay inside the selector diff --git a/crates/noticenterctl/src/css_check/tests/policy.rs b/crates/noticenterctl/src/css_check/tests/policy.rs new file mode 100644 index 00000000..cec02448 --- /dev/null +++ b/crates/noticenterctl/src/css_check/tests/policy.rs @@ -0,0 +1,33 @@ +use super::super::policy::{ + is_complex_geometry_warning_property, is_horizontal_size_property, is_vertical_size_property, + parsing_error_hint, +}; + +#[test] +fn size_property_classifiers_keep_width_height_and_complex_sets_distinct() { + assert!(is_horizontal_size_property("width")); + assert!(is_horizontal_size_property("border-right-width")); + assert!(!is_horizontal_size_property("height")); + + assert!(is_vertical_size_property("height")); + assert!(is_vertical_size_property("border-bottom-width")); + assert!(!is_vertical_size_property("min-width")); + + assert!(is_complex_geometry_warning_property("padding-left")); + assert!(!is_complex_geometry_warning_property("border-left-width")); + assert!(!is_complex_geometry_warning_property("color")); +} + +#[test] +fn parsing_error_hint_reports_targeted_layout_value_guidance() { + assert!(parsing_error_hint("width: 80%;") + .expect("percentage hint") + .contains("percentage sizing")); + assert!(parsing_error_hint("width: calc(10px + 2px);") + .expect("calc hint") + .contains("GTK supports calc()")); + assert!(parsing_error_hint("padding: var(--gap);") + .expect("var hint") + .contains("custom properties need")); + assert!(parsing_error_hint("color: red;").is_none()); +} diff --git a/crates/noticenterctl/src/css_check/tests/runtime.rs b/crates/noticenterctl/src/css_check/tests/runtime.rs new file mode 100644 index 00000000..3936d791 --- /dev/null +++ b/crates/noticenterctl/src/css_check/tests/runtime.rs @@ -0,0 +1,45 @@ +use std::path::{Path, PathBuf}; + +use unixnotis_core::{Config, PANEL_RUNTIME_WIDTH_MIN}; + +use super::super::runtime::{display_config_path, panel_width_floor_warning}; + +#[test] +fn display_config_path_matches_css_display_style_when_config_is_inside_root() { + let config_dir = PathBuf::from("/tmp/unixnotis-config"); + let config_path = config_dir.join("config.toml"); + + assert_eq!( + display_config_path(&config_dir, "$CONFIG/unixnotis", &config_path), + "$CONFIG/unixnotis/config.toml" + ); +} + +#[test] +fn display_config_path_keeps_external_config_paths_literal() { + let config_dir = PathBuf::from("/tmp/unixnotis-config"); + + assert_eq!( + display_config_path( + &config_dir, + "$CONFIG/unixnotis", + Path::new("/tmp/other/config.toml") + ), + "/tmp/other/config.toml" + ); +} + +#[test] +fn panel_width_floor_warning_only_reports_widths_below_runtime_floor() { + let mut config = Config::default(); + config.panel.width = PANEL_RUNTIME_WIDTH_MIN - 1; + assert!(panel_width_floor_warning(&config) + .expect("floor warning") + .contains("runtime floor")); + + config.panel.width = PANEL_RUNTIME_WIDTH_MIN; + assert!(panel_width_floor_warning(&config).is_none()); + + config.panel.width = PANEL_RUNTIME_WIDTH_MIN + 1; + assert!(panel_width_floor_warning(&config).is_none()); +} From eab615ceaff5aca950f59964dbb33c303d12ed2c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:15 -0500 Subject: [PATCH 24/65] noticenterctl: cover css geometry feature folders Add focused geometry tests for model targets, custom-property scopes, length parsing, stock class handling, and nested rule collection. Fix three-value horizontal shorthand parsing so left and right both follow the CSS horizontal value. --- .../noticenterctl/src/css_check/geometry.rs | 10 +- .../src/css_check/geometry/model.rs | 8 +- .../src/css_check/geometry/model/tests/mod.rs | 68 +++++++++++++ .../src/css_check/geometry/parse.rs | 16 +-- .../geometry/parse/custom_properties.rs | 6 +- .../css_check/geometry/parse/lengths/mod.rs | 7 +- .../geometry/parse/lengths/tests/mod.rs | 80 +++++++++++++++ .../src/css_check/geometry/parse/tests/mod.rs | 97 +++++++++++++++++++ .../src/css_check/geometry/stock/baselines.rs | 10 +- .../src/css_check/geometry/stock/classes.rs | 49 +--------- .../src/css_check/geometry/stock/mod.rs | 5 +- .../css_check/geometry/stock/size_rules.rs | 8 +- .../css_check/geometry/stock/tests/classes.rs | 43 ++++++++ .../src/css_check/geometry/stock/tests/mod.rs | 2 + .../geometry/stock/tests/size_rules.rs | 44 +++++++++ .../src/css_check/geometry/tests/mod.rs | 4 - 16 files changed, 369 insertions(+), 88 deletions(-) create mode 100644 crates/noticenterctl/src/css_check/geometry/model/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/parse/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/stock/tests/mod.rs create mode 100644 crates/noticenterctl/src/css_check/geometry/stock/tests/size_rules.rs diff --git a/crates/noticenterctl/src/css_check/geometry.rs b/crates/noticenterctl/src/css_check/geometry.rs index c9e2cd18..0cf06baf 100644 --- a/crates/noticenterctl/src/css_check/geometry.rs +++ b/crates/noticenterctl/src/css_check/geometry.rs @@ -1,13 +1,9 @@ //! Geometry-aware lint rules for css-check -#[path = "main_css_check_geometry/model.rs"] mod model; -#[path = "main_css_check_geometry/parse.rs"] mod parse; -#[path = "main_css_check_geometry/stock/mod.rs"] mod stock; #[cfg(test)] -#[path = "main_css_check_geometry/tests/mod.rs"] mod tests; use std::fs; @@ -21,9 +17,9 @@ use self::parse::collect_geometry_from_contents_with_properties; pub(super) use self::parse::{ can_model_horizontal_size_value, collect_custom_property_scopes, CssCustomPropertyScopes, }; -use super::main_css_check_files::format_display_path; -use super::main_css_check_report::{CssCheckCategory, CssCheckDiagnostic}; -use super::main_css_check_runtime::display_config_path; +use super::files::format_display_path; +use super::report::{CssCheckCategory, CssCheckDiagnostic}; +use super::runtime::display_config_path; pub(super) fn lint_geometry_css_files( files: &[PathBuf], diff --git a/crates/noticenterctl/src/css_check/geometry/model.rs b/crates/noticenterctl/src/css_check/geometry/model.rs index 75bae346..ed646561 100644 --- a/crates/noticenterctl/src/css_check/geometry/model.rs +++ b/crates/noticenterctl/src/css_check/geometry/model.rs @@ -5,17 +5,15 @@ use unixnotis_core::Config; use super::stock::baselines::{stock_config, stock_geometry_model}; // Keep the geometry model split by job so width math changes stay easy to trace -#[path = "model/box_metrics.rs"] mod box_metrics; -#[path = "model/constants.rs"] mod constants; -#[path = "model/fixed_grid.rs"] mod fixed_grid; -#[path = "media/mod.rs"] mod media; -#[path = "model/tracking.rs"] mod tracking; +#[cfg(test)] +mod tests; + pub(super) use self::box_metrics::{ HorizontalBoxMetrics, HorizontalEdges, VerticalBoxMetrics, VerticalEdges, }; diff --git a/crates/noticenterctl/src/css_check/geometry/model/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/model/tests/mod.rs new file mode 100644 index 00000000..568187c3 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/model/tests/mod.rs @@ -0,0 +1,68 @@ +use std::collections::HashMap; + +use super::{GeometryModel, HorizontalBoxMetrics, VerticalBoxMetrics}; + +#[test] +fn horizontal_box_metrics_use_larger_content_width_and_all_horizontal_edges() { + let custom_properties = HashMap::new(); + let mut metrics = HorizontalBoxMetrics::default(); + + metrics.apply_property("width", "100px", &custom_properties); + metrics.apply_property("min-width", "120px", &custom_properties); + metrics.apply_property("margin", "2px 4px 6px 8px", &custom_properties); + metrics.apply_property("padding-left", "3px", &custom_properties); + metrics.apply_property("padding-right", "5px", &custom_properties); + metrics.apply_property("border-width", "1px 2px", &custom_properties); + + assert_eq!(metrics.outer_width_px(10), 144); + assert_eq!(metrics.outer_insets_px(), 24); + assert_eq!(metrics.inner_insets_px(), 12); +} + +#[test] +fn vertical_box_metrics_use_larger_content_height_and_vertical_edges() { + let custom_properties = HashMap::new(); + let mut metrics = VerticalBoxMetrics::default(); + + metrics.apply_property("height", "20px", &custom_properties); + metrics.apply_property("min-height", "28px", &custom_properties); + metrics.apply_property("margin", "1px 2px 3px 4px", &custom_properties); + metrics.apply_property("padding", "2px 9px", &custom_properties); + metrics.apply_property("border-top-width", "4px", &custom_properties); + metrics.apply_property("border-bottom-width", "6px", &custom_properties); + + assert_eq!(metrics.outer_height_px(10), 46); +} + +#[test] +fn media_aliases_map_to_shared_width_and_height_targets() { + let mut model = GeometryModel::default(); + + model + .target_mut(".unixnotis-media-button-prev") + .expect("media button alias") + .apply_property("min-width", "31px", &HashMap::new()); + let button = model + .target_mut(".unixnotis-media-button") + .expect("media button target"); + assert_eq!(button.outer_width_px(0), 31); + + model + .target_vertical_mut(".unixnotis-media-card-carousel") + .expect("media card alias") + .apply_property("min-height", "44px", &HashMap::new()); + let card = model + .target_vertical_mut(".unixnotis-media-card") + .expect("media card target"); + assert_eq!(card.outer_height_px(0), 44); +} + +#[test] +fn unknown_classes_do_not_return_geometry_targets() { + let mut model = GeometryModel::default(); + + assert!(model.target_mut(".unixnotis-not-a-widget").is_none()); + assert!(model + .target_vertical_mut(".unixnotis-not-a-widget") + .is_none()); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse.rs b/crates/noticenterctl/src/css_check/geometry/parse.rs index 80701b08..b98cdb90 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse.rs @@ -2,11 +2,11 @@ use std::collections::{HashMap, HashSet}; -use super::super::main_css_check_parse::{ +use super::super::parse::{ next_css_block, normalize_selector, parse_css_declarations, should_recurse_at_rule, split_selectors, strip_css_comments, }; -use super::super::main_css_check_policy::{ +use super::super::policy::{ is_complex_geometry_warning_property, is_horizontal_size_property, is_vertical_size_property, }; use super::model::GeometryModel; @@ -14,17 +14,17 @@ use super::stock::classes::known_unixnotis_classes; use super::stock::should_warn_for_unmodeled_known_class; // Split the parser by job so width parsing, selector checks, and token collection stay separate -#[path = "parse/custom_properties.rs"] mod custom_properties; -#[path = "parse/lengths/mod.rs"] mod lengths; -#[path = "parse/selectors.rs"] mod selectors; +#[cfg(test)] +mod tests; + pub(super) type CssCustomProperties = HashMap; use self::custom_properties::collect_custom_properties; -pub(in crate::main_css_check) use self::custom_properties::CssCustomPropertyScopes; +pub(in crate::css_check) use self::custom_properties::CssCustomPropertyScopes; pub(super) use self::lengths::{ parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge, }; @@ -64,14 +64,14 @@ pub(super) fn collect_geometry_from_contents_with_properties( warnings } -pub(in crate::main_css_check) fn collect_custom_property_scopes( +pub(in crate::css_check) fn collect_custom_property_scopes( contents: &str, ) -> CssCustomPropertyScopes { // Lint and geometry both need the same custom-property view of the file collect_custom_properties(contents) } -pub(in crate::main_css_check) fn can_model_horizontal_size_value( +pub(in crate::css_check) fn can_model_horizontal_size_value( selector: &str, property: &str, value: &str, diff --git a/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs index aeefbfab..997ac31f 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; -use super::super::super::main_css_check_parse::{ +use super::super::super::parse::{ next_css_block, normalize_selector, parse_css_declarations, should_recurse_at_rule, split_selectors, }; use super::selectors::simple_class_selector; use super::CssCustomProperties; -pub(in crate::main_css_check) struct CssCustomPropertyScopes { +pub(in crate::css_check) struct CssCustomPropertyScopes { // Root tokens apply everywhere unless a tracked selector overrides them later root: CssCustomProperties, // Selector scopes only keep simple class selectors that geometry can reason about @@ -15,7 +15,7 @@ pub(in crate::main_css_check) struct CssCustomPropertyScopes { } impl CssCustomPropertyScopes { - pub(in crate::main_css_check) fn for_selector(&self, selector: &str) -> CssCustomProperties { + pub(in crate::css_check) fn for_selector(&self, selector: &str) -> CssCustomProperties { let mut resolved = self.root.clone(); if let Some(selector_scope) = self.selectors.get(selector) { // Selector-local tokens override root tokens for that widget class diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs index 4c06be02..aea6e73a 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs @@ -7,6 +7,9 @@ mod resolve_var; mod tokenize; mod units; +#[cfg(test)] +mod tests; + use self::tokenize::{consume_balanced_group, split_css_value_tokens}; use self::units::parse_atomic_value; @@ -44,8 +47,8 @@ pub(in super::super) fn parse_box_edges( left: *left, right: *right, }), - [_, right, left] => Some(HorizontalEdges { - left: *left, + [_, right, _] => Some(HorizontalEdges { + left: *right, right: *right, }), _ => None, diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs new file mode 100644 index 00000000..5e4c853e --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs @@ -0,0 +1,80 @@ +use std::collections::HashMap; + +use super::{parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge}; + +#[test] +fn parse_single_length_resolves_calc_compare_and_var_fallbacks() { + let mut properties = HashMap::new(); + properties.insert("--base".to_string(), "calc(10px + 2px)".to_string()); + properties.insert("--chosen".to_string(), "var(--missing, 18px)".to_string()); + + assert_eq!(parse_single_length("var(--base)", &properties), Some(12.0)); + assert_eq!( + parse_single_length("var(--chosen)", &properties), + Some(18.0) + ); + assert_eq!( + parse_single_length("clamp(4px, max(12px, 14px), 20px)", &properties), + Some(14.0) + ); +} + +#[test] +fn parse_single_length_rejects_percentages_units_and_bad_math() { + let properties = HashMap::new(); + + assert_eq!(parse_single_length("80%", &properties), None); + assert_eq!(parse_single_length("1rem", &properties), None); + assert_eq!(parse_single_length("calc(10px / 0)", &properties), None); + assert_eq!(parse_single_length("calc(10px + 2)", &properties), None); +} + +#[test] +fn parse_box_edges_follows_css_horizontal_shorthand_rules() { + let properties = HashMap::new(); + + let one = parse_box_edges("3px", &properties).expect("one value"); + assert_eq!(one.left, 3.0); + assert_eq!(one.right, 3.0); + + let two = parse_box_edges("1px 4px", &properties).expect("two values"); + assert_eq!(two.left, 4.0); + assert_eq!(two.right, 4.0); + + let three = parse_box_edges("1px 4px 7px", &properties).expect("three values"); + assert_eq!(three.left, 4.0); + assert_eq!(three.right, 4.0); + + let four = parse_box_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!(four.left, 4.0); + assert_eq!(four.right, 2.0); +} + +#[test] +fn parse_box_vertical_edges_follows_css_vertical_shorthand_rules() { + let properties = HashMap::new(); + + let two = parse_box_vertical_edges("6px 9px", &properties).expect("two values"); + assert_eq!(two.top, 6.0); + assert_eq!(two.bottom, 6.0); + + let three = parse_box_vertical_edges("1px 2px 3px", &properties).expect("three values"); + assert_eq!(three.top, 1.0); + assert_eq!(three.bottom, 3.0); + + let four = parse_box_vertical_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!(four.top, 1.0); + assert_eq!(four.bottom, 3.0); +} + +#[test] +fn set_edge_leaves_existing_value_when_length_cannot_resolve() { + let properties = HashMap::new(); + let mut edge = 8.0; + + set_edge(&mut edge, "var(--missing)", &properties); + assert_eq!(edge, 8.0); + + set_edge(&mut edge, "12px", &properties); + assert_eq!(edge, 12.0); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/tests/mod.rs new file mode 100644 index 00000000..2ddf8a33 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/tests/mod.rs @@ -0,0 +1,97 @@ +use super::{ + can_model_horizontal_size_value, collect_custom_property_scopes, + collect_geometry_from_contents, CssCustomProperties, +}; +use crate::css_check::geometry::model::GeometryModel; + +#[test] +fn custom_property_scopes_apply_root_tokens_and_selector_overrides() { + let css = r#" + :root { --gap: 10px; --shared: 12px; } + .unixnotis-toggle { --gap: 18px; } + .unixnotis-toggle:hover { --ignored: 99px; } + "#; + + let scopes = collect_custom_property_scopes(css); + + let toggle = scopes.for_selector(".unixnotis-toggle"); + assert_eq!(toggle.get("--gap").map(String::as_str), Some("18px")); + assert_eq!(toggle.get("--shared").map(String::as_str), Some("12px")); + assert!(!toggle.contains_key("--ignored")); + + let panel = scopes.for_selector(".unixnotis-panel"); + assert_eq!(panel.get("--gap").map(String::as_str), Some("10px")); +} + +#[test] +fn can_model_horizontal_size_value_rejects_unresolved_width_tokens() { + let css = ":root { --known: calc(10px + 2px); }"; + let scopes = collect_custom_property_scopes(css); + + assert!(can_model_horizontal_size_value( + ".unixnotis-toggle", + "min-width", + "var(--known)", + &scopes + )); + assert!(!can_model_horizontal_size_value( + ".unixnotis-toggle", + "min-width", + "var(--missing)", + &scopes + )); + assert!(can_model_horizontal_size_value( + ".unixnotis-toggle", + "color", + "var(--missing)", + &scopes + )); +} + +#[test] +fn geometry_collection_warns_once_for_unknown_unixnotis_size_class() { + let css = r#" + .unixnotis-made-up { min-width: 40px; } + .unixnotis-made-up { padding-left: 4px; } + "#; + let mut model = GeometryModel::default(); + + let warnings = collect_geometry_from_contents(css, &mut model); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("unknown UnixNotis class '.unixnotis-made-up'")); +} + +#[test] +fn geometry_collection_warns_for_complex_unmodeled_unixnotis_width_selector() { + let css = ".unixnotis-panel .unixnotis-toggle { min-width: 40px; }"; + let mut model = GeometryModel::default(); + + let warnings = collect_geometry_from_contents(css, &mut model); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("complex UnixNotis selector")); +} + +#[test] +fn geometry_collection_applies_nested_media_rules_to_width_model() { + let css = "@media (min-width: 1px) { .unixnotis-panel { padding: 11px; } }"; + let mut model = GeometryModel::default(); + + let warnings = collect_geometry_from_contents(css, &mut model); + + assert!(warnings.is_empty(), "{warnings:?}"); + let panel = model.target_mut(".unixnotis-panel").expect("panel target"); + assert_eq!(panel.inner_insets_px(), 22); +} + +#[test] +fn plain_custom_properties_type_accepts_direct_lengths() { + let mut properties = CssCustomProperties::new(); + properties.insert("--w".to_string(), "42px".to_string()); + + assert_eq!( + super::parse_single_length("var(--w)", &properties), + Some(42.0) + ); +} diff --git a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs index 44441538..26053daa 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs @@ -6,17 +6,17 @@ use unixnotis_core::{ DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, }; -use super::super::super::main_css_check_parse::{ +use super::super::super::parse::{ next_css_block, normalize_selector, parse_css_declarations, split_selectors, strip_css_comments, }; -use super::super::super::main_css_check_policy::is_horizontal_size_property; +use super::super::super::policy::is_horizontal_size_property; use super::super::model::GeometryModel; use super::super::parse::{ collect_custom_property_scopes, collect_geometry_from_contents_with_properties, }; use super::normalized_horizontal_size_rules; -pub(in crate::main_css_check) fn stock_matches_complex_selector_rules( +pub(in crate::css_check) fn stock_matches_complex_selector_rules( selector: &str, properties: &[(String, String)], ) -> bool { @@ -39,13 +39,13 @@ pub(in crate::main_css_check) fn stock_matches_complex_selector_rules( }) } -pub(in crate::main_css_check) fn stock_config() -> &'static Config { +pub(in crate::css_check) fn stock_config() -> &'static Config { static CONFIG: OnceLock = OnceLock::new(); // Default config is a stable baseline for false-positive control CONFIG.get_or_init(Config::default) } -pub(in crate::main_css_check) fn stock_geometry_model() -> &'static GeometryModel { +pub(in crate::css_check) fn stock_geometry_model() -> &'static GeometryModel { static MODEL: OnceLock = OnceLock::new(); MODEL.get_or_init(|| { let mut model = GeometryModel::default(); diff --git a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs index 2bdccc38..d32d2bb7 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs @@ -6,7 +6,7 @@ use unixnotis_core::{ DEFAULT_WIDGETS_CSS, }; -pub(in crate::main_css_check) fn known_unixnotis_classes() -> &'static HashSet { +pub(in crate::css_check) fn known_unixnotis_classes() -> &'static HashSet { static CLASSES: OnceLock> = OnceLock::new(); CLASSES.get_or_init(|| { let mut classes = HashSet::new(); @@ -138,50 +138,3 @@ fn hook_unixnotis_classes() -> &'static [&'static str] { "unixnotis-media-button-next", ] } - -#[cfg(test)] -mod tests { - use super::known_unixnotis_classes; - - #[test] - fn player_button_hooks_are_treated_as_known_public_classes() { - let classes = known_unixnotis_classes(); - - assert!(classes.contains(".unixnotis-media-button-prev")); - assert!(classes.contains(".unixnotis-media-button-play")); - assert!(classes.contains(".unixnotis-media-button-next")); - } - - #[test] - fn section_header_hooks_are_treated_as_known_public_classes() { - let classes = known_unixnotis_classes(); - - assert!(classes.contains(".unixnotis-section-header")); - assert!(classes.contains(".unixnotis-recent-section")); - assert!(classes.contains(".unixnotis-recent-header")); - assert!(classes.contains(".unixnotis-recent-header-row")); - assert!(classes.contains(".unixnotis-panel-footer")); - } - - #[test] - fn notification_metadata_hooks_are_treated_as_known_public_classes() { - let classes = known_unixnotis_classes(); - - assert!(classes.contains(".unixnotis-panel-card-meta-top")); - assert!(classes.contains(".unixnotis-panel-card-time-badge")); - assert!(classes.contains(".unixnotis-panel-card-thumbnail")); - } - - #[test] - fn decorative_theme_hooks_are_treated_as_known_public_classes() { - let classes = known_unixnotis_classes(); - - assert!(classes.contains(".unixnotis-panel-edge-top")); - assert!(classes.contains(".unixnotis-panel-rail-left")); - assert!(classes.contains(".unixnotis-panel-search-shell")); - assert!(classes.contains(".unixnotis-quick-slider-segments")); - assert!(classes.contains(".unixnotis-info-media")); - assert!(classes.contains(".unixnotis-info-card-banner")); - assert!(classes.contains(".unixnotis-panel-action-label-hidden")); - } -} diff --git a/crates/noticenterctl/src/css_check/geometry/stock/mod.rs b/crates/noticenterctl/src/css_check/geometry/stock/mod.rs index 9eb8f042..7acd57bb 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/mod.rs @@ -1,11 +1,12 @@ //! Stock theme helpers for geometry lint -#[path = "baselines.rs"] pub(super) mod baselines; -#[path = "classes.rs"] pub(super) mod classes; mod size_rules; pub(super) use self::size_rules::{ normalized_horizontal_size_rules, should_warn_for_unmodeled_known_class, }; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs b/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs index d0500088..99c6c25c 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; -use crate::main_css_check::main_css_check_policy; +use crate::css_check::policy; const SMALL_INLINE_WIDTH_WARNING_THRESHOLD_PX: f32 = 64.0; -pub(in crate::main_css_check) fn should_warn_for_unmodeled_known_class( +pub(in crate::css_check) fn should_warn_for_unmodeled_known_class( class_name: &str, properties: &[(String, String)], ) -> bool { @@ -54,13 +54,13 @@ fn stock_matches_horizontal_size_rules(class_name: &str, properties: &[(String, }) } -pub(in crate::main_css_check) fn normalized_horizontal_size_rules( +pub(in crate::css_check) fn normalized_horizontal_size_rules( properties: &[(String, String)], ) -> HashMap { let mut current_rules = HashMap::new(); for (name, value) in properties .iter() - .filter(|(name, _)| main_css_check_policy::is_horizontal_size_property(name)) + .filter(|(name, _)| policy::is_horizontal_size_property(name)) { // Later duplicate properties win in GTK CSS, so the baseline check does the same current_rules.insert(name.trim().to_string(), value.trim().to_string()); diff --git a/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs new file mode 100644 index 00000000..703ae7d0 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs @@ -0,0 +1,43 @@ +use super::super::classes::known_unixnotis_classes; + +#[test] +fn player_button_hooks_are_treated_as_known_public_classes() { + let classes = known_unixnotis_classes(); + + assert!(classes.contains(".unixnotis-media-button-prev")); + assert!(classes.contains(".unixnotis-media-button-play")); + assert!(classes.contains(".unixnotis-media-button-next")); +} + +#[test] +fn section_header_hooks_are_treated_as_known_public_classes() { + let classes = known_unixnotis_classes(); + + assert!(classes.contains(".unixnotis-section-header")); + assert!(classes.contains(".unixnotis-recent-section")); + assert!(classes.contains(".unixnotis-recent-header")); + assert!(classes.contains(".unixnotis-recent-header-row")); + assert!(classes.contains(".unixnotis-panel-footer")); +} + +#[test] +fn notification_metadata_hooks_are_treated_as_known_public_classes() { + let classes = known_unixnotis_classes(); + + assert!(classes.contains(".unixnotis-panel-card-meta-top")); + assert!(classes.contains(".unixnotis-panel-card-time-badge")); + assert!(classes.contains(".unixnotis-panel-card-thumbnail")); +} + +#[test] +fn decorative_theme_hooks_are_treated_as_known_public_classes() { + let classes = known_unixnotis_classes(); + + assert!(classes.contains(".unixnotis-panel-edge-top")); + assert!(classes.contains(".unixnotis-panel-rail-left")); + assert!(classes.contains(".unixnotis-panel-search-shell")); + assert!(classes.contains(".unixnotis-quick-slider-segments")); + assert!(classes.contains(".unixnotis-info-media")); + assert!(classes.contains(".unixnotis-info-card-banner")); + assert!(classes.contains(".unixnotis-panel-action-label-hidden")); +} diff --git a/crates/noticenterctl/src/css_check/geometry/stock/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/stock/tests/mod.rs new file mode 100644 index 00000000..08aea7b8 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/stock/tests/mod.rs @@ -0,0 +1,2 @@ +mod classes; +mod size_rules; diff --git a/crates/noticenterctl/src/css_check/geometry/stock/tests/size_rules.rs b/crates/noticenterctl/src/css_check/geometry/stock/tests/size_rules.rs new file mode 100644 index 00000000..8473edfa --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/stock/tests/size_rules.rs @@ -0,0 +1,44 @@ +use super::super::size_rules::{ + normalized_horizontal_size_rules, should_warn_for_unmodeled_known_class, +}; + +fn props(values: &[(&str, &str)]) -> Vec<(String, String)> { + values + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect() +} + +#[test] +fn normalized_horizontal_size_rules_keeps_later_duplicate_value() { + let rules = normalized_horizontal_size_rules(&props(&[ + ("width", "10px"), + ("color", "red"), + ("width", "12px"), + ("padding-left", " 2px "), + ])); + + assert_eq!(rules.get("width").map(String::as_str), Some("12px")); + assert_eq!(rules.get("padding-left").map(String::as_str), Some("2px")); + assert!(!rules.contains_key("color")); +} + +#[test] +fn small_known_badge_width_stays_quiet_but_large_width_warns() { + assert!(!should_warn_for_unmodeled_known_class( + ".unixnotis-panel-count", + &props(&[("width", "24px"), ("padding", "2px 4px")]) + )); + assert!(should_warn_for_unmodeled_known_class( + ".unixnotis-panel-count", + &props(&[("width", "96px")]) + )); +} + +#[test] +fn non_size_rules_do_not_warn_for_unmodeled_known_class() { + assert!(!should_warn_for_unmodeled_known_class( + ".unixnotis-panel-action", + &props(&[("color", "red")]) + )); +} diff --git a/crates/noticenterctl/src/css_check/geometry/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/tests/mod.rs index 10ab4825..c2bac7c8 100644 --- a/crates/noticenterctl/src/css_check/geometry/tests/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/tests/mod.rs @@ -2,11 +2,7 @@ //! //! Keeps the root geometry entry file focused on runtime behavior -#[path = "custom_properties.rs"] mod custom_properties; -#[path = "height_budget.rs"] mod height_budget; -#[path = "stock_and_selectors.rs"] mod stock_and_selectors; -#[path = "width_budget.rs"] mod width_budget; From 922cb82d62e1558254f0b3da9ef234c9d7bdcfc6 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:19 -0500 Subject: [PATCH 25/65] noticenterctl: organize dbus commands and output Keep D-Bus command dispatch under dbus/commands with timeout handling beside the client. Move output gating and formatting tests under output so command execution and presentation stay separate. --- crates/noticenterctl/src/dbus/client.rs | 23 +-------- crates/noticenterctl/src/dbus/commands.rs | 7 +-- crates/noticenterctl/src/dbus/mod.rs | 6 +-- crates/noticenterctl/src/dbus/tests/mod.rs | 3 +- crates/noticenterctl/src/dbus/timeout.rs | 23 +++++++++ crates/noticenterctl/src/output/mod.rs | 55 ++-------------------- crates/noticenterctl/src/output/tests.rs | 52 +++++++++++++++++++- 7 files changed, 88 insertions(+), 81 deletions(-) create mode 100644 crates/noticenterctl/src/dbus/timeout.rs diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index f6409321..c4cf459e 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -1,11 +1,10 @@ use std::future::Future; use std::pin::Pin; -use std::time::Duration; -use anyhow::{anyhow, Result}; +use anyhow::Result; use unixnotis_core::{ControlProxy, InhibitorInfo, NotificationView, PanelDebugLevel}; -const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); +use super::timeout::run_control_call; // A boxed future lets every control command return the same kind of async wrapper pub(crate) type ControlFuture<'a, T> = Pin> + 'a>>; @@ -137,21 +136,3 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::list_inhibitors(self))) } } - -// Runs one daemon call while making sure it cannot hang forever -async fn run_control_call(call: impl Future>) -> Result { - // Race the real D-Bus call against the maximum allowed wait time - match tokio::time::timeout(CONTROL_CALL_TIMEOUT, call).await { - // The daemon answered in time and the call itself worked - Ok(Ok(value)) => Ok(value), - - // The daemon answered in time, but reported a D-Bus error - Ok(Err(err)) => Err(err.into()), - - // The daemon did not answer before the timeout finished - Err(_) => Err(anyhow!( - "timed out waiting for unixnotis daemon response after {}s", - CONTROL_CALL_TIMEOUT.as_secs() - )), - } -} diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index b82c4228..5e5806bb 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -2,11 +2,12 @@ use anyhow::Result; use unixnotis_core::util; use crate::cli::{Command, DndState}; -use crate::main_log_follow::follow_debug_logs; -use crate::main_output::{print_inhibitors, print_notifications}; +use crate::debug_logs::follow_debug_logs; +use crate::output::{ + allow_full_output, print_inhibitors, print_notifications, warn_full_requires_diagnostic, +}; use super::client::ControlClient; -use super::output_gate::{allow_full_output, warn_full_requires_diagnostic}; pub(crate) async fn handle_command(client: &impl ControlClient, command: Command) -> Result<()> { // CLI forwards work to the daemon diff --git a/crates/noticenterctl/src/dbus/mod.rs b/crates/noticenterctl/src/dbus/mod.rs index e494faa0..b7d18552 100644 --- a/crates/noticenterctl/src/dbus/mod.rs +++ b/crates/noticenterctl/src/dbus/mod.rs @@ -1,10 +1,10 @@ //! Control-plane D-Bus command execution mod client; -mod dispatch; -mod output_gate; +mod commands; +mod timeout; -pub(crate) use dispatch::handle_command; +pub(crate) use commands::handle_command; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/dbus/tests/mod.rs b/crates/noticenterctl/src/dbus/tests/mod.rs index 4f951768..de78c4aa 100644 --- a/crates/noticenterctl/src/dbus/tests/mod.rs +++ b/crates/noticenterctl/src/dbus/tests/mod.rs @@ -1,3 +1,2 @@ -mod dispatch; -mod output_gate; +mod commands; mod support; diff --git a/crates/noticenterctl/src/dbus/timeout.rs b/crates/noticenterctl/src/dbus/timeout.rs new file mode 100644 index 00000000..a9d84116 --- /dev/null +++ b/crates/noticenterctl/src/dbus/timeout.rs @@ -0,0 +1,23 @@ +use std::future::Future; +use std::time::Duration; + +use anyhow::{anyhow, Result}; + +const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); + +pub(super) async fn run_control_call(call: impl Future>) -> Result { + // Race the real D-Bus call against the maximum allowed wait time + match tokio::time::timeout(CONTROL_CALL_TIMEOUT, call).await { + // The daemon answered in time and the call itself worked + Ok(Ok(value)) => Ok(value), + + // The daemon answered in time, but reported a D-Bus error + Ok(Err(err)) => Err(err.into()), + + // The daemon did not answer before the timeout finished + Err(_) => Err(anyhow!( + "timed out waiting for unixnotis daemon response after {}s", + CONTROL_CALL_TIMEOUT.as_secs() + )), + } +} diff --git a/crates/noticenterctl/src/output/mod.rs b/crates/noticenterctl/src/output/mod.rs index 08b2e5f9..94aefdf6 100644 --- a/crates/noticenterctl/src/output/mod.rs +++ b/crates/noticenterctl/src/output/mod.rs @@ -1,7 +1,11 @@ //! Output formatting helpers for noticenterctl +mod gate; + use unixnotis_core::{util, NotificationView}; +pub(crate) use gate::{allow_full_output, warn_full_requires_diagnostic}; + pub(crate) fn print_notifications(label: &str, notifications: &[NotificationView], full: bool) { // One place for CLI output print!("{}", format_notifications(label, notifications, full)); @@ -69,53 +73,4 @@ fn format_inhibitors(inhibitors: &[(u64, String, u32, String)]) -> String { } #[cfg(test)] -mod tests { - use unixnotis_core::{Action, NotificationImage, NotificationView}; - - use super::{format_inhibitors, format_notifications}; - - fn sample_notification() -> NotificationView { - // Bad bytes on purpose - NotificationView { - id: 7, - app_name: "mailer\n\x1b[31m".to_string(), - summary: "subject\rline".to_string(), - body: "body\ttext\nnext".to_string(), - actions: vec![Action { - key: "open".to_string(), - label: "Open".to_string(), - }], - urgency: 1, - is_transient: false, - // CLI formatting only needs the lightweight transport fields - image: NotificationImage::default(), - } - } - - #[test] - fn format_notifications_sanitizes_terminal_control_sequences() { - // Compact output stays clean - let output = format_notifications("active", &[sample_notification()], false); - assert!(output.contains("mailer")); - assert!(output.contains("[31m]")); - assert!(output.contains("subject line")); - assert!(!output.contains('\n') || output.lines().count() == 2); - assert!(!output.contains('\u{1b}')); - } - - #[test] - fn format_notifications_full_mode_includes_body() { - // Full mode prints the body - let output = format_notifications("history", &[sample_notification()], true); - assert!(output.contains("body: body text next")); - } - - #[test] - fn format_inhibitors_sanitizes_reason_and_owner() { - // Both fields print straight to the terminal - let output = - format_inhibitors(&[(5, "present\nmode".to_string(), 1, ":1.2\r".to_string())]); - assert!(output.contains("owner=:1.2 ")); - assert!(output.contains("reason=present mode")); - } -} +mod tests; diff --git a/crates/noticenterctl/src/output/tests.rs b/crates/noticenterctl/src/output/tests.rs index 5f863b2a..315b22bf 100644 --- a/crates/noticenterctl/src/output/tests.rs +++ b/crates/noticenterctl/src/output/tests.rs @@ -1,7 +1,55 @@ -use super::super::output_gate::{allow_full_output, warn_full_requires_diagnostic}; +use unixnotis_core::{Action, NotificationImage, NotificationView}; + +use super::{ + allow_full_output, format_inhibitors, format_notifications, warn_full_requires_diagnostic, +}; + +fn sample_notification() -> NotificationView { + // Bad bytes on purpose + NotificationView { + id: 7, + app_name: "mailer\n\x1b[31m".to_string(), + summary: "subject\rline".to_string(), + body: "body\ttext\nnext".to_string(), + actions: vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }], + urgency: 1, + is_transient: false, + // CLI formatting only needs the lightweight transport fields + image: NotificationImage::default(), + } +} + +#[test] +fn format_notifications_sanitizes_terminal_control_sequences() { + // Compact output stays clean + let output = format_notifications("active", &[sample_notification()], false); + assert!(output.contains("mailer")); + assert!(output.contains("[31m]")); + assert!(output.contains("subject line")); + assert!(!output.contains('\n') || output.lines().count() == 2); + assert!(!output.contains('\u{1b}')); +} + +#[test] +fn format_notifications_full_mode_includes_body() { + // Full mode prints the body + let output = format_notifications("history", &[sample_notification()], true); + assert!(output.contains("body: body text next")); +} + +#[test] +fn format_inhibitors_sanitizes_reason_and_owner() { + // Both fields print straight to the terminal + let output = format_inhibitors(&[(5, "present\nmode".to_string(), 1, ":1.2\r".to_string())]); + assert!(output.contains("owner=:1.2 ")); + assert!(output.contains("reason=present mode")); +} #[test] -fn full_output_gate_requires_request_and_diagnostic_mode() { +fn full_output_requires_request_and_diagnostic_mode() { assert!(allow_full_output(true, true)); assert!(!allow_full_output(true, false)); assert!(!allow_full_output(false, true)); From 38cd82d78bfe4e0c2f3c6c60feebf7f1ffddac77 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:24 -0500 Subject: [PATCH 26/65] noticenterctl: split debug log follower Break debug log following into command and journal modules. Add tests for daemon unit selection and journalctl argument construction so debug_logs/mod.rs stays as a small module surface. --- .../noticenterctl/src/debug_logs/command.rs | 24 +++++++ .../noticenterctl/src/debug_logs/journal.rs | 70 +++++++++++++++++++ crates/noticenterctl/src/debug_logs/mod.rs | 58 ++------------- .../src/debug_logs/tests/journal.rs | 45 ++++++++++++ .../noticenterctl/src/debug_logs/tests/mod.rs | 1 + 5 files changed, 145 insertions(+), 53 deletions(-) create mode 100644 crates/noticenterctl/src/debug_logs/command.rs create mode 100644 crates/noticenterctl/src/debug_logs/journal.rs create mode 100644 crates/noticenterctl/src/debug_logs/tests/journal.rs create mode 100644 crates/noticenterctl/src/debug_logs/tests/mod.rs diff --git a/crates/noticenterctl/src/debug_logs/command.rs b/crates/noticenterctl/src/debug_logs/command.rs new file mode 100644 index 00000000..364a19af --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/command.rs @@ -0,0 +1,24 @@ +use anyhow::{anyhow, Result}; +use std::env; + +use super::journal::{ + daemon_unit_from_env, follow_user_unit_logs, journal_has_user_unit_logs, + journalctl_is_available, +}; + +pub(crate) fn follow_debug_logs() -> Result<()> { + if !journalctl_is_available() { + return Err(anyhow!( + "journalctl is not available; run unixnotis-daemon in a terminal to watch logs directly" + )); + } + let unit = daemon_unit_from_env(|key| env::var(key)); + if !journal_has_user_unit_logs(&unit)? { + return Err(anyhow!( + "no user journal stream for {}; debug panel open will continue without log follow", + unit + )); + } + + follow_user_unit_logs(&unit) +} diff --git a/crates/noticenterctl/src/debug_logs/journal.rs b/crates/noticenterctl/src/debug_logs/journal.rs new file mode 100644 index 00000000..3799e03e --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/journal.rs @@ -0,0 +1,70 @@ +use anyhow::{anyhow, Context, Result}; +use std::env; +use std::process::{Command as ProcCommand, Stdio}; + +const DEFAULT_DAEMON_UNIT: &str = "unixnotis-daemon.service"; + +pub(super) fn daemon_unit_from_env( + get_var: impl FnOnce(&str) -> Result, +) -> String { + get_var("UNIXNOTIS_DAEMON_UNIT").unwrap_or_else(|_| DEFAULT_DAEMON_UNIT.to_string()) +} + +pub(super) fn follow_args(unit: &str) -> Vec { + vec![ + "--user".to_string(), + "-f".to_string(), + "-u".to_string(), + unit.to_string(), + "-o".to_string(), + "cat".to_string(), + ] +} + +pub(super) fn probe_args(unit: &str) -> Vec { + vec![ + "--user".to_string(), + "--no-pager".to_string(), + "-n".to_string(), + "1".to_string(), + "-u".to_string(), + unit.to_string(), + "-o".to_string(), + "cat".to_string(), + ] +} + +pub(super) fn journalctl_is_available() -> bool { + ProcCommand::new("journalctl") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +pub(super) fn journal_has_user_unit_logs(unit: &str) -> Result { + let status = ProcCommand::new("journalctl") + .args(probe_args(unit)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("check journal availability for {unit}"))?; + Ok(status.success()) +} + +pub(super) fn follow_user_unit_logs(unit: &str) -> Result<()> { + // Follow the user-level systemd unit so the output matches the active session + let status = ProcCommand::new("journalctl") + .args(follow_args(unit)) + .status() + .with_context(|| format!("start journalctl follow for {unit}"))?; + + if status.success() { + Ok(()) + } else { + // Propagate a clear failure when the subprocess exits non-zero + Err(anyhow!("journalctl exited with status {}", status)) + } +} diff --git a/crates/noticenterctl/src/debug_logs/mod.rs b/crates/noticenterctl/src/debug_logs/mod.rs index b5d322a0..2e4ccda9 100644 --- a/crates/noticenterctl/src/debug_logs/mod.rs +++ b/crates/noticenterctl/src/debug_logs/mod.rs @@ -1,57 +1,9 @@ //! Journalctl follower used when the panel opens in debug mode -use anyhow::{anyhow, Context, Result}; -use std::env; -use std::process::Command as ProcCommand; -use std::process::Stdio; +mod command; +mod journal; -const DEFAULT_DAEMON_UNIT: &str = "unixnotis-daemon.service"; +pub(crate) use command::follow_debug_logs; -pub(crate) fn follow_debug_logs() -> Result<()> { - if !journalctl_is_available() { - return Err(anyhow!( - "journalctl is not available; run unixnotis-daemon in a terminal to watch logs directly" - )); - } - let unit = - env::var("UNIXNOTIS_DAEMON_UNIT").unwrap_or_else(|_| DEFAULT_DAEMON_UNIT.to_string()); - if !journal_has_user_unit_logs(&unit)? { - return Err(anyhow!( - "no user journal stream for {}; debug panel open will continue without log follow", - unit - )); - } - - // Follow the user-level systemd unit so the output matches the active session - let status = ProcCommand::new("journalctl") - .args(["--user", "-f", "-u", &unit, "-o", "cat"]) - .status() - .with_context(|| format!("start journalctl follow for {unit}"))?; - - if status.success() { - Ok(()) - } else { - // Propagate a clear failure when the subprocess exits non-zero - Err(anyhow!("journalctl exited with status {}", status)) - } -} - -fn journalctl_is_available() -> bool { - ProcCommand::new("journalctl") - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) -} - -fn journal_has_user_unit_logs(unit: &str) -> Result { - let status = ProcCommand::new("journalctl") - .args(["--user", "--no-pager", "-n", "1", "-u", unit, "-o", "cat"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .with_context(|| format!("check journal availability for {unit}"))?; - Ok(status.success()) -} +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/debug_logs/tests/journal.rs b/crates/noticenterctl/src/debug_logs/tests/journal.rs new file mode 100644 index 00000000..c44d731e --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/tests/journal.rs @@ -0,0 +1,45 @@ +use std::env; + +use super::super::journal::{daemon_unit_from_env, follow_args, probe_args}; + +#[test] +fn daemon_unit_from_env_uses_override_when_present() { + let unit = daemon_unit_from_env(|key| { + assert_eq!(key, "UNIXNOTIS_DAEMON_UNIT"); + Ok("custom.service".to_string()) + }); + + assert_eq!(unit, "custom.service"); +} + +#[test] +fn daemon_unit_from_env_uses_default_when_override_is_absent() { + let unit = daemon_unit_from_env(|_| Err(env::VarError::NotPresent)); + + assert_eq!(unit, "unixnotis-daemon.service"); +} + +#[test] +fn follow_args_target_user_unit_and_stream_plain_messages() { + assert_eq!( + follow_args("custom.service"), + vec!["--user", "-f", "-u", "custom.service", "-o", "cat"] + ); +} + +#[test] +fn probe_args_read_one_user_unit_entry_without_pager() { + assert_eq!( + probe_args("custom.service"), + vec![ + "--user", + "--no-pager", + "-n", + "1", + "-u", + "custom.service", + "-o", + "cat" + ] + ); +} diff --git a/crates/noticenterctl/src/debug_logs/tests/mod.rs b/crates/noticenterctl/src/debug_logs/tests/mod.rs new file mode 100644 index 00000000..f8512912 --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/tests/mod.rs @@ -0,0 +1 @@ +mod journal; From 51396c3f6b4f6010401cfc9d7e48b7f455dd7b5c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:30 -0500 Subject: [PATCH 27/65] noticenterctl: add preset core coverage Normalize preset archive, command rule, css asset, manifest, inspect, config-root, and pathing tests into feature test folders. Cover top-level preset helpers that are not already owned by a subfolder. --- crates/noticenterctl/src/preset/archive.rs | 4 - .../noticenterctl/src/preset/command_rules.rs | 5 - .../noticenterctl/src/preset/config_root.rs | 131 ------------------ .../src/preset/css_asset_refs.rs | 4 - crates/noticenterctl/src/preset/inspect.rs | 70 ---------- crates/noticenterctl/src/preset/manifest.rs | 37 ----- crates/noticenterctl/src/preset/mod.rs | 3 + crates/noticenterctl/src/preset/pathing.rs | 43 +----- .../src/preset/tests/config_root.rs | 128 +++++++++++++++++ .../noticenterctl/src/preset/tests/inspect.rs | 67 +++++++++ .../src/preset/tests/manifest.rs | 49 +++++++ crates/noticenterctl/src/preset/tests/mod.rs | 4 + .../noticenterctl/src/preset/tests/pathing.rs | 72 ++++++++++ 13 files changed, 324 insertions(+), 293 deletions(-) create mode 100644 crates/noticenterctl/src/preset/tests/config_root.rs create mode 100644 crates/noticenterctl/src/preset/tests/inspect.rs create mode 100644 crates/noticenterctl/src/preset/tests/manifest.rs create mode 100644 crates/noticenterctl/src/preset/tests/mod.rs create mode 100644 crates/noticenterctl/src/preset/tests/pathing.rs diff --git a/crates/noticenterctl/src/preset/archive.rs b/crates/noticenterctl/src/preset/archive.rs index eeed7be2..7ff18b90 100644 --- a/crates/noticenterctl/src/preset/archive.rs +++ b/crates/noticenterctl/src/preset/archive.rs @@ -3,14 +3,10 @@ //! Keeps archive reads, writes, mode checks, and tests grouped under one tree //! so the preset layer has one clear module boundary for bundle I/O -#[path = "archive/modes.rs"] mod modes; -#[path = "archive/read.rs"] mod read; #[cfg(test)] -#[path = "archive/tests.rs"] mod tests; -#[path = "archive/write.rs"] mod write; use std::path::PathBuf; diff --git a/crates/noticenterctl/src/preset/command_rules.rs b/crates/noticenterctl/src/preset/command_rules.rs index fdca45f1..5b95d7e1 100644 --- a/crates/noticenterctl/src/preset/command_rules.rs +++ b/crates/noticenterctl/src/preset/command_rules.rs @@ -4,16 +4,11 @@ //! what command slots exist, which ones point outside the config root, //! and which ones can be rewritten into portable config-relative paths -#[path = "command_rules/checks.rs"] mod checks; -#[path = "command_rules/collect.rs"] mod collect; -#[path = "command_rules/rewrite.rs"] mod rewrite; #[cfg(test)] -#[path = "command_rules/tests.rs"] mod tests; -#[path = "command_rules/tokens.rs"] mod tokens; use std::path::PathBuf; diff --git a/crates/noticenterctl/src/preset/config_root.rs b/crates/noticenterctl/src/preset/config_root.rs index ac87bed8..6fa64033 100644 --- a/crates/noticenterctl/src/preset/config_root.rs +++ b/crates/noticenterctl/src/preset/config_root.rs @@ -196,134 +196,3 @@ fn is_backup_dir(relative_path: &Path) -> bool { .map(|name| name.starts_with(BACKUP_PREFIX)) .unwrap_or(false) } - -#[cfg(test)] -mod tests { - use super::{collect_config_files, override_collected_file_contents}; - use crate::preset::pathing::format_relative_path; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-preset-filesystem-{}-{}-{}", - name, stamp, serial - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &str) { - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(path, contents).expect("write file"); - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn collect_config_files_skips_backups_symlinks_and_output_file() { - // Export should keep the tree portable and avoid self-inclusion - let root = TempDirGuard::new("collect"); - root.write("config.toml", "demo = true"); - root.write("assets/bg.png", "png"); - root.write("Backup-2026-04-11/config.toml", "old"); - root.write("scripts/run.sh", "echo hi"); - root.write("bundle.unixnotis", "old bundle"); - #[cfg(unix)] - std::os::unix::fs::symlink( - root.path.join("assets/bg.png"), - root.path.join("linked.png"), - ) - .expect("create symlink"); - - let collected = collect_config_files( - &root.path, - Some(&root.path.join("bundle.unixnotis")), - &[PathBuf::from("scripts")], - ) - .expect("collect files"); - - let paths = collected - .files - .iter() - .map(|file| format_relative_path(&file.relative_path)) - .collect::>(); - assert_eq!(paths, vec!["assets/bg.png", "config.toml"]); - #[cfg(unix)] - assert_eq!( - collected - .skipped_symlinks - .iter() - .map(|path| format_relative_path(path)) - .collect::>(), - vec!["linked.png"] - ); - } - - #[cfg(unix)] - #[test] - fn collect_config_files_rejects_special_permission_bits() { - let root = TempDirGuard::new("special-mode"); - root.write("config.toml", "demo = true"); - root.write("scripts/run.sh", "#!/bin/sh\necho hi\n"); - - let script_path = root.path.join("scripts/run.sh"); - let mut perms = fs::metadata(&script_path) - .expect("script metadata") - .permissions(); - perms.set_mode(0o4755); - fs::set_permissions(&script_path, perms).expect("set script mode"); - - let error = collect_config_files(&root.path, None, &[]).expect_err("reject special mode"); - assert!(error.to_string().contains("special permission bits")); - } - - #[test] - fn override_collected_file_contents_updates_manifest_size() { - let root = TempDirGuard::new("override"); - root.write("config.toml", "demo = true"); - - let mut collected = collect_config_files(&root.path, None, &[]).expect("collect files"); - override_collected_file_contents( - &mut collected, - Path::new("config.toml"), - b"demo = false\n".to_vec(), - ) - .expect("override config"); - - let config = collected - .files - .iter() - .find(|file| file.relative_path == Path::new("config.toml")) - .expect("config file"); - assert_eq!(config.size, b"demo = false\n".len() as u64); - assert_eq!( - config.contents_override.as_deref(), - Some(&b"demo = false\n"[..]) - ); - } -} diff --git a/crates/noticenterctl/src/preset/css_asset_refs.rs b/crates/noticenterctl/src/preset/css_asset_refs.rs index 5573d0f1..c8aed4dc 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs.rs @@ -3,14 +3,10 @@ //! This module keeps CSS asset scanning and bundle-only rewrites in one tree //! so preset import, export, inspect, and css-check all read the same rules -#[path = "css_asset_refs/collect.rs"] mod collect; -#[path = "css_asset_refs/parse.rs"] mod parse; -#[path = "css_asset_refs/rewrite.rs"] mod rewrite; #[cfg(test)] -#[path = "css_asset_refs/tests.rs"] mod tests; use std::path::{Path, PathBuf}; diff --git a/crates/noticenterctl/src/preset/inspect.rs b/crates/noticenterctl/src/preset/inspect.rs index 348cdeb6..14871081 100644 --- a/crates/noticenterctl/src/preset/inspect.rs +++ b/crates/noticenterctl/src/preset/inspect.rs @@ -192,73 +192,3 @@ fn collect_theme_path_warnings(config: &Config) -> Vec { } warnings } - -#[cfg(test)] -mod tests { - use super::inspect_preset_at; - use crate::preset::export::export_preset_from; - use std::fs; - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - // Unique temp roots keep inspect tests independent from export and import tests - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-preset-inspect-{}-{}-{}", - name, stamp, serial - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &str) { - // Helper keeps the test body focused on the reported output - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(path, contents).expect("write file"); - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn inspect_lists_bundle_metadata_and_commands() { - // Inspect should expose the command-bearing parts of the shared config - let root = TempDirGuard::new("report"); - root.write( - "config.toml", - "[theme]\nbase_css = \"base.css\"\n[widgets.volume]\nget_cmd = \"wpctl get-volume @DEFAULT_AUDIO_SINK@\"\n", - ); - root.write("base.css", ".a { color: red; }"); - let bundle_path = root.path.join("demo.unixnotis"); - export_preset_from(&root.path, &bundle_path, &[], false).expect("export"); - - let report = inspect_preset_at(&bundle_path).expect("inspect"); - - assert!(report.contains("preset: demo")); - assert!(report.contains("widgets.volume.get_cmd")); - assert!(report.contains("command path warnings:")); - assert!(report.contains("host-specific command paths:")); - assert!(report.contains("file list:")); - assert!(report.contains("config.toml")); - } -} diff --git a/crates/noticenterctl/src/preset/manifest.rs b/crates/noticenterctl/src/preset/manifest.rs index be668870..ea55e331 100644 --- a/crates/noticenterctl/src/preset/manifest.rs +++ b/crates/noticenterctl/src/preset/manifest.rs @@ -64,40 +64,3 @@ impl PresetManifest { Ok(toml::from_str(contents)?) } } - -#[cfg(test)] -mod tests { - use super::{PresetManifest, PresetManifestFile, PRESET_FORMAT_VERSION}; - - #[test] - fn manifest_round_trip_preserves_file_flags() { - // Asset and script flags should survive encode and decode intact - let manifest = PresetManifest::new( - "anime".to_string(), - "2026-04-11T12:00:00Z".to_string(), - "0.1.0".to_string(), - vec![ - PresetManifestFile { - path: "config.toml".to_string(), - size: 10, - }, - PresetManifestFile { - path: "assets/bg.png".to_string(), - size: 20, - }, - PresetManifestFile { - path: "scripts/fetch.sh".to_string(), - size: 30, - }, - ], - ); - - let encoded = manifest.encode().expect("encode manifest"); - let decoded = PresetManifest::decode(&encoded).expect("decode manifest"); - - assert_eq!(decoded.format_version, PRESET_FORMAT_VERSION); - assert!(decoded.has_assets); - assert!(decoded.has_scripts); - assert_eq!(decoded.files.len(), 3); - } -} diff --git a/crates/noticenterctl/src/preset/mod.rs b/crates/noticenterctl/src/preset/mod.rs index 28163829..9d2884a8 100644 --- a/crates/noticenterctl/src/preset/mod.rs +++ b/crates/noticenterctl/src/preset/mod.rs @@ -14,6 +14,9 @@ mod inspect; mod manifest; mod pathing; +#[cfg(test)] +mod tests; + use anyhow::Result; use std::path::Path; diff --git a/crates/noticenterctl/src/preset/pathing.rs b/crates/noticenterctl/src/preset/pathing.rs index 6f06ff17..d0ffff02 100644 --- a/crates/noticenterctl/src/preset/pathing.rs +++ b/crates/noticenterctl/src/preset/pathing.rs @@ -179,7 +179,7 @@ pub(super) fn format_relative_path(path: &Path) -> String { .join("/") } -fn resolve_cli_bundle_path_with_prompt(path: &Path, mut prompt: F) -> Result +pub(super) fn resolve_cli_bundle_path_with_prompt(path: &Path, mut prompt: F) -> Result where F: FnMut(&Path, &Path) -> Result, { @@ -233,44 +233,3 @@ pub(super) fn prompt_yes_no(prompt: &str) -> Result { let reply = reply.trim(); Ok(reply.eq_ignore_ascii_case("y") || reply.eq_ignore_ascii_case("yes")) } - -#[cfg(test)] -mod tests { - use super::{normalize_relative_path, parse_except_paths, resolve_cli_bundle_path_with_prompt}; - use std::path::Path; - - #[test] - fn parse_except_rejects_parent_traversal() { - // Traversal should be blocked before any filesystem work starts - let error = parse_except_paths(&["../escape".to_string()]).expect_err("reject traversal"); - assert!(error.to_string().contains("parent traversal")); - } - - #[test] - fn normalize_relative_path_strips_dot_segments() { - // Leading `./` should not change the stored path - let normalized = - normalize_relative_path(Path::new("./assets/../assets/bg.png")).expect_err("reject .."); - assert!(normalized.to_string().contains("parent traversal")); - } - - #[test] - fn resolve_cli_bundle_path_appends_extension_after_confirmation() { - // Missing extension should be fixable through the shared CLI path helper - let resolved = - resolve_cli_bundle_path_with_prompt(Path::new("dog"), |_original, _suggested| Ok(true)) - .expect("resolve preset path"); - assert_eq!(resolved, Path::new("dog.unixnotis")); - } - - #[test] - fn resolve_cli_bundle_path_cancels_when_prompt_is_declined() { - // Declining the prompt should cancel the command instead of guessing - let error = - resolve_cli_bundle_path_with_prompt(Path::new("dog"), |_original, _suggested| { - Ok(false) - }) - .expect_err("cancel preset path"); - assert!(error.to_string().contains("canceled")); - } -} diff --git a/crates/noticenterctl/src/preset/tests/config_root.rs b/crates/noticenterctl/src/preset/tests/config_root.rs new file mode 100644 index 00000000..68b00251 --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/config_root.rs @@ -0,0 +1,128 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::super::config_root::{collect_config_files, override_collected_file_contents}; +use super::super::pathing::format_relative_path; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "unixnotis-preset-filesystem-{}-{}-{}", + name, stamp, serial + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write(&self, relative_path: &str, contents: &str) { + let path = self.path.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dirs"); + } + fs::write(path, contents).expect("write file"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn collect_config_files_skips_backups_symlinks_and_output_file() { + // Export should keep the tree portable and avoid self-inclusion + let root = TempDirGuard::new("collect"); + root.write("config.toml", "demo = true"); + root.write("assets/bg.png", "png"); + root.write("Backup-2026-04-11/config.toml", "old"); + root.write("scripts/run.sh", "echo hi"); + root.write("bundle.unixnotis", "old bundle"); + #[cfg(unix)] + std::os::unix::fs::symlink( + root.path.join("assets/bg.png"), + root.path.join("linked.png"), + ) + .expect("create symlink"); + + let collected = collect_config_files( + &root.path, + Some(&root.path.join("bundle.unixnotis")), + &[PathBuf::from("scripts")], + ) + .expect("collect files"); + + let paths = collected + .files + .iter() + .map(|file| format_relative_path(&file.relative_path)) + .collect::>(); + assert_eq!(paths, vec!["assets/bg.png", "config.toml"]); + #[cfg(unix)] + assert_eq!( + collected + .skipped_symlinks + .iter() + .map(|path| format_relative_path(path)) + .collect::>(), + vec!["linked.png"] + ); +} + +#[cfg(unix)] +#[test] +fn collect_config_files_rejects_special_permission_bits() { + let root = TempDirGuard::new("special-mode"); + root.write("config.toml", "demo = true"); + root.write("scripts/run.sh", "#!/bin/sh\necho hi\n"); + + let script_path = root.path.join("scripts/run.sh"); + let mut perms = fs::metadata(&script_path) + .expect("script metadata") + .permissions(); + perms.set_mode(0o4755); + fs::set_permissions(&script_path, perms).expect("set script mode"); + + let error = collect_config_files(&root.path, None, &[]).expect_err("reject special mode"); + assert!(error.to_string().contains("special permission bits")); +} + +#[test] +fn override_collected_file_contents_updates_manifest_size() { + let root = TempDirGuard::new("override"); + root.write("config.toml", "demo = true"); + + let mut collected = collect_config_files(&root.path, None, &[]).expect("collect files"); + override_collected_file_contents( + &mut collected, + Path::new("config.toml"), + b"demo = false\n".to_vec(), + ) + .expect("override config"); + + let config = collected + .files + .iter() + .find(|file| file.relative_path == Path::new("config.toml")) + .expect("config file"); + assert_eq!(config.size, b"demo = false\n".len() as u64); + assert_eq!( + config.contents_override.as_deref(), + Some(&b"demo = false\n"[..]) + ); +} diff --git a/crates/noticenterctl/src/preset/tests/inspect.rs b/crates/noticenterctl/src/preset/tests/inspect.rs new file mode 100644 index 00000000..17356091 --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/inspect.rs @@ -0,0 +1,67 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::super::export::export_preset_from; +use super::super::inspect::inspect_preset_at; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + // Unique temp roots keep inspect tests independent from export and import tests + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "unixnotis-preset-inspect-{}-{}-{}", + name, stamp, serial + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write(&self, relative_path: &str, contents: &str) { + // Helper keeps the test body focused on the reported output + let path = self.path.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dirs"); + } + fs::write(path, contents).expect("write file"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn inspect_lists_bundle_metadata_and_commands() { + // Inspect should expose the command-bearing parts of the shared config + let root = TempDirGuard::new("report"); + root.write( + "config.toml", + "[theme]\nbase_css = \"base.css\"\n[widgets.volume]\nget_cmd = \"wpctl get-volume @DEFAULT_AUDIO_SINK@\"\n", + ); + root.write("base.css", ".a { color: red; }"); + let bundle_path = root.path.join("demo.unixnotis"); + export_preset_from(&root.path, &bundle_path, &[], false).expect("export"); + + let report = inspect_preset_at(&bundle_path).expect("inspect"); + + assert!(report.contains("preset: demo")); + assert!(report.contains("widgets.volume.get_cmd")); + assert!(report.contains("command path warnings:")); + assert!(report.contains("host-specific command paths:")); + assert!(report.contains("file list:")); + assert!(report.contains("config.toml")); +} diff --git a/crates/noticenterctl/src/preset/tests/manifest.rs b/crates/noticenterctl/src/preset/tests/manifest.rs new file mode 100644 index 00000000..fa589726 --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/manifest.rs @@ -0,0 +1,49 @@ +use super::super::manifest::{PresetManifest, PresetManifestFile, PRESET_FORMAT_VERSION}; + +#[test] +fn manifest_round_trip_preserves_file_flags() { + // Asset and script flags should survive encode and decode intact + let manifest = PresetManifest::new( + "anime".to_string(), + "2026-04-11T12:00:00Z".to_string(), + "0.1.0".to_string(), + vec![ + PresetManifestFile { + path: "config.toml".to_string(), + size: 10, + }, + PresetManifestFile { + path: "assets/bg.png".to_string(), + size: 20, + }, + PresetManifestFile { + path: "scripts/fetch.sh".to_string(), + size: 30, + }, + ], + ); + + let encoded = manifest.encode().expect("encode manifest"); + let decoded = PresetManifest::decode(&encoded).expect("decode manifest"); + + assert_eq!(decoded.format_version, PRESET_FORMAT_VERSION); + assert!(decoded.has_assets); + assert!(decoded.has_scripts); + assert_eq!(decoded.files.len(), 3); +} + +#[test] +fn manifest_flags_stay_false_when_bundle_has_only_config_files() { + let manifest = PresetManifest::new( + "minimal".to_string(), + "2026-04-11T12:00:00Z".to_string(), + "0.1.0".to_string(), + vec![PresetManifestFile { + path: "config.toml".to_string(), + size: 10, + }], + ); + + assert!(!manifest.has_assets); + assert!(!manifest.has_scripts); +} diff --git a/crates/noticenterctl/src/preset/tests/mod.rs b/crates/noticenterctl/src/preset/tests/mod.rs new file mode 100644 index 00000000..52135df0 --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/mod.rs @@ -0,0 +1,4 @@ +mod config_root; +mod inspect; +mod manifest; +mod pathing; diff --git a/crates/noticenterctl/src/preset/tests/pathing.rs b/crates/noticenterctl/src/preset/tests/pathing.rs new file mode 100644 index 00000000..e2c0777e --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/pathing.rs @@ -0,0 +1,72 @@ +use std::path::{Path, PathBuf}; + +use super::super::pathing::{ + archive_payload_path, archive_payload_relative, normalize_relative_path, parse_except_paths, + relative_path_matches_exclusion, resolve_cli_bundle_path_with_prompt, MANIFEST_ARCHIVE_PATH, +}; + +#[test] +fn parse_except_rejects_parent_traversal() { + // Traversal should be blocked before any filesystem work starts + let error = parse_except_paths(&["../escape".to_string()]).expect_err("reject traversal"); + assert!(error.to_string().contains("parent traversal")); +} + +#[test] +fn normalize_relative_path_strips_dot_segments() { + let normalized = normalize_relative_path(Path::new("./assets/bg.png")).expect("normalize path"); + assert_eq!(normalized, Path::new("assets/bg.png")); +} + +#[test] +fn normalize_relative_path_rejects_parent_segments() { + let error = + normalize_relative_path(Path::new("./assets/../bg.png")).expect_err("reject parent"); + assert!(error.to_string().contains("parent traversal")); +} + +#[test] +fn resolve_cli_bundle_path_appends_extension_after_confirmation() { + // Missing extension should be fixable through the shared CLI path helper + let resolved = + resolve_cli_bundle_path_with_prompt(Path::new("dog"), |_original, _suggested| Ok(true)) + .expect("resolve preset path"); + assert_eq!(resolved, Path::new("dog.unixnotis")); +} + +#[test] +fn resolve_cli_bundle_path_cancels_when_prompt_is_declined() { + // Declining the prompt should cancel the command instead of guessing + let error = + resolve_cli_bundle_path_with_prompt(Path::new("dog"), |_original, _suggested| Ok(false)) + .expect_err("cancel preset path"); + assert!(error.to_string().contains("canceled")); +} + +#[test] +fn archive_payload_round_trip_keeps_relative_payload_path() { + let relative_path = Path::new("assets/bg.png"); + let archive_path = archive_payload_path(relative_path); + let decoded = archive_payload_relative(&archive_path).expect("decode payload path"); + + assert_eq!(decoded, Some(PathBuf::from(relative_path))); +} + +#[test] +fn archive_payload_relative_ignores_manifest_entry() { + let decoded = + archive_payload_relative(Path::new(MANIFEST_ARCHIVE_PATH)).expect("decode manifest path"); + assert_eq!(decoded, None); +} + +#[test] +fn relative_path_matches_exclusion_accepts_directory_prefixes() { + assert!(relative_path_matches_exclusion( + Path::new("scripts/install.sh"), + &[PathBuf::from("scripts")] + )); + assert!(!relative_path_matches_exclusion( + Path::new("assets/scripts/icon.png"), + &[PathBuf::from("scripts")] + )); +} From 1c86fdb77517bb2aaea99e088693320c11db8c6d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:35 -0500 Subject: [PATCH 28/65] noticenterctl: harden preset import and export tests Move export, import, prompt, exec-review, and filesystem tests into nested tests folders. Keep practical coverage for host-specific rewrites, executable import review, path safety, dry-run behavior, and secure writes. --- .../noticenterctl/src/preset/export/checks.rs | 127 +--------------- .../src/preset/export/checks/tests/mod.rs | 123 ++++++++++++++++ .../src/preset/export/prompts.rs | 117 +-------------- .../src/preset/export/prompts/tests/mod.rs | 112 +++++++++++++++ crates/noticenterctl/src/preset/filesystem.rs | 5 +- .../src/preset/filesystem/checks.rs | 96 ------------- .../src/preset/filesystem/secure.rs | 60 -------- .../src/preset/filesystem/tests/checks.rs | 92 ++++++++++++ .../src/preset/filesystem/tests/mod.rs | 2 + .../src/preset/filesystem/tests/secure.rs | 56 ++++++++ .../noticenterctl/src/preset/import/checks.rs | 115 +-------------- .../src/preset/import/checks/tests/mod.rs | 112 +++++++++++++++ .../src/preset/import/exec_review.rs | 135 +----------------- .../preset/import/exec_review/tests/mod.rs | 130 +++++++++++++++++ 14 files changed, 634 insertions(+), 648 deletions(-) create mode 100644 crates/noticenterctl/src/preset/export/checks/tests/mod.rs create mode 100644 crates/noticenterctl/src/preset/export/prompts/tests/mod.rs create mode 100644 crates/noticenterctl/src/preset/filesystem/tests/checks.rs create mode 100644 crates/noticenterctl/src/preset/filesystem/tests/mod.rs create mode 100644 crates/noticenterctl/src/preset/filesystem/tests/secure.rs create mode 100644 crates/noticenterctl/src/preset/import/checks/tests/mod.rs create mode 100644 crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs diff --git a/crates/noticenterctl/src/preset/export/checks.rs b/crates/noticenterctl/src/preset/export/checks.rs index dfc5550f..189f9643 100644 --- a/crates/noticenterctl/src/preset/export/checks.rs +++ b/crates/noticenterctl/src/preset/export/checks.rs @@ -188,129 +188,4 @@ fn is_script_path(relative_path: &Path) -> bool { } #[cfg(test)] -mod tests { - use super::{ - capture_file_overrides, restore_file_overrides, - rewrite_host_specific_script_paths_in_sources, - }; - use crate::preset::config_root::PresetFileSource; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir() - .join(format!("unixnotis-script-rewrite-{name}-{stamp}-{serial}")); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write_bytes(&self, relative_path: &str, contents: &[u8]) -> PathBuf { - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(&path, contents).expect("write file"); - path - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - fn make_source(root: &TempDirGuard, relative_path: &str, contents: &[u8]) -> PresetFileSource { - let source_path = root.write_bytes(relative_path, contents); - let metadata = fs::metadata(&source_path).expect("metadata"); - #[cfg(unix)] - let mode = metadata.permissions().mode() & 0o777; - #[cfg(not(unix))] - let mode = 0o644; - - PresetFileSource { - relative_path: PathBuf::from(relative_path), - source_path, - size: metadata.len(), - mode, - contents_override: None, - } - } - - #[test] - fn script_rewrite_skips_non_utf8_files_instead_of_corrupting_them() { - let root = TempDirGuard::new("binary"); - let binary = make_source( - &root, - "scripts/demo-widget", - &[0xff, 0xfe, 0xfd, 0x00, b'/', b'h', b'o', b'm', b'e'], - ); - let original_size = binary.size; - let mut files = vec![binary]; - - let leaks = rewrite_host_specific_script_paths_in_sources(&root.path, &mut files) - .expect("rewrite check"); - - assert!(leaks.is_empty()); - assert!(files[0].contents_override.is_none()); - assert_eq!(files[0].size, original_size); - } - - #[test] - fn script_rewrite_reports_all_matched_needles_for_one_file() { - let root = TempDirGuard::new("multi-needle"); - let root_text = root.path.display().to_string(); - let script = format!( - "#!/bin/sh\nprintf '%s\\n' \"{root_text}/assets/a.png\"\nprintf '%s\\n' \"file://{root_text}/assets/b.png\"\n" - ); - let mut files = vec![make_source(&root, "scripts/demo-widget", script.as_bytes())]; - - let leaks = rewrite_host_specific_script_paths_in_sources(&root.path, &mut files) - .expect("rewrite check"); - - assert_eq!(leaks.len(), 1); - assert_eq!(leaks[0].needles.len(), 2); - assert!(files[0] - .contents_override - .as_ref() - .expect("override bytes") - .windows(b"${XDG_CONFIG_HOME:-$HOME/.config}/unixnotis".len()) - .any(|window| window == b"${XDG_CONFIG_HOME:-$HOME/.config}/unixnotis")); - } - - #[test] - fn restore_file_overrides_recovers_size_and_bytes_without_metadata() { - let root = TempDirGuard::new("restore"); - let mut files = vec![make_source( - &root, - "scripts/demo-widget", - b"#!/bin/sh\necho ok\n", - )]; - let snapshots = capture_file_overrides(&files); - - files[0].size = 999; - files[0].contents_override = Some(b"changed".to_vec()); - fs::remove_file(&files[0].source_path).expect("remove source file"); - - restore_file_overrides(&mut files, &snapshots); - - assert_eq!(files[0].size, snapshots[0].size); - assert_eq!(files[0].contents_override, snapshots[0].contents_override); - } -} +mod tests; diff --git a/crates/noticenterctl/src/preset/export/checks/tests/mod.rs b/crates/noticenterctl/src/preset/export/checks/tests/mod.rs new file mode 100644 index 00000000..098d49cd --- /dev/null +++ b/crates/noticenterctl/src/preset/export/checks/tests/mod.rs @@ -0,0 +1,123 @@ +use super::{ + capture_file_overrides, restore_file_overrides, rewrite_host_specific_script_paths_in_sources, +}; +use crate::preset::config_root::PresetFileSource; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("unixnotis-script-rewrite-{name}-{stamp}-{serial}")); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write_bytes(&self, relative_path: &str, contents: &[u8]) -> PathBuf { + let path = self.path.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dirs"); + } + fs::write(&path, contents).expect("write file"); + path + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn make_source(root: &TempDirGuard, relative_path: &str, contents: &[u8]) -> PresetFileSource { + let source_path = root.write_bytes(relative_path, contents); + let metadata = fs::metadata(&source_path).expect("metadata"); + #[cfg(unix)] + let mode = metadata.permissions().mode() & 0o777; + #[cfg(not(unix))] + let mode = 0o644; + + PresetFileSource { + relative_path: PathBuf::from(relative_path), + source_path, + size: metadata.len(), + mode, + contents_override: None, + } +} + +#[test] +fn script_rewrite_skips_non_utf8_files_instead_of_corrupting_them() { + let root = TempDirGuard::new("binary"); + let binary = make_source( + &root, + "scripts/demo-widget", + &[0xff, 0xfe, 0xfd, 0x00, b'/', b'h', b'o', b'm', b'e'], + ); + let original_size = binary.size; + let mut files = vec![binary]; + + let leaks = rewrite_host_specific_script_paths_in_sources(&root.path, &mut files) + .expect("rewrite check"); + + assert!(leaks.is_empty()); + assert!(files[0].contents_override.is_none()); + assert_eq!(files[0].size, original_size); +} + +#[test] +fn script_rewrite_reports_all_matched_needles_for_one_file() { + let root = TempDirGuard::new("multi-needle"); + let root_text = root.path.display().to_string(); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"{root_text}/assets/a.png\"\nprintf '%s\\n' \"file://{root_text}/assets/b.png\"\n" + ); + let mut files = vec![make_source(&root, "scripts/demo-widget", script.as_bytes())]; + + let leaks = rewrite_host_specific_script_paths_in_sources(&root.path, &mut files) + .expect("rewrite check"); + + assert_eq!(leaks.len(), 1); + assert_eq!(leaks[0].needles.len(), 2); + assert!(files[0] + .contents_override + .as_ref() + .expect("override bytes") + .windows(b"${XDG_CONFIG_HOME:-$HOME/.config}/unixnotis".len()) + .any(|window| window == b"${XDG_CONFIG_HOME:-$HOME/.config}/unixnotis")); +} + +#[test] +fn restore_file_overrides_recovers_size_and_bytes_without_metadata() { + let root = TempDirGuard::new("restore"); + let mut files = vec![make_source( + &root, + "scripts/demo-widget", + b"#!/bin/sh\necho ok\n", + )]; + let snapshots = capture_file_overrides(&files); + + files[0].size = 999; + files[0].contents_override = Some(b"changed".to_vec()); + fs::remove_file(&files[0].source_path).expect("remove source file"); + + restore_file_overrides(&mut files, &snapshots); + + assert_eq!(files[0].size, snapshots[0].size); + assert_eq!(files[0].contents_override, snapshots[0].contents_override); +} diff --git a/crates/noticenterctl/src/preset/export/prompts.rs b/crates/noticenterctl/src/preset/export/prompts.rs index 82b8a97f..a477294e 100644 --- a/crates/noticenterctl/src/preset/export/prompts.rs +++ b/crates/noticenterctl/src/preset/export/prompts.rs @@ -293,119 +293,4 @@ fn format_host_specific_script_path_lines(leaked_refs: &[HostSpecificScriptLeak] } #[cfg(test)] -mod tests { - use super::{ - rewrite_host_specific_css_asset_refs_if_requested, - rewrite_host_specific_script_paths_if_requested, - }; - use crate::preset::config_root::CollectedConfigFiles; - use crate::preset::config_root::PresetFileSource; - use anyhow::anyhow; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir() - .join(format!("unixnotis-export-prompts-{name}-{stamp}-{serial}")); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &[u8]) -> PresetFileSource { - let source_path = self.path.join(relative_path); - if let Some(parent) = source_path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(&source_path, contents).expect("write file"); - let metadata = fs::metadata(&source_path).expect("metadata"); - #[cfg(unix)] - let mode = metadata.permissions().mode() & 0o777; - #[cfg(not(unix))] - let mode = 0o644; - - PresetFileSource { - relative_path: PathBuf::from(relative_path), - source_path, - size: metadata.len(), - mode, - contents_override: None, - } - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn css_prompt_error_restores_staged_bytes_and_size() { - let root = TempDirGuard::new("css-restore"); - let asset_path = root.path.join("assets/example.png"); - fs::create_dir_all(asset_path.parent().expect("asset parent")).expect("create asset dir"); - fs::write(&asset_path, b"png").expect("write asset"); - let original = format!( - ".panel {{ background-image: url(\"file://{}\"); }}\n", - asset_path.display() - ); - let file = root.write("base.css", original.as_bytes()); - let original_size = file.size; - let mut collected = CollectedConfigFiles { - files: vec![file], - ..CollectedConfigFiles::default() - }; - - let error = rewrite_host_specific_css_asset_refs_if_requested( - &root.path, - &mut collected, - |_leaks| Err(anyhow!("prompt failed")), - ) - .expect_err("prompt should fail"); - - assert!(error.to_string().contains("prompt failed")); - assert!(collected.files[0].contents_override.is_none()); - assert_eq!(collected.files[0].size, original_size); - } - - #[test] - fn script_prompt_error_restores_staged_bytes_and_size() { - let root = TempDirGuard::new("script-restore"); - let original = format!( - "#!/bin/sh\necho \"{}/assets/example.png\"\n", - root.path.display() - ); - let file = root.write("scripts/demo-widget", original.as_bytes()); - let original_size = file.size; - let mut collected = CollectedConfigFiles { - files: vec![file], - ..CollectedConfigFiles::default() - }; - - let error = - rewrite_host_specific_script_paths_if_requested(&root.path, &mut collected, |_leaks| { - Err(anyhow!("prompt failed")) - }) - .expect_err("prompt should fail"); - - assert!(error.to_string().contains("prompt failed")); - assert!(collected.files[0].contents_override.is_none()); - assert_eq!(collected.files[0].size, original_size); - } -} +mod tests; diff --git a/crates/noticenterctl/src/preset/export/prompts/tests/mod.rs b/crates/noticenterctl/src/preset/export/prompts/tests/mod.rs new file mode 100644 index 00000000..94806f42 --- /dev/null +++ b/crates/noticenterctl/src/preset/export/prompts/tests/mod.rs @@ -0,0 +1,112 @@ +use super::{ + rewrite_host_specific_css_asset_refs_if_requested, + rewrite_host_specific_script_paths_if_requested, +}; +use crate::preset::config_root::{CollectedConfigFiles, PresetFileSource}; +use anyhow::anyhow; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("unixnotis-export-prompts-{name}-{stamp}-{serial}")); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write(&self, relative_path: &str, contents: &[u8]) -> PresetFileSource { + let source_path = self.path.join(relative_path); + if let Some(parent) = source_path.parent() { + fs::create_dir_all(parent).expect("create parent dirs"); + } + fs::write(&source_path, contents).expect("write file"); + let metadata = fs::metadata(&source_path).expect("metadata"); + #[cfg(unix)] + let mode = metadata.permissions().mode() & 0o777; + #[cfg(not(unix))] + let mode = 0o644; + + PresetFileSource { + relative_path: PathBuf::from(relative_path), + source_path, + size: metadata.len(), + mode, + contents_override: None, + } + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn css_prompt_error_restores_staged_bytes_and_size() { + let root = TempDirGuard::new("css-restore"); + let asset_path = root.path.join("assets/example.png"); + fs::create_dir_all(asset_path.parent().expect("asset parent")).expect("create asset dir"); + fs::write(&asset_path, b"png").expect("write asset"); + let original = format!( + ".panel {{ background-image: url(\"file://{}\"); }}\n", + asset_path.display() + ); + let file = root.write("base.css", original.as_bytes()); + let original_size = file.size; + let mut collected = CollectedConfigFiles { + files: vec![file], + ..CollectedConfigFiles::default() + }; + + let error = + rewrite_host_specific_css_asset_refs_if_requested(&root.path, &mut collected, |_leaks| { + Err(anyhow!("prompt failed")) + }) + .expect_err("prompt should fail"); + + assert!(error.to_string().contains("prompt failed")); + assert!(collected.files[0].contents_override.is_none()); + assert_eq!(collected.files[0].size, original_size); +} + +#[test] +fn script_prompt_error_restores_staged_bytes_and_size() { + let root = TempDirGuard::new("script-restore"); + let original = format!( + "#!/bin/sh\necho \"{}/assets/example.png\"\n", + root.path.display() + ); + let file = root.write("scripts/demo-widget", original.as_bytes()); + let original_size = file.size; + let mut collected = CollectedConfigFiles { + files: vec![file], + ..CollectedConfigFiles::default() + }; + + let error = + rewrite_host_specific_script_paths_if_requested(&root.path, &mut collected, |_leaks| { + Err(anyhow!("prompt failed")) + }) + .expect_err("prompt should fail"); + + assert!(error.to_string().contains("prompt failed")); + assert!(collected.files[0].contents_override.is_none()); + assert_eq!(collected.files[0].size, original_size); +} diff --git a/crates/noticenterctl/src/preset/filesystem.rs b/crates/noticenterctl/src/preset/filesystem.rs index ed66f4a2..aacca67a 100644 --- a/crates/noticenterctl/src/preset/filesystem.rs +++ b/crates/noticenterctl/src/preset/filesystem.rs @@ -3,9 +3,7 @@ //! Keeps filesystem validation and secure write helpers grouped under one tree //! so callers can import one module name instead of a flat list of files -#[path = "filesystem/checks.rs"] mod checks; -#[path = "filesystem/secure.rs"] mod secure; pub(super) use self::checks::{ @@ -16,3 +14,6 @@ pub(super) use self::secure::{ remove_empty_relative_dirs_secure, remove_relative_dir_secure, remove_relative_file_secure, write_relative_file_atomic_secure, }; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/preset/filesystem/checks.rs b/crates/noticenterctl/src/preset/filesystem/checks.rs index e678192b..f02a4b28 100644 --- a/crates/noticenterctl/src/preset/filesystem/checks.rs +++ b/crates/noticenterctl/src/preset/filesystem/checks.rs @@ -104,99 +104,3 @@ pub(crate) fn ensure_dir_fd_matches_live_path(root_dir: &OwnedFd, live_path: &Pa Ok(()) } - -#[cfg(test)] -mod tests { - #[cfg(target_os = "linux")] - use super::ensure_dir_fd_matches_live_path; - use super::{ensure_no_symlink_ancestors, ensure_safe_target_path}; - #[cfg(target_os = "linux")] - use crate::preset::filesystem::open_secure_dir_all; - use std::fs; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-preset-filesystem-checks-{}-{}-{}", - name, stamp, serial - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[cfg(unix)] - #[test] - fn ensure_no_symlink_ancestors_rejects_symlinked_parent_path() { - // A symlinked ancestor can redirect the whole config root outside the expected tree - let root = TempDirGuard::new("symlink-ancestor"); - let real_xdg = root.path.join("real-xdg"); - let linked_xdg = root.path.join("linked-xdg"); - fs::create_dir_all(real_xdg.join("unixnotis")).expect("create real config dir"); - std::os::unix::fs::symlink(&real_xdg, &linked_xdg).expect("create xdg symlink"); - - let error = - ensure_no_symlink_ancestors(&linked_xdg.join("unixnotis")).expect_err("reject symlink"); - assert!(error - .to_string() - .contains("config directory path goes through a symlink")); - } - - #[cfg(unix)] - #[test] - fn ensure_safe_target_path_rejects_symlinked_child_path() { - // A symlink inside the config tree must not be accepted as a real write target - let root = TempDirGuard::new("symlink-child"); - let config_dir = root.path.join("unixnotis"); - let outside_dir = root.path.join("outside"); - fs::create_dir_all(&config_dir).expect("create config dir"); - fs::create_dir_all(&outside_dir).expect("create outside dir"); - std::os::unix::fs::symlink(&outside_dir, config_dir.join("assets")) - .expect("create assets symlink"); - - let error = ensure_safe_target_path(&config_dir, Path::new("assets/bg.png")) - .expect_err("reject symlinked child"); - assert!(error - .to_string() - .contains("leaves the UnixNotis config directory through a symlink")); - } - - #[cfg(target_os = "linux")] - #[test] - fn ensure_dir_fd_matches_live_path_rejects_root_move() { - // A moved config root should not keep accepting writes through an old directory fd - let root = TempDirGuard::new("root-move"); - let xdg = root.path.join("xdg"); - let config_dir = xdg.join("unixnotis"); - let moved_dir = root.path.join("moved-unixnotis"); - fs::create_dir_all(&config_dir).expect("create config dir"); - - let config_root_fd = open_secure_dir_all(&config_dir).expect("open secure root"); - fs::rename(&config_dir, &moved_dir).expect("move config dir"); - fs::create_dir_all(&config_dir).expect("recreate config dir path"); - - let error = ensure_dir_fd_matches_live_path(&config_root_fd, &config_dir) - .expect_err("reject moved config root"); - assert!(error.to_string().contains("changed during import")); - } -} diff --git a/crates/noticenterctl/src/preset/filesystem/secure.rs b/crates/noticenterctl/src/preset/filesystem/secure.rs index 6f6b9a20..39f3a932 100644 --- a/crates/noticenterctl/src/preset/filesystem/secure.rs +++ b/crates/noticenterctl/src/preset/filesystem/secure.rs @@ -332,63 +332,3 @@ fn secure_anchor_resolve_flags() -> ResolveFlags { fn mode_from_bits(mode: u32) -> Mode { Mode::from_raw_mode(mode) } - -#[cfg(test)] -mod tests { - use super::{open_secure_dir_all, write_relative_file_atomic_secure}; - use std::fs; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-preset-filesystem-secure-{}-{}-{}", - name, stamp, serial - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write(&self, relative_path: &str, contents: &str) { - // Plain test writes keep the fixture setup simple and separate from the secure helpers - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create parent dirs"); - } - fs::write(path, contents).expect("write file"); - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn secure_atomic_write_replaces_existing_file() { - // Secure writes should keep the final file in place with new contents - let root = TempDirGuard::new("atomic"); - let target = root.path.join("scripts/run.sh"); - root.write("scripts/run.sh", "old"); - - let root_fd = open_secure_dir_all(&root.path).expect("open secure root"); - write_relative_file_atomic_secure(&root_fd, Path::new("scripts/run.sh"), b"new", 0o755) - .expect("write file"); - - assert_eq!(fs::read_to_string(&target).expect("read file"), "new"); - } -} diff --git a/crates/noticenterctl/src/preset/filesystem/tests/checks.rs b/crates/noticenterctl/src/preset/filesystem/tests/checks.rs new file mode 100644 index 00000000..d077e20d --- /dev/null +++ b/crates/noticenterctl/src/preset/filesystem/tests/checks.rs @@ -0,0 +1,92 @@ +#[cfg(target_os = "linux")] +use super::super::ensure_dir_fd_matches_live_path; +use super::super::{ensure_no_symlink_ancestors, ensure_safe_target_path}; +#[cfg(target_os = "linux")] +use crate::preset::filesystem::open_secure_dir_all; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "unixnotis-preset-filesystem-checks-{}-{}-{}", + name, stamp, serial + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[cfg(unix)] +#[test] +fn ensure_no_symlink_ancestors_rejects_symlinked_parent_path() { + // A symlinked ancestor can redirect the whole config root outside the expected tree + let root = TempDirGuard::new("symlink-ancestor"); + let real_xdg = root.path.join("real-xdg"); + let linked_xdg = root.path.join("linked-xdg"); + fs::create_dir_all(real_xdg.join("unixnotis")).expect("create real config dir"); + std::os::unix::fs::symlink(&real_xdg, &linked_xdg).expect("create xdg symlink"); + + let error = + ensure_no_symlink_ancestors(&linked_xdg.join("unixnotis")).expect_err("reject symlink"); + assert!(error + .to_string() + .contains("config directory path goes through a symlink")); +} + +#[cfg(unix)] +#[test] +fn ensure_safe_target_path_rejects_symlinked_child_path() { + // A symlink inside the config tree must not be accepted as a real write target + let root = TempDirGuard::new("symlink-child"); + let config_dir = root.path.join("unixnotis"); + let outside_dir = root.path.join("outside"); + fs::create_dir_all(&config_dir).expect("create config dir"); + fs::create_dir_all(&outside_dir).expect("create outside dir"); + std::os::unix::fs::symlink(&outside_dir, config_dir.join("assets")) + .expect("create assets symlink"); + + let error = ensure_safe_target_path(&config_dir, Path::new("assets/bg.png")) + .expect_err("reject symlinked child"); + assert!(error + .to_string() + .contains("leaves the UnixNotis config directory through a symlink")); +} + +#[cfg(target_os = "linux")] +#[test] +fn ensure_dir_fd_matches_live_path_rejects_root_move() { + // A moved config root should not keep accepting writes through an old directory fd + let root = TempDirGuard::new("root-move"); + let xdg = root.path.join("xdg"); + let config_dir = xdg.join("unixnotis"); + let moved_dir = root.path.join("moved-unixnotis"); + fs::create_dir_all(&config_dir).expect("create config dir"); + + let config_root_fd = open_secure_dir_all(&config_dir).expect("open secure root"); + fs::rename(&config_dir, &moved_dir).expect("move config dir"); + fs::create_dir_all(&config_dir).expect("recreate config dir path"); + + let error = ensure_dir_fd_matches_live_path(&config_root_fd, &config_dir) + .expect_err("reject moved config root"); + assert!(error.to_string().contains("changed during import")); +} diff --git a/crates/noticenterctl/src/preset/filesystem/tests/mod.rs b/crates/noticenterctl/src/preset/filesystem/tests/mod.rs new file mode 100644 index 00000000..4f6ea880 --- /dev/null +++ b/crates/noticenterctl/src/preset/filesystem/tests/mod.rs @@ -0,0 +1,2 @@ +mod checks; +mod secure; diff --git a/crates/noticenterctl/src/preset/filesystem/tests/secure.rs b/crates/noticenterctl/src/preset/filesystem/tests/secure.rs new file mode 100644 index 00000000..1f1224da --- /dev/null +++ b/crates/noticenterctl/src/preset/filesystem/tests/secure.rs @@ -0,0 +1,56 @@ +use super::super::{open_secure_dir_all, write_relative_file_atomic_secure}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +struct TempDirGuard { + path: PathBuf, +} + +impl TempDirGuard { + fn new(name: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "unixnotis-preset-filesystem-secure-{}-{}-{}", + name, stamp, serial + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write(&self, relative_path: &str, contents: &str) { + // Plain test writes keep the fixture setup simple and separate from the secure helpers + let path = self.path.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dirs"); + } + fs::write(path, contents).expect("write file"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn secure_atomic_write_replaces_existing_file() { + // Secure writes should keep the final file in place with new contents + let root = TempDirGuard::new("atomic"); + let target = root.path.join("scripts/run.sh"); + root.write("scripts/run.sh", "old"); + + let root_fd = open_secure_dir_all(&root.path).expect("open secure root"); + write_relative_file_atomic_secure(&root_fd, Path::new("scripts/run.sh"), b"new", 0o755) + .expect("write file"); + + assert_eq!(fs::read_to_string(&target).expect("read file"), "new"); +} diff --git a/crates/noticenterctl/src/preset/import/checks.rs b/crates/noticenterctl/src/preset/import/checks.rs index 043d4e24..da40b85e 100644 --- a/crates/noticenterctl/src/preset/import/checks.rs +++ b/crates/noticenterctl/src/preset/import/checks.rs @@ -208,117 +208,4 @@ fn key_is_exec_slot(key: &str) -> bool { } #[cfg(test)] -mod tests { - use super::{ - collect_imported_exec_content, validate_imported_command_paths_stay_in_root, - validate_imported_theme_paths_stay_in_root, - }; - use crate::preset::archive::BundleFile; - use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - fn temp_root(name: &str) -> PathBuf { - // Unique absolute paths keep these lexical checks stable under parallel cargo runs - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "unixnotis-preset-import-checks-{}-{}-{}", - name, stamp, serial - )) - } - - #[test] - fn imported_theme_checks_reject_parent_traversal_targets() { - // `../` theme paths should be treated the same as any other root escape - let config_dir = temp_root("relative-escape"); - let config = b"[theme]\nbase_css = \"../escaped-base.css\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n"; - - let error = validate_imported_theme_paths_stay_in_root(&config_dir, config) - .expect_err("reject relative theme escape"); - - assert!(error - .to_string() - .contains("tries to leave the UnixNotis config directory")); - } - - #[test] - fn imported_command_checks_reject_absolute_plugin_command() { - // Shared presets should not carry explicit command paths that leave the config root - let config_dir = temp_root("outside-command"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - - let error = validate_imported_command_paths_stay_in_root(&config_dir, config) - .expect_err("reject outside command path"); - - assert!(error - .to_string() - .contains("points outside the UnixNotis config directory")); - } - - #[test] - fn imported_exec_collection_finds_command_bearing_config() { - let config = br#" -[theme] -base_css = "base.css" -[[widgets.stats]] -label = "Probe" -cmd = "scripts/check.sh" -"#; - - let content = collect_imported_exec_content(config, &[]).expect("collect exec content"); - - assert_eq!(content.commands.len(), 1); - assert_eq!(content.commands[0].slot, "widgets.stats[0].cmd"); - assert_eq!(content.commands[0].command, "scripts/check.sh"); - } - - #[test] - fn imported_exec_collection_finds_script_payloads() { - let config = br#" -[theme] -base_css = "base.css" -"#; - let bundle_files = vec![BundleFile { - relative_path: PathBuf::from("scripts/demo-widget"), - contents: b"#!/bin/sh\necho ok\n".to_vec(), - mode: 0o755, - }]; - - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect script payload"); - - assert_eq!(content.files.len(), 1); - assert_eq!( - content.files[0].relative_path, - PathBuf::from("scripts/demo-widget") - ); - } - - #[test] - fn imported_exec_collection_keeps_command_and_script_details() { - let config = br#" -[theme] -base_css = "base.css" -[[widgets.stats]] -label = "Probe" -cmd = "scripts/check.sh" -"#; - let bundle_files = vec![BundleFile { - relative_path: PathBuf::from("scripts/check.sh"), - contents: b"#!/bin/sh\necho ok\n".to_vec(), - mode: 0o755, - }]; - - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect trusted exec"); - - assert_eq!(content.commands.len(), 1); - assert_eq!(content.files.len(), 1); - } -} +mod tests; diff --git a/crates/noticenterctl/src/preset/import/checks/tests/mod.rs b/crates/noticenterctl/src/preset/import/checks/tests/mod.rs new file mode 100644 index 00000000..2f334726 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/checks/tests/mod.rs @@ -0,0 +1,112 @@ +use super::{ + collect_imported_exec_content, validate_imported_command_paths_stay_in_root, + validate_imported_theme_paths_stay_in_root, +}; +use crate::preset::archive::BundleFile; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +fn temp_root(name: &str) -> PathBuf { + // Unique absolute paths keep these lexical checks stable under parallel cargo runs + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "unixnotis-preset-import-checks-{}-{}-{}", + name, stamp, serial + )) +} + +#[test] +fn imported_theme_checks_reject_parent_traversal_targets() { + // `../` theme paths should be treated the same as any other root escape + let config_dir = temp_root("relative-escape"); + let config = b"[theme]\nbase_css = \"../escaped-base.css\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n"; + + let error = validate_imported_theme_paths_stay_in_root(&config_dir, config) + .expect_err("reject relative theme escape"); + + assert!(error + .to_string() + .contains("tries to leave the UnixNotis config directory")); +} + +#[test] +fn imported_command_checks_reject_absolute_plugin_command() { + // Shared presets should not carry explicit command paths that leave the config root + let config_dir = temp_root("outside-command"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; + + let error = validate_imported_command_paths_stay_in_root(&config_dir, config) + .expect_err("reject outside command path"); + + assert!(error + .to_string() + .contains("points outside the UnixNotis config directory")); +} + +#[test] +fn imported_exec_collection_finds_command_bearing_config() { + let config = br#" +[theme] +base_css = "base.css" +[[widgets.stats]] +label = "Probe" +cmd = "scripts/check.sh" +"#; + + let content = collect_imported_exec_content(config, &[]).expect("collect exec content"); + + assert_eq!(content.commands.len(), 1); + assert_eq!(content.commands[0].slot, "widgets.stats[0].cmd"); + assert_eq!(content.commands[0].command, "scripts/check.sh"); +} + +#[test] +fn imported_exec_collection_finds_script_payloads() { + let config = br#" +[theme] +base_css = "base.css" +"#; + let bundle_files = vec![BundleFile { + relative_path: PathBuf::from("scripts/demo-widget"), + contents: b"#!/bin/sh\necho ok\n".to_vec(), + mode: 0o755, + }]; + + let content = + collect_imported_exec_content(config, &bundle_files).expect("collect script payload"); + + assert_eq!(content.files.len(), 1); + assert_eq!( + content.files[0].relative_path, + PathBuf::from("scripts/demo-widget") + ); +} + +#[test] +fn imported_exec_collection_keeps_command_and_script_details() { + let config = br#" +[theme] +base_css = "base.css" +[[widgets.stats]] +label = "Probe" +cmd = "scripts/check.sh" +"#; + let bundle_files = vec![BundleFile { + relative_path: PathBuf::from("scripts/check.sh"), + contents: b"#!/bin/sh\necho ok\n".to_vec(), + mode: 0o755, + }]; + + let content = + collect_imported_exec_content(config, &bundle_files).expect("collect trusted exec"); + + assert_eq!(content.commands.len(), 1); + assert_eq!(content.files.len(), 1); +} diff --git a/crates/noticenterctl/src/preset/import/exec_review.rs b/crates/noticenterctl/src/preset/import/exec_review.rs index 84be320d..f77eead8 100644 --- a/crates/noticenterctl/src/preset/import/exec_review.rs +++ b/crates/noticenterctl/src/preset/import/exec_review.rs @@ -250,137 +250,4 @@ impl ReviewStyle { } #[cfg(test)] -mod tests { - use super::{ - finish_pager, pager_command_parts, pager_enables_raw_control, - render_exec_content_review_with_style, ReviewStyle, - }; - use crate::preset::import::checks::{ - ImportedExecCommand, ImportedExecContent, ImportedExecFile, - }; - use std::env; - use std::io; - use std::path::PathBuf; - use std::process::Command; - use std::sync::Mutex; - - // Pager tests mutate one process-global env var, so they need one tiny lock - static PAGER_ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn exec_review_renders_commands_and_files() { - let review = render_exec_content_review_with_style( - &ImportedExecContent { - commands: vec![ImportedExecCommand { - slot: "widgets.stats[0].cmd".to_string(), - command: "scripts/check.sh".to_string(), - }], - files: vec![ImportedExecFile { - relative_path: PathBuf::from("scripts/check.sh"), - contents: b"#!/bin/sh\necho ok\n".to_vec(), - mode: 0o755, - }], - }, - ReviewStyle { color: false }, - ); - - assert!(review.contains("widgets.stats[0].cmd = scripts/check.sh")); - assert!(review.contains("== scripts/check.sh (mode 755) ==")); - assert!(review.contains("#!/bin/sh")); - } - - #[test] - fn exec_review_style_can_add_color() { - let title = ReviewStyle { color: true }.title("review"); - assert!(title.contains("\u{1b}[1;36m")); - assert!(title.ends_with("\u{1b}[0m")); - } - - #[test] - fn pager_command_adds_raw_control_for_less() { - let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env"); - let original = env::var_os("PAGER"); - unsafe { - env::set_var("PAGER", "less -F"); - } - - let pager = pager_command_parts().expect("build pager"); - - match original { - Some(value) => unsafe { - env::set_var("PAGER", value); - }, - None => unsafe { - env::remove_var("PAGER"); - }, - } - - assert_eq!(pager, vec!["less", "-F", "-R"]); - } - - #[test] - fn pager_command_keeps_existing_raw_control_flag() { - assert!(pager_enables_raw_control(&[ - "less".to_string(), - "-FR".to_string() - ])); - assert!(pager_enables_raw_control(&[ - "less".to_string(), - "-R".to_string() - ])); - assert!(!pager_enables_raw_control(&[ - "less".to_string(), - "-F".to_string() - ])); - } - - #[test] - fn pager_command_respects_quoted_arguments() { - let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env"); - let original = env::var_os("PAGER"); - unsafe { - env::set_var("PAGER", "less --prompt='unixnotis review'"); - } - - let pager = pager_command_parts().expect("build pager"); - - match original { - Some(value) => unsafe { - env::set_var("PAGER", value); - }, - None => unsafe { - env::remove_var("PAGER"); - }, - } - - assert_eq!( - pager, - vec![ - "less".to_string(), - "--prompt=unixnotis review".to_string(), - "-R".to_string() - ] - ); - } - - #[test] - fn finish_pager_reaps_child_after_stdin_failure() { - let child = Command::new("sh") - .arg("-c") - .arg("exit 0") - .stdin(std::process::Stdio::piped()) - .spawn() - .expect("spawn pager"); - - let error = finish_pager( - child, - &["sh".to_string(), "-c".to_string(), "exit 0".to_string()], - Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")), - ) - .expect_err("stdin failure should surface"); - - assert!(error - .to_string() - .contains("write executable content review to pager")); - } -} +mod tests; diff --git a/crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs b/crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs new file mode 100644 index 00000000..a54b5989 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs @@ -0,0 +1,130 @@ +use super::{ + finish_pager, pager_command_parts, pager_enables_raw_control, + render_exec_content_review_with_style, ReviewStyle, +}; +use crate::preset::import::checks::{ImportedExecCommand, ImportedExecContent, ImportedExecFile}; +use std::env; +use std::io; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Mutex; + +// Pager tests mutate one process-global env var, so they need one tiny lock +static PAGER_ENV_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn exec_review_renders_commands_and_files() { + let review = render_exec_content_review_with_style( + &ImportedExecContent { + commands: vec![ImportedExecCommand { + slot: "widgets.stats[0].cmd".to_string(), + command: "scripts/check.sh".to_string(), + }], + files: vec![ImportedExecFile { + relative_path: PathBuf::from("scripts/check.sh"), + contents: b"#!/bin/sh\necho ok\n".to_vec(), + mode: 0o755, + }], + }, + ReviewStyle { color: false }, + ); + + assert!(review.contains("widgets.stats[0].cmd = scripts/check.sh")); + assert!(review.contains("== scripts/check.sh (mode 755) ==")); + assert!(review.contains("#!/bin/sh")); +} + +#[test] +fn exec_review_style_can_add_color() { + let title = ReviewStyle { color: true }.title("review"); + assert!(title.contains("\u{1b}[1;36m")); + assert!(title.ends_with("\u{1b}[0m")); +} + +#[test] +fn pager_command_adds_raw_control_for_less() { + let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env"); + let original = env::var_os("PAGER"); + unsafe { + env::set_var("PAGER", "less -F"); + } + + let pager = pager_command_parts().expect("build pager"); + + match original { + Some(value) => unsafe { + env::set_var("PAGER", value); + }, + None => unsafe { + env::remove_var("PAGER"); + }, + } + + assert_eq!(pager, vec!["less", "-F", "-R"]); +} + +#[test] +fn pager_command_keeps_existing_raw_control_flag() { + assert!(pager_enables_raw_control(&[ + "less".to_string(), + "-FR".to_string() + ])); + assert!(pager_enables_raw_control(&[ + "less".to_string(), + "-R".to_string() + ])); + assert!(!pager_enables_raw_control(&[ + "less".to_string(), + "-F".to_string() + ])); +} + +#[test] +fn pager_command_respects_quoted_arguments() { + let _guard = PAGER_ENV_LOCK.lock().expect("lock pager env"); + let original = env::var_os("PAGER"); + unsafe { + env::set_var("PAGER", "less --prompt='unixnotis review'"); + } + + let pager = pager_command_parts().expect("build pager"); + + match original { + Some(value) => unsafe { + env::set_var("PAGER", value); + }, + None => unsafe { + env::remove_var("PAGER"); + }, + } + + assert_eq!( + pager, + vec![ + "less".to_string(), + "--prompt=unixnotis review".to_string(), + "-R".to_string() + ] + ); +} + +#[test] +fn finish_pager_reaps_child_after_stdin_failure() { + let child = Command::new("sh") + .arg("-c") + .arg("exit 0") + .stdin(std::process::Stdio::piped()) + .spawn() + .expect("spawn pager"); + + let error = finish_pager( + child, + &["sh".to_string(), "-c".to_string(), "exit 0".to_string()], + Err(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")), + ) + .expect_err("stdin failure should surface"); + + assert!(error + .to_string() + .contains("write executable content review to pager")); +} From 4625a087455db7fbcb788410ac0c2dafea2042b0 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:40 -0500 Subject: [PATCH 29/65] noticenterctl: add binary cli integration tests Replace the single shallow entrypoint test with help and error-path integration tests. Verify clap handles help and invalid input locally before any daemon or D-Bus setup is needed. --- crates/noticenterctl/tests/errors.rs | 27 +++++++++++++++++ crates/noticenterctl/tests/help.rs | 43 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 crates/noticenterctl/tests/errors.rs create mode 100644 crates/noticenterctl/tests/help.rs diff --git a/crates/noticenterctl/tests/errors.rs b/crates/noticenterctl/tests/errors.rs new file mode 100644 index 00000000..320fdfc8 --- /dev/null +++ b/crates/noticenterctl/tests/errors.rs @@ -0,0 +1,27 @@ +use std::process::Command; + +#[test] +fn binary_rejects_unknown_command_before_dbus_setup() { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .arg("definitely-not-a-command") + .output() + .expect("run noticenterctl invalid command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("error output is utf8"); + assert!(stderr.contains("unrecognized subcommand")); + assert!(stderr.contains("definitely-not-a-command")); +} + +#[test] +fn binary_rejects_invalid_dnd_state_before_dbus_setup() { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["dnd", "maybe"]) + .output() + .expect("run noticenterctl invalid dnd state"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("error output is utf8"); + assert!(stderr.contains("invalid value")); + assert!(stderr.contains("maybe")); +} diff --git a/crates/noticenterctl/tests/help.rs b/crates/noticenterctl/tests/help.rs new file mode 100644 index 00000000..82e11c96 --- /dev/null +++ b/crates/noticenterctl/tests/help.rs @@ -0,0 +1,43 @@ +use std::process::Command; + +#[test] +fn binary_help_prints_cli_usage() { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .arg("--help") + .output() + .expect("run noticenterctl --help"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("css-check")); + assert!(stdout.contains("preset")); +} + +#[test] +fn binary_open_panel_help_lists_optional_debug_flag() { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["open-panel", "--help"]) + .output() + .expect("run noticenterctl open-panel --help"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); + assert!(stdout.contains("--debug")); + assert!(stdout.contains("critical")); + assert!(stdout.contains("verbose")); +} + +#[test] +fn binary_preset_help_lists_local_bundle_commands() { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["preset", "--help"]) + .output() + .expect("run noticenterctl preset --help"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); + assert!(stdout.contains("export")); + assert!(stdout.contains("import")); + assert!(stdout.contains("inspect")); +} From 0e88d35161549973917b3edb1ea293e05d5e5bb0 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:32:44 -0500 Subject: [PATCH 30/65] daemon: split store root implementation Keep store/mod.rs as the module surface and move implementation into focused core, dnd, and types modules. Preserve the renamed store submodules and make store/tests/mod.rs the organized test index. --- crates/unixnotis-daemon/src/store/core.rs | 111 +++++++ crates/unixnotis-daemon/src/store/dnd.rs | 56 ++++ .../src/store/inhibitor_api.rs | 2 +- crates/unixnotis-daemon/src/store/mod.rs | 273 ++---------------- .../unixnotis-daemon/src/store/tests/dnd.rs | 4 +- .../src/store/tests/{support.rs => mod.rs} | 2 +- crates/unixnotis-daemon/src/store/types.rs | 77 +++++ 7 files changed, 265 insertions(+), 260 deletions(-) create mode 100644 crates/unixnotis-daemon/src/store/core.rs create mode 100644 crates/unixnotis-daemon/src/store/dnd.rs rename crates/unixnotis-daemon/src/store/tests/{support.rs => mod.rs} (97%) create mode 100644 crates/unixnotis-daemon/src/store/types.rs diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs new file mode 100644 index 00000000..ff894e93 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/core.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use indexmap::IndexMap; +use tracing::{debug, warn}; +use unixnotis_core::{Config, NotificationView}; + +use super::{DndStateStore, HistoryStore, NotificationStore, DND_STATE_VERSION}; + +#[cfg(test)] +use std::path::PathBuf; + +impl NotificationStore { + pub fn new(config: Config) -> Self { + // Default constructor attempts to bind persistence to XDG state dir + let dnd_state_store = DndStateStore::new(); + Self::new_with_state_store(config, dnd_state_store) + } + + #[cfg(test)] + pub(crate) fn new_with_state_dir(config: Config, state_dir: PathBuf) -> Self { + // Test helper with explicit state path and no env mutations + let dnd_state_store = Some(DndStateStore::from_state_dir(state_dir)); + Self::new_with_state_store(config, dnd_state_store) + } + + fn new_with_state_store(config: Config, dnd_state_store: Option) -> Self { + // Config default is used unless a valid persisted value overrides it + let mut dnd_enabled = config.general.dnd_default; + if let Some(store) = dnd_state_store.as_ref() { + match store.load() { + Ok(Some(state)) if state.version == DND_STATE_VERSION => { + // Versioned state prevents accidental decode of incompatible formats + dnd_enabled = state.dnd_enabled; + debug!(dnd_enabled, "loaded persisted do-not-disturb state"); + } + Ok(Some(state)) => { + // Unknown version is ignored but logged for troubleshooting + warn!( + version = state.version, + "unsupported dnd state version; ignoring persisted value" + ); + } + Ok(None) => {} + Err(err) => { + // Persistence failures must never block daemon startup + warn!(?err, "failed to read persisted do-not-disturb state"); + } + } + } + + Self { + // IDs start at 1 to preserve protocol expectations + next_id: 1, + dnd_enabled, + dnd_revision: 0, + config, + active: IndexMap::new(), + history: HistoryStore::new(), + expirations: HashMap::new(), + dnd_state_store, + next_inhibitor_id: 1, + inhibitors: HashMap::new(), + inhibited: false, + inhibitor_count: 0, + } + } + + pub fn config(&self) -> &Config { + &self.config + } + + pub fn inhibited(&self) -> bool { + self.inhibited + } + + pub fn inhibitor_count(&self) -> u32 { + self.inhibitor_count + } + + pub fn list_active(&self) -> Vec { + // Reverse iteration returns newest entries first for panel rendering + self.active + .values() + .rev() + .map(|notification| notification.to_list_view()) + .collect() + } + + pub fn list_history(&self) -> Vec { + // HistoryStore already returns newest first + self.history.list_views() + } + + pub fn active_notification_view(&self, id: u32) -> Option { + // Active rows use the richer popup-oriented view because add/update signals + // are consumed by trusted UIs that may need current image payloads + self.active + .get(&id) + .map(|notification| notification.to_view()) + } + + pub fn history_len(&self) -> usize { + // Exposed for diagnostics and test assertions + self.history.len() + } + + pub fn clear_history(&mut self) { + // Explicit history wipe used by CLI and control commands + self.history.clear(); + } +} diff --git a/crates/unixnotis-daemon/src/store/dnd.rs b/crates/unixnotis-daemon/src/store/dnd.rs new file mode 100644 index 00000000..2d1ba6e2 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd.rs @@ -0,0 +1,56 @@ +use super::{DndWrite, NotificationStore}; + +impl NotificationStore { + pub fn dnd_enabled(&self) -> bool { + self.dnd_enabled + } + + pub fn set_dnd(&mut self, enabled: bool) -> DndWrite { + // Shared mutation path keeps set and toggle behavior aligned + self.write_dnd(enabled) + } + + pub fn toggle_dnd(&mut self) -> DndWrite { + // Toggle and write happen under one lock at the call site + self.write_dnd(!self.dnd_enabled) + } + + pub(crate) fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { + // No-op writes do not need rollback + if !write.changed { + return false; + } + // Guarded rollback avoids clobbering newer successful writes + if self.dnd_revision != write.revision || self.dnd_enabled != write.current { + return false; + } + self.dnd_enabled = write.previous; + // Rollback is also a state transition + self.dnd_revision = self.dnd_revision.saturating_add(1); + true + } + + fn write_dnd(&mut self, enabled: bool) -> DndWrite { + let previous = self.dnd_enabled; + if previous == enabled { + // Returning unchanged avoids unnecessary disk writes and state signals + return DndWrite { + changed: false, + previous, + current: previous, + revision: self.dnd_revision, + persist: None, + }; + } + self.dnd_enabled = enabled; + self.dnd_revision = self.dnd_revision.saturating_add(1); + // Persist outside the store lock so notification flow stays responsive + DndWrite { + changed: true, + previous, + current: enabled, + revision: self.dnd_revision, + persist: self.dnd_state_store.clone(), + } + } +} diff --git a/crates/unixnotis-daemon/src/store/inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitor_api.rs index e84eeb8e..dae26ebb 100644 --- a/crates/unixnotis-daemon/src/store/inhibitor_api.rs +++ b/crates/unixnotis-daemon/src/store/inhibitor_api.rs @@ -1,6 +1,6 @@ use unixnotis_core::InhibitMode; -use super::store_inhibit::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; +use super::inhibit::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; use super::NotificationStore; impl NotificationStore { diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index 49d4b23c..fb7519c9 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -1,265 +1,26 @@ //! Notification store with ordering, history, and suppression policies -//! -//! store.rs stays as the main entry point and wires focused modules under store/ // Focused modules keep policy and lifecycle logic isolated and easier to test -#[path = "store/store_history.rs"] -mod store_history; -#[path = "store/store_identity.rs"] -mod store_identity; -#[path = "store/store_inhibit.rs"] -mod store_inhibit; -#[path = "store/store_inhibitor_api.rs"] -mod store_inhibitor_api; -#[path = "store/store_lifecycle.rs"] -mod store_lifecycle; -#[path = "store/store_rules.rs"] -mod store_rules; -#[path = "store/store_state.rs"] -mod store_state; - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; - -use indexmap::IndexMap; -use tracing::{debug, warn}; -use unixnotis_core::{Config, Notification, NotificationView}; +mod core; +mod dnd; +mod history; +mod identity; +mod inhibit; +mod inhibitor_api; +mod lifecycle; +mod rules; +mod state; +mod types; // Internal store primitives used by the main NotificationStore type -use store_history::HistoryStore; -use store_inhibit::Inhibitor; -use store_state::{DndStateStore, DND_STATE_VERSION}; - -#[cfg(test)] -use std::path::PathBuf; +use history::HistoryStore; +use inhibit::Inhibitor; +use state::{DndStateStore, DND_STATE_VERSION}; +pub(crate) use types::DndWrite; +pub use types::{DismissOutcome, InsertOutcome, NotificationStore}; #[cfg(test)] -use store_rules::contains_ci; - -/// Mutable notification state owned by the daemon -pub struct NotificationStore { - // Immutable runtime config snapshot - config: Config, - // Next candidate id for allocation - next_id: u32, - // Active notifications in insertion order - active: IndexMap>, - // Archived notifications with bounded retention - history: HistoryStore, - // Optional expiration deadline per active id - expirations: HashMap, - // Effective DND switch after loading persisted state - dnd_enabled: bool, - // Monotonic in-memory revision for DND writes - dnd_revision: u64, - // Optional persistence layer for DND; absent store keeps behavior in-memory - dnd_state_store: Option, - // Token counter for inhibitors; never reused in a process - next_inhibitor_id: u64, - // Active inhibitors keyed by token for quick lookup/removal - inhibitors: HashMap, - // Cached flags avoid rescanning inhibitors on every notification - inhibited: bool, - inhibitor_count: u32, -} - -pub struct InsertOutcome { - // Stored notification instance returned to callers - pub notification: Arc, - // True when insertion replaced an existing id - pub replaced: bool, - // Whether popup rendering is allowed for this payload - pub show_popup: bool, - // Whether sound playback is allowed for this payload - pub allow_sound: bool, - // Active ids evicted because max_active was exceeded - pub evicted: Vec, - // True when payload was intentionally dropped by inhibit mode - pub dropped: bool, -} - -pub(crate) struct DndWrite { - // True when the in-memory DND value changed - pub(crate) changed: bool, - // Value seen before this write - pub(crate) previous: bool, - // Value written by this operation - pub(crate) current: bool, - // Monotonic revision captured for guarded rollback - pub(crate) revision: u64, - // Persistence backend used outside the store lock - pub(crate) persist: Option, -} - -pub struct DismissOutcome { - // True when an active entry was removed - pub removed_active: bool, - // True when a history entry was removed - pub removed_history: bool, -} - -impl DismissOutcome { - pub fn removed_any(&self) -> bool { - // Convenience helper for callers that only need yes/no - self.removed_active || self.removed_history - } -} - -impl NotificationStore { - pub fn new(config: Config) -> Self { - // Default constructor attempts to bind persistence to XDG state dir - let dnd_state_store = DndStateStore::new(); - Self::new_with_state_store(config, dnd_state_store) - } - - #[cfg(test)] - pub(crate) fn new_with_state_dir(config: Config, state_dir: PathBuf) -> Self { - // Test helper with explicit state path and no env mutations - let dnd_state_store = Some(DndStateStore::from_state_dir(state_dir)); - Self::new_with_state_store(config, dnd_state_store) - } - - fn new_with_state_store(config: Config, dnd_state_store: Option) -> Self { - // Config default is used unless a valid persisted value overrides it - let mut dnd_enabled = config.general.dnd_default; - if let Some(store) = dnd_state_store.as_ref() { - match store.load() { - Ok(Some(state)) if state.version == DND_STATE_VERSION => { - // Versioned state prevents accidental decode of incompatible formats - dnd_enabled = state.dnd_enabled; - debug!(dnd_enabled, "loaded persisted do-not-disturb state"); - } - Ok(Some(state)) => { - // Unknown version is ignored but logged for troubleshooting - warn!( - version = state.version, - "unsupported dnd state version; ignoring persisted value" - ); - } - Ok(None) => {} - Err(err) => { - // Persistence failures must never block daemon startup - warn!(?err, "failed to read persisted do-not-disturb state"); - } - } - } - - Self { - // IDs start at 1 to preserve protocol expectations - next_id: 1, - dnd_enabled, - dnd_revision: 0, - config, - active: IndexMap::new(), - history: HistoryStore::new(), - expirations: HashMap::new(), - dnd_state_store, - next_inhibitor_id: 1, - inhibitors: HashMap::new(), - inhibited: false, - inhibitor_count: 0, - } - } - - pub fn config(&self) -> &Config { - &self.config - } - - pub fn dnd_enabled(&self) -> bool { - self.dnd_enabled - } - - pub fn set_dnd(&mut self, enabled: bool) -> DndWrite { - // Shared mutation path keeps set and toggle behavior aligned - self.write_dnd(enabled) - } - - pub fn toggle_dnd(&mut self) -> DndWrite { - // Toggle and write happen under one lock at the call site - self.write_dnd(!self.dnd_enabled) - } - - pub(crate) fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { - // No-op writes do not need rollback - if !write.changed { - return false; - } - // Guarded rollback avoids clobbering newer successful writes - if self.dnd_revision != write.revision || self.dnd_enabled != write.current { - return false; - } - self.dnd_enabled = write.previous; - // Rollback is also a state transition - self.dnd_revision = self.dnd_revision.saturating_add(1); - true - } - - fn write_dnd(&mut self, enabled: bool) -> DndWrite { - let previous = self.dnd_enabled; - if previous == enabled { - // Returning unchanged avoids unnecessary disk writes and state signals - return DndWrite { - changed: false, - previous, - current: previous, - revision: self.dnd_revision, - persist: None, - }; - } - self.dnd_enabled = enabled; - self.dnd_revision = self.dnd_revision.saturating_add(1); - // Persist outside the store lock so notification flow stays responsive - DndWrite { - changed: true, - previous, - current: enabled, - revision: self.dnd_revision, - persist: self.dnd_state_store.clone(), - } - } - - pub fn inhibited(&self) -> bool { - self.inhibited - } - - pub fn inhibitor_count(&self) -> u32 { - self.inhibitor_count - } - - pub fn list_active(&self) -> Vec { - // Reverse iteration returns newest entries first for panel rendering - self.active - .values() - .rev() - .map(|notification| notification.to_list_view()) - .collect() - } - - pub fn list_history(&self) -> Vec { - // HistoryStore already returns newest first - self.history.list_views() - } - - pub fn active_notification_view(&self, id: u32) -> Option { - // Active rows use the richer popup-oriented view because add/update signals - // are consumed by trusted UIs that may need current image payloads - self.active - .get(&id) - .map(|notification| notification.to_view()) - } - - pub fn history_len(&self) -> usize { - // Exposed for diagnostics and test assertions - self.history.len() - } - - pub fn clear_history(&mut self) { - // Explicit history wipe used by CLI and control commands - self.history.clear(); - } -} +use rules::contains_ci; #[cfg(test)] -#[path = "store/store_tests/support.rs"] -mod store_tests; +mod tests; diff --git a/crates/unixnotis-daemon/src/store/tests/dnd.rs b/crates/unixnotis-daemon/src/store/tests/dnd.rs index b4ace410..9a8ceb4a 100644 --- a/crates/unixnotis-daemon/src/store/tests/dnd.rs +++ b/crates/unixnotis-daemon/src/store/tests/dnd.rs @@ -31,7 +31,7 @@ fn dnd_state_invalid_payload_falls_back_to_default() { #[test] fn dnd_state_store_load_returns_none_when_file_is_missing() { let state_dir = make_temp_state_dir("dnd-missing-file"); - let state_store = super::super::store_state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); // A first run has no state file yet, which should not be treated as corruption let loaded = state_store @@ -47,7 +47,7 @@ fn dnd_state_store_load_reports_non_missing_filesystem_errors() { let state_dir = make_temp_state_dir("dnd-path-is-directory"); let path = state_dir.join("unixnotis").join(DND_STATE_FILE); std::fs::create_dir_all(&path).expect("create directory at state file path"); - let state_store = super::super::store_state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); // Wrong path shape is a real filesystem problem and should not look like first run let err = state_store diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs similarity index 97% rename from crates/unixnotis-daemon/src/store/tests/support.rs rename to crates/unixnotis-daemon/src/store/tests/mod.rs index 1ba0c693..86f203d0 100644 --- a/crates/unixnotis-daemon/src/store/tests/support.rs +++ b/crates/unixnotis-daemon/src/store/tests/mod.rs @@ -1,6 +1,6 @@ //! Store regression coverage and persistence validation -use super::store_state::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; +use super::state::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; use super::{contains_ci, NotificationStore}; use chrono::Utc; use std::collections::HashMap; diff --git a/crates/unixnotis-daemon/src/store/types.rs b/crates/unixnotis-daemon/src/store/types.rs new file mode 100644 index 00000000..174aa7db --- /dev/null +++ b/crates/unixnotis-daemon/src/store/types.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use indexmap::IndexMap; +use unixnotis_core::{Config, Notification}; + +use super::{DndStateStore, HistoryStore, Inhibitor}; + +/// Mutable notification state owned by the daemon +pub struct NotificationStore { + // Immutable runtime config snapshot + pub(super) config: Config, + // Next candidate id for allocation + pub(super) next_id: u32, + // Active notifications in insertion order + pub(super) active: IndexMap>, + // Archived notifications with bounded retention + pub(super) history: HistoryStore, + // Optional expiration deadline per active id + pub(super) expirations: HashMap, + // Effective DND switch after loading persisted state + pub(super) dnd_enabled: bool, + // Monotonic in-memory revision for DND writes + pub(super) dnd_revision: u64, + // Optional persistence layer for DND; absent store keeps behavior in-memory + pub(super) dnd_state_store: Option, + // Token counter for inhibitors; never reused in a process + pub(super) next_inhibitor_id: u64, + // Active inhibitors keyed by token for quick lookup/removal + pub(super) inhibitors: HashMap, + // Cached flags avoid rescanning inhibitors on every notification + pub(super) inhibited: bool, + pub(super) inhibitor_count: u32, +} + +pub struct InsertOutcome { + // Stored notification instance returned to callers + pub notification: Arc, + // True when insertion replaced an existing id + pub replaced: bool, + // Whether popup rendering is allowed for this payload + pub show_popup: bool, + // Whether sound playback is allowed for this payload + pub allow_sound: bool, + // Active ids evicted because max_active was exceeded + pub evicted: Vec, + // True when payload was intentionally dropped by inhibit mode + pub dropped: bool, +} + +pub(crate) struct DndWrite { + // True when the in-memory DND value changed + pub(crate) changed: bool, + // Value seen before this write + pub(crate) previous: bool, + // Value written by this operation + pub(crate) current: bool, + // Monotonic revision captured for guarded rollback + pub(crate) revision: u64, + // Persistence backend used outside the store lock + pub(crate) persist: Option, +} + +pub struct DismissOutcome { + // True when an active entry was removed + pub removed_active: bool, + // True when a history entry was removed + pub removed_history: bool, +} + +impl DismissOutcome { + pub fn removed_any(&self) -> bool { + // Convenience helper for callers that only need yes/no + self.removed_active || self.removed_history + } +} From 69ad530daa4f3b846c8e5af757606f60c9585127 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 16:33:07 -0500 Subject: [PATCH 31/65] noticenterctl: remove preset path redirects Drop the remaining #[path] redirects from preset import/export now that their child modules live in real folders. Update preset import to call the css_check module through its domain name. --- crates/noticenterctl/src/preset/export.rs | 3 --- crates/noticenterctl/src/preset/import.rs | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/noticenterctl/src/preset/export.rs b/crates/noticenterctl/src/preset/export.rs index 0c46fd7d..4e10471e 100644 --- a/crates/noticenterctl/src/preset/export.rs +++ b/crates/noticenterctl/src/preset/export.rs @@ -3,12 +3,9 @@ //! Export reads the active config root, applies explicit exclusions, //! rejects host-specific escape paths, and writes one shareable bundle file -#[path = "export/checks.rs"] mod checks; -#[path = "export/prompts.rs"] mod prompts; #[cfg(test)] -#[path = "export/tests.rs"] mod tests; use anyhow::{anyhow, Context, Result}; diff --git a/crates/noticenterctl/src/preset/import.rs b/crates/noticenterctl/src/preset/import.rs index 91af941c..77ac0338 100644 --- a/crates/noticenterctl/src/preset/import.rs +++ b/crates/noticenterctl/src/preset/import.rs @@ -3,20 +3,16 @@ //! Import validates the bundle first, builds a write plan, optionally reports it, //! then commits the final backup snapshot only after the staged import is ready to finish -#[path = "import/apply.rs"] mod apply; -#[path = "import/checks.rs"] mod checks; -#[path = "import/exec_review.rs"] mod exec_review; -#[path = "import/plan.rs"] mod plan; use anyhow::{anyhow, Context, Result}; use std::path::{Path, PathBuf}; use unixnotis_core::Config; -use crate::main_css_check::run_css_check; +use crate::css_check::run_css_check; use self::apply::{apply_import_plan, finalize_import_transaction, rollback_import_transaction}; use self::checks::{ @@ -360,5 +356,4 @@ fn confirm_import_exec_content_for_tests( } #[cfg(test)] -#[path = "import/tests/mod.rs"] mod tests; From cf4d31e30d0db08eed670feb8b502d68fa56c65f Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 17:05:29 -0500 Subject: [PATCH 32/65] noticenterctl: align integration tests with CI clippy Move binary integration tests under cfg(test) modules and give the standalone test crates the same restriction-lint policy as the main binary. This matches the CI command that enables clippy::restriction in addition to pedantic and nursery. --- crates/noticenterctl/tests/errors.rs | 56 ++++++++++------- crates/noticenterctl/tests/help.rs | 92 ++++++++++++++++------------ 2 files changed, 86 insertions(+), 62 deletions(-) diff --git a/crates/noticenterctl/tests/errors.rs b/crates/noticenterctl/tests/errors.rs index 320fdfc8..8010a891 100644 --- a/crates/noticenterctl/tests/errors.rs +++ b/crates/noticenterctl/tests/errors.rs @@ -1,27 +1,39 @@ -use std::process::Command; +#![allow( + clippy::blanket_clippy_restriction_lints, + clippy::restriction, + reason = "workspace CI enables clippy::restriction as a review signal" +)] -#[test] -fn binary_rejects_unknown_command_before_dbus_setup() { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("definitely-not-a-command") - .output() - .expect("run noticenterctl invalid command"); +#[cfg(test)] +mod tests { + use std::error::Error; + use std::process::Command; - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).expect("error output is utf8"); - assert!(stderr.contains("unrecognized subcommand")); - assert!(stderr.contains("definitely-not-a-command")); -} + type TestResult = Result<(), Box>; + + #[test] + fn binary_rejects_unknown_command_before_dbus_setup() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .arg("definitely-not-a-command") + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.contains("unrecognized subcommand")); + assert!(stderr.contains("definitely-not-a-command")); + Ok(()) + } -#[test] -fn binary_rejects_invalid_dnd_state_before_dbus_setup() { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["dnd", "maybe"]) - .output() - .expect("run noticenterctl invalid dnd state"); + #[test] + fn binary_rejects_invalid_dnd_state_before_dbus_setup() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["dnd", "maybe"]) + .output()?; - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).expect("error output is utf8"); - assert!(stderr.contains("invalid value")); - assert!(stderr.contains("maybe")); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!(stderr.contains("invalid value")); + assert!(stderr.contains("maybe")); + Ok(()) + } } diff --git a/crates/noticenterctl/tests/help.rs b/crates/noticenterctl/tests/help.rs index 82e11c96..f681fe5a 100644 --- a/crates/noticenterctl/tests/help.rs +++ b/crates/noticenterctl/tests/help.rs @@ -1,43 +1,55 @@ -use std::process::Command; - -#[test] -fn binary_help_prints_cli_usage() { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("--help") - .output() - .expect("run noticenterctl --help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); - assert!(stdout.contains("Usage:")); - assert!(stdout.contains("css-check")); - assert!(stdout.contains("preset")); -} +#![allow( + clippy::blanket_clippy_restriction_lints, + clippy::restriction, + reason = "workspace CI enables clippy::restriction as a review signal" +)] -#[test] -fn binary_open_panel_help_lists_optional_debug_flag() { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["open-panel", "--help"]) - .output() - .expect("run noticenterctl open-panel --help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); - assert!(stdout.contains("--debug")); - assert!(stdout.contains("critical")); - assert!(stdout.contains("verbose")); -} +#[cfg(test)] +mod tests { + use std::error::Error; + use std::process::Command; + + type TestResult = Result<(), Box>; + + #[test] + fn binary_help_prints_cli_usage() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .arg("--help") + .output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("css-check")); + assert!(stdout.contains("preset")); + Ok(()) + } + + #[test] + fn binary_open_panel_help_lists_optional_debug_flag() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["open-panel", "--help"]) + .output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("--debug")); + assert!(stdout.contains("critical")); + assert!(stdout.contains("verbose")); + Ok(()) + } + + #[test] + fn binary_preset_help_lists_local_bundle_commands() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) + .args(["preset", "--help"]) + .output()?; -#[test] -fn binary_preset_help_lists_local_bundle_commands() { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["preset", "--help"]) - .output() - .expect("run noticenterctl preset --help"); - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("help output is utf8"); - assert!(stdout.contains("export")); - assert!(stdout.contains("import")); - assert!(stdout.contains("inspect")); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("export")); + assert!(stdout.contains("import")); + assert!(stdout.contains("inspect")); + Ok(()) + } } From 1f102ca52827511f254764e99cbbfcb47a39929d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 33/65] refactor(noticenterctl): rename CLI command module Rename the misleading route module to command and update parser/test wiring so the file name matches the Command enum it owns. --- crates/noticenterctl/src/cli/args.rs | 2 +- crates/noticenterctl/src/cli/{route.rs => command.rs} | 0 crates/noticenterctl/src/cli/mod.rs | 4 ++-- crates/noticenterctl/src/cli/tests/{route.rs => command.rs} | 0 crates/noticenterctl/src/cli/tests/mod.rs | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename crates/noticenterctl/src/cli/{route.rs => command.rs} (100%) rename crates/noticenterctl/src/cli/tests/{route.rs => command.rs} (100%) diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index b5ca1ae1..9e3dcc4c 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -1,7 +1,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; -use super::route::Command; +use super::command::Command; #[derive(Parser, Debug)] #[command(author, version, about)] diff --git a/crates/noticenterctl/src/cli/route.rs b/crates/noticenterctl/src/cli/command.rs similarity index 100% rename from crates/noticenterctl/src/cli/route.rs rename to crates/noticenterctl/src/cli/command.rs diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs index fd8b66c3..7370daef 100644 --- a/crates/noticenterctl/src/cli/mod.rs +++ b/crates/noticenterctl/src/cli/mod.rs @@ -1,12 +1,12 @@ //! Command-line argument types for noticenterctl mod args; -mod route; +mod command; pub(crate) use args::{Args, DndState, PresetCommand}; #[cfg(test)] pub(crate) use args::{DebugLevelArg, InhibitScopeArg}; -pub(crate) use route::Command; +pub(crate) use command::Command; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/cli/tests/route.rs b/crates/noticenterctl/src/cli/tests/command.rs similarity index 100% rename from crates/noticenterctl/src/cli/tests/route.rs rename to crates/noticenterctl/src/cli/tests/command.rs diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs index cfe2741d..5c448cf4 100644 --- a/crates/noticenterctl/src/cli/tests/mod.rs +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -1,2 +1,2 @@ mod args; -mod route; +mod command; From 2c957b9baed4823441d4b77f6724a01e86fb156e Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 34/65] refactor(noticenterctl): isolate local command dispatch Move local-only command handling behind an injectable helper and use the shorter css_check::run entrypoint so app startup remains a thin runner. --- crates/noticenterctl/src/app.rs | 80 +++++++++++++++++++---- crates/noticenterctl/src/css_check/mod.rs | 2 +- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/crates/noticenterctl/src/app.rs b/crates/noticenterctl/src/app.rs index 79ce083a..2296d751 100644 --- a/crates/noticenterctl/src/app.rs +++ b/crates/noticenterctl/src/app.rs @@ -10,18 +10,11 @@ use crate::cli::{Args, Command}; pub(crate) async fn run() -> Result<()> { // Parse CLI arguments before any daemon work starts let args = Args::parse(); + let command = args.command; - if args.command.is_local_only() { + if command.is_local_only() { // Local commands must work even when the daemon is not running - match args.command { - Command::CssCheck => { - crate::css_check::run_css_check()?; - } - Command::Preset { command } => { - crate::preset::run_preset(command).context("preset command failed")?; - } - _ => {} - } + handle_local_command(command, crate::css_check::run, crate::preset::run_preset)?; return Ok(()); } @@ -33,5 +26,70 @@ pub(crate) async fn run() -> Result<()> { .await .context("connect to unixnotis control interface")?; - crate::dbus::handle_command(&proxy, args.command).await + crate::dbus::handle_command(&proxy, command).await +} + +fn handle_local_command( + command: Command, + mut run_css: impl FnMut() -> Result<()>, + mut run_preset: impl FnMut(crate::cli::PresetCommand) -> Result<()>, +) -> Result<()> { + match command { + Command::CssCheck => run_css(), + Command::Preset { command } => run_preset(command).context("preset command failed"), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use anyhow::Result; + + use crate::cli::{Command, PresetCommand}; + + use super::handle_local_command; + + #[test] + fn handle_local_command_runs_css_check_branch() { + let css_called = Cell::new(false); + + handle_local_command( + Command::CssCheck, + || { + css_called.set(true); + Ok(()) + }, + |_| -> Result<()> { panic!("preset runner should not be called for css check") }, + ) + .expect("css check should dispatch"); + + assert!(css_called.get()); + } + + #[test] + fn handle_local_command_runs_preset_branch_with_command_payload() { + let preset_called = Cell::new(false); + + handle_local_command( + Command::Preset { + command: PresetCommand::Inspect { + input: "theme.unixnotis".to_string(), + }, + }, + || -> Result<()> { panic!("css runner should not be called for preset command") }, + |command| { + let PresetCommand::Inspect { input } = command else { + panic!("expected inspect preset command"); + }; + assert_eq!(input, "theme.unixnotis"); + preset_called.set(true); + Ok(()) + }, + ) + .expect("preset should dispatch"); + + assert!(preset_called.get()); + } } diff --git a/crates/noticenterctl/src/css_check/mod.rs b/crates/noticenterctl/src/css_check/mod.rs index e0be9de8..09116d5e 100644 --- a/crates/noticenterctl/src/css_check/mod.rs +++ b/crates/noticenterctl/src/css_check/mod.rs @@ -25,7 +25,7 @@ use self::report::{ use self::runtime::lint_runtime_config; use self::theme::collect_css_check_inputs; -pub(crate) fn run_css_check() -> Result<()> { +pub(crate) fn run() -> Result<()> { // GTK needs to be ready before CSS parsing starts gtk::init().context("initialize gtk")?; From c24b775e4a08120f875b49d33402a08b6325cadb Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 35/65] test(noticenterctl): cover D-Bus timeout helper Expose the timeout wrapper inside the dbus module and add fast timeout tests for successful and expired control calls. --- crates/noticenterctl/src/dbus/tests/mod.rs | 1 + .../noticenterctl/src/dbus/tests/timeout.rs | 27 +++++++++++++++++++ crates/noticenterctl/src/dbus/timeout.rs | 11 ++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 crates/noticenterctl/src/dbus/tests/timeout.rs diff --git a/crates/noticenterctl/src/dbus/tests/mod.rs b/crates/noticenterctl/src/dbus/tests/mod.rs index de78c4aa..5a974dcf 100644 --- a/crates/noticenterctl/src/dbus/tests/mod.rs +++ b/crates/noticenterctl/src/dbus/tests/mod.rs @@ -1,2 +1,3 @@ mod commands; mod support; +mod timeout; diff --git a/crates/noticenterctl/src/dbus/tests/timeout.rs b/crates/noticenterctl/src/dbus/tests/timeout.rs new file mode 100644 index 00000000..951dc4b5 --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/timeout.rs @@ -0,0 +1,27 @@ +use std::time::Duration; + +use super::super::timeout::run_control_call_with_timeout; + +#[tokio::test] +async fn run_control_call_with_timeout_returns_ready_value() { + let value = + run_control_call_with_timeout(Duration::from_secs(1), async { Ok::<_, zbus::Error>(7_u8) }) + .await + .expect("ready call should succeed"); + + assert_eq!(value, 7); +} + +#[tokio::test] +async fn run_control_call_with_timeout_reports_expired_call_quickly() { + let result = run_control_call_with_timeout(Duration::from_millis(1), async { + tokio::time::sleep(Duration::from_secs(60)).await; + Ok::<_, zbus::Error>(()) + }) + .await; + + let error = result.expect_err("slow call should time out"); + assert!(error + .to_string() + .contains("timed out waiting for unixnotis daemon response")); +} diff --git a/crates/noticenterctl/src/dbus/timeout.rs b/crates/noticenterctl/src/dbus/timeout.rs index a9d84116..8d7f6d8d 100644 --- a/crates/noticenterctl/src/dbus/timeout.rs +++ b/crates/noticenterctl/src/dbus/timeout.rs @@ -6,8 +6,15 @@ use anyhow::{anyhow, Result}; const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); pub(super) async fn run_control_call(call: impl Future>) -> Result { + run_control_call_with_timeout(CONTROL_CALL_TIMEOUT, call).await +} + +pub(super) async fn run_control_call_with_timeout( + timeout: Duration, + call: impl Future>, +) -> Result { // Race the real D-Bus call against the maximum allowed wait time - match tokio::time::timeout(CONTROL_CALL_TIMEOUT, call).await { + match tokio::time::timeout(timeout, call).await { // The daemon answered in time and the call itself worked Ok(Ok(value)) => Ok(value), @@ -17,7 +24,7 @@ pub(super) async fn run_control_call(call: impl Future Err(anyhow!( "timed out waiting for unixnotis daemon response after {}s", - CONTROL_CALL_TIMEOUT.as_secs() + timeout.as_secs() )), } } From 62757c2c8a706b5e1728ae65dd232a7b028d469e Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 36/65] test(noticenterctl): cover debug panel log following Inject the debug log follower through the D-Bus command handler and verify debug panel opens attempt log follow without making journalctl part of the test path. --- crates/noticenterctl/src/dbus/commands.rs | 10 ++- .../noticenterctl/src/dbus/tests/commands.rs | 61 ++++++++++++++++++- .../noticenterctl/src/dbus/tests/support.rs | 36 ++++++++--- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index 5e5806bb..ae14b92f 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -10,6 +10,14 @@ use crate::output::{ use super::client::ControlClient; pub(crate) async fn handle_command(client: &impl ControlClient, command: Command) -> Result<()> { + handle_command_with_debug_logs(client, command, follow_debug_logs).await +} + +pub(super) async fn handle_command_with_debug_logs( + client: &impl ControlClient, + command: Command, + mut follow_logs: impl FnMut() -> Result<()>, +) -> Result<()> { // CLI forwards work to the daemon match command { Command::TogglePanel => { @@ -21,7 +29,7 @@ pub(crate) async fn handle_command(client: &impl ControlClient, command: Command if let Some(level) = debug { client.open_panel_debug(level.into()).await?; // Panel open should still succeed when journal follow is unavailable - if let Err(err) = follow_debug_logs() { + if let Err(err) = follow_logs() { eprintln!("debug log follow unavailable: {err}"); } } else { diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs index f473eb10..fc2fd562 100644 --- a/crates/noticenterctl/src/dbus/tests/commands.rs +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -1,7 +1,10 @@ -use crate::cli::{Command, DndState, InhibitScopeArg, PresetCommand}; +use anyhow::anyhow; +use unixnotis_core::PanelDebugLevel; -use super::super::handle_command; -use super::support::{RecordedCall, RecordingControlClient}; +use crate::cli::{Command, DebugLevelArg, DndState, InhibitScopeArg, PresetCommand}; + +use super::super::commands::{handle_command, handle_command_with_debug_logs}; +use super::support::{RecordedCall, RecordedEvent, RecordingControlClient}; #[tokio::test] async fn clear_commands_dispatch_to_matching_control_calls() { @@ -38,6 +41,58 @@ async fn panel_commands_dispatch_to_matching_control_calls() { } } +#[tokio::test] +async fn debug_open_dispatches_debug_panel_and_attempts_log_follow() { + let client = RecordingControlClient::default(); + + handle_command_with_debug_logs( + &client, + Command::OpenPanel { + debug: Some(DebugLevelArg::Verbose), + }, + || { + client.record_debug_log_follow(); + Ok(()) + }, + ) + .await + .expect("dispatch debug open"); + + assert_eq!( + client.take_events(), + vec![ + RecordedEvent::Control(RecordedCall::OpenPanelDebug(PanelDebugLevel::Verbose)), + RecordedEvent::DebugLogFollow, + ] + ); +} + +#[tokio::test] +async fn debug_open_still_succeeds_when_log_follow_fails() { + let client = RecordingControlClient::default(); + + handle_command_with_debug_logs( + &client, + Command::OpenPanel { + debug: Some(DebugLevelArg::Warn), + }, + || { + client.record_debug_log_follow(); + Err(anyhow!("journal unavailable")) + }, + ) + .await + .expect("debug open should survive log follow errors"); + + assert_eq!( + client.take_events(), + vec![ + RecordedEvent::Control(RecordedCall::OpenPanelDebug(PanelDebugLevel::Warn)), + RecordedEvent::DebugLogFollow, + ] + ); +} + #[tokio::test] async fn dnd_commands_dispatch_to_matching_control_calls() { let cases = [ diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs index 60f71c6c..a8b255b9 100644 --- a/crates/noticenterctl/src/dbus/tests/support.rs +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -23,21 +23,41 @@ pub(super) enum RecordedCall { ListInhibitors, } +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RecordedEvent { + Control(RecordedCall), + DebugLogFollow, +} + #[derive(Default)] pub(super) struct RecordingControlClient { - calls: RefCell>, + events: RefCell>, } impl RecordingControlClient { fn record<'a, T: 'a>(&'a self, call: RecordedCall, value: T) -> ControlFuture<'a, T> { Box::pin(async move { - self.calls.borrow_mut().push(call); + self.events.borrow_mut().push(RecordedEvent::Control(call)); Ok(value) }) } + pub(super) fn record_debug_log_follow(&self) { + self.events.borrow_mut().push(RecordedEvent::DebugLogFollow); + } + + pub(super) fn take_events(&self) -> Vec { + self.events.replace(Vec::new()) + } + pub(super) fn take_calls(&self) -> Vec { - self.calls.replace(Vec::new()) + self.take_events() + .into_iter() + .filter_map(|event| match event { + RecordedEvent::Control(call) => Some(call), + RecordedEvent::DebugLogFollow => None, + }) + .collect() } } @@ -92,10 +112,12 @@ impl ControlClient for RecordingControlClient { fn inhibit<'a>(&'a self, reason: &'a str, scope: u32) -> ControlFuture<'a, u64> { Box::pin(async move { - self.calls.borrow_mut().push(RecordedCall::Inhibit { - reason: reason.to_owned(), - scope, - }); + self.events + .borrow_mut() + .push(RecordedEvent::Control(RecordedCall::Inhibit { + reason: reason.to_owned(), + scope, + })); Ok(42) }) } From 19fb521bd48651dd08c3f352fce1dcab60ecd816 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 37/65] refactor(noticenterctl): split preset import modules Replace the flat import file with focused import modules for preparation, prompting, summary rendering, commit validation, and the CLI-facing runner. --- crates/noticenterctl/src/preset/import.rs | 359 ------------------ .../noticenterctl/src/preset/import/commit.rs | 51 +++ crates/noticenterctl/src/preset/import/mod.rs | 21 + .../src/preset/import/prepare.rs | 102 +++++ .../src/preset/import/prompts.rs | 52 +++ .../noticenterctl/src/preset/import/runner.rs | 55 +++ .../src/preset/import/summary.rs | 59 +++ .../src/preset/import/test_helpers.rs | 98 +++++ 8 files changed, 438 insertions(+), 359 deletions(-) delete mode 100644 crates/noticenterctl/src/preset/import.rs create mode 100644 crates/noticenterctl/src/preset/import/commit.rs create mode 100644 crates/noticenterctl/src/preset/import/mod.rs create mode 100644 crates/noticenterctl/src/preset/import/prepare.rs create mode 100644 crates/noticenterctl/src/preset/import/prompts.rs create mode 100644 crates/noticenterctl/src/preset/import/runner.rs create mode 100644 crates/noticenterctl/src/preset/import/summary.rs create mode 100644 crates/noticenterctl/src/preset/import/test_helpers.rs diff --git a/crates/noticenterctl/src/preset/import.rs b/crates/noticenterctl/src/preset/import.rs deleted file mode 100644 index 77ac0338..00000000 --- a/crates/noticenterctl/src/preset/import.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Preset import flow for applying a bundle into the live config tree -//! -//! Import validates the bundle first, builds a write plan, optionally reports it, -//! then commits the final backup snapshot only after the staged import is ready to finish - -mod apply; -mod checks; -mod exec_review; -mod plan; - -use anyhow::{anyhow, Context, Result}; -use std::path::{Path, PathBuf}; -use unixnotis_core::Config; - -use crate::css_check::run_css_check; - -use self::apply::{apply_import_plan, finalize_import_transaction, rollback_import_transaction}; -use self::checks::{ - collect_imported_exec_content, validate_config_command_paths_for_import, - validate_config_theme_paths_stay_in_root, validate_imported_command_paths_stay_in_root, - validate_imported_theme_paths_stay_in_root, ImportedExecContent, -}; -use self::exec_review::confirm_import_exec_content; -use self::plan::{build_import_plan, ImportPlan}; -use super::archive::read_bundle; -use super::css_asset_refs::{collect_external_css_asset_refs_from_bundle, ExternalCssAssetRef}; -use super::filesystem::ensure_no_symlink_ancestors; -use super::pathing::{ - confirm_continue_or_abort, parse_except_paths, relative_path_matches_exclusion, - resolve_cli_bundle_path, validate_preset_bundle_path, -}; - -#[derive(Debug)] -pub(super) struct ImportSummary { - // Number of files that will be or were applied from the bundle - pub(super) file_count: usize, - // Files that did not exist locally before import - pub(super) created: usize, - // Files that already existed and needed a backup first - pub(super) overwritten: usize, - // Bundle files intentionally left untouched because of --except - pub(super) excluded: usize, - // Backup directory is present only when an overwrite happened - pub(super) backup_dir: Option, - // Dry-run keeps the same output shape without touching the filesystem - pub(super) dry_run: bool, -} - -struct PreparedImport { - // The write plan is reused by dry-run, the test helper, and the CLI import path - plan: ImportPlan, -} - -pub(super) fn run_import( - input_path: &Path, - except: &[String], - dry_run: bool, - allow_exec: bool, -) -> Result<()> { - // Resolve the live config root once for the CLI path - let config_dir = Config::default_config_dir().context("resolve config directory")?; - // CLI import accepts a missing extension and can append it after confirmation - let input_path = resolve_cli_bundle_path(input_path)?; - let prepared = prepare_import( - &config_dir, - &input_path, - except, - allow_exec, - confirm_import_external_css_refs, - confirm_import_exec_content, - )?; - - if dry_run { - let summary = build_summary(&prepared.plan, None, true); - print_summary(&summary); - return Ok(()); - } - - // Apply first, then keep the transaction open until the post-import checks finish - let transaction = apply_import_plan(&config_dir, &prepared.plan)?; - - // Reload the active config after import so css-check validates the setup that was just applied - let config_path = config_dir.join("config.toml"); - let config = match Config::load_from_path(&config_path) - .context("load imported config.toml before css-check") - { - Ok(config) => config, - Err(err) => { - rollback_import_transaction(transaction)?; - return Err(err); - } - }; - // Recheck the live config so `--except config.toml` cannot reuse an outside local theme path - if let Err(err) = validate_config_theme_paths_stay_in_root(&config_dir, &config) { - rollback_import_transaction(transaction)?; - return Err(err); - } - if let Err(err) = validate_config_command_paths_for_import(&config_dir, &config) { - rollback_import_transaction(transaction)?; - return Err(err); - } - - // Imported presets should be checked right away so broken shared CSS is obvious - println!("preset import check: running css-check on imported theme files"); - let css_check_result = run_css_check(); - let backup_dir = finalize_import_transaction(transaction)?; - let summary = build_summary(&prepared.plan, backup_dir, false); - print_summary(&summary); - - if let Err(err) = css_check_result { - // The import committed, but the shared theme still has CSS problems the user should see - return Err(anyhow!( - "preset import completed, but css-check failed after import: {err}" - )); - } - - Ok(()) -} - -#[cfg(test)] -pub(super) fn import_preset_into( - config_dir: &Path, - input_path: &Path, - except: &[String], - dry_run: bool, -) -> Result { - // Tests should stay deterministic even when `cargo test` owns a real terminal - // Reuse the same shared import flow, but swap the prompt hooks for fixed answers - import_preset_into_with_confirm( - config_dir, - input_path, - except, - dry_run, - false, - confirm_import_external_css_refs_for_tests, - confirm_import_exec_content_for_tests, - ) -} - -#[cfg(test)] -pub(super) fn import_preset_into_with_confirm( - config_dir: &Path, - input_path: &Path, - except: &[String], - dry_run: bool, - allow_exec: bool, - confirm_external_css_refs: F, - confirm_exec_content: G, -) -> Result -where - F: FnOnce(&[ExternalCssAssetRef]) -> Result<()>, - G: FnOnce(&ImportedExecContent, bool) -> Result<()>, -{ - // Tests inject a fixed answer here so the import plan can be checked without terminal prompts - let prepared = prepare_import( - config_dir, - input_path, - except, - allow_exec, - confirm_external_css_refs, - confirm_exec_content, - )?; - - if dry_run { - // Dry-run reports the exact write plan without creating backups or files - return Ok(build_summary(&prepared.plan, None, true)); - } - - // Test helpers do not run css-check, but they still use the same staged apply and commit flow - let transaction = apply_import_plan(config_dir, &prepared.plan)?; - let backup_dir = finalize_import_transaction(transaction)?; - Ok(build_summary(&prepared.plan, backup_dir, false)) -} - -fn prepare_import( - config_dir: &Path, - input_path: &Path, - except: &[String], - allow_exec: bool, - confirm_external_css_refs: impl FnOnce(&[ExternalCssAssetRef]) -> Result<()>, - confirm_exec_content: impl FnOnce(&ImportedExecContent, bool) -> Result<()>, -) -> Result { - validate_preset_bundle_path(input_path)?; - // The whole config-root path must be free of symlink hops before any write plan is built - ensure_no_symlink_ancestors(config_dir)?; - - let exclusions = parse_except_paths(except)?; - // A kept-local config.toml means the bundle config never drives post-import theme setup - let imports_config_toml = - !relative_path_matches_exclusion(Path::new("config.toml"), &exclusions); - // Read and validate the full bundle before touching the local config tree - let bundle = read_bundle(input_path).context("read preset bundle for import")?; - - if !bundle - .files - .iter() - .any(|file| file.relative_path == Path::new("config.toml")) - { - // Import depends on one config source of truth, so bundles without config.toml are invalid - return Err(anyhow!( - "preset bundle is missing config.toml and cannot be imported" - )); - } - - // Import should validate the config that will actually drive post-import theme setup - let effective_config_bytes = if imports_config_toml { - let bundled_config = bundle - .files - .iter() - // Reuse the already validated bundle payload instead of reading from disk again - .find(|file| file.relative_path == Path::new("config.toml")) - .ok_or_else(|| { - anyhow!("preset bundle is missing config.toml and cannot be imported") - })?; - bundled_config.contents.clone() - } else { - let local_config_path = config_dir.join("config.toml"); - // Keeping the local config means its theme paths still control the later css-check setup - std::fs::read(&local_config_path).with_context(|| { - format!( - "read existing config.toml kept by --except from {}", - local_config_path.display() - ) - })? - }; - - let included_bundle_files = bundle - .files - .iter() - // Warning and review prompts should only talk about files that will actually be applied - .filter(|file| !relative_path_matches_exclusion(&file.relative_path, &exclusions)) - .cloned() - .collect::>(); - - // This closes both bundled and kept-local config chains before any file is written - validate_imported_theme_paths_stay_in_root(config_dir, &effective_config_bytes)?; - // Explicit path commands should stay inside the shared config root too - validate_imported_command_paths_stay_in_root(config_dir, &effective_config_bytes)?; - // Shared presets default to data-only imports unless the caller explicitly trusts exec content - let exec_content = - collect_imported_exec_content(&effective_config_bytes, &included_bundle_files)?; - // The exec review prompt must run before the CSS prompt so trust comes first - confirm_exec_content(&exec_content, allow_exec)?; - // CSS asset refs are warning-only, but the prompt still needs to happen before any write starts - let external_css_refs = - collect_external_css_asset_refs_from_bundle(config_dir, &included_bundle_files); - confirm_external_css_refs(&external_css_refs)?; - // The write plan is built last so prompts cannot leave behind partial staging state - let plan = build_import_plan(config_dir, bundle.files, &exclusions)?; - Ok(PreparedImport { plan }) -} - -fn build_summary(plan: &ImportPlan, backup_dir: Option, dry_run: bool) -> ImportSummary { - ImportSummary { - file_count: plan.items.len(), - created: plan.created, - overwritten: plan.overwritten, - excluded: plan.excluded, - backup_dir, - dry_run, - } -} - -fn print_summary(summary: &ImportSummary) { - println!( - "preset import {}: {} file(s), {} created, {} overwritten, {} excluded", - if summary.dry_run { "dry-run ok" } else { "ok" }, - summary.file_count, - summary.created, - summary.overwritten, - summary.excluded - ); - if let Some(backup_dir) = summary.backup_dir.as_ref() { - println!("preset import backup: {}", backup_dir.display()); - } -} - -fn confirm_import_external_css_refs(external_refs: &[ExternalCssAssetRef]) -> Result<()> { - if external_refs.is_empty() { - return Ok(()); - } - - // The caller needs the concrete file and ref before deciding whether portability matters here - let details = format_external_css_ref_lines(external_refs); - eprintln!( - "preset import warning: found {} CSS asset reference(s) that leave the UnixNotis config directory or use remote URLs", - external_refs.len() - ); - for line in &details { - eprintln!("{line}"); - } - - confirm_continue_or_abort( - "External CSS asset references were found; continue importing anyway?", - &format!( - "preset import found CSS asset references that leave the UnixNotis config directory or use remote URLs; rerun interactively to confirm anyway\n{}", - details.join("\n") - ), - ) -} - -fn format_external_css_ref_lines(external_refs: &[ExternalCssAssetRef]) -> Vec { - external_refs - .iter() - .map(|asset_ref| { - let detail = if asset_ref.reason == "remote url" { - "remote URL".to_string() - } else { - asset_ref.reason.clone() - }; - // One-line rows make the warning easy to scan before the prompt is shown - format!( - " - {} -> {} ({})", - asset_ref.css_file.display(), - asset_ref.asset_ref, - detail - ) - }) - .collect() -} - -#[cfg(test)] -fn confirm_import_external_css_refs_for_tests(external_refs: &[ExternalCssAssetRef]) -> Result<()> { - // Most tests do not care about the warning path, so empty input should stay quiet - if external_refs.is_empty() { - return Ok(()); - } - - // Test runs should fail fast instead of waiting for a terminal answer - let details = format_external_css_ref_lines(external_refs); - Err(anyhow!( - "preset import found CSS asset references that leave the UnixNotis config directory or use remote URLs; rerun interactively to confirm anyway\n{}", - details.join("\n") - )) -} - -#[cfg(test)] -fn confirm_import_exec_content_for_tests( - exec_content: &ImportedExecContent, - allow_exec: bool, -) -> Result<()> { - // Explicit trust should keep the shared helper aligned with the real import path - if allow_exec { - return Ok(()); - } - - // Empty bundles should stay on the normal import path - if exec_content.commands.is_empty() && exec_content.files.is_empty() { - return Ok(()); - } - - // Test runs should surface the same guidance every time instead of prompting - Err(anyhow!( - "preset import found executable commands or bundled scripts; rerun interactively to inspect them or use --allow-exec only if the preset is trusted" - )) -} - -#[cfg(test)] -mod tests; diff --git a/crates/noticenterctl/src/preset/import/commit.rs b/crates/noticenterctl/src/preset/import/commit.rs new file mode 100644 index 00000000..da713208 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/commit.rs @@ -0,0 +1,51 @@ +//! Import commit and post-apply validation + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use unixnotis_core::Config; + +use super::apply::{apply_import_plan, finalize_import_transaction, rollback_import_transaction}; +use super::checks::{ + validate_config_command_paths_for_import, validate_config_theme_paths_stay_in_root, +}; +use super::plan::ImportPlan; + +pub(super) fn commit_import_plan( + config_dir: &Path, + plan: &ImportPlan, + run_css_check: impl FnOnce() -> Result<()>, +) -> Result<(Option, Result<()>)> { + // Apply first, then keep the transaction open until the post-import checks finish + let transaction = apply_import_plan(config_dir, plan)?; + let config = match load_imported_config(config_dir) { + Ok(config) => config, + Err(err) => { + rollback_import_transaction(transaction)?; + return Err(err); + } + }; + + if let Err(err) = validate_imported_live_config(config_dir, &config) { + rollback_import_transaction(transaction)?; + return Err(err); + } + + // Imported presets should be checked right away so broken shared CSS is obvious + println!("preset import check: running css-check on imported theme files"); + let css_check_result = run_css_check(); + let backup_dir = finalize_import_transaction(transaction)?; + Ok((backup_dir, css_check_result)) +} + +fn load_imported_config(config_dir: &Path) -> Result { + // Reload the active config after import so css-check validates the setup that was just applied + let config_path = config_dir.join("config.toml"); + Config::load_from_path(&config_path).context("load imported config.toml before css-check") +} + +fn validate_imported_live_config(config_dir: &Path, config: &Config) -> Result<()> { + // Recheck the live config so `--except config.toml` cannot reuse an outside local theme path + validate_config_theme_paths_stay_in_root(config_dir, config)?; + validate_config_command_paths_for_import(config_dir, config) +} diff --git a/crates/noticenterctl/src/preset/import/mod.rs b/crates/noticenterctl/src/preset/import/mod.rs new file mode 100644 index 00000000..b6a0813a --- /dev/null +++ b/crates/noticenterctl/src/preset/import/mod.rs @@ -0,0 +1,21 @@ +//! Preset import flow for applying a bundle into the live config tree +//! +//! Import validates the bundle first, builds a write plan, optionally reports it, +//! then commits the final backup snapshot only after the staged import is ready to finish + +mod apply; +mod checks; +mod commit; +mod exec_review; +mod plan; +mod prepare; +mod prompts; +mod runner; +mod summary; +#[cfg(test)] +mod test_helpers; + +pub(super) use self::runner::run_import; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/preset/import/prepare.rs b/crates/noticenterctl/src/preset/import/prepare.rs new file mode 100644 index 00000000..f26d431c --- /dev/null +++ b/crates/noticenterctl/src/preset/import/prepare.rs @@ -0,0 +1,102 @@ +//! Bundle validation and write-plan preparation + +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; + +use super::super::archive::read_bundle; +use super::super::css_asset_refs::{ + collect_external_css_asset_refs_from_bundle, ExternalCssAssetRef, +}; +use super::super::filesystem::ensure_no_symlink_ancestors; +use super::super::pathing::{ + parse_except_paths, relative_path_matches_exclusion, validate_preset_bundle_path, +}; +use super::checks::{ + collect_imported_exec_content, validate_imported_command_paths_stay_in_root, + validate_imported_theme_paths_stay_in_root, ImportedExecContent, +}; +use super::plan::{build_import_plan, ImportPlan}; + +pub(super) struct PreparedImport { + // The write plan is reused by dry-run, the test helper, and the CLI import path + pub(super) plan: ImportPlan, +} + +pub(super) fn prepare_import( + config_dir: &Path, + input_path: &Path, + except: &[String], + allow_exec: bool, + confirm_external_css_refs: impl FnOnce(&[ExternalCssAssetRef]) -> Result<()>, + confirm_exec_content: impl FnOnce(&ImportedExecContent, bool) -> Result<()>, +) -> Result { + validate_preset_bundle_path(input_path)?; + // The whole config-root path must be free of symlink hops before any write plan is built + ensure_no_symlink_ancestors(config_dir)?; + + let exclusions = parse_except_paths(except)?; + // A kept-local config.toml means the bundle config never drives post-import theme setup + let imports_config_toml = + !relative_path_matches_exclusion(Path::new("config.toml"), &exclusions); + // Read and validate the full bundle before touching the local config tree + let bundle = read_bundle(input_path).context("read preset bundle for import")?; + + if !bundle + .files + .iter() + .any(|file| file.relative_path == Path::new("config.toml")) + { + // Import depends on one config source of truth, so bundles without config.toml are invalid + return Err(anyhow!( + "preset bundle is missing config.toml and cannot be imported" + )); + } + + // Import should validate the config that will actually drive post-import theme setup + let effective_config_bytes = if imports_config_toml { + let bundled_config = bundle + .files + .iter() + // Reuse the already validated bundle payload instead of reading from disk again + .find(|file| file.relative_path == Path::new("config.toml")) + .ok_or_else(|| { + anyhow!("preset bundle is missing config.toml and cannot be imported") + })?; + bundled_config.contents.clone() + } else { + let local_config_path = config_dir.join("config.toml"); + // Keeping the local config means its theme paths still control the later css-check setup + std::fs::read(&local_config_path).with_context(|| { + format!( + "read existing config.toml kept by --except from {}", + local_config_path.display() + ) + })? + }; + + let included_bundle_files = bundle + .files + .iter() + // Warning and review prompts should only talk about files that will actually be applied + .filter(|file| !relative_path_matches_exclusion(&file.relative_path, &exclusions)) + .cloned() + .collect::>(); + + // This closes both bundled and kept-local config chains before any file is written + validate_imported_theme_paths_stay_in_root(config_dir, &effective_config_bytes)?; + // Explicit path commands should stay inside the shared config root too + validate_imported_command_paths_stay_in_root(config_dir, &effective_config_bytes)?; + // Shared presets default to data-only imports unless the caller explicitly trusts exec content + let exec_content = + collect_imported_exec_content(&effective_config_bytes, &included_bundle_files)?; + // The exec review prompt must run before the CSS prompt so trust comes first + confirm_exec_content(&exec_content, allow_exec)?; + // CSS asset refs are warning-only, but the prompt still needs to happen before any write starts + let external_css_refs = + collect_external_css_asset_refs_from_bundle(config_dir, &included_bundle_files); + confirm_external_css_refs(&external_css_refs)?; + // The write plan is built last so prompts cannot leave behind partial staging state + let plan = build_import_plan(config_dir, bundle.files, &exclusions)?; + Ok(PreparedImport { plan }) +} diff --git a/crates/noticenterctl/src/preset/import/prompts.rs b/crates/noticenterctl/src/preset/import/prompts.rs new file mode 100644 index 00000000..0903685a --- /dev/null +++ b/crates/noticenterctl/src/preset/import/prompts.rs @@ -0,0 +1,52 @@ +//! Interactive import warning prompts + +use anyhow::Result; + +use super::super::css_asset_refs::ExternalCssAssetRef; +use super::super::pathing::confirm_continue_or_abort; + +pub(super) fn confirm_import_external_css_refs( + external_refs: &[ExternalCssAssetRef], +) -> Result<()> { + if external_refs.is_empty() { + return Ok(()); + } + + // The caller needs the concrete file and ref before deciding whether portability matters here + let details = format_external_css_ref_lines(external_refs); + eprintln!( + "preset import warning: found {} CSS asset reference(s) that leave the UnixNotis config directory or use remote URLs", + external_refs.len() + ); + for line in &details { + eprintln!("{line}"); + } + + confirm_continue_or_abort( + "External CSS asset references were found; continue importing anyway?", + &format!( + "preset import found CSS asset references that leave the UnixNotis config directory or use remote URLs; rerun interactively to confirm anyway\n{}", + details.join("\n") + ), + ) +} + +pub(super) fn format_external_css_ref_lines(external_refs: &[ExternalCssAssetRef]) -> Vec { + external_refs + .iter() + .map(|asset_ref| { + let detail = if asset_ref.reason == "remote url" { + "remote URL".to_string() + } else { + asset_ref.reason.clone() + }; + // One-line rows make the warning easy to scan before the prompt is shown + format!( + " - {} -> {} ({})", + asset_ref.css_file.display(), + asset_ref.asset_ref, + detail + ) + }) + .collect() +} diff --git a/crates/noticenterctl/src/preset/import/runner.rs b/crates/noticenterctl/src/preset/import/runner.rs new file mode 100644 index 00000000..a78d36af --- /dev/null +++ b/crates/noticenterctl/src/preset/import/runner.rs @@ -0,0 +1,55 @@ +//! CLI-facing preset import runner + +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use unixnotis_core::Config; + +use crate::css_check::run as run_css_check; + +use super::super::pathing::resolve_cli_bundle_path; +use super::commit::commit_import_plan; +use super::exec_review::confirm_import_exec_content; +use super::prepare::prepare_import; +use super::prompts::confirm_import_external_css_refs; +use super::summary::{build_summary, print_summary}; + +pub(in crate::preset) fn run_import( + input_path: &Path, + except: &[String], + dry_run: bool, + allow_exec: bool, +) -> Result<()> { + // Resolve the live config root once for the CLI path + let config_dir = Config::default_config_dir().context("resolve config directory")?; + // CLI import accepts a missing extension and can append it after confirmation + let input_path = resolve_cli_bundle_path(input_path)?; + let prepared = prepare_import( + &config_dir, + &input_path, + except, + allow_exec, + confirm_import_external_css_refs, + confirm_import_exec_content, + )?; + + if dry_run { + let summary = build_summary(&prepared.plan, None, true); + print_summary(&summary); + return Ok(()); + } + + let (backup_dir, css_check_result) = + commit_import_plan(&config_dir, &prepared.plan, run_css_check)?; + let summary = build_summary(&prepared.plan, backup_dir, false); + print_summary(&summary); + + if let Err(err) = css_check_result { + // The import committed, but the shared theme still has CSS problems the user should see + return Err(anyhow!( + "preset import completed, but css-check failed after import: {err}" + )); + } + + Ok(()) +} diff --git a/crates/noticenterctl/src/preset/import/summary.rs b/crates/noticenterctl/src/preset/import/summary.rs new file mode 100644 index 00000000..baacf9f4 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/summary.rs @@ -0,0 +1,59 @@ +//! Import summary data and reporting + +use std::path::PathBuf; + +use super::plan::ImportPlan; + +#[derive(Debug)] +pub(super) struct ImportSummary { + // Number of files that will be or were applied from the bundle + pub(super) file_count: usize, + // Files that did not exist locally before import + pub(super) created: usize, + // Files that already existed and needed a backup first + pub(super) overwritten: usize, + // Bundle files intentionally left untouched because of --except + pub(super) excluded: usize, + // Backup directory is present only when an overwrite happened + pub(super) backup_dir: Option, + // Dry-run keeps the same output shape without touching the filesystem + pub(super) dry_run: bool, +} + +pub(super) fn build_summary( + plan: &ImportPlan, + backup_dir: Option, + dry_run: bool, +) -> ImportSummary { + ImportSummary { + file_count: plan.items.len(), + created: plan.created, + overwritten: plan.overwritten, + excluded: plan.excluded, + backup_dir, + dry_run, + } +} + +pub(super) fn print_summary(summary: &ImportSummary) -> Vec { + let lines = summary_lines(summary); + for line in &lines { + println!("{line}"); + } + lines +} + +pub(super) fn summary_lines(summary: &ImportSummary) -> Vec { + let mut lines = vec![format!( + "preset import {}: {} file(s), {} created, {} overwritten, {} excluded", + if summary.dry_run { "dry-run ok" } else { "ok" }, + summary.file_count, + summary.created, + summary.overwritten, + summary.excluded + )]; + if let Some(backup_dir) = summary.backup_dir.as_ref() { + lines.push(format!("preset import backup: {}", backup_dir.display())); + } + lines +} diff --git a/crates/noticenterctl/src/preset/import/test_helpers.rs b/crates/noticenterctl/src/preset/import/test_helpers.rs new file mode 100644 index 00000000..e79b1dd8 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/test_helpers.rs @@ -0,0 +1,98 @@ +//! Test-only import helpers with deterministic prompts + +use std::path::Path; + +use anyhow::{anyhow, Result}; + +use super::super::css_asset_refs::ExternalCssAssetRef; +use super::apply::{apply_import_plan, finalize_import_transaction}; +use super::checks::ImportedExecContent; +use super::prepare::prepare_import; +use super::summary::{build_summary, ImportSummary}; + +pub(super) fn import_preset_into( + config_dir: &Path, + input_path: &Path, + except: &[String], + dry_run: bool, +) -> Result { + // Tests should stay deterministic even when `cargo test` owns a real terminal + // Reuse the same shared import flow, but swap the prompt hooks for fixed answers + import_preset_into_with_confirm( + config_dir, + input_path, + except, + dry_run, + false, + confirm_import_external_css_refs_for_tests, + confirm_import_exec_content_for_tests, + ) +} + +pub(super) fn import_preset_into_with_confirm( + config_dir: &Path, + input_path: &Path, + except: &[String], + dry_run: bool, + allow_exec: bool, + confirm_external_css_refs: F, + confirm_exec_content: G, +) -> Result +where + F: FnOnce(&[ExternalCssAssetRef]) -> Result<()>, + G: FnOnce(&ImportedExecContent, bool) -> Result<()>, +{ + // Tests inject a fixed answer here so the import plan can be checked without terminal prompts + let prepared = prepare_import( + config_dir, + input_path, + except, + allow_exec, + confirm_external_css_refs, + confirm_exec_content, + )?; + + if dry_run { + // Dry-run reports the exact write plan without creating backups or files + return Ok(build_summary(&prepared.plan, None, true)); + } + + // Test helpers do not run css-check, but they still use the same staged apply and commit flow + let transaction = apply_import_plan(config_dir, &prepared.plan)?; + let backup_dir = finalize_import_transaction(transaction)?; + Ok(build_summary(&prepared.plan, backup_dir, false)) +} + +fn confirm_import_external_css_refs_for_tests(external_refs: &[ExternalCssAssetRef]) -> Result<()> { + // Most tests do not care about the warning path, so empty input should stay quiet + if external_refs.is_empty() { + return Ok(()); + } + + // Test runs should fail fast instead of waiting for a terminal answer + let details = super::prompts::format_external_css_ref_lines(external_refs); + Err(anyhow!( + "preset import found CSS asset references that leave the UnixNotis config directory or use remote URLs; rerun interactively to confirm anyway\n{}", + details.join("\n") + )) +} + +fn confirm_import_exec_content_for_tests( + exec_content: &ImportedExecContent, + allow_exec: bool, +) -> Result<()> { + // Explicit trust should keep the shared helper aligned with the real import path + if allow_exec { + return Ok(()); + } + + // Empty bundles should stay on the normal import path + if exec_content.commands.is_empty() && exec_content.files.is_empty() { + return Ok(()); + } + + // Test runs should surface the same guidance every time instead of prompting + Err(anyhow!( + "preset import found executable commands or bundled scripts; rerun interactively to inspect them or use --allow-exec only if the preset is trusted" + )) +} From 9a80c748fe3953ec5af5e476fab28b503fc33771 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 38/65] test(noticenterctl): expand preset import core coverage Add import regression coverage for missing bundles, dry-run writes, config-only bundles, and missing config rejection. --- .../src/preset/import/tests/core.rs | 79 +++++++++++++++++++ .../src/preset/import/tests/mod.rs | 5 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/crates/noticenterctl/src/preset/import/tests/core.rs b/crates/noticenterctl/src/preset/import/tests/core.rs index e654ea0e..235c604a 100644 --- a/crates/noticenterctl/src/preset/import/tests/core.rs +++ b/crates/noticenterctl/src/preset/import/tests/core.rs @@ -1,5 +1,19 @@ use super::*; +#[test] +fn run_import_reports_missing_bundle_instead_of_succeeding() { + let missing = std::env::temp_dir().join("unixnotis-missing-import-bundle.unixnotis"); + let _ = fs::remove_file(&missing); + + let error = super::super::runner::run_import(&missing, &[], true, false) + .expect_err("missing bundle should fail"); + + assert!( + error.to_string().contains("does not exist") + || error.to_string().contains("read preset bundle") + ); +} + #[test] fn import_dry_run_reports_create_and_overwrite_counts() { // Dry-run should plan writes without changing the target tree @@ -30,6 +44,25 @@ fn import_dry_run_reports_create_and_overwrite_counts() { ); } +#[test] +fn import_dry_run_does_not_create_included_files() { + let export_root = TempDirGuard::new("dry-run-no-create-export"); + export_root.write("config.toml", "[theme]\nbase_css = \"base.css\"\n"); + export_root.write("base.css", ".a { color: red; }"); + let bundle_path = export_root.path.join("demo.unixnotis"); + export_preset_from(&export_root.path, &bundle_path, &[], false).expect("export bundle"); + + let import_root = TempDirGuard::new("dry-run-no-create-import"); + let summary = + import_preset_into(&import_root.path, &bundle_path, &[], true).expect("dry-run import"); + + assert!(summary.dry_run); + assert_eq!(summary.file_count, 2); + assert_eq!(summary.created, 2); + assert!(!import_root.path.join("config.toml").exists()); + assert!(!import_root.path.join("base.css").exists()); +} + #[test] fn import_writes_files_and_creates_backup_for_overwrites() { // Real import should overwrite live files and keep a rollback copy @@ -55,6 +88,52 @@ fn import_writes_files_and_creates_backup_for_overwrites() { ); } +#[test] +fn import_accepts_bundle_that_contains_only_config_toml() { + let export_root = TempDirGuard::new("config-only-export"); + export_root.write("config.toml", "title = \"only config\"\n"); + let bundle_path = export_root.path.join("demo.unixnotis"); + write_collected_bundle( + &export_root, + &bundle_path, + "2026-04-18T00:00:00Z", + &[("config.toml", "config.toml")], + ); + + let import_root = TempDirGuard::new("config-only-import"); + let summary = + import_preset_into(&import_root.path, &bundle_path, &[], false).expect("import config"); + + assert_eq!(summary.file_count, 1); + assert_eq!(summary.created, 1); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), + "title = \"only config\"\n" + ); +} + +#[test] +fn import_rejects_bundle_without_config_toml_before_writing() { + let export_root = TempDirGuard::new("missing-config-export"); + export_root.write("base.css", ".a { color: red; }"); + let bundle_path = export_root.path.join("demo.unixnotis"); + write_collected_bundle( + &export_root, + &bundle_path, + "2026-04-17T00:00:00Z", + &[("base.css", "base.css")], + ); + + let import_root = TempDirGuard::new("missing-config-import"); + let error = import_preset_into(&import_root.path, &bundle_path, &[], false) + .expect_err("reject missing config"); + + assert!(error + .to_string() + .contains("preset bundle is missing config.toml")); + assert!(!import_root.path.join("base.css").exists()); +} + #[test] fn import_rejects_bundled_absolute_theme_paths_before_writing() { // A hostile config should not make post-import setup create files outside the config root diff --git a/crates/noticenterctl/src/preset/import/tests/mod.rs b/crates/noticenterctl/src/preset/import/tests/mod.rs index 7e4fde88..f9ea27f1 100644 --- a/crates/noticenterctl/src/preset/import/tests/mod.rs +++ b/crates/noticenterctl/src/preset/import/tests/mod.rs @@ -1,4 +1,4 @@ -pub(super) use super::{import_preset_into, import_preset_into_with_confirm}; +pub(super) use super::test_helpers::{import_preset_into, import_preset_into_with_confirm}; pub(super) use crate::preset::archive::write_bundle; pub(super) use crate::preset::config_root::{CollectedConfigFiles, PresetFileSource}; pub(super) use crate::preset::export::export_preset_from; @@ -10,9 +10,12 @@ pub(super) use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +mod commit; mod core; mod css; mod exec; +mod exec_review; +mod prompts; static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); From 29ad008f0982dda1cdfa8ff9ae8554be58c25261 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 39/65] test(noticenterctl): cover preset import commit rollback Exercise the extracted import commit path for successful writes, backup creation, invalid config rollback, and outside-root validation rollback. --- .../src/preset/import/tests/commit.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 crates/noticenterctl/src/preset/import/tests/commit.rs diff --git a/crates/noticenterctl/src/preset/import/tests/commit.rs b/crates/noticenterctl/src/preset/import/tests/commit.rs new file mode 100644 index 00000000..093a2192 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/tests/commit.rs @@ -0,0 +1,117 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; +use crate::preset::archive::BundleFile; +use crate::preset::import::commit::commit_import_plan; +use crate::preset::import::plan::build_import_plan; + +fn bundle_file(relative_path: &str, contents: &str) -> BundleFile { + BundleFile { + relative_path: PathBuf::from(relative_path), + contents: contents.as_bytes().to_vec(), + mode: 0o644, + } +} + +#[test] +fn commit_import_plan_writes_files_runs_css_check_and_returns_backup() { + let import_root = TempDirGuard::new("commit-success"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + + let plan = build_import_plan( + &import_root.path, + vec![ + bundle_file("config.toml", "[panel]\nwidth = 444\n"), + bundle_file("theme/base.css", ".panel { color: red; }\n"), + ], + &[], + ) + .expect("build plan"); + let css_calls = AtomicUsize::new(0); + + let (backup_dir, css_result) = commit_import_plan(&import_root.path, &plan, || { + css_calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + }) + .expect("commit import"); + + assert_eq!(css_calls.load(Ordering::Relaxed), 1); + assert!(css_result.is_ok()); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read imported config"), + "[panel]\nwidth = 444\n" + ); + assert_eq!( + fs::read_to_string(import_root.path.join("theme/base.css")).expect("read imported css"), + ".panel { color: red; }\n" + ); + let backup_dir = backup_dir.expect("overwritten config should create backup"); + assert_eq!( + fs::read_to_string(backup_dir.join("config.toml")).expect("read backup config"), + "[panel]\nwidth = 320\n" + ); +} + +#[test] +fn commit_import_plan_rolls_back_when_imported_config_cannot_load() { + let import_root = TempDirGuard::new("commit-invalid-config"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + + let plan = build_import_plan( + &import_root.path, + vec![bundle_file("config.toml", "[panel\nwidth = broken\n")], + &[], + ) + .expect("build plan"); + let css_calls = AtomicUsize::new(0); + + let error = commit_import_plan(&import_root.path, &plan, || { + css_calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + }) + .expect_err("invalid imported config should fail"); + + assert!(error.to_string().contains("load imported config.toml")); + assert_eq!(css_calls.load(Ordering::Relaxed), 0); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), + "[panel]\nwidth = 320\n" + ); +} + +#[test] +fn commit_import_plan_rolls_back_when_imported_config_points_outside_root() { + let import_root = TempDirGuard::new("commit-outside-theme"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + let outside_theme = import_root.path.with_file_name("outside-theme.css"); + + let plan = build_import_plan( + &import_root.path, + vec![bundle_file( + "config.toml", + &format!( + "[theme]\nbase_css = {:?}\n", + outside_theme.display().to_string() + ), + )], + &[], + ) + .expect("build plan"); + let css_calls = AtomicUsize::new(0); + + let error = commit_import_plan(&import_root.path, &plan, || { + css_calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + }) + .expect_err("outside theme path should fail"); + + assert!(error + .to_string() + .contains("tries to leave the UnixNotis config directory")); + assert_eq!(css_calls.load(Ordering::Relaxed), 0); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), + "[panel]\nwidth = 320\n" + ); + assert!(!outside_theme.exists()); +} From f01ca34dff8099cf1df52a2b27015213626e9556 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 40/65] test(noticenterctl): cover preset import prompts and summaries Add focused tests for external CSS warning formatting, noninteractive rejection, summary lines, and printed summary return values. --- .../src/preset/import/tests/prompts.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 crates/noticenterctl/src/preset/import/tests/prompts.rs diff --git a/crates/noticenterctl/src/preset/import/tests/prompts.rs b/crates/noticenterctl/src/preset/import/tests/prompts.rs new file mode 100644 index 00000000..056d1192 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/tests/prompts.rs @@ -0,0 +1,104 @@ +use super::*; + +use crate::preset::css_asset_refs::ExternalCssAssetRef; +use crate::preset::import::summary::{print_summary, summary_lines, ImportSummary}; + +#[test] +fn format_external_css_ref_lines_normalizes_remote_url_reason() { + let refs = vec![ExternalCssAssetRef { + css_file: PathBuf::from("theme/panel.css"), + asset_ref: "https://example.test/image.png".to_string(), + reason: "remote url".to_string(), + }]; + + let lines = super::super::prompts::format_external_css_ref_lines(&refs); + + assert_eq!( + lines, + vec![" - theme/panel.css -> https://example.test/image.png (remote URL)"] + ); +} + +#[test] +fn format_external_css_ref_lines_preserves_local_path_reason() { + let refs = vec![ExternalCssAssetRef { + css_file: PathBuf::from("base.css"), + asset_ref: "../outside.png".to_string(), + reason: "local path points outside the config root".to_string(), + }]; + + let lines = super::super::prompts::format_external_css_ref_lines(&refs); + + assert_eq!( + lines, + vec![" - base.css -> ../outside.png (local path points outside the config root)"] + ); +} + +#[test] +fn confirm_import_external_css_refs_errors_without_interactive_confirmation() { + let refs = vec![ExternalCssAssetRef { + css_file: PathBuf::from("base.css"), + asset_ref: "../outside.png".to_string(), + reason: "local path points outside the config root".to_string(), + }]; + + let error = super::super::prompts::confirm_import_external_css_refs(&refs) + .expect_err("noninteractive import should reject external refs"); + + assert!(error.to_string().contains( + "CSS asset references that leave the UnixNotis config directory or use remote URLs" + )); +} + +#[test] +fn summary_lines_include_backup_only_when_present() { + let dry_run = ImportSummary { + file_count: 2, + created: 1, + overwritten: 1, + excluded: 0, + backup_dir: None, + dry_run: true, + }; + assert_eq!( + summary_lines(&dry_run), + vec!["preset import dry-run ok: 2 file(s), 1 created, 1 overwritten, 0 excluded"] + ); + + let committed = ImportSummary { + file_count: 1, + created: 0, + overwritten: 1, + excluded: 2, + backup_dir: Some(PathBuf::from("Backup-2026")), + dry_run: false, + }; + assert_eq!( + summary_lines(&committed), + vec![ + "preset import ok: 1 file(s), 0 created, 1 overwritten, 2 excluded", + "preset import backup: Backup-2026", + ] + ); +} + +#[test] +fn print_summary_returns_the_lines_it_prints() { + let summary = ImportSummary { + file_count: 3, + created: 2, + overwritten: 1, + excluded: 4, + backup_dir: Some(PathBuf::from("Backup-printed")), + dry_run: false, + }; + + assert_eq!( + print_summary(&summary), + vec![ + "preset import ok: 3 file(s), 2 created, 1 overwritten, 4 excluded", + "preset import backup: Backup-printed", + ] + ); +} From e170616280fce5375a356ce1669ac86bc90352d2 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 41/65] refactor(noticenterctl): move exec review tests under import tests Remove the nested exec_review/tests folder and keep executable-content review coverage in the main preset import tests tree. --- .../src/preset/import/exec_review.rs | 17 +++++++---------- .../tests/mod.rs => tests/exec_review.rs} | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) rename crates/noticenterctl/src/preset/import/{exec_review/tests/mod.rs => tests/exec_review.rs} (99%) diff --git a/crates/noticenterctl/src/preset/import/exec_review.rs b/crates/noticenterctl/src/preset/import/exec_review.rs index f77eead8..ec8f242e 100644 --- a/crates/noticenterctl/src/preset/import/exec_review.rs +++ b/crates/noticenterctl/src/preset/import/exec_review.rs @@ -77,7 +77,7 @@ fn show_exec_content_in_pager(exec_content: &ImportedExecContent) -> Result<()> finish_pager(child, &pager, write_result) } -fn pager_command_parts() -> Result> { +pub(super) fn pager_command_parts() -> Result> { // `$PAGER` wins so local pager setup keeps working during import review too let configured = env::var("PAGER").unwrap_or_else(|_| "less".to_string()); let mut parts = shell_words::split(&configured).context("parse pager command")?; @@ -91,7 +91,7 @@ fn pager_command_parts() -> Result> { Ok(parts) } -fn finish_pager( +pub(super) fn finish_pager( mut child: std::process::Child, pager: &[String], write_result: io::Result<()>, @@ -119,7 +119,7 @@ fn render_exec_content_review(exec_content: &ImportedExecContent) -> String { render_exec_content_review_with_style(exec_content, ReviewStyle::for_terminal()) } -fn render_exec_content_review_with_style( +pub(super) fn render_exec_content_review_with_style( exec_content: &ImportedExecContent, style: ReviewStyle, ) -> String { @@ -178,7 +178,7 @@ fn pager_looks_like_less(parts: &[String]) -> bool { .is_some_and(|name| name == "less") } -fn pager_enables_raw_control(parts: &[String]) -> bool { +pub(super) fn pager_enables_raw_control(parts: &[String]) -> bool { parts.iter().skip(1).any(|part| { part == "-R" || part == "-r" @@ -189,8 +189,8 @@ fn pager_enables_raw_control(parts: &[String]) -> bool { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct ReviewStyle { - color: bool, +pub(super) struct ReviewStyle { + pub(super) color: bool, } impl ReviewStyle { @@ -216,7 +216,7 @@ impl ReviewStyle { format!("\u{1b}[{prefix}m{text}\u{1b}[0m") } - fn title(self, text: impl Into) -> String { + pub(super) fn title(self, text: impl Into) -> String { self.paint(text, "1;36") } @@ -248,6 +248,3 @@ impl ReviewStyle { self.paint(text, "37") } } - -#[cfg(test)] -mod tests; diff --git a/crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs b/crates/noticenterctl/src/preset/import/tests/exec_review.rs similarity index 99% rename from crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs rename to crates/noticenterctl/src/preset/import/tests/exec_review.rs index a54b5989..5af3fff9 100644 --- a/crates/noticenterctl/src/preset/import/exec_review/tests/mod.rs +++ b/crates/noticenterctl/src/preset/import/tests/exec_review.rs @@ -1,4 +1,4 @@ -use super::{ +use super::super::exec_review::{ finish_pager, pager_command_parts, pager_enables_raw_control, render_exec_content_review_with_style, ReviewStyle, }; From 070daf67b9cd4c23023ea783ee9380e6a4ff976d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 42/65] feat(center): apply configured panel action order Wire configured action order into the panel action row and add tests for ordering, close placement, and action config application. --- .../unixnotis-center/src/ui/panel/actions.rs | 96 ++++++++++--------- .../src/ui/panel/tests/actions.rs | 93 ++++++++++++++++++ crates/unixnotis-center/src/ui/panel/types.rs | 3 +- 3 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/tests/actions.rs diff --git a/crates/unixnotis-center/src/ui/panel/actions.rs b/crates/unixnotis-center/src/ui/panel/actions.rs index 6aead6d3..e34f821d 100644 --- a/crates/unixnotis-center/src/ui/panel/actions.rs +++ b/crates/unixnotis-center/src/ui/panel/actions.rs @@ -49,6 +49,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { &dnd_toggle, &clear_button, &search_toggle, + &close_button, &config.action_order, ); actions.append(&action_primary); @@ -71,43 +72,51 @@ pub(super) fn build_clear_button(config: &PanelConfig) -> gtk::Button { } pub(in crate::ui::panel) fn apply_panel_action_config( - group: >k::Box, - focus_toggle: >k::ToggleButton, - dnd_toggle: >k::ToggleButton, - clear_button: >k::Button, - search_toggle: >k::ToggleButton, - close_button: >k::Button, + header_top: >k::Box, + widgets: &PanelActionWidgets, config: &PanelConfig, ) { update_action_button( - focus_toggle, + &widgets.focus_toggle, hooks::panel_action::FOCUS, &config.focus_action, ); - update_action_button(dnd_toggle, hooks::panel_action::PRIMARY, &config.dnd_action); update_action_button( - clear_button, + &widgets.dnd_toggle, + hooks::panel_action::PRIMARY, + &config.dnd_action, + ); + update_action_button( + &widgets.clear_button, hooks::panel_action::MUTED, &resolved_clear_action(config), ); + widgets.clear_button.set_visible(matches!( + config.clear_button_placement, + PanelClearButtonPlacement::ActionRow + )); update_action_button( - search_toggle, + &widgets.search_toggle, hooks::panel_action::SEARCH, &config.search_action, ); update_action_button( - close_button, + &widgets.close_button, hooks::panel_action::CLOSE, &config.close_action, ); append_ordered_actions( - group, - focus_toggle, - dnd_toggle, - clear_button, - search_toggle, + &widgets.group, + &widgets.focus_toggle, + &widgets.dnd_toggle, + &widgets.clear_button, + &widgets.search_toggle, + &widgets.close_button, &config.action_order, ); + if !action_order_contains_close(&config.action_order) { + move_child_to_box(header_top, &widgets.close_button.clone().upcast()); + } } pub(in crate::ui::panel) fn apply_clear_button_config(button: >k::Button, config: &PanelConfig) { @@ -116,6 +125,10 @@ pub(in crate::ui::panel) fn apply_clear_button_config(button: >k::Button, conf hooks::panel_action::MUTED, &resolved_clear_action(config), ); + button.set_visible(matches!( + config.clear_button_placement, + PanelClearButtonPlacement::NotificationHeader + )); } fn build_toggle_action(role_class: &str, config: &PanelActionConfig) -> gtk::ToggleButton { @@ -204,6 +217,7 @@ fn append_ordered_actions( dnd_toggle: >k::ToggleButton, clear_button: >k::Button, search_toggle: >k::ToggleButton, + close_button: >k::Button, order: &[PanelActionId], ) { let mut previous: Option = None; @@ -213,15 +227,29 @@ fn append_ordered_actions( PanelActionId::Dnd => dnd_toggle.clone().upcast(), PanelActionId::Clear => clear_button.clone().upcast(), PanelActionId::Search => search_toggle.clone().upcast(), + PanelActionId::Close => close_button.clone().upcast(), }; - if child.parent().is_none() { - group.append(&child); - } + move_child_to_box(group, &child); group.reorder_child_after(&child, previous.as_ref()); previous = Some(child); } } +pub(in crate::ui::panel) fn action_order_contains_close(order: &[PanelActionId]) -> bool { + order.contains(&PanelActionId::Close) +} + +fn move_child_to_box(parent: >k::Box, child: >k::Widget) { + let target: gtk::Widget = parent.clone().upcast(); + if child.parent().as_ref() == Some(&target) { + return; + } + if child.parent().is_some() { + child.unparent(); + } + parent.append(child); +} + fn resolved_clear_action(config: &PanelConfig) -> PanelActionConfig { let mut action = config.clear_action.clone(); if action == PanelActionConfig::clear() { @@ -232,31 +260,5 @@ fn resolved_clear_action(config: &PanelConfig) -> PanelActionConfig { } #[cfg(test)] -mod tests { - use unixnotis_core::{PanelActionConfig, PanelConfig}; - - use super::resolved_clear_action; - - #[test] - fn legacy_clear_label_updates_stock_clear_action() { - let config = PanelConfig { - clear_label: "Wipe".to_string(), - ..PanelConfig::default() - }; - - assert_eq!(resolved_clear_action(&config).label, "Wipe"); - } - - #[test] - fn custom_clear_action_with_clear_label_is_not_rewritten() { - let mut custom = PanelActionConfig::clear(); - custom.icon = "edit-delete-symbolic".to_string(); - let config = PanelConfig { - clear_label: "Wipe".to_string(), - clear_action: custom.clone(), - ..PanelConfig::default() - }; - - assert_eq!(resolved_clear_action(&config), custom); - } -} +#[path = "tests/actions.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/tests/actions.rs new file mode 100644 index 00000000..38b08ee2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/actions.rs @@ -0,0 +1,93 @@ +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelActionConfig, PanelActionId, PanelConfig}; + +use super::{ + action_order_contains_close, apply_panel_action_config, build_panel_actions, + resolved_clear_action, +}; + +fn child_with_class(parent: >k::Box, class_name: &str) -> Option { + let mut child = parent.first_child(); + while let Some(widget) = child { + if widget.has_css_class(class_name) { + return Some(widget); + } + child = widget.next_sibling(); + } + None +} + +#[test] +fn legacy_clear_label_updates_stock_clear_action() { + let config = PanelConfig { + clear_label: "Wipe".to_string(), + ..PanelConfig::default() + }; + + assert_eq!(resolved_clear_action(&config).label, "Wipe"); +} + +#[test] +fn custom_clear_action_with_clear_label_is_not_rewritten() { + let mut custom = PanelActionConfig::clear(); + custom.icon = "edit-delete-symbolic".to_string(); + let config = PanelConfig { + clear_label: "Wipe".to_string(), + clear_action: custom.clone(), + ..PanelConfig::default() + }; + + assert_eq!(resolved_clear_action(&config), custom); +} + +#[test] +fn action_order_contains_close_only_when_close_is_configured() { + assert!(!action_order_contains_close(&[ + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + ])); + assert!(action_order_contains_close(&[ + PanelActionId::Close, + PanelActionId::Search, + ])); +} + +#[gtk::test] +fn build_panel_actions_places_explicit_close_inside_action_group() { + let config = PanelConfig { + action_order: vec![PanelActionId::Close, PanelActionId::Search], + ..PanelConfig::default() + }; + + let actions = build_panel_actions(&config); + + assert!(child_with_class(&actions.widgets.group, hooks::panel_action::CLOSE).is_some()); +} + +#[gtk::test] +fn apply_panel_action_config_moves_close_between_header_and_action_group() { + let header_top = gtk::Box::new(gtk::Orientation::Horizontal, 0); + let initial = PanelConfig::default(); + let actions = build_panel_actions(&initial); + header_top.append(&actions.widgets.close_button); + + let row_config = PanelConfig { + action_order: vec![ + PanelActionId::Close, + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + ], + ..PanelConfig::default() + }; + apply_panel_action_config(&header_top, &actions.widgets, &row_config); + assert!(child_with_class(&actions.widgets.group, hooks::panel_action::CLOSE).is_some()); + assert!(child_with_class(&header_top, hooks::panel_action::CLOSE).is_none()); + + apply_panel_action_config(&header_top, &actions.widgets, &PanelConfig::default()); + assert!(child_with_class(&actions.widgets.group, hooks::panel_action::CLOSE).is_none()); + assert!(child_with_class(&header_top, hooks::panel_action::CLOSE).is_some()); +} diff --git a/crates/unixnotis-center/src/ui/panel/types.rs b/crates/unixnotis-center/src/ui/panel/types.rs index fcb52e7c..8dfcb822 100644 --- a/crates/unixnotis-center/src/ui/panel/types.rs +++ b/crates/unixnotis-center/src/ui/panel/types.rs @@ -2,7 +2,7 @@ //! //! Keeping the widget bundle here lets `mod.rs` stay as module wiring only -/// GTK widgets backing the notification center panel window. +/// GTK widgets backing the notification center panel window pub struct PanelWidgets { pub window: gtk::ApplicationWindow, pub surface: gtk::Overlay, @@ -22,6 +22,7 @@ pub struct PanelWidgets { pub header_title: gtk::Label, pub header_subtitle: gtk::Label, pub header_count: gtk::Label, + pub header_top: gtk::Box, pub header_action_row: gtk::Box, pub header_action_group: gtk::Box, pub notification_container: gtk::Box, From 3ea73e81102e1695ba655c70d2320f8dcf5aa2d3 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 43/65] fix(center): keep panel close placement consistent Use the configured action set when deciding whether the header should render its separate close button and cover both placement modes. --- .../unixnotis-center/src/ui/panel/header.rs | 14 +++++-- .../src/ui/panel/tests/header.rs | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/tests/header.rs diff --git a/crates/unixnotis-center/src/ui/panel/header.rs b/crates/unixnotis-center/src/ui/panel/header.rs index 1aa5c3ff..fb76b4ad 100644 --- a/crates/unixnotis-center/src/ui/panel/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header.rs @@ -4,11 +4,12 @@ use gtk::prelude::*; use gtk::Align; use unixnotis_core::{css::hooks, PanelConfig}; -use super::actions::{build_panel_actions, PanelActionWidgets}; +use super::actions::{action_order_contains_close, build_panel_actions, PanelActionWidgets}; use super::search::{build_panel_search, PanelSearchWidgets}; pub(super) struct PanelHeaderWidgets { pub(super) root: gtk::Box, + pub(super) top: gtk::Box, pub(super) action_row: gtk::Box, pub(super) title: gtk::Label, pub(super) subtitle: gtk::Label, @@ -17,6 +18,10 @@ pub(super) struct PanelHeaderWidgets { pub(super) actions: PanelActionWidgets, } +#[cfg(test)] +#[path = "tests/header.rs"] +mod tests; + pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { let header = gtk::Box::new(gtk::Orientation::Vertical, 8); header.add_css_class(hooks::panel_shell::HEADER); @@ -60,8 +65,10 @@ pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { header_top.append(&title_box); header_top.append(&spacer); - // Keep close away from clear so destructive actions do not blend together - header_top.append(&action_area.widgets.close_button); + if !action_order_contains_close(&config.action_order) { + // Keep close away from clear so destructive actions do not blend together + header_top.append(&action_area.widgets.close_button); + } header.append(&header_top); // Action row sits below the title so narrow panels stay stable header.append(&action_area.row); @@ -71,6 +78,7 @@ pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { PanelHeaderWidgets { root: header, + top: header_top, action_row: action_area.row, title, subtitle, diff --git a/crates/unixnotis-center/src/ui/panel/tests/header.rs b/crates/unixnotis-center/src/ui/panel/tests/header.rs new file mode 100644 index 00000000..bd9e5abb --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/header.rs @@ -0,0 +1,42 @@ +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelActionId, PanelConfig}; + +use super::build_panel_header; + +fn child_with_class(parent: >k::Box, class_name: &str) -> Option { + let mut child = parent.first_child(); + while let Some(widget) = child { + if widget.has_css_class(class_name) { + return Some(widget); + } + child = widget.next_sibling(); + } + None +} + +#[gtk::test] +fn build_panel_header_keeps_default_close_in_header_top() { + let header = build_panel_header(&PanelConfig::default()); + + assert!(child_with_class(&header.top, hooks::panel_action::CLOSE).is_some()); + assert!(child_with_class(&header.actions.group, hooks::panel_action::CLOSE).is_none()); +} + +#[gtk::test] +fn build_panel_header_places_explicit_close_inside_action_group() { + let config = PanelConfig { + action_order: vec![ + PanelActionId::Close, + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + ], + ..PanelConfig::default() + }; + + let header = build_panel_header(&config); + + assert!(child_with_class(&header.top, hooks::panel_action::CLOSE).is_none()); + assert!(child_with_class(&header.actions.group, hooks::panel_action::CLOSE).is_some()); +} From bb2ae8407397f93ca294f31ae35238b56486952c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 44/65] fix(center): preserve panel section order on reload Track panel section widgets through reload so configured body order survives refreshes and visibility changes. --- .../unixnotis-center/src/ui/panel/reload.rs | 19 ++-- .../src/ui/panel/tests/reload.rs | 87 +++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 crates/unixnotis-center/src/ui/panel/tests/reload.rs diff --git a/crates/unixnotis-center/src/ui/panel/reload.rs b/crates/unixnotis-center/src/ui/panel/reload.rs index 6564f54a..baae2ef0 100644 --- a/crates/unixnotis-center/src/ui/panel/reload.rs +++ b/crates/unixnotis-center/src/ui/panel/reload.rs @@ -6,12 +6,15 @@ use super::types::PanelWidgets; pub(crate) fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { super::actions::apply_panel_action_config( - &panel.header_action_group, - &panel.focus_toggle, - &panel.dnd_toggle, - &panel.clear_action_button, - &panel.search_toggle, - &panel.close_button, + &panel.header_top, + &super::actions::PanelActionWidgets { + group: panel.header_action_group.clone(), + focus_toggle: panel.focus_toggle.clone(), + dnd_toggle: panel.dnd_toggle.clone(), + clear_button: panel.clear_action_button.clone(), + search_toggle: panel.search_toggle.clone(), + close_button: panel.close_button.clone(), + }, config, ); super::actions::apply_clear_button_config(&panel.clear_header_button, config); @@ -25,3 +28,7 @@ pub(crate) fn apply_reloaded_body_order(panel: &PanelWidgets, order: &[PanelSect order, ); } + +#[cfg(test)] +#[path = "tests/reload.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/reload.rs b/crates/unixnotis-center/src/ui/panel/tests/reload.rs new file mode 100644 index 00000000..a557d520 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/reload.rs @@ -0,0 +1,87 @@ +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelActionId, PanelClearButtonPlacement, PanelConfig}; + +use super::super::header::build_panel_header; +use super::super::sections::build_panel_sections; +use super::super::types::PanelWidgets; +use super::apply_reloaded_panel_chrome; + +fn child_with_class(parent: >k::Box, class_name: &str) -> Option { + let mut child = parent.first_child(); + while let Some(widget) = child { + if widget.has_css_class(class_name) { + return Some(widget); + } + child = widget.next_sibling(); + } + None +} + +fn panel_widgets(config: &PanelConfig) -> PanelWidgets { + let app = gtk::Application::builder() + .application_id("dev.unixnotis.panel.reload.test") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + let header = build_panel_header(config); + let sections = build_panel_sections(config); + + PanelWidgets { + window: gtk::ApplicationWindow::new(&app), + surface: gtk::Overlay::new(), + root: gtk::Box::new(gtk::Orientation::Vertical, 0), + body_stack: sections.body_stack, + widget_revealer: sections.widget_revealer, + widget_stack: sections.widget_stack, + quick_controls: sections.quick_controls, + toggle_container: sections.toggle_container, + stat_container: sections.stat_container, + card_container: sections.card_container, + scroller: sections.scroller, + media_container: sections.media_container, + search_revealer: header.search.revealer, + search_entry: header.search.entry, + search_toggle: header.actions.search_toggle, + header_title: header.title, + header_subtitle: header.subtitle, + header_count: header.count, + header_top: header.top, + header_action_row: header.action_row, + header_action_group: header.actions.group, + notification_container: sections.notification_container, + notification_header_row: sections.notification_header_row, + notification_header: sections.notification_header, + toggle_section_header: sections.toggle_section_header, + stat_section_header: sections.stat_section_header, + footer_label: sections.footer, + focus_toggle: header.actions.focus_toggle, + dnd_toggle: header.actions.dnd_toggle, + clear_action_button: header.actions.clear_button, + clear_header_button: sections.clear_header_button, + close_button: header.actions.close_button, + } +} + +#[gtk::test] +fn apply_reloaded_panel_chrome_updates_clear_buttons_and_close_placement() { + let panel = panel_widgets(&PanelConfig::default()); + let config = PanelConfig { + clear_button_placement: PanelClearButtonPlacement::NotificationHeader, + action_order: vec![ + PanelActionId::Close, + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + ], + ..PanelConfig::default() + }; + + apply_reloaded_panel_chrome(&panel, &config); + + assert!(!panel.clear_action_button.get_visible()); + assert!(panel.clear_header_button.get_visible()); + assert!(child_with_class(&panel.header_top, hooks::panel_action::CLOSE).is_none()); + assert!(child_with_class(&panel.header_action_group, hooks::panel_action::CLOSE).is_some()); +} From ef75a53084937a4aea88a8019333e74249c79a4d Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 45/65] test(center): cover panel section ordering Add panel body tests for notification/widget ordering and make build wiring keep section application explicit. --- crates/unixnotis-center/src/ui/panel/build.rs | 1 + .../src/ui/panel/tests/sections.rs | 44 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index 79c87b09..63befc55 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -98,6 +98,7 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg header_title: header.title, header_subtitle: header.subtitle, header_count: header.count, + header_top: header.top, header_action_row: header.action_row, header_action_group: header.actions.group, notification_container: sections.notification_container, diff --git a/crates/unixnotis-center/src/ui/panel/tests/sections.rs b/crates/unixnotis-center/src/ui/panel/tests/sections.rs index 1d9e08c8..28f08c35 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/sections.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/sections.rs @@ -1,6 +1,9 @@ -use unixnotis_core::{PanelClearButtonPlacement, PanelConfig}; +use gtk::prelude::*; +use unixnotis_core::{PanelClearButtonPlacement, PanelConfig, PanelSection}; -use super::notification_header_row_visible; +use super::{ + apply_panel_body_section_order, build_panel_sections, notification_header_row_visible, +}; #[test] fn notification_header_row_stays_visible_for_header_clear_button() { @@ -26,3 +29,40 @@ fn notification_header_row_uses_section_label_when_section_is_visible() { assert!(notification_header_row_visible(&config)); } + +#[gtk::test] +fn build_panel_sections_can_place_notifications_before_widgets() { + let config = PanelConfig { + section_order: vec![PanelSection::Notifications, PanelSection::Widgets], + ..PanelConfig::default() + }; + + let sections = build_panel_sections(&config); + let first = sections + .body_stack + .first_child() + .expect("body stack should contain notification section"); + let expected: gtk::Widget = sections.notification_container.clone().upcast(); + + assert_eq!(first, expected); +} + +#[gtk::test] +fn apply_panel_body_section_order_can_move_notifications_before_widgets() { + let config = PanelConfig::default(); + let sections = build_panel_sections(&config); + + apply_panel_body_section_order( + §ions.body_stack, + §ions.widget_revealer, + §ions.notification_container, + &[PanelSection::Notifications, PanelSection::Widgets], + ); + let first = sections + .body_stack + .first_child() + .expect("body stack should contain notification section"); + let expected: gtk::Widget = sections.notification_container.clone().upcast(); + + assert_eq!(first, expected); +} From f5e9d2e869362339abef764d5a3c3f57dab0a47c Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 46/65] refactor(core): move panel config into panel module Move panel config, action metadata, and section ordering out of layout into config/panel while preserving the existing public re-exports. --- .../unixnotis-core/src/config/layout/mod.rs | 12 +-------- crates/unixnotis-core/src/config/mod.rs | 4 ++- .../panel_actions.rs => panel/actions.rs} | 1 + .../{layout/panel.rs => panel/config.rs} | 6 ++--- crates/unixnotis-core/src/config/panel/mod.rs | 16 ++++++++++++ .../panel_sections.rs => panel/sections.rs} | 0 crates/unixnotis-core/src/config/types.rs | 25 ++++++++++--------- 7 files changed, 37 insertions(+), 27 deletions(-) rename crates/unixnotis-core/src/config/{layout/panel_actions.rs => panel/actions.rs} (99%) rename crates/unixnotis-core/src/config/{layout/panel.rs => panel/config.rs} (95%) create mode 100644 crates/unixnotis-core/src/config/panel/mod.rs rename crates/unixnotis-core/src/config/{layout/panel_sections.rs => panel/sections.rs} (100%) diff --git a/crates/unixnotis-core/src/config/layout/mod.rs b/crates/unixnotis-core/src/config/layout/mod.rs index 95737167..732cb3f7 100644 --- a/crates/unixnotis-core/src/config/layout/mod.rs +++ b/crates/unixnotis-core/src/config/layout/mod.rs @@ -1,20 +1,10 @@ -//! Layout-related configuration types for the panel and popups +//! Shared layout primitives and popup configuration mod common; -mod panel; -mod panel_actions; -mod panel_sections; mod popup; pub use self::common::{ Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT, PANEL_RUNTIME_WIDTH_MIN, }; -pub use self::panel::PanelConfig; -pub use self::panel_actions::{ - default_panel_action_order, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, -}; -pub use self::panel_sections::{ - default_panel_section_order, default_panel_widget_order, PanelSection, PanelWidgetSection, -}; pub use self::popup::PopupConfig; diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index c2d03f11..7c438d84 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -1,4 +1,4 @@ -//! Configuration module wiring for UnixNotis. +//! Configuration module wiring for UnixNotis //! //! Keeps config types, I/O, and runtime cleanup in separate files @@ -6,6 +6,7 @@ mod commands; mod io; mod layout; mod media; +mod panel; mod rules; mod runtime; mod theme; @@ -15,6 +16,7 @@ mod widget_config; pub use io::{ConfigError, ThemePaths}; pub use layout::*; pub use media::*; +pub use panel::*; pub use rules::*; pub use theme::*; pub use types::*; diff --git a/crates/unixnotis-core/src/config/layout/panel_actions.rs b/crates/unixnotis-core/src/config/panel/actions.rs similarity index 99% rename from crates/unixnotis-core/src/config/layout/panel_actions.rs rename to crates/unixnotis-core/src/config/panel/actions.rs index 8e75b04f..773ac7f8 100644 --- a/crates/unixnotis-core/src/config/layout/panel_actions.rs +++ b/crates/unixnotis-core/src/config/panel/actions.rs @@ -20,6 +20,7 @@ pub enum PanelActionId { Dnd, Clear, Search, + Close, } /// Return the stock left-to-right panel action order diff --git a/crates/unixnotis-core/src/config/layout/panel.rs b/crates/unixnotis-core/src/config/panel/config.rs similarity index 95% rename from crates/unixnotis-core/src/config/layout/panel.rs rename to crates/unixnotis-core/src/config/panel/config.rs index 51977f7d..7aaa3de9 100644 --- a/crates/unixnotis-core/src/config/layout/panel.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -2,10 +2,10 @@ use serde::{Deserialize, Serialize}; +use super::super::{Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT}; use super::{ - default_panel_action_order, default_panel_section_order, default_panel_widget_order, Anchor, - Margins, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, - PanelKeyboardInteractivity, PanelSection, PanelWidgetSection, PANEL_HEIGHT_PERCENT_DEFAULT, + default_panel_action_order, default_panel_section_order, default_panel_widget_order, + PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelSection, PanelWidgetSection, }; #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/unixnotis-core/src/config/panel/mod.rs b/crates/unixnotis-core/src/config/panel/mod.rs new file mode 100644 index 00000000..cc67996c --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/mod.rs @@ -0,0 +1,16 @@ +//! Panel configuration types, ordering, and action metadata + +mod actions; +mod config; +mod sections; + +pub use self::actions::{ + default_panel_action_order, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, +}; +pub use self::config::PanelConfig; +pub use self::sections::{ + default_panel_section_order, default_panel_widget_order, PanelSection, PanelWidgetSection, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/config/layout/panel_sections.rs b/crates/unixnotis-core/src/config/panel/sections.rs similarity index 100% rename from crates/unixnotis-core/src/config/layout/panel_sections.rs rename to crates/unixnotis-core/src/config/panel/sections.rs diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index 0e0e8f5c..d839f3c5 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -1,17 +1,18 @@ -//! Configuration types and defaults for UnixNotis. +//! Configuration types and defaults for UnixNotis //! //! Keeps high-level config categories together and delegates detailed schemas -//! to focused modules for maintainability. +//! to focused modules for maintainability use serde::{Deserialize, Serialize}; -use super::layout::{PanelConfig, PopupConfig}; +use super::layout::PopupConfig; use super::media::MediaConfig; +use super::panel::PanelConfig; use super::rules::RuleConfig; use super::theme::ThemeConfig; use super::widget_config::WidgetsConfig; -/// Top-level configuration loaded from config.toml. +/// Top-level configuration loaded from config.toml #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default)] pub struct Config { @@ -35,7 +36,7 @@ pub struct GeneralConfig { pub log_level: Option, } -/// Inhibit behavior controls how the daemon handles suppression requests. +/// Inhibit behavior controls how the daemon handles suppression requests #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct InhibitConfig { @@ -53,9 +54,9 @@ impl Default for InhibitConfig { #[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum InhibitMode { - /// Store notifications but suppress popup rendering. + /// Store notifications but suppress popup rendering NoPopups, - /// Drop incoming notifications entirely while inhibited. + /// Drop incoming notifications entirely while inhibited DropAll, } @@ -81,17 +82,17 @@ impl Default for HistoryConfig { } } -/// Notification sound behavior. +/// Notification sound behavior #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct SoundConfig { - /// Enables sound playback when the daemon receives notifications. + /// Enables sound playback when the daemon receives notifications pub enabled: bool, - /// Default named sound from the freedesktop sound theme. + /// Default named sound from the freedesktop sound theme pub default_name: Option, - /// Default sound file path, resolves relative to the UnixNotis config dir. + /// Default sound file path, resolves relative to the UnixNotis config dir pub default_file: Option, - /// Directory containing custom sound files, resolves relative to config dir. + /// Directory containing custom sound files, resolves relative to config dir pub default_dir: Option, } From 846bb6a277e4fc51738b38ba5085408d223c8bf3 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 47/65] test(core): add panel config module coverage Add panel-local tests for default config values, custom TOML ordering, action IDs, clear placement, and section parsing. --- .../src/config/panel/tests/actions.rs | 66 +++++++++++ .../src/config/panel/tests/config.rs | 106 ++++++++++++++++++ .../src/config/panel/tests/mod.rs | 5 + .../src/config/panel/tests/sections.rs | 55 +++++++++ 4 files changed, 232 insertions(+) create mode 100644 crates/unixnotis-core/src/config/panel/tests/actions.rs create mode 100644 crates/unixnotis-core/src/config/panel/tests/config.rs create mode 100644 crates/unixnotis-core/src/config/panel/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/panel/tests/sections.rs diff --git a/crates/unixnotis-core/src/config/panel/tests/actions.rs b/crates/unixnotis-core/src/config/panel/tests/actions.rs new file mode 100644 index 00000000..fb2a264b --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/actions.rs @@ -0,0 +1,66 @@ +use super::*; + +#[derive(serde::Deserialize)] +struct ActionOrderFixture { + action_order: Vec, +} + +#[test] +fn default_panel_actions_keep_expected_labels_icons_and_modes() { + let actions = [ + PanelActionConfig::widgets(), + PanelActionConfig::dnd(), + PanelActionConfig::clear(), + PanelActionConfig::search(), + PanelActionConfig::close(), + ]; + + assert_eq!(actions[0].label, "Widgets"); + assert_eq!(actions[0].icon, "applications-system-symbolic"); + assert_eq!(actions[1].label, "DND"); + assert_eq!(actions[1].tooltip, "Silence incoming notifications"); + assert_eq!(actions[2].icon, "user-trash-symbolic"); + assert_eq!(actions[3].label, "Search"); + assert!(actions[3].icon_only); + assert_eq!(actions[4].label, "Close"); + assert!(actions[4].icon_only); +} + +#[test] +fn default_panel_action_order_keeps_close_out_of_action_row() { + assert_eq!( + default_panel_action_order(), + vec![ + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + ] + ); +} + +#[test] +fn panel_action_ids_parse_from_kebab_case_config_values() { + let fixture: ActionOrderFixture = + toml::from_str(r#"action_order = ["widgets", "dnd", "clear", "search", "close"]"#) + .expect("panel action ids should parse"); + + assert_eq!( + fixture.action_order, + vec![ + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + PanelActionId::Search, + PanelActionId::Close, + ] + ); +} + +#[test] +fn clear_button_placement_defaults_to_action_row() { + assert_eq!( + PanelClearButtonPlacement::default(), + PanelClearButtonPlacement::ActionRow + ); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs new file mode 100644 index 00000000..731aa6eb --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::{Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT}; + +#[test] +fn default_panel_config_keeps_expected_layout_and_text_contract() { + let panel = PanelConfig::default(); + + assert!(matches!(panel.anchor, Anchor::Right)); + assert_eq!( + panel.margin, + Margins { + top: 16, + right: 10, + bottom: 14, + left: 10, + } + ); + assert_eq!(panel.width, 420); + assert_eq!(panel.height, PANEL_HEIGHT_PERCENT_DEFAULT); + assert_eq!(panel.height_override, None); + assert!(matches!( + panel.keyboard_interactivity, + PanelKeyboardInteractivity::OnDemand + )); + assert_eq!(panel.title, "Notifications"); + assert_eq!(panel.empty_text, "NO NOTIFICATIONS"); + assert_eq!(panel.search_placeholder, "Search app, title, or message"); + assert!(panel.action_row_visible); + assert!(panel.notification_list_expand); + assert!(panel.close_on_click_outside); + assert!(panel.respect_work_area); +} + +#[test] +fn panel_config_parses_custom_ordering_and_action_blocks() { + let panel: PanelConfig = toml::from_str( + r#" + width = 512 + height = 75 + height_override = 640 + section_order = ["notifications", "widgets"] + widget_order = ["stats", "cards", "media", "toggles", "sliders"] + action_order = ["close", "search", "widgets", "dnd", "clear"] + clear_button_placement = "notification-header" + + [search_action] + label = "Find" + icon = "system-search-symbolic" + tooltip = "Find notifications" + icon_only = false + "#, + ) + .expect("panel config should parse"); + + assert_eq!(panel.width, 512); + assert_eq!(panel.height, 75); + assert_eq!(panel.height_override, Some(640)); + assert_eq!( + panel.section_order, + vec![PanelSection::Notifications, PanelSection::Widgets] + ); + assert_eq!( + panel.widget_order, + vec![ + PanelWidgetSection::Stats, + PanelWidgetSection::Cards, + PanelWidgetSection::Media, + PanelWidgetSection::Toggles, + PanelWidgetSection::Sliders, + ] + ); + assert_eq!( + panel.action_order, + vec![ + PanelActionId::Close, + PanelActionId::Search, + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + ] + ); + assert_eq!( + panel.clear_button_placement, + PanelClearButtonPlacement::NotificationHeader + ); + assert_eq!(panel.search_action.label, "Find"); + assert_eq!(panel.search_action.tooltip, "Find notifications"); + assert!(!panel.search_action.icon_only); +} + +#[test] +fn panel_config_defaults_are_filled_for_partial_toml() { + let panel: PanelConfig = toml::from_str( + r#" + title = "Ops" + action_row_visible = false + "#, + ) + .expect("partial panel config should parse"); + + assert_eq!(panel.title, "Ops"); + assert!(!panel.action_row_visible); + assert_eq!(panel.width, PanelConfig::default().width); + assert_eq!(panel.action_order, default_panel_action_order()); + assert_eq!(panel.focus_action, PanelActionConfig::widgets()); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/mod.rs b/crates/unixnotis-core/src/config/panel/tests/mod.rs new file mode 100644 index 00000000..c46bf825 --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/mod.rs @@ -0,0 +1,5 @@ +use super::*; + +mod actions; +mod config; +mod sections; diff --git a/crates/unixnotis-core/src/config/panel/tests/sections.rs b/crates/unixnotis-core/src/config/panel/tests/sections.rs new file mode 100644 index 00000000..c34d6aab --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/sections.rs @@ -0,0 +1,55 @@ +use super::*; + +#[derive(serde::Deserialize)] +struct SectionOrderFixture { + sections: Vec, + widgets: Vec, +} + +#[test] +fn default_panel_section_order_keeps_widgets_above_notifications() { + assert_eq!( + default_panel_section_order(), + vec![PanelSection::Widgets, PanelSection::Notifications] + ); +} + +#[test] +fn default_panel_widget_order_keeps_sliders_first() { + assert_eq!( + default_panel_widget_order(), + vec![ + PanelWidgetSection::Sliders, + PanelWidgetSection::Media, + PanelWidgetSection::Toggles, + PanelWidgetSection::Stats, + PanelWidgetSection::Cards, + ] + ); +} + +#[test] +fn panel_sections_parse_from_kebab_case_config_values() { + let fixture: SectionOrderFixture = toml::from_str( + r#" + sections = ["widgets", "notifications"] + widgets = ["media", "toggles", "sliders", "stats", "cards"] + "#, + ) + .expect("sections should parse"); + + assert_eq!( + fixture.sections, + vec![PanelSection::Widgets, PanelSection::Notifications] + ); + assert_eq!( + fixture.widgets, + vec![ + PanelWidgetSection::Media, + PanelWidgetSection::Toggles, + PanelWidgetSection::Sliders, + PanelWidgetSection::Stats, + PanelWidgetSection::Cards, + ] + ); +} From dc89d7422550456774033f88c0bccc82cdea01fe Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 48/65] test(core): move media config tests beside media module Relocate media effective-behavior tests under config/media/tests so the media folder owns its coverage. --- crates/unixnotis-core/src/config/media/effective.rs | 2 +- .../src/config/{tests/media.rs => media/tests/effective.rs} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename crates/unixnotis-core/src/config/{tests/media.rs => media/tests/effective.rs} (100%) diff --git a/crates/unixnotis-core/src/config/media/effective.rs b/crates/unixnotis-core/src/config/media/effective.rs index 191b3c03..fae2fd7f 100644 --- a/crates/unixnotis-core/src/config/media/effective.rs +++ b/crates/unixnotis-core/src/config/media/effective.rs @@ -49,5 +49,5 @@ impl MediaConfig { } #[cfg(test)] -#[path = "../tests/media.rs"] +#[path = "tests/effective.rs"] mod tests; diff --git a/crates/unixnotis-core/src/config/tests/media.rs b/crates/unixnotis-core/src/config/media/tests/effective.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/media.rs rename to crates/unixnotis-core/src/config/media/tests/effective.rs From c390cb4b35cc00b9ef7e7426cccac506ebf5e560 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 49/65] test(core): move runtime sanitize tests beside sanitizer Relocate runtime sanitize tests into config/runtime/sanitize/tests and keep sanitizer modules responsible for their own edge-case coverage. --- .../src/config/runtime/sanitize/media.rs | 2 +- .../src/config/runtime/sanitize/pipeline.rs | 2 +- .../src/config/runtime/sanitize/plugins.rs | 2 +- .../src/config/runtime/sanitize/shell.rs | 2 +- .../sanitize/tests}/media.rs | 0 .../sanitize/tests/pipeline.rs} | 23 +++++++++++++++++++ .../sanitize/tests}/plugins.rs | 0 .../sanitize/tests}/shell.rs | 0 .../sanitize/tests}/theme.rs | 0 .../src/config/runtime/sanitize/theme.rs | 2 +- 10 files changed, 28 insertions(+), 5 deletions(-) rename crates/unixnotis-core/src/config/{tests/runtime => runtime/sanitize/tests}/media.rs (100%) rename crates/unixnotis-core/src/config/{tests/runtime/layout.rs => runtime/sanitize/tests/pipeline.rs} (95%) rename crates/unixnotis-core/src/config/{tests/runtime => runtime/sanitize/tests}/plugins.rs (100%) rename crates/unixnotis-core/src/config/{tests/runtime => runtime/sanitize/tests}/shell.rs (100%) rename crates/unixnotis-core/src/config/{tests/runtime => runtime/sanitize/tests}/theme.rs (100%) diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/media.rs b/crates/unixnotis-core/src/config/runtime/sanitize/media.rs index 04abfaee..dfcc4dbf 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/media.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/media.rs @@ -117,5 +117,5 @@ fn normalize_media_aliases(aliases: &BTreeMap) -> BTreeMap bool { } #[cfg(test)] -#[path = "../../tests/runtime/shell.rs"] +#[path = "tests/shell.rs"] mod tests; diff --git a/crates/unixnotis-core/src/config/tests/runtime/media.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/media.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/runtime/media.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/media.rs diff --git a/crates/unixnotis-core/src/config/tests/runtime/layout.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs similarity index 95% rename from crates/unixnotis-core/src/config/tests/runtime/layout.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 212b7388..ada654bc 100644 --- a/crates/unixnotis-core/src/config/tests/runtime/layout.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -154,6 +154,29 @@ fn sanitize_preserves_optional_panel_labels_and_repairs_widget_order() { assert_eq!(config.panel.close_action, PanelActionConfig::close()); } +#[test] +fn sanitize_preserves_explicit_close_action_order() { + let mut config = Config::default(); + config.panel.action_order = vec![ + PanelActionId::Close, + PanelActionId::Search, + PanelActionId::Close, + ]; + + sanitize_config(&mut config); + + assert_eq!( + config.panel.action_order, + vec![ + PanelActionId::Close, + PanelActionId::Search, + PanelActionId::Widgets, + PanelActionId::Dnd, + PanelActionId::Clear, + ] + ); +} + #[test] fn default_panel_section_labels_do_not_force_widget_headings() { let config = PanelConfig::default(); diff --git a/crates/unixnotis-core/src/config/tests/runtime/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/runtime/plugins.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs diff --git a/crates/unixnotis-core/src/config/tests/runtime/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/runtime/shell.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs diff --git a/crates/unixnotis-core/src/config/tests/runtime/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/runtime/theme.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs index e17155ef..5f3b2690 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs @@ -53,5 +53,5 @@ fn clamp_alpha_finite(value: &mut f32) { } #[cfg(test)] -#[path = "../../tests/runtime/theme.rs"] +#[path = "tests/theme.rs"] mod tests; From fe5b9da3deb6b91f87195597a72fab72d753031a Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 50/65] test(core): move runtime widget backend tests Move runtime widget backend migration tests under config/runtime/tests so backend detection coverage sits beside runtime shaping. --- .../{tests/runtime_widgets.rs => runtime/tests/widgets.rs} | 0 crates/unixnotis-core/src/config/runtime/widgets.rs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename crates/unixnotis-core/src/config/{tests/runtime_widgets.rs => runtime/tests/widgets.rs} (100%) diff --git a/crates/unixnotis-core/src/config/tests/runtime_widgets.rs b/crates/unixnotis-core/src/config/runtime/tests/widgets.rs similarity index 100% rename from crates/unixnotis-core/src/config/tests/runtime_widgets.rs rename to crates/unixnotis-core/src/config/runtime/tests/widgets.rs diff --git a/crates/unixnotis-core/src/config/runtime/widgets.rs b/crates/unixnotis-core/src/config/runtime/widgets.rs index f0b4d7f3..97508fdc 100644 --- a/crates/unixnotis-core/src/config/runtime/widgets.rs +++ b/crates/unixnotis-core/src/config/runtime/widgets.rs @@ -67,5 +67,5 @@ pub(in super::super) fn apply_brightness_backend(brightness: &mut SliderWidgetCo } #[cfg(test)] -#[path = "../tests/runtime_widgets.rs"] +#[path = "tests/widgets.rs"] mod tests; From 31dcc816f4fb3c85528a6f467b3ac1d823e65585 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 51/65] test(core): split widget root tests into folder Replace the flat widget test file with a tests module and keep root WidgetsConfig coverage in a dedicated test file. --- .../src/config/tests/widgets.rs | 227 ------------------ .../unixnotis-core/src/config/widgets/root.rs | 2 +- .../src/config/widgets/tests/mod.rs | 6 + .../src/config/widgets/tests/root.rs | 52 ++++ 4 files changed, 59 insertions(+), 228 deletions(-) delete mode 100644 crates/unixnotis-core/src/config/tests/widgets.rs create mode 100644 crates/unixnotis-core/src/config/widgets/tests/mod.rs create mode 100644 crates/unixnotis-core/src/config/widgets/tests/root.rs diff --git a/crates/unixnotis-core/src/config/tests/widgets.rs b/crates/unixnotis-core/src/config/tests/widgets.rs deleted file mode 100644 index 0c61386d..00000000 --- a/crates/unixnotis-core/src/config/tests/widgets.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::collections::HashSet; - -use crate::{ - CardWidgetConfig, SliderWidgetConfig, StatWidgetConfig, ToggleLayout, ToggleWidgetConfig, - WidgetPluginConfig, WidgetsConfig, -}; - -#[test] -fn default_widgets_keep_expected_grid_shape() { - let widgets = WidgetsConfig::default(); - - // These counts define the visible stock control-center sections - assert_eq!(widgets.toggle_layout, ToggleLayout::Horizontal); - assert_eq!(widgets.toggle_columns, 4); - assert_eq!(widgets.stat_columns, 2); - assert_eq!(widgets.card_columns, 2); - assert_eq!(widgets.toggles.len(), 4); - assert_eq!(widgets.stats.len(), 3); - assert_eq!(widgets.cards.len(), 2); -} - -#[test] -fn default_toggles_have_unique_stable_kinds() { - let widgets = WidgetsConfig::default(); - let mut seen = HashSet::new(); - - for toggle in &widgets.toggles { - let kind = toggle.kind.as_deref().expect("default toggle kind"); - assert!( - seen.insert(kind.to_string()), - "duplicate toggle kind: {kind}" - ); - } -} - -#[test] -fn default_night_toggle_uses_shipped_relative_scripts() { - let night = WidgetsConfig::default() - .toggles - .into_iter() - .find(|toggle| toggle.kind.as_deref() == Some("night")) - .expect("night toggle"); - - // The commands stay config-owned while core startup guarantees the files exist - assert_eq!( - night.state_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-state") - ); - assert_eq!( - night.on_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-on") - ); - assert_eq!( - night.off_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-off") - ); - assert_eq!(night.toggle_cmd, None); - assert_eq!(night.watch_cmd, None); -} - -#[test] -fn default_toggles_keep_commands_config_owned() { - let widgets = WidgetsConfig::default(); - - for toggle in widgets.toggles { - for command in [ - toggle.state_cmd.as_deref(), - toggle.toggle_cmd.as_deref(), - toggle.on_cmd.as_deref(), - toggle.off_cmd.as_deref(), - toggle.watch_cmd.as_deref(), - ] - .into_iter() - .flatten() - { - // Stock commands should stay relative or PATH based so config files remain portable - assert!( - !command.starts_with('/'), - "absolute command leaked: {command}" - ); - } - } -} - -#[test] -fn custom_toggles_round_trip_arbitrary_user_commands() { - let widgets: WidgetsConfig = toml::from_str( - r#" - [[toggles]] - enabled = true - label = "Build" - icon = "applications-development-symbolic" - state_cmd = "scripts/build-state" - toggle_cmd = "sh -c 'make test && notify-send done'" - on_cmd = "scripts/build-on" - off_cmd = "scripts/build-off" - watch_cmd = "scripts/build-watch" - "#, - ) - .expect("widgets config should parse"); - - let toggle = widgets.toggles.first().expect("custom toggle"); - assert_eq!(toggle.label, "Build"); - assert_eq!(toggle.state_cmd.as_deref(), Some("scripts/build-state")); - assert_eq!( - toggle.toggle_cmd.as_deref(), - Some("sh -c 'make test && notify-send done'") - ); - assert_eq!(toggle.on_cmd.as_deref(), Some("scripts/build-on")); - assert_eq!(toggle.off_cmd.as_deref(), Some("scripts/build-off")); - assert_eq!(toggle.watch_cmd.as_deref(), Some("scripts/build-watch")); -} - -#[test] -fn blank_toggle_default_is_disabled_and_action_free() { - let toggle = ToggleWidgetConfig::default(); - - assert!(!toggle.enabled); - assert_eq!(toggle.kind, None); - assert_eq!(toggle.state_cmd, None); - assert_eq!(toggle.toggle_cmd, None); - assert_eq!(toggle.on_cmd, None); - assert_eq!(toggle.off_cmd, None); - assert_eq!(toggle.watch_cmd, None); -} - -#[test] -fn default_panel_actions_keep_expected_labels_icons_and_modes() { - let actions = [ - crate::PanelActionConfig::widgets(), - crate::PanelActionConfig::dnd(), - crate::PanelActionConfig::clear(), - crate::PanelActionConfig::search(), - crate::PanelActionConfig::close(), - ]; - - assert_eq!(actions[0].label, "Widgets"); - assert_eq!(actions[0].icon, "applications-system-symbolic"); - assert_eq!(actions[1].label, "DND"); - assert_eq!(actions[1].tooltip, "Silence incoming notifications"); - assert_eq!(actions[2].icon, "user-trash-symbolic"); - assert_eq!(actions[3].label, "Search"); - assert!(actions[3].icon_only); - assert_eq!(actions[4].label, "Close"); - assert!(actions[4].icon_only); -} - -#[test] -fn default_card_widgets_keep_builtin_identity_and_layout() { - let widgets = WidgetsConfig::default(); - let calendar = &widgets.cards[0]; - let weather = &widgets.cards[1]; - - assert_eq!(calendar.kind.as_deref(), Some("calendar")); - assert_eq!(calendar.title, "Calendar"); - assert_eq!(calendar.icon.as_deref(), Some("x-office-calendar-symbolic")); - assert_eq!(calendar.min_height, 180); - assert_eq!(calendar.cmd, None); - - assert_eq!(weather.kind.as_deref(), Some("weather")); - assert_eq!(weather.title, "Weather"); - assert_eq!(weather.subtitle.as_deref(), Some("No data")); - assert_eq!(weather.icon.as_deref(), Some("weather-clear-symbolic")); - assert_eq!(weather.min_height, 160); -} - -#[test] -fn default_slider_widgets_keep_stock_commands() { - let widgets = WidgetsConfig::default(); - - assert!(widgets.volume.enabled); - assert_eq!(widgets.volume.label, "Volume"); - assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::WPCTL_GET); - assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::WPCTL_SET); - assert_eq!( - widgets.volume.toggle_cmd.as_deref(), - Some(SliderWidgetConfig::WPCTL_TOGGLE) - ); - assert_eq!(widgets.volume.watch_cmd, None); - - assert!(widgets.brightness.enabled); - assert_eq!(widgets.brightness.label, "Brightness"); - assert_eq!(widgets.brightness.get_cmd, "brightnessctl -m"); - assert_eq!(widgets.brightness.set_cmd, "brightnessctl s {value}%"); - assert_eq!(widgets.brightness.watch_cmd, None); -} - -#[test] -fn default_stat_widgets_keep_builtin_commands() { - let widgets = WidgetsConfig::default(); - let expected = [ - ("CPU", "utilities-system-monitor-symbolic", "builtin:cpu"), - ("RAM", "drive-harddisk-symbolic", "builtin:memory"), - ("Battery", "battery-full-symbolic", "builtin:battery"), - ]; - - for (stat, (label, icon, command)) in widgets.stats.iter().zip(expected) { - assert!(stat.enabled); - assert_eq!(stat.label, label); - assert_eq!(stat.icon.as_deref(), Some(icon)); - assert_eq!(stat.cmd.as_deref(), Some(command)); - assert_eq!(stat.min_height, 72); - } -} - -#[test] -fn blank_card_and_stat_defaults_are_disabled_placeholders() { - let card = CardWidgetConfig::default(); - let stat = StatWidgetConfig::default(); - - assert!(!card.enabled); - assert_eq!(card.title, "Card"); - assert_eq!(card.min_height, 120); - assert!(!stat.enabled); - assert_eq!(stat.label, "Stat"); - assert_eq!(stat.min_height, 72); -} - -#[test] -fn widget_plugin_defaults_keep_contract_limits() { - let plugin = WidgetPluginConfig::default(); - - assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); - assert_eq!(plugin.command, ""); - assert_eq!(plugin.timeout_ms, 2_000); - assert_eq!(plugin.max_output_bytes, 16 * 1024); -} diff --git a/crates/unixnotis-core/src/config/widgets/root.rs b/crates/unixnotis-core/src/config/widgets/root.rs index 2b4cd189..80c02add 100644 --- a/crates/unixnotis-core/src/config/widgets/root.rs +++ b/crates/unixnotis-core/src/config/widgets/root.rs @@ -59,5 +59,5 @@ impl Default for WidgetsConfig { } #[cfg(test)] -#[path = "../tests/widgets.rs"] +#[path = "tests/mod.rs"] mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/tests/mod.rs b/crates/unixnotis-core/src/config/widgets/tests/mod.rs new file mode 100644 index 00000000..8de095ac --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/mod.rs @@ -0,0 +1,6 @@ +mod cards; +mod plugin; +mod root; +mod sliders; +mod stats; +mod toggles; diff --git a/crates/unixnotis-core/src/config/widgets/tests/root.rs b/crates/unixnotis-core/src/config/widgets/tests/root.rs new file mode 100644 index 00000000..9b3b9d2c --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/root.rs @@ -0,0 +1,52 @@ +use crate::{ToggleLayout, WidgetsConfig}; + +#[test] +fn default_widgets_keep_expected_grid_shape() { + let widgets = WidgetsConfig::default(); + + // These counts define the visible stock control-center sections + assert_eq!(widgets.toggle_layout, ToggleLayout::Horizontal); + assert_eq!(widgets.toggle_columns, 4); + assert_eq!(widgets.stat_columns, 2); + assert_eq!(widgets.card_columns, 2); + assert_eq!(widgets.toggles.len(), 4); + assert_eq!(widgets.stats.len(), 3); + assert_eq!(widgets.cards.len(), 2); +} + +#[test] +fn partial_widgets_toml_keeps_unspecified_stock_defaults() { + let widgets: WidgetsConfig = toml::from_str( + r#" + toggle_layout = "vertical" + toggle_columns = 3 + refresh_interval_ms = 2500 + "#, + ) + .expect("partial widgets config should parse"); + + assert_eq!(widgets.toggle_layout, ToggleLayout::Vertical); + assert_eq!(widgets.toggle_columns, 3); + assert_eq!(widgets.refresh_interval_ms, 2500); + assert_eq!(widgets.stat_columns, WidgetsConfig::default().stat_columns); + assert_eq!(widgets.cards.len(), WidgetsConfig::default().cards.len()); + assert_eq!(widgets.volume.label, "Volume"); +} + +#[test] +fn empty_widget_arrays_replace_stock_sections() { + let widgets: WidgetsConfig = toml::from_str( + r#" + toggles = [] + stats = [] + cards = [] + "#, + ) + .expect("empty widget arrays should parse"); + + assert!(widgets.toggles.is_empty()); + assert!(widgets.stats.is_empty()); + assert!(widgets.cards.is_empty()); + assert!(widgets.volume.enabled); + assert!(widgets.brightness.enabled); +} From 5f461c8e70ac8751504e2b81ecea463732364cb4 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 52/65] test(core): add slider and toggle config coverage Add TOML and default coverage for slider bounds, parse modes, toggle aliases, layout values, and stock command portability. --- .../src/config/widgets/tests/sliders.rs | 85 ++++++++++++ .../src/config/widgets/tests/toggles.rs | 126 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 crates/unixnotis-core/src/config/widgets/tests/sliders.rs create mode 100644 crates/unixnotis-core/src/config/widgets/tests/toggles.rs diff --git a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs new file mode 100644 index 00000000..79df10d8 --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs @@ -0,0 +1,85 @@ +use crate::{NumericParseMode, SliderWidgetConfig, WidgetsConfig}; + +#[test] +fn default_slider_widgets_keep_stock_commands() { + let widgets = WidgetsConfig::default(); + + assert!(widgets.volume.enabled); + assert_eq!(widgets.volume.label, "Volume"); + assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::WPCTL_GET); + assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::WPCTL_SET); + assert_eq!( + widgets.volume.toggle_cmd.as_deref(), + Some(SliderWidgetConfig::WPCTL_TOGGLE) + ); + assert_eq!(widgets.volume.watch_cmd, None); + + assert!(widgets.brightness.enabled); + assert_eq!(widgets.brightness.label, "Brightness"); + assert_eq!(widgets.brightness.get_cmd, "brightnessctl -m"); + assert_eq!(widgets.brightness.set_cmd, "brightnessctl s {value}%"); + assert_eq!(widgets.brightness.watch_cmd, None); +} + +#[test] +fn custom_slider_config_parses_numeric_bounds_and_labels() { + let slider: SliderWidgetConfig = toml::from_str( + r#" + enabled = true + label = "Mic" + icon = "audio-input-microphone-symbolic" + icon_muted = "microphone-disabled-symbolic" + get_cmd = "scripts/mic get" + set_cmd = "scripts/mic set {value}" + toggle_cmd = "scripts/mic toggle" + watch_cmd = "scripts/mic watch" + min = -12.5 + max = 12.5 + step = 0.5 + show_value = false + segments = 8 + show_sublabels = true + sublabel_min = "quiet" + sublabel_max = "loud" + parse_mode = "ratio" + "#, + ) + .expect("custom slider should parse"); + + assert!(slider.enabled); + assert_eq!(slider.label, "Mic"); + assert_eq!( + slider.icon_muted.as_deref(), + Some("microphone-disabled-symbolic") + ); + assert_eq!(slider.toggle_cmd.as_deref(), Some("scripts/mic toggle")); + assert_eq!(slider.watch_cmd.as_deref(), Some("scripts/mic watch")); + assert_eq!(slider.min, -12.5); + assert_eq!(slider.max, 12.5); + assert_eq!(slider.step, 0.5); + assert!(!slider.show_value); + assert_eq!(slider.segments, 8); + assert!(slider.show_sublabels); + assert_eq!(slider.sublabel_min, "quiet"); + assert_eq!(slider.sublabel_max, "loud"); + assert_eq!(slider.parse_mode, NumericParseMode::Ratio); +} + +#[test] +fn numeric_parse_modes_accept_only_known_kebab_case_values() { + let percent: NumericParseMode = toml::from_str("mode = \"percent\"") + .map(|wrapper: NumericParseModeWrapper| wrapper.mode) + .expect("percent mode should parse"); + let ratio: NumericParseMode = toml::from_str("mode = \"ratio\"") + .map(|wrapper: NumericParseModeWrapper| wrapper.mode) + .expect("ratio mode should parse"); + + assert_eq!(percent, NumericParseMode::Percent); + assert_eq!(ratio, NumericParseMode::Ratio); + assert!(toml::from_str::("mode = \"raw\"").is_err()); +} + +#[derive(serde::Deserialize)] +struct NumericParseModeWrapper { + mode: NumericParseMode, +} diff --git a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs new file mode 100644 index 00000000..fe367d48 --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs @@ -0,0 +1,126 @@ +use std::collections::HashSet; + +use crate::{ToggleLayout, ToggleWidgetConfig, WidgetsConfig}; + +#[test] +fn default_toggles_have_unique_stable_kinds() { + let widgets = WidgetsConfig::default(); + let mut seen = HashSet::new(); + + for toggle in &widgets.toggles { + let kind = toggle.kind.as_deref().expect("default toggle kind"); + assert!( + seen.insert(kind.to_string()), + "duplicate toggle kind: {kind}" + ); + } +} + +#[test] +fn default_night_toggle_uses_shipped_relative_scripts() { + let night = WidgetsConfig::default() + .toggles + .into_iter() + .find(|toggle| toggle.kind.as_deref() == Some("night")) + .expect("night toggle"); + + // The commands stay config-owned while core startup guarantees the files exist + assert_eq!( + night.state_cmd.as_deref(), + Some("scripts/unixnotis-blue-light-state") + ); + assert_eq!( + night.on_cmd.as_deref(), + Some("scripts/unixnotis-blue-light-on") + ); + assert_eq!( + night.off_cmd.as_deref(), + Some("scripts/unixnotis-blue-light-off") + ); + assert_eq!(night.toggle_cmd, None); + assert_eq!(night.watch_cmd, None); +} + +#[test] +fn default_toggles_keep_commands_config_owned() { + let widgets = WidgetsConfig::default(); + + for toggle in widgets.toggles { + for command in [ + toggle.state_cmd.as_deref(), + toggle.toggle_cmd.as_deref(), + toggle.on_cmd.as_deref(), + toggle.off_cmd.as_deref(), + toggle.watch_cmd.as_deref(), + ] + .into_iter() + .flatten() + { + // Stock commands should stay relative or PATH based so config files remain portable + assert!( + !command.starts_with('/'), + "absolute command leaked: {command}" + ); + } + } +} + +#[test] +fn custom_toggles_round_trip_arbitrary_user_commands() { + let widgets: WidgetsConfig = toml::from_str( + r#" + [[toggles]] + enabled = true + id = "build" + label = "Build" + icon = "applications-development-symbolic" + state_cmd = "scripts/build-state" + toggle_cmd = "sh -c 'make test && notify-send done'" + on_cmd = "scripts/build-on" + off_cmd = "scripts/build-off" + watch_cmd = "scripts/build-watch" + "#, + ) + .expect("widgets config should parse"); + + let toggle = widgets.toggles.first().expect("custom toggle"); + assert_eq!(toggle.kind.as_deref(), Some("build")); + assert_eq!(toggle.label, "Build"); + assert_eq!(toggle.state_cmd.as_deref(), Some("scripts/build-state")); + assert_eq!( + toggle.toggle_cmd.as_deref(), + Some("sh -c 'make test && notify-send done'") + ); + assert_eq!(toggle.on_cmd.as_deref(), Some("scripts/build-on")); + assert_eq!(toggle.off_cmd.as_deref(), Some("scripts/build-off")); + assert_eq!(toggle.watch_cmd.as_deref(), Some("scripts/build-watch")); +} + +#[test] +fn blank_toggle_default_is_disabled_and_action_free() { + let toggle = ToggleWidgetConfig::default(); + + assert!(!toggle.enabled); + assert_eq!(toggle.kind, None); + assert_eq!(toggle.state_cmd, None); + assert_eq!(toggle.toggle_cmd, None); + assert_eq!(toggle.on_cmd, None); + assert_eq!(toggle.off_cmd, None); + assert_eq!(toggle.watch_cmd, None); +} + +#[test] +fn toggle_layout_parses_kebab_case_values() { + #[derive(serde::Deserialize)] + struct LayoutFixture { + layout: ToggleLayout, + } + + let horizontal: LayoutFixture = + toml::from_str("layout = \"horizontal\"").expect("horizontal should parse"); + let vertical: LayoutFixture = + toml::from_str("layout = \"vertical\"").expect("vertical should parse"); + + assert_eq!(horizontal.layout, ToggleLayout::Horizontal); + assert_eq!(vertical.layout, ToggleLayout::Vertical); +} From fed6f6979e13bba2b6727f39de225cc0e0deff86 Mon Sep 17 00:00:00 2001 From: locainin Date: Sun, 5 Jul 2026 22:25:29 -0500 Subject: [PATCH 53/65] test(core): add card stat and plugin config coverage Cover card layouts, carousel options, stat plugin configs, blank defaults, and widget plugin fallback limits. --- .../src/config/widgets/tests/cards.rs | 99 +++++++++++++++++++ .../src/config/widgets/tests/plugin.rs | 29 ++++++ .../src/config/widgets/tests/stats.rs | 69 +++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 crates/unixnotis-core/src/config/widgets/tests/cards.rs create mode 100644 crates/unixnotis-core/src/config/widgets/tests/plugin.rs create mode 100644 crates/unixnotis-core/src/config/widgets/tests/stats.rs diff --git a/crates/unixnotis-core/src/config/widgets/tests/cards.rs b/crates/unixnotis-core/src/config/widgets/tests/cards.rs new file mode 100644 index 00000000..68d979f3 --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/cards.rs @@ -0,0 +1,99 @@ +use crate::{CardLayout, CardWidgetConfig, WidgetPluginConfig, WidgetsConfig}; + +#[test] +fn default_card_widgets_keep_builtin_identity_and_layout() { + let widgets = WidgetsConfig::default(); + let calendar = &widgets.cards[0]; + let weather = &widgets.cards[1]; + + assert_eq!(calendar.kind.as_deref(), Some("calendar")); + assert_eq!(calendar.layout, CardLayout::Default); + assert_eq!(calendar.title, "Calendar"); + assert_eq!(calendar.icon.as_deref(), Some("x-office-calendar-symbolic")); + assert_eq!(calendar.min_height, 180); + assert_eq!(calendar.cmd, None); + + assert_eq!(weather.kind.as_deref(), Some("weather")); + assert_eq!(weather.title, "Weather"); + assert_eq!(weather.subtitle.as_deref(), Some("No data")); + assert_eq!(weather.icon.as_deref(), Some("weather-clear-symbolic")); + assert_eq!(weather.min_height, 160); +} + +#[test] +fn blank_card_default_is_disabled_placeholder() { + let card = CardWidgetConfig::default(); + + assert!(!card.enabled); + assert_eq!(card.kind, None); + assert_eq!(card.layout, CardLayout::Default); + assert_eq!(card.title, "Card"); + assert_eq!(card.subtitle, None); + assert_eq!(card.icon, None); + assert_eq!(card.cmd, None); + assert_eq!(card.plugin, None); + assert_eq!(card.min_height, 120); + assert!(!card.monospace); + assert_eq!(card.carousel_dots, 0); + assert!(!card.carousel_arrows); +} + +#[test] +fn custom_card_layout_and_carousel_options_parse() { + let card: CardWidgetConfig = toml::from_str( + r#" + enabled = true + kind = "hero" + layout = "image-row" + title = "Now" + subtitle = "Live" + icon = "image-x-generic-symbolic" + cmd = "scripts/card" + min_height = 220 + monospace = true + carousel_dots = 5 + carousel_arrows = true + + [plugin] + api_version = 1 + command = "scripts/card-plugin" + timeout_ms = 3000 + max_output_bytes = 4096 + "#, + ) + .expect("card should parse"); + + assert!(card.enabled); + assert_eq!(card.kind.as_deref(), Some("hero")); + assert_eq!(card.layout, CardLayout::ImageRow); + assert_eq!(card.title, "Now"); + assert_eq!(card.subtitle.as_deref(), Some("Live")); + assert_eq!(card.icon.as_deref(), Some("image-x-generic-symbolic")); + assert_eq!(card.cmd.as_deref(), Some("scripts/card")); + assert_eq!(card.min_height, 220); + assert!(card.monospace); + assert_eq!(card.carousel_dots, 5); + assert!(card.carousel_arrows); + assert_eq!( + card.plugin, + Some(WidgetPluginConfig { + api_version: 1, + command: "scripts/card-plugin".to_string(), + timeout_ms: 3000, + max_output_bytes: 4096, + }) + ); +} + +#[test] +fn card_layout_parses_banner_and_rejects_unknown_values() { + #[derive(serde::Deserialize)] + struct LayoutFixture { + layout: CardLayout, + } + + let parsed: LayoutFixture = toml::from_str("layout = \"banner\"").expect("banner should parse"); + + assert_eq!(parsed.layout, CardLayout::Banner); + assert!(toml::from_str::("layout = \"poster\"").is_err()); +} diff --git a/crates/unixnotis-core/src/config/widgets/tests/plugin.rs b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs new file mode 100644 index 00000000..fc70753e --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs @@ -0,0 +1,29 @@ +use crate::WidgetPluginConfig; + +#[test] +fn widget_plugin_defaults_keep_contract_limits() { + let plugin = WidgetPluginConfig::default(); + + assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); + assert_eq!(plugin.command, ""); + assert_eq!(plugin.timeout_ms, 2_000); + assert_eq!(plugin.max_output_bytes, 16 * 1024); +} + +#[test] +fn widget_plugin_partial_toml_uses_default_limits() { + let plugin: WidgetPluginConfig = toml::from_str( + r#" + command = "scripts/widget" + "#, + ) + .expect("plugin should parse"); + + assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); + assert_eq!(plugin.command, "scripts/widget"); + assert_eq!(plugin.timeout_ms, WidgetPluginConfig::default().timeout_ms); + assert_eq!( + plugin.max_output_bytes, + WidgetPluginConfig::default().max_output_bytes + ); +} diff --git a/crates/unixnotis-core/src/config/widgets/tests/stats.rs b/crates/unixnotis-core/src/config/widgets/tests/stats.rs new file mode 100644 index 00000000..5a7821b3 --- /dev/null +++ b/crates/unixnotis-core/src/config/widgets/tests/stats.rs @@ -0,0 +1,69 @@ +use crate::{StatWidgetConfig, WidgetPluginConfig, WidgetsConfig}; + +#[test] +fn default_stat_widgets_keep_builtin_commands() { + let widgets = WidgetsConfig::default(); + let expected = [ + ("CPU", "utilities-system-monitor-symbolic", "builtin:cpu"), + ("RAM", "drive-harddisk-symbolic", "builtin:memory"), + ("Battery", "battery-full-symbolic", "builtin:battery"), + ]; + + for (stat, (label, icon, command)) in widgets.stats.iter().zip(expected) { + assert!(stat.enabled); + assert_eq!(stat.label, label); + assert_eq!(stat.icon.as_deref(), Some(icon)); + assert_eq!(stat.cmd.as_deref(), Some(command)); + assert_eq!(stat.min_height, 72); + } +} + +#[test] +fn blank_stat_default_is_disabled_placeholder() { + let stat = StatWidgetConfig::default(); + + assert!(!stat.enabled); + assert_eq!(stat.label, "Stat"); + assert_eq!(stat.icon, None); + assert_eq!(stat.kind, None); + assert_eq!(stat.cmd, None); + assert_eq!(stat.plugin, None); + assert_eq!(stat.min_height, 72); +} + +#[test] +fn custom_stat_plugin_config_parses_with_command_fallback() { + let stat: StatWidgetConfig = toml::from_str( + r#" + enabled = true + label = "GPU" + icon = "video-display-symbolic" + kind = "gpu" + cmd = "scripts/gpu-fallback" + min_height = 96 + + [plugin] + api_version = 1 + command = "scripts/gpu-plugin" + timeout_ms = 1500 + max_output_bytes = 2048 + "#, + ) + .expect("stat should parse"); + + assert!(stat.enabled); + assert_eq!(stat.label, "GPU"); + assert_eq!(stat.icon.as_deref(), Some("video-display-symbolic")); + assert_eq!(stat.kind.as_deref(), Some("gpu")); + assert_eq!(stat.cmd.as_deref(), Some("scripts/gpu-fallback")); + assert_eq!(stat.min_height, 96); + assert_eq!( + stat.plugin, + Some(WidgetPluginConfig { + api_version: 1, + command: "scripts/gpu-plugin".to_string(), + timeout_ms: 1500, + max_output_bytes: 2048, + }) + ); +} From 15a8c1c94af0d3954caaf65844a61554ef5212c6 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:36:55 -0500 Subject: [PATCH 54/65] test(noticenterctl): cover debug log command behavior Add debug-log command tests that use a temporary journalctl shim on PATH instead of depending on the host journal. Cover journalctl availability, unit probing, follow failures, missing unit logs, and the successful probe-then-follow path. --- .../src/debug_logs/tests/command.rs | 171 ++++++++++++++++++ .../noticenterctl/src/debug_logs/tests/mod.rs | 1 + 2 files changed, 172 insertions(+) create mode 100644 crates/noticenterctl/src/debug_logs/tests/command.rs diff --git a/crates/noticenterctl/src/debug_logs/tests/command.rs b/crates/noticenterctl/src/debug_logs/tests/command.rs new file mode 100644 index 00000000..ca9a45ac --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/tests/command.rs @@ -0,0 +1,171 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::super::command::follow_debug_logs; +use super::super::journal::{ + follow_user_unit_logs, journal_has_user_unit_logs, journalctl_is_available, +}; + +struct EnvGuard { + name: &'static str, + previous: Option, +} + +impl EnvGuard { + fn set(name: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(name); + std::env::set_var(name, value); + Self { name, previous } + } + + fn remove(name: &'static str) -> Self { + let previous = std::env::var_os(name); + std::env::remove_var(name); + Self { name, previous } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } +} + +struct TempDirGuard { + path: std::path::PathBuf, +} + +impl TempDirGuard { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-debug-logs-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn write_journalctl(&self, script: &str) { + let path = self.path.join("journalctl"); + fs::write(&path, script).expect("write journalctl stub"); + let mut permissions = fs::metadata(&path) + .expect("journalctl metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("chmod journalctl stub"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("debug log env lock") +} + +fn prepend_path(path: &std::path::Path) -> EnvGuard { + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let new_path = format!("{}:{}", path.display(), old_path.to_string_lossy()); + EnvGuard::set("PATH", new_path) +} + +#[test] +fn journalctl_availability_tracks_executable_status() { + let _lock = env_lock(); + let root = TempDirGuard::new("availability"); + root.write_journalctl("#!/bin/sh\nexit 0\n"); + let _path = prepend_path(&root.path); + + assert!(journalctl_is_available()); + + root.write_journalctl("#!/bin/sh\nexit 3\n"); + + assert!(!journalctl_is_available()); +} + +#[test] +fn journal_probe_reports_user_unit_presence_from_exit_status() { + let _lock = env_lock(); + let root = TempDirGuard::new("probe"); + root.write_journalctl("#!/bin/sh\nexit 0\n"); + let _path = prepend_path(&root.path); + + assert!(journal_has_user_unit_logs("unixnotis-daemon.service").expect("probe success")); + + root.write_journalctl("#!/bin/sh\nexit 7\n"); + + assert!(!journal_has_user_unit_logs("unixnotis-daemon.service").expect("probe failure")); +} + +#[test] +fn journal_follow_returns_error_for_nonzero_journalctl_exit() { + let _lock = env_lock(); + let root = TempDirGuard::new("follow-failure"); + root.write_journalctl("#!/bin/sh\nexit 11\n"); + let _path = prepend_path(&root.path); + + let error = follow_user_unit_logs("unixnotis-daemon.service").expect_err("follow should fail"); + + assert!(error.to_string().contains("journalctl exited with status")); +} + +#[test] +fn follow_debug_logs_requires_journalctl_on_path() { + let _lock = env_lock(); + let root = TempDirGuard::new("missing"); + let _path = EnvGuard::set("PATH", &root.path); + let _unit = EnvGuard::remove("UNIXNOTIS_DAEMON_UNIT"); + + let error = follow_debug_logs().expect_err("missing journalctl should fail"); + + assert!(error.to_string().contains("journalctl is not available")); +} + +#[test] +fn follow_debug_logs_rejects_missing_user_unit_logs_before_following() { + let _lock = env_lock(); + let root = TempDirGuard::new("no-logs"); + root.write_journalctl("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then exit 0; fi\nexit 4\n"); + let _path = prepend_path(&root.path); + let _unit = EnvGuard::set("UNIXNOTIS_DAEMON_UNIT", "custom.service"); + + let error = follow_debug_logs().expect_err("missing unit logs should fail"); + + assert!(error.to_string().contains("custom.service")); + assert!(error.to_string().contains("debug panel open will continue")); +} + +#[test] +fn follow_debug_logs_runs_probe_then_follow_for_available_unit() { + let _lock = env_lock(); + let root = TempDirGuard::new("follow-success"); + let calls = root.path.join("calls"); + root.write_journalctl(&format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {:?}\nexit 0\n", + calls + )); + let _path = prepend_path(&root.path); + let _unit = EnvGuard::set("UNIXNOTIS_DAEMON_UNIT", "custom.service"); + + follow_debug_logs().expect("follow should succeed"); + + let calls = fs::read_to_string(calls).expect("read journalctl calls"); + assert!(calls.contains("--version")); + assert!(calls.contains("--user --no-pager -n 1 -u custom.service -o cat")); + assert!(calls.contains("--user -f -u custom.service -o cat")); +} diff --git a/crates/noticenterctl/src/debug_logs/tests/mod.rs b/crates/noticenterctl/src/debug_logs/tests/mod.rs index f8512912..20ecc51e 100644 --- a/crates/noticenterctl/src/debug_logs/tests/mod.rs +++ b/crates/noticenterctl/src/debug_logs/tests/mod.rs @@ -1 +1,2 @@ +mod command; mod journal; From 3c3c14ee69a6cd4489addb3465b8fa29aa459d10 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:37:00 -0500 Subject: [PATCH 55/65] test(noticenterctl): cover preset import failure policy Add deterministic coverage for import rollback when imported command paths leave the config root and when backup writing fails after a partial backup. Assert that CSS-check failures happen after the import is committed, so the command returns an error without rolling back the imported files. --- .../noticenterctl/src/preset/import/apply.rs | 61 ++++++++++ .../src/preset/import/tests/commit.rs | 110 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/crates/noticenterctl/src/preset/import/apply.rs b/crates/noticenterctl/src/preset/import/apply.rs index bb506ec2..f9082e03 100644 --- a/crates/noticenterctl/src/preset/import/apply.rs +++ b/crates/noticenterctl/src/preset/import/apply.rs @@ -5,6 +5,8 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; use super::super::filesystem::ensure_dir_fd_matches_live_path; use super::super::filesystem::{ @@ -114,6 +116,18 @@ pub(super) fn finalize_import_transaction( continue; }; + #[cfg(test)] + if backup_write_failure_should_fire(written_backup_paths.len()) { + cleanup_backup_snapshot( + &transaction.config_root_fd, + &backup_relative_dir, + &backup_root_fd, + &written_backup_paths, + )?; + rollback_applied_import_items(&transaction.config_root_fd, &transaction.applied_items)?; + return Err(anyhow::anyhow!("forced backup write failure")); + } + // Backup bytes come from the captured pre-import state, not from the live tree after apply if let Err(err) = write_relative_file_atomic_secure( &backup_root_fd, @@ -159,6 +173,53 @@ pub(super) fn finalize_import_transaction( Ok(Some(transaction.config_dir.join(&backup_relative_dir))) } +#[cfg(test)] +pub(super) struct BackupWriteFailureGuard { + _lock: MutexGuard<'static, ()>, +} + +#[cfg(test)] +pub(super) fn fail_backup_write_after_for_test( + successful_writes: usize, +) -> BackupWriteFailureGuard { + let lock = backup_write_failure_lock() + .lock() + .expect("backup failpoint test lock"); + *backup_write_failure_after() + .lock() + .expect("backup failpoint lock") = Some(successful_writes); + BackupWriteFailureGuard { _lock: lock } +} + +#[cfg(test)] +fn backup_write_failure_should_fire(successful_writes: usize) -> bool { + backup_write_failure_after() + .lock() + .expect("backup failpoint lock") + .is_some_and(|target| target == successful_writes) +} + +#[cfg(test)] +fn backup_write_failure_after() -> &'static Mutex> { + static FAILURE_AFTER: OnceLock>> = OnceLock::new(); + FAILURE_AFTER.get_or_init(|| Mutex::new(None)) +} + +#[cfg(test)] +fn backup_write_failure_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[cfg(test)] +impl Drop for BackupWriteFailureGuard { + fn drop(&mut self) { + *backup_write_failure_after() + .lock() + .expect("backup failpoint lock") = None; + } +} + pub(super) fn rollback_import_transaction(transaction: ImportTransaction) -> Result<()> { rollback_applied_import_items(&transaction.config_root_fd, &transaction.applied_items) } diff --git a/crates/noticenterctl/src/preset/import/tests/commit.rs b/crates/noticenterctl/src/preset/import/tests/commit.rs index 093a2192..6d0bf4ec 100644 --- a/crates/noticenterctl/src/preset/import/tests/commit.rs +++ b/crates/noticenterctl/src/preset/import/tests/commit.rs @@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; use crate::preset::archive::BundleFile; +use crate::preset::import::apply::fail_backup_write_after_for_test; use crate::preset::import::commit::commit_import_plan; use crate::preset::import::plan::build_import_plan; @@ -115,3 +116,112 @@ fn commit_import_plan_rolls_back_when_imported_config_points_outside_root() { ); assert!(!outside_theme.exists()); } + +#[test] +fn commit_import_plan_rolls_back_when_imported_command_points_outside_root() { + let import_root = TempDirGuard::new("commit-outside-command"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + let outside_command = import_root.path.with_file_name("outside-command.sh"); + + let plan = build_import_plan( + &import_root.path, + vec![ + bundle_file( + "config.toml", + &format!( + "[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", + outside_command.display().to_string() + ), + ), + bundle_file("scripts/probe.sh", "#!/bin/sh\necho should-not-stay\n"), + ], + &[], + ) + .expect("build plan"); + let css_calls = AtomicUsize::new(0); + + let error = commit_import_plan(&import_root.path, &plan, || { + css_calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + }) + .expect_err("outside command path should fail"); + + assert!(error.to_string().contains("preset import blocked")); + assert_eq!(css_calls.load(Ordering::Relaxed), 0); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), + "[panel]\nwidth = 320\n" + ); + assert!(!import_root.path.join("scripts/probe.sh").exists()); + assert!(!outside_command.exists()); +} + +#[test] +fn commit_import_plan_cleans_partial_backup_and_rolls_back_when_backup_write_fails() { + let import_root = TempDirGuard::new("commit-backup-failure"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + import_root.write("theme/base.css", ".old { color: blue; }\n"); + + let plan = build_import_plan( + &import_root.path, + vec![ + bundle_file("config.toml", "[panel]\nwidth = 444\n"), + bundle_file("theme/base.css", ".new { color: red; }\n"), + ], + &[], + ) + .expect("build plan"); + let _failure = fail_backup_write_after_for_test(1); + let css_calls = AtomicUsize::new(0); + + let error = commit_import_plan(&import_root.path, &plan, || { + css_calls.fetch_add(1, Ordering::Relaxed); + Ok(()) + }) + .expect_err("backup failure should rollback"); + + assert!(error.to_string().contains("forced backup write failure")); + assert_eq!(css_calls.load(Ordering::Relaxed), 1); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), + "[panel]\nwidth = 320\n" + ); + assert_eq!( + fs::read_to_string(import_root.path.join("theme/base.css")).expect("read restored css"), + ".old { color: blue; }\n" + ); + let backup_dirs = fs::read_dir(&import_root.path) + .expect("read import root") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(); + assert_eq!(backup_dirs, 0); +} + +#[test] +fn commit_import_plan_keeps_import_committed_when_css_check_fails() { + let import_root = TempDirGuard::new("commit-css-failure"); + import_root.write("config.toml", "[panel]\nwidth = 320\n"); + + let plan = build_import_plan( + &import_root.path, + vec![bundle_file("config.toml", "[panel]\nwidth = 444\n")], + &[], + ) + .expect("build plan"); + + let (backup_dir, css_result) = commit_import_plan(&import_root.path, &plan, || { + Err(anyhow!("css-check failed for test")) + }) + .expect("import should commit before reporting css-check failure"); + + assert!(backup_dir.is_some()); + assert!(css_result + .expect_err("css-check failure should be returned") + .to_string() + .contains("css-check failed for test")); + assert_eq!( + fs::read_to_string(import_root.path.join("config.toml")).expect("read committed config"), + "[panel]\nwidth = 444\n" + ); +} From a537fd18587045ac35888fb1945fdb97ffce06a3 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:37:08 -0500 Subject: [PATCH 56/65] test(daemon): expose expiration scheduler test channel Add a test-only constructor for ExpirationScheduler that returns the scheduler and receiver together. This keeps later daemon state tests observable without changing production scheduler behavior. --- crates/unixnotis-daemon/src/expire.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/unixnotis-daemon/src/expire.rs b/crates/unixnotis-daemon/src/expire.rs index 486d3ec7..d7e44979 100644 --- a/crates/unixnotis-daemon/src/expire.rs +++ b/crates/unixnotis-daemon/src/expire.rs @@ -115,6 +115,12 @@ impl ExpirationScheduler { warn!(?err, "expiration schedule request dropped"); } } + + #[cfg(test)] + pub(crate) fn channel_for_test() -> (Self, mpsc::UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded_channel(); + (Self { sender }, receiver) + } } #[derive(Debug, Copy, Clone)] From f4626a6ddcc89748100a52366cfb3291380d729a Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:37:14 -0500 Subject: [PATCH 57/65] test(daemon): cover state scheduler and notification paths Add tests for expiration cancel commands, ordered multi-cancel behavior, duplicate scheduler installation, and missing-scheduler no-op behavior. Cover panel dismiss and close behavior for active, history, and missing notification ids, including the scheduler cancellation side effects. Extract the missing-scheduler warning guard so the first-use and already-reported cases are observable without relying on log capture. --- .../src/daemon/state/scheduler.rs | 6 +- .../src/daemon/state/tests/mod.rs | 2 + .../src/daemon/state/tests/notifications.rs | 151 ++++++++++++++++++ .../src/daemon/state/tests/scheduler.rs | 85 ++++++++++ 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs create mode 100644 crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs diff --git a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs index 07ce6760..0f39d757 100644 --- a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs +++ b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs @@ -19,12 +19,16 @@ impl DaemonState { fn scheduler(&self) -> Option { // Cloning the sender handle is cheap and keeps await points simple let scheduler = self.scheduler.get().cloned(); - if scheduler.is_none() && !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) { + if scheduler.is_none() && self.mark_missing_scheduler_warning_needed() { warn!("expiration scheduler is unavailable during live daemon operation"); } scheduler } + pub(in crate::daemon::state) fn mark_missing_scheduler_warning_needed(&self) -> bool { + !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) + } + pub(in crate::daemon) fn cancel_expiration(&self, id: u32) { // Missing scheduler means startup is still incomplete, so skip quietly let Some(scheduler) = self.scheduler() else { diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs index a391b21e..a526c7c3 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -1,3 +1,5 @@ mod cache; +mod notifications; mod runtime; +mod scheduler; mod signals; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs new file mode 100644 index 00000000..77b1b13a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; +use std::time::Duration; + +use chrono::Utc; +use unixnotis_core::{CloseReason, Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; + +use crate::expire::{ExpirationCommand, ExpirationScheduler}; +use crate::test_support::daemon_state_for_test; + +fn notification(summary: &str) -> Notification { + Notification { + id: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +async fn next_cancel_id( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u32 { + let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("cancel command should arrive") + .expect("scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id } => id, + ExpirationCommand::Schedule { .. } => panic!("dismiss should cancel expiration"), + } +} + +#[tokio::test] +async fn dismiss_from_panel_removes_active_notification_and_cancels_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let id = { + let mut store = state.store.lock().await; + store.insert(notification("active"), 0).notification.id + }; + + state + .dismiss_from_panel(id) + .await + .expect("panel dismiss should succeed"); + + assert_eq!(next_cancel_id(&mut receiver).await, id); + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_none()); +} + +#[tokio::test] +async fn dismiss_from_panel_removes_history_without_canceling_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let id = { + let mut store = state.store.lock().await; + let inserted = store.insert(notification("history"), 0); + let id = inserted.notification.id; + store.close(id, CloseReason::DismissedByUser); + id + }; + + state + .dismiss_from_panel(id) + .await + .expect("history dismiss should succeed"); + + assert!(receiver.try_recv().is_err()); + assert!(state + .store + .lock() + .await + .list_history() + .into_iter() + .all(|view| view.id != id)); +} + +#[tokio::test] +async fn dismiss_from_panel_missing_id_is_noop() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + + state + .dismiss_from_panel(999) + .await + .expect("missing dismiss should succeed"); + + assert!(receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn close_notification_removes_active_notification_and_cancels_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let id = { + let mut store = state.store.lock().await; + store.insert(notification("close"), 0).notification.id + }; + + state + .close_notification(id, CloseReason::ClosedByCall) + .await + .expect("close should succeed"); + + assert_eq!(next_cancel_id(&mut receiver).await, id); + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_none()); +} + +#[tokio::test] +async fn close_notification_missing_id_is_noop() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + + state + .close_notification(777, CloseReason::ClosedByCall) + .await + .expect("missing close should succeed"); + + assert!(receiver.try_recv().is_err()); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs new file mode 100644 index 00000000..33d8a6eb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use crate::expire::{ExpirationCommand, ExpirationScheduler}; +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn cancel_expiration_sends_cancel_command_when_scheduler_is_installed() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + + state.set_scheduler(scheduler); + state.cancel_expiration(42); + + let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("cancel command should arrive") + .expect("scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id } => assert_eq!(id, 42), + ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), + } +} + +#[tokio::test] +async fn cancel_expirations_sends_cancel_for_each_id_in_order() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + + state.set_scheduler(scheduler); + state.cancel_expirations(&[7, 8, 9]); + + let mut ids = Vec::new(); + for _ in 0..3 { + let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("cancel command should arrive") + .expect("scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id } => ids.push(id), + ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), + } + } + + assert_eq!(ids, [7, 8, 9]); + assert!(receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn duplicate_scheduler_install_keeps_original_sender() { + let state = daemon_state_for_test(false).await; + let (first_scheduler, mut first_receiver) = ExpirationScheduler::channel_for_test(); + let (second_scheduler, mut second_receiver) = ExpirationScheduler::channel_for_test(); + + state.set_scheduler(first_scheduler); + state.set_scheduler(second_scheduler); + state.cancel_expiration(11); + + let command = tokio::time::timeout(Duration::from_millis(100), first_receiver.recv()) + .await + .expect("original scheduler should receive cancel") + .expect("original scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id } => assert_eq!(id, 11), + ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), + } + assert!(second_receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn missing_scheduler_cancel_is_a_noop() { + let state = daemon_state_for_test(false).await; + + state.cancel_expiration(1); + state.cancel_expirations(&[2, 3]); + + assert!(!state.mark_missing_scheduler_warning_needed()); +} + +#[tokio::test] +async fn missing_scheduler_warning_guard_reports_only_first_missing_scheduler_use() { + let state = daemon_state_for_test(false).await; + + assert!(state.mark_missing_scheduler_warning_needed()); + assert!(!state.mark_missing_scheduler_warning_needed()); +} From 12003312eeafb203cb8266cb91f1a3fa06193e4f Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:43:20 -0500 Subject: [PATCH 58/65] test(daemon): cover control server state changes Add tests for draining active notifications, clearing saved history, DND persistence rollback, and successful DND toggles. Cover unauthorized clear and action calls so rejected control requests are proven not to mutate daemon state. Tighten the inhibitor watch export to avoid keeping an extra wrapper around the watch module. --- .../src/daemon/control/mod.rs | 6 +- .../src/daemon/control/server.rs | 17 +- .../src/daemon/control/tests/mod.rs | 1 + .../src/daemon/control/tests/server.rs | 229 ++++++++++++++++++ .../src/daemon/control/watch.rs | 2 +- crates/unixnotis-daemon/src/daemon/root.rs | 3 +- 6 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/control/tests/server.rs diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 3fd1a1cf..7cf63127 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -10,11 +10,7 @@ mod server; mod watch; pub use server::ControlServer; -pub async fn spawn_inhibitor_owner_watch( - state: std::sync::Arc, -) -> zbus::Result<()> { - watch::spawn_inhibitor_owner_watch(state).await -} +pub(crate) use watch::spawn_inhibitor_owner_watch; // Cap inhibitor count so memory use stays bounded even under abusive clients const MAX_ACTIVE_INHIBITORS: u32 = 128; diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 368ba51a..8cf6ec84 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -173,7 +173,7 @@ impl ControlServer { .map_err(to_fdo_error) } - async fn invoke_action( + pub(super) async fn invoke_action( &self, id: u32, action_key: &str, @@ -188,7 +188,10 @@ impl ControlServer { .map_err(to_fdo_error) } - async fn clear_all(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + pub(super) async fn clear_all( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearAll").await?; let ids = self.drain_active_notifications().await; self.clear_saved_history().await; @@ -196,14 +199,20 @@ impl ControlServer { Ok(()) } - async fn clear_active(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + pub(super) async fn clear_active( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearActive").await?; let ids = self.drain_active_notifications().await; clear::emit_clear_all_signals(&self.state, ids).await; Ok(()) } - async fn clear_history(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + pub(super) async fn clear_history( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearHistory").await?; self.clear_saved_history().await; clear::emit_clear_all_signals(&self.state, Vec::new()).await; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index 93800e13..9ae383e1 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,2 +1,3 @@ mod clear; mod sanitize; +mod server; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs new file mode 100644 index 00000000..5f30612e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -0,0 +1,229 @@ +use std::collections::HashMap; +use std::time::Duration; + +use chrono::Utc; +use unixnotis_core::{CloseReason, Config, Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; +use zbus::Message; + +use super::super::ControlServer; +use crate::expire::{ExpirationCommand, ExpirationScheduler}; +use crate::store::NotificationStore; +use crate::test_support::{daemon_state_for_test, TempRoot}; + +fn notification(summary: &str) -> Notification { + Notification { + id: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +fn control_header_message(method: &str) -> Message { + Message::method("/com/unixnotis/Control", method) + .expect("method builder") + .interface("com.unixnotis.Control") + .expect("interface") + .sender(":1.4242") + .expect("sender") + .build(&()) + .expect("message") +} + +async fn next_cancel_id( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u32 { + let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("cancel command should arrive") + .expect("scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id } => id, + ExpirationCommand::Schedule { .. } => panic!("clear should cancel expiration"), + } +} + +#[tokio::test] +async fn drain_active_notifications_returns_ids_and_cancels_expirations() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let server = ControlServer::new(state.clone()); + let ids = { + let mut store = state.store.lock().await; + let first = store.insert(notification("first"), 0).notification.id; + let second = store.insert(notification("second"), 0).notification.id; + vec![second, first] + }; + + let drained = server.drain_active_notifications().await; + + assert_eq!(drained, ids); + assert_eq!(next_cancel_id(&mut receiver).await, ids[0]); + assert_eq!(next_cancel_id(&mut receiver).await, ids[1]); + assert!(state.store.lock().await.list_active().is_empty()); +} + +#[tokio::test] +async fn clear_saved_history_removes_archived_notifications() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + let id = { + let mut store = state.store.lock().await; + let id = store.insert(notification("history"), 0).notification.id; + store.close(id, CloseReason::Undefined); + id + }; + assert!(state + .store + .lock() + .await + .list_history() + .into_iter() + .any(|view| view.id == id)); + + server.clear_saved_history().await; + + assert!(state + .store + .lock() + .await + .list_history() + .into_iter() + .all(|view| view.id != id)); +} + +#[tokio::test] +async fn apply_dnd_state_rolls_back_when_persistence_fails() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-persist-failure"); + let state_dir = root.join("state"); + std::fs::create_dir_all(&state_dir).expect("create state dir"); + std::fs::write(state_dir.join("unixnotis"), "not a directory").expect("block dnd parent"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir); + } + let server = ControlServer::new(state.clone()); + + let error = server + .apply_dnd_state(true) + .await + .expect_err("persistence failure should be reported"); + + assert!(error.to_string().contains("failed to persist")); + assert!(!state.store.lock().await.dnd_enabled()); +} + +#[tokio::test] +async fn apply_toggle_dnd_persists_successful_state_change() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-toggle-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + let server = ControlServer::new(state.clone()); + + server + .apply_toggle_dnd() + .await + .expect("toggle should persist"); + + assert!(state.store.lock().await.dnd_enabled()); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted dnd state"); + assert!(persisted.contains("\"dnd_enabled\":true")); +} + +#[tokio::test] +async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + { + let mut store = state.store.lock().await; + store.insert(notification("active"), 0); + } + let message = control_header_message("ClearAll"); + + server + .clear_all(message.header()) + .await + .expect_err("unauthorized clear all should fail"); + + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +#[tokio::test] +async fn clear_active_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + { + let mut store = state.store.lock().await; + store.insert(notification("active"), 0); + } + let message = control_header_message("ClearActive"); + + server + .clear_active(message.header()) + .await + .expect_err("unauthorized clear active should fail"); + + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +#[tokio::test] +async fn clear_history_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + let id = { + let mut store = state.store.lock().await; + let id = store.insert(notification("history"), 0).notification.id; + store.close(id, CloseReason::Undefined); + id + }; + let message = control_header_message("ClearHistory"); + + server + .clear_history(message.header()) + .await + .expect_err("unauthorized clear history should fail"); + + assert!(state + .store + .lock() + .await + .list_history() + .into_iter() + .any(|view| view.id == id)); +} + +#[tokio::test] +async fn invoke_action_rejects_unauthorized_sender_before_signal_emit() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state); + let message = control_header_message("InvokeAction"); + + server + .invoke_action(7, "default", message.header()) + .await + .expect_err("unauthorized action should fail"); +} diff --git a/crates/unixnotis-daemon/src/daemon/control/watch.rs b/crates/unixnotis-daemon/src/daemon/control/watch.rs index bd7979e3..00c809aa 100644 --- a/crates/unixnotis-daemon/src/daemon/control/watch.rs +++ b/crates/unixnotis-daemon/src/daemon/control/watch.rs @@ -12,7 +12,7 @@ use zbus::SignalContext; use crate::daemon::{ControlServer, DaemonState}; -pub(super) async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Result<()> { +pub(crate) async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Result<()> { // Subscribe once and process updates in the background let proxy = DBusProxy::new(state.connection()).await?; let mut stream = proxy.receive_name_owner_changed().await?; diff --git a/crates/unixnotis-daemon/src/daemon/root.rs b/crates/unixnotis-daemon/src/daemon/root.rs index c45339d1..4f10e573 100644 --- a/crates/unixnotis-daemon/src/daemon/root.rs +++ b/crates/unixnotis-daemon/src/daemon/root.rs @@ -16,7 +16,8 @@ mod signal_burst; mod state; pub use bus_names::{log_name_reply, request_control_name, request_well_known_name}; -pub use control::{spawn_inhibitor_owner_watch, ControlServer}; +pub(crate) use control::spawn_inhibitor_owner_watch; +pub use control::ControlServer; pub(crate) use errors::to_fdo_error; pub use notifications::NotificationServer; pub(in crate::daemon) use signal_burst::NotificationSignalMode; From 1e25e9e37b60279f7bb13de028fcb6cb0c8ba052 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:43:40 -0500 Subject: [PATCH 59/65] test(daemon): cover notification server wrappers Add notification server tests for capabilities, server information, notify storage, and close-notification ownership behavior. Make the received-notification debug helper report whether it actually logged, then cover both disabled and enabled debug paths. --- .../src/daemon/notifications/server.rs | 3 + .../src/daemon/notifications/server/flow.rs | 13 +- .../src/daemon/notifications/tests/flow.rs | 28 ++++ .../src/daemon/notifications/tests/server.rs | 120 ++++++++++++++++++ 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 crates/unixnotis-daemon/src/daemon/notifications/tests/server.rs diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server.rs b/crates/unixnotis-daemon/src/daemon/notifications/server.rs index 95b8decf..42dcfea6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server.rs @@ -15,6 +15,9 @@ use capabilities::notification_capabilities; mod capabilities; mod close; mod flow; +#[cfg(test)] +#[path = "tests/server.rs"] +mod tests; /// D-Bus server for org.freedesktop.Notifications pub struct NotificationServer { diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index d8421c42..15942834 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -47,7 +47,13 @@ impl NotificationServer { header: &Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { - Self::log_received_notification(&app_name, &summary, &body, replaces_id, expire_timeout); + let _ = Self::log_received_notification( + &app_name, + &summary, + &body, + replaces_id, + expire_timeout, + ); let notification = self .notification_from_wire( WireNotification { @@ -73,10 +79,10 @@ impl NotificationServer { body: &str, replaces_id: u32, expire_timeout: i32, - ) { + ) -> bool { // Debug logging is guarded so normal operation keeps log volume small if !tracing::enabled!(tracing::Level::DEBUG) { - return; + return false; } let summary_snip = unixnotis_core::util::log_snippet(summary); debug!( @@ -92,6 +98,7 @@ impl NotificationServer { let body_snip = unixnotis_core::util::log_snippet(body); debug!(body = %body_snip, "notification body snippet"); } + true } async fn notification_from_wire( diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs index 331bd509..5cfc391b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs @@ -4,6 +4,8 @@ use std::time::Duration; use chrono::Utc; use futures_util::TryStreamExt; +use tracing::Level; +use tracing_subscriber::filter::LevelFilter; use unixnotis_core::{ CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, }; @@ -139,6 +141,32 @@ fn handle_dropped_notification_returns_none_for_stored_payload() { assert_eq!(id, None); } +#[test] +fn log_received_notification_reports_false_when_debug_is_disabled() { + let subscriber = tracing_subscriber::fmt() + .with_max_level(LevelFilter::INFO) + .finish(); + + let logged = tracing::subscriber::with_default(subscriber, || { + NotificationServer::log_received_notification("app", "summary", "body", 0, 100) + }); + + assert!(!logged); +} + +#[test] +fn log_received_notification_reports_true_when_debug_is_enabled() { + let subscriber = tracing_subscriber::fmt() + .with_max_level(Level::DEBUG) + .finish(); + + let logged = tracing::subscriber::with_default(subscriber, || { + NotificationServer::log_received_notification("app", "summary", "body", 0, 100) + }); + + assert!(logged); +} + #[tokio::test] async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { let state = daemon_state_for_test(false).await; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/server.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/server.rs new file mode 100644 index 00000000..1a48a30c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/server.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; + +use zbus::Message; + +use super::NotificationServer; +use crate::expire::ExpirationScheduler; +use crate::test_support::daemon_state_for_test; + +fn notify_header_message() -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("interface") + .sender(":1.42") + .expect("sender") + .build(&()) + .expect("message") +} + +#[tokio::test] +async fn get_capabilities_returns_freedesktop_capability_contract() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state, scheduler); + + let capabilities = server.get_capabilities().await; + + assert!(capabilities.contains(&"actions".to_string())); + assert!(capabilities.contains(&"body".to_string())); + assert!(capabilities.contains(&"body-markup".to_string())); + assert!(capabilities.contains(&"icon-static".to_string())); + assert!(!capabilities.contains(&"xyzzy".to_string())); +} + +#[tokio::test] +async fn get_server_information_returns_stable_identity_and_spec_version() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state, scheduler); + + let info = server.get_server_information().await; + + assert_eq!( + info, + ( + "UnixNotis".to_string(), + "UnixNotis".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + "1.2".to_string() + ) + ); +} + +#[tokio::test] +async fn notify_wrapper_stores_notification_and_returns_assigned_id() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + header.clone(), + 0, + ) + .await + .expect("notify should store"); + + assert_eq!(id, 1); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be active"); + assert_eq!(active.summary, "summary"); +} + +#[tokio::test] +async fn close_notification_wrapper_removes_owned_active_notification() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let id = server + .notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new(), + header.clone(), + 0, + ) + .await + .expect("notify should store"); + + server + .close_notification(id, header) + .await + .expect("owned close should succeed"); + + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_none()); +} From cae2e35a73a7a95eb4144d2ae9221d10b6f01573 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:43:47 -0500 Subject: [PATCH 60/65] test(daemon): cover runtime and entrypoint edges Return a small tracing initialization outcome so invalid RUST_LOG fallback behavior is observable in tests. Add coverage for shutdown_signal staying pending without a signal and D-Bus owner wait behavior for immediate and missing owner states. Add a CI-compatible binary help test for the daemon entrypoint. --- crates/unixnotis-daemon/src/runtime_config.rs | 13 +++++++- .../unixnotis-daemon/src/shutdown_signal.rs | 13 ++++++++ .../unixnotis-daemon/src/tests/dbus_owner.rs | 33 +++++++++++++++++++ .../src/tests/runtime_config/tracing.rs | 16 ++++++++- crates/unixnotis-daemon/tests/cli.rs | 27 +++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 crates/unixnotis-daemon/tests/cli.rs diff --git a/crates/unixnotis-daemon/src/runtime_config.rs b/crates/unixnotis-daemon/src/runtime_config.rs index 3d1d82c4..4704ecdc 100644 --- a/crates/unixnotis-daemon/src/runtime_config.rs +++ b/crates/unixnotis-daemon/src/runtime_config.rs @@ -22,7 +22,13 @@ pub(super) fn load_config(args: &Args) -> Result { } } -pub(super) fn init_tracing(config: &Config) { +#[derive(Debug, PartialEq, Eq)] +pub(super) struct TracingInitOutcome { + pub(super) attempted_init: bool, + pub(super) had_env_warning: bool, +} + +pub(super) fn init_tracing(config: &Config) -> TracingInitOutcome { let (filter, warning) = match EnvFilter::try_from_default_env() { Ok(filter) => (filter, None), Err(err) => { @@ -52,9 +58,14 @@ pub(super) fn init_tracing(config: &Config) { if let Err(err) = tracing_subscriber::fmt().with_env_filter(filter).try_init() { eprintln!("unixnotis-daemon: tracing already initialized or unavailable: {err}"); } + let had_env_warning = warning.is_some(); if let Some(message) = warning { tracing::warn!("{message}"); } + TracingInitOutcome { + attempted_init: true, + had_env_warning, + } } pub(super) async fn ensure_wayland_session(timeout: Duration) -> Result<()> { diff --git a/crates/unixnotis-daemon/src/shutdown_signal.rs b/crates/unixnotis-daemon/src/shutdown_signal.rs index 101682ed..adc9feeb 100644 --- a/crates/unixnotis-daemon/src/shutdown_signal.rs +++ b/crates/unixnotis-daemon/src/shutdown_signal.rs @@ -30,3 +30,16 @@ pub(super) async fn shutdown_signal() { _ = terminate => {}, } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + #[tokio::test] + async fn shutdown_signal_waits_when_no_signal_arrives() { + let result = + tokio::time::timeout(Duration::from_millis(25), super::shutdown_signal()).await; + + assert!(result.is_err()); + } +} diff --git a/crates/unixnotis-daemon/src/tests/dbus_owner.rs b/crates/unixnotis-daemon/src/tests/dbus_owner.rs index 1b948498..116ba595 100644 --- a/crates/unixnotis-daemon/src/tests/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/tests/dbus_owner.rs @@ -1,4 +1,6 @@ use super::{owner_name_is_self, owner_state_matches}; +use std::time::Duration; +use zbus::fdo::DBusProxy; #[test] fn owner_state_matches_expected_presence_and_release() { @@ -21,3 +23,34 @@ fn owner_name_is_self_requires_exact_unique_name_match() { assert!(!owner_name_is_self(Some(":1.70"), ":1.7")); assert!(!owner_name_is_self(None, ":1.7")); } + +#[tokio::test] +async fn wait_for_owner_state_returns_true_when_expected_owner_is_already_present() { + let connection = zbus::Connection::session().await.expect("session bus"); + let proxy = DBusProxy::new(&connection).await.expect("dbus proxy"); + let unique_name = connection + .unique_name() + .expect("connection unique name") + .to_string(); + let bus_name = zbus::names::BusName::try_from(unique_name.as_str()).expect("bus name"); + + let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + .await + .expect("wait for owned name"); + + assert!(matched); +} + +#[tokio::test] +async fn wait_for_owner_state_returns_false_when_expected_owner_never_appears() { + let connection = zbus::Connection::session().await.expect("session bus"); + let proxy = DBusProxy::new(&connection).await.expect("dbus proxy"); + let missing_name = format!("com.unixnotis.TestMissing{}", std::process::id()); + let bus_name = zbus::names::BusName::try_from(missing_name.as_str()).expect("bus name"); + + let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + .await + .expect("wait for missing name"); + + assert!(!matched); +} diff --git a/crates/unixnotis-daemon/src/tests/runtime_config/tracing.rs b/crates/unixnotis-daemon/src/tests/runtime_config/tracing.rs index 69cbfbd0..1879d331 100644 --- a/crates/unixnotis-daemon/src/tests/runtime_config/tracing.rs +++ b/crates/unixnotis-daemon/src/tests/runtime_config/tracing.rs @@ -8,8 +8,22 @@ fn init_tracing_installs_a_global_dispatcher() { let _rust_log = EnvVarGuard::set("RUST_LOG", "warn"); let config = unixnotis_core::Config::default(); - init_tracing(&config); + let outcome = init_tracing(&config); // The daemon should always leave a dispatcher available for later logs + assert!(outcome.attempted_init); + assert!(!outcome.had_env_warning); assert!(tracing::dispatcher::has_been_set()); } + +#[test] +fn init_tracing_reports_invalid_rust_log_warning_path() { + let _guard = env_lock(); + let _rust_log = EnvVarGuard::set("RUST_LOG", "unixnotis_daemon=definitely-not-a-level"); + let config = unixnotis_core::Config::default(); + + let outcome = init_tracing(&config); + + assert!(outcome.attempted_init); + assert!(outcome.had_env_warning); +} diff --git a/crates/unixnotis-daemon/tests/cli.rs b/crates/unixnotis-daemon/tests/cli.rs new file mode 100644 index 00000000..e4b80dc6 --- /dev/null +++ b/crates/unixnotis-daemon/tests/cli.rs @@ -0,0 +1,27 @@ +#![allow( + clippy::blanket_clippy_restriction_lints, + clippy::restriction, + reason = "workspace CI enables clippy::restriction as a review signal" +)] + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::process::Command; + + type TestResult = Result<(), Box>; + + #[test] + fn daemon_help_prints_usage_from_entrypoint() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-daemon")) + .arg("--help") + .output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("--check")); + assert!(stdout.contains("--trial")); + Ok(()) + } +} From 3052d8491a96400828f13a33550c37f243f220d3 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 12:43:52 -0500 Subject: [PATCH 61/65] test(installer): cover path cleanup and binary help Exercise shell PATH block removal through the high-level environment action path across selected startup files. Add a CI-compatible installer --help integration test that verifies the service manager option remains exposed. --- .../actions/environment/tests/shell_path.rs | 84 ++++++++++++++++++- crates/unixnotis-installer/tests/cli.rs | 26 ++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 crates/unixnotis-installer/tests/cli.rs diff --git a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs index 9a0ae5f4..80367062 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs @@ -1,10 +1,18 @@ use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc, Mutex, MutexGuard, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use super::super::{ ensure_path_entry_in_file, format_path_for_shell_line, remove_path_entry_from_file, - shell_path_entry_exists, shell_startup_files, + remove_shell_path_entry, shell_path_entry_exists, shell_startup_files, }; +use crate::actions::ActionContext; +use crate::detect::Detection; +use crate::events::{UiMessage, WorkerEvent}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; #[test] fn shell_startup_files_prefers_zsh_and_profile() { @@ -230,6 +238,51 @@ fn remove_path_entry_from_file_treats_missing_file_as_noop() { let _ = fs::remove_dir_all(&root); } +#[test] +fn remove_shell_path_entry_removes_managed_block_from_selected_startup_files() { + let _lock = env_lock(); + let root = test_root("path-entry-remove-high-level"); + let home = root.join("home"); + let bin_dir = home.join(".local").join("bin"); + let startup = home.join(".bashrc"); + fs::create_dir_all(&home).expect("create home"); + ensure_path_entry_in_file(&startup, &home, &bin_dir).expect("managed block"); + let _home = EnvGuard::set("HOME", &home); + let _shell = EnvGuard::set("SHELL", "/bin/bash"); + let (tx, rx) = mpsc::sync_channel::(16); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = InstallPaths { + repo_root: root.clone(), + bin_dir, + service: ServiceManager::systemd_user(home.join(".config/systemd/user")), + }; + let mut ctx = ActionContext { + detection: &detection, + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Uninstall, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + remove_shell_path_entry(&mut ctx).expect("remove shell path entry"); + + let contents = fs::read_to_string(&startup).expect("read startup"); + assert!(!contents.contains("# unixnotis-installer path entry")); + let logs = rx.try_iter().collect::>(); + assert!(logs.iter().any(|message| matches!( + message, + UiMessage::Worker(WorkerEvent::LogLine(line)) + if line.contains("Removed installer-owned PATH entry") + ))); + + let _ = fs::remove_dir_all(&root); +} + #[test] fn remove_path_entry_from_file_reports_directory_read_errors() { let root = test_root("path-entry-remove-directory-read-error"); @@ -296,3 +349,32 @@ fn test_root(name: &str) -> std::path::PathBuf { std::process::id() )) } + +struct EnvGuard { + name: &'static str, + previous: Option, +} + +impl EnvGuard { + fn set(name: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(name); + std::env::set_var(name, value); + Self { name, previous } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.name, value), + None => std::env::remove_var(self.name), + } + } +} + +fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .expect("environment test lock") +} diff --git a/crates/unixnotis-installer/tests/cli.rs b/crates/unixnotis-installer/tests/cli.rs new file mode 100644 index 00000000..044a5d60 --- /dev/null +++ b/crates/unixnotis-installer/tests/cli.rs @@ -0,0 +1,26 @@ +#![allow( + clippy::blanket_clippy_restriction_lints, + clippy::restriction, + reason = "workspace CI enables clippy::restriction as a review signal" +)] + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::process::Command; + + type TestResult = Result<(), Box>; + + #[test] + fn installer_help_prints_usage_from_entrypoint() -> TestResult { + let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-installer")) + .arg("--help") + .output()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("Usage: unixnotis-installer")); + assert!(stdout.contains("--service-manager")); + Ok(()) + } +} From 4730fb370dad169e55ef9f5cb28e9fab810c7e1c Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 13:00:38 -0500 Subject: [PATCH 62/65] test(installer): make stdout probe tests mutation-stable Run stdout probe tests through /bin/sh directly instead of writing temporary executable scripts. This avoids transient Text file busy failures when cargo-mutants runs a fresh baseline in a copied workspace while preserving the success and failure probe assertions. --- .../src/service_manager/tests/probe.rs | 31 ++----------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/crates/unixnotis-installer/src/service_manager/tests/probe.rs b/crates/unixnotis-installer/src/service_manager/tests/probe.rs index 4881e43f..4384b3be 100644 --- a/crates/unixnotis-installer/src/service_manager/tests/probe.rs +++ b/crates/unixnotis-installer/src/service_manager/tests/probe.rs @@ -1,19 +1,9 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; - -use crate::service_manager::{use_fake_command_bin, CommandSpec, ServiceProbe}; +use crate::service_manager::{CommandSpec, ServiceProbe}; #[test] fn stdout_probe_uses_parser_only_after_successful_command() { - let root = test_root("stdout-probe-success"); - let fake_bin = root.join("bin"); - fs::create_dir_all(&fake_bin).expect("fake bin dir"); - let probe_bin = fake_bin.join("probe"); - fs::write(&probe_bin, "#!/bin/sh\nprintf 'true\\n'\nexit 0\n").expect("fake probe"); - fs::set_permissions(&probe_bin, fs::Permissions::from_mode(0o755)).expect("fake probe mode"); - let _fake = use_fake_command_bin(&fake_bin); let probe = ServiceProbe::stdout( - CommandSpec::new("probe", "probe", std::iter::empty::<&str>()), + CommandSpec::new("probe", "/bin/sh", ["-c", "printf 'true\\n'; exit 0"]), |stdout| stdout.trim() == "true", ); @@ -21,20 +11,12 @@ fn stdout_probe_uses_parser_only_after_successful_command() { // Successful stdout probes are allowed to derive active state from command output assert!(active); - let _ = fs::remove_dir_all(root); } #[test] fn stdout_probe_treats_failed_command_as_inactive_even_with_matching_output() { - let root = test_root("stdout-probe-failure"); - let fake_bin = root.join("bin"); - fs::create_dir_all(&fake_bin).expect("fake bin dir"); - let probe_bin = fake_bin.join("probe"); - fs::write(&probe_bin, "#!/bin/sh\nprintf 'true\\n'\nexit 1\n").expect("fake probe"); - fs::set_permissions(&probe_bin, fs::Permissions::from_mode(0o755)).expect("fake probe mode"); - let _fake = use_fake_command_bin(&fake_bin); let probe = ServiceProbe::stdout( - CommandSpec::new("probe", "probe", std::iter::empty::<&str>()), + CommandSpec::new("probe", "/bin/sh", ["-c", "printf 'true\\n'; exit 1"]), |stdout| stdout.trim() == "true", ); @@ -42,11 +24,4 @@ fn stdout_probe_treats_failed_command_as_inactive_even_with_matching_output() { // Command failure means the manager did not provide trustworthy status output assert!(!active); - let _ = fs::remove_dir_all(root); -} - -fn test_root(name: &str) -> std::path::PathBuf { - let root = std::env::temp_dir().join(format!("unixnotis-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - root } From 123fdf5a185297056d755e6bc97e9fa34db48ce4 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 14:44:46 -0500 Subject: [PATCH 63/65] style: normalize inline comment punctuation Remove trailing periods from plain inline comments touched by the dev branch so the code follows the project comment style before the v1.1.0 release. --- .../unixnotis-core/src/model/image/hints.rs | 32 +++++++++---------- .../unixnotis-core/src/model/notification.rs | 28 ++++++++-------- .../src/actions/environment/shell_path.rs | 28 ++++++++-------- .../src/actions/install/service.rs | 6 ++-- crates/unixnotis-installer/src/main.rs | 2 +- .../src/paths/discovery.rs | 20 ++++++------ crates/unixnotis-installer/src/ui/confirm.rs | 10 +++--- 7 files changed, 63 insertions(+), 63 deletions(-) diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 80fa7d8d..a79211f5 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -118,11 +118,11 @@ fn resolve_icon_name( fn normalize_app_icon_path(app_icon: &str) -> Option { // Normalize the incoming icon path first so later checks operate on a cleaned, - // bounded value rather than raw metadata input. + // bounded value rather than raw metadata input let path = normalize_image_path(app_icon); - // Only accept paths that are already absolute filesystem paths or valid file URIs. - // Relative paths are rejected because app icons need to resolve unambiguously. + // Only accept paths that are already absolute filesystem paths or valid file URIs + // Relative paths are rejected because app icons need to resolve unambiguously if path.starts_with('/') || path.starts_with("file://") { Some(path) } else { @@ -132,29 +132,29 @@ fn normalize_app_icon_path(app_icon: &str) -> Option { fn normalize_image_path(value: &str) -> String { // Sanitize display-facing metadata and enforce the maximum byte length before - // doing any URI-specific normalization. + // doing any URI-specific normalization let bounded = sanitize_metadata_string(value, MAX_IMAGE_PATH_BYTES); // File URIs get normalized into the accepted form when possible. Invalid or - // unsupported file URI shapes fall back to an empty string. + // unsupported file URI shapes fall back to an empty string if bounded.starts_with("file://") { return normalize_file_uri(&bounded).unwrap_or_default(); } - // Non-file URI values are returned after sanitization/truncation only. + // Non-file URI values are returned after sanitization/truncation only bounded } fn normalize_file_uri(value: &str) -> Option { - // This function only handles file:// URIs; anything else is rejected immediately. + // This function only handles file:// URIs; anything else is rejected immediately let stripped = value.strip_prefix("file://")?; - // A file URI with an absolute path is already in the expected form. + // A file URI with an absolute path is already in the expected form if stripped.starts_with('/') { return Some(value.to_string()); } - // Convert localhost-based file URIs into the canonical absolute-path form. + // Convert localhost-based file URIs into the canonical absolute-path form stripped .strip_prefix("localhost/") .map(|path| format!("file:///{path}")) @@ -162,25 +162,25 @@ fn normalize_file_uri(value: &str) -> Option { fn bound_icon_name(value: &str) -> String { // Icon names use the same metadata sanitization path, but with the icon-name - // byte limit instead of the image-path byte limit. + // byte limit instead of the image-path byte limit sanitize_metadata_string(value, MAX_ICON_NAME_BYTES) } fn sanitize_metadata_string(value: &str, max_bytes: usize) -> String { // Remove inline display control/problematic characters before trimming and - // applying the final UTF-8-safe byte limit. + // applying the final UTF-8-safe byte limit let cleaned = util::sanitize_inline_display_text(value); truncate_utf8_bytes(cleaned.trim(), max_bytes) } fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - // Fast path: avoid allocation/truncation work when the value already fits. + // Fast path: avoid allocation/truncation work when the value already fits if value.len() <= max_bytes { return value.to_string(); } // Find the last valid UTF-8 character boundary that does not exceed max_bytes, - // so slicing never cuts through the middle of a multi-byte character. + // so slicing never cuts through the middle of a multi-byte character let end = value .char_indices() .map(|(index, _)| index) @@ -188,13 +188,13 @@ fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { .last() .unwrap_or(0); - // Return only the byte-safe prefix. + // Return only the byte-safe prefix value[..end].to_string() } pub(super) fn owned_to_string(value: &OwnedValue) -> Option { - // Clone the owned D-Bus value first, then attempt to extract it as a String. - // Any clone or conversion failure is represented as None. + // Clone the owned D-Bus value first, then attempt to extract it as a String + // Any clone or conversion failure is represented as None value .try_clone() .ok() diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index a09b38f4..b5ee39b0 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -12,22 +12,22 @@ use super::types::{Action, Urgency}; /// Full notification record stored by the daemon. #[derive(Debug)] pub struct Notification { - // Stable identifier assigned by the daemon. + // Stable identifier assigned by the daemon pub id: u32, - // Origin metadata for display and filtering. + // Origin metadata for display and filtering pub app_name: String, pub app_icon: String, - // User-facing content as provided by the sender. + // User-facing content as provided by the sender pub summary: String, pub body: String, - // Optional actions supplied by the app. + // Optional actions supplied by the app pub actions: Vec, - // Raw hints preserved for storage and downstream consumers. + // Raw hints preserved for storage and downstream consumers pub hints: HashMap, - // Derived urgency used for styling and escalation. + // Derived urgency used for styling and escalation pub urgency: Urgency, pub category: Option, - // Flags from the notification protocol. + // Flags from the notification protocol pub is_transient: bool, pub is_resident: bool, /// Suppress showing this notification as a popup. @@ -37,9 +37,9 @@ pub struct Notification { pub image: NotificationImage, pub expire_timeout: i32, pub received_at: DateTime, - // D-Bus unique sender name for ownership checks in daemon-side operations. + // D-Bus unique sender name for ownership checks in daemon-side operations pub sender_name: Option, - // Sender process metadata is retained for diagnostics and audit logging. + // Sender process metadata is retained for diagnostics and audit logging pub sender_pid: Option, pub sender_start_time: Option, pub sender_executable: Option, @@ -82,7 +82,7 @@ impl Notification { /// Create a history entry with heavyweight hint data stripped out. pub fn to_history(&self) -> Notification { - // History entries should never retain raw image-data blobs. + // History entries should never retain raw image-data blobs let mut image = self.image.clone(); image.has_image_data = false; image.image_data = Default::default(); @@ -93,7 +93,7 @@ impl Notification { summary: self.summary.clone(), body: self.body.clone(), actions: self.actions.clone(), - // Keep history entries lightweight by dropping raw hint payloads. + // Keep history entries lightweight by dropping raw hint payloads hints: HashMap::new(), urgency: self.urgency, category: self.category.clone(), @@ -247,9 +247,9 @@ fn collapse_notification_whitespace(input: &str) -> String { /// Serializable view of a notification for D-Bus signals. #[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] pub struct NotificationView { - // Identifier matches Notification::id. + // Identifier matches Notification::id pub id: u32, - // Lightweight fields used for UI display and filtering. + // Lightweight fields used for UI display and filtering // Intentionally omits daemon-only protocol flags and timestamps pub app_name: String, pub summary: String, @@ -258,7 +258,7 @@ pub struct NotificationView { pub urgency: u8, // Close handling needs this flag so history policy stays shared pub is_transient: bool, - // Image metadata intended for UI usage. + // Image metadata intended for UI usage pub image: NotificationImage, } diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index ea707d3f..b712bab7 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -48,34 +48,34 @@ pub(crate) fn ensure_shell_path_entry(ctx: &mut ActionContext) -> Result<()> { pub(crate) fn remove_shell_path_entry(ctx: &mut ActionContext) -> Result<()> { // Resolve the user's home directory so shell startup files and path entries - // can be located relative to the current user. + // can be located relative to the current user let home = crate::paths::home_dir()?; // Read the active shell when available so the cleanup can target the startup - // files that are most likely to contain the installer-added PATH entry. + // files that are most likely to contain the installer-added PATH entry let shell = env::var("SHELL").ok(); - // Build the list of shell startup files that should be checked for PATH entries. + // Build the list of shell startup files that should be checked for PATH entries let startup_files = shell_startup_files(&home, shell.as_deref()); - // Track which files were actually modified so the final log output is accurate. + // Track which files were actually modified so the final log output is accurate let mut removed_files = Vec::new(); for startup_file in startup_files { - // Remove only installer-owned references to the configured bin directory. - // Files that do not contain such an entry are left untouched. + // Remove only installer-owned references to the configured bin directory + // Files that do not contain such an entry are left untouched if remove_path_entry_from_file(&startup_file, &home, &ctx.paths.bin_dir)? { removed_files.push(startup_file); } } if removed_files.is_empty() { - // Nothing matched the installer-managed PATH entry, so no startup file changed. + // Nothing matched the installer-managed PATH entry, so no startup file changed log_line(ctx, "No installer-owned shell PATH entries found."); } else { for startup_file in removed_files { // Report each modified startup file using a home-relative display path - // to keep the message readable and user-specific. + // to keep the message readable and user-specific log_line( ctx, format!( @@ -162,17 +162,17 @@ pub(in crate::actions::environment) fn remove_path_entry_from_file( Err(err) => return Err(anyhow!("failed to read {}: {}", file.display(), err)), }; - // If the installer marker is not present, this file was not modified by this installer. + // If the installer marker is not present, this file was not modified by this installer if !existing.contains(PATH_BLOCK_MARKER) { return Ok(false); } - // Track whether anything was removed while collecting the lines that should remain. + // Track whether anything was removed while collecting the lines that should remain let mut changed = false; let mut kept = Vec::new(); // Use a peekable iterator so a marker line can inspect the following PATH line - // before deciding whether both should be removed. + // before deciding whether both should be removed let mut lines = existing.lines().peekable(); while let Some(line) = lines.next() { @@ -181,7 +181,7 @@ pub(in crate::actions::environment) fn remove_path_entry_from_file( if line.trim() == PATH_BLOCK_MARKER { if let Some(next) = lines.peek() { // Remove the marker and the following PATH entry only when the next - // line points to the installer-managed bin directory. + // line points to the installer-managed bin directory if is_shell_path_entry_line(next.trim(), home, bin_dir) { lines.next(); changed = true; @@ -206,7 +206,7 @@ pub(in crate::actions::environment) fn remove_path_entry_from_file( updated.push('\n'); } - // Write the cleaned startup file back to disk. + // Write the cleaned startup file back to disk fs::write(file, updated) .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; Ok(true) @@ -231,7 +231,7 @@ fn is_shell_path_entry_line(trimmed: &str, home: &Path, bin_dir: &Path) -> bool let absolute_path = bin_dir.display().to_string(); // Only consider export PATH lines, and only when they reference the installer - // bin directory in either supported path format. + // bin directory in either supported path format trimmed.starts_with("export PATH=") && (trimmed.contains(&shell_path) || trimmed.contains(&absolute_path)) } diff --git a/crates/unixnotis-installer/src/actions/install/service.rs b/crates/unixnotis-installer/src/actions/install/service.rs index b15007be..a65793c8 100644 --- a/crates/unixnotis-installer/src/actions/install/service.rs +++ b/crates/unixnotis-installer/src/actions/install/service.rs @@ -156,11 +156,11 @@ pub(crate) fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { } // Remove any Hyprland autostart entry managed by the installer before - // cleaning up shell startup files. + // cleaning up shell startup files remove_hyprland_autostart(ctx); // Shell PATH cleanup is non-fatal: uninstall should continue even if one - // startup file cannot be read or updated. + // startup file cannot be read or updated if let Err(err) = remove_shell_path_entry(ctx) { log_line( ctx, @@ -176,7 +176,7 @@ fn log_unsafe_service_artifacts( artifacts: &[crate::service_manager::ServiceArtifact], ) -> bool { // Track whether any unsafe artifact path was detected so the caller can - // decide whether cleanup should proceed. + // decide whether cleanup should proceed let mut found = false; for artifact in artifacts { diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index fe99c6ef..061aa29e 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -14,7 +14,7 @@ mod checks; mod cli; mod detect; mod events; -// Keep installer entrypoint lean by delegating to modules stored under src/main/. +// Keep installer entrypoint lean by delegating to modules stored under src/main/ #[path = "main/action_workflow.rs"] mod action_workflow; #[path = "main/main_flow.rs"] diff --git a/crates/unixnotis-installer/src/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index 5b0171e1..fda7d42c 100644 --- a/crates/unixnotis-installer/src/paths/discovery.rs +++ b/crates/unixnotis-installer/src/paths/discovery.rs @@ -198,20 +198,20 @@ fn find_release_root_from_current_exe() -> Option { } pub(in crate::paths) fn is_unixnotis_release_archive(root: &Path) -> bool { - // A valid release archive must include the manifest file at the expected root location. + // A valid release archive must include the manifest file at the expected root location let manifest = root.join(RELEASE_MANIFEST_FILE); - // If the manifest cannot be read, the directory cannot be treated as a release archive. + // If the manifest cannot be read, the directory cannot be treated as a release archive let Ok(contents) = fs::read_to_string(manifest) else { return false; }; - // The manifest must also match the expected JSON shape before any archive contents are trusted. + // The manifest must also match the expected JSON shape before any archive contents are trusted let Ok(manifest) = serde_json::from_str::(&contents) else { return false; }; - // Runtime binaries are expected to live under the archive's bin directory. + // Runtime binaries are expected to live under the archive's bin directory let release_bin_dir = root.join(RELEASE_BIN_DIR); // The archive layout is intentionally simple: installer at root, runtime tools in bin @@ -219,20 +219,20 @@ pub(in crate::paths) fn is_unixnotis_release_archive(root: &Path) -> bool { return false; } - // Normalize manifest binary names by trimming whitespace and collecting into a set. - // The set removes duplicates so each listed binary only needs to be checked once. + // Normalize manifest binary names by trimming whitespace and collecting into a set + // The set removes duplicates so each listed binary only needs to be checked once let names = manifest .binaries .into_iter() .map(|name| name.trim().to_string()) .collect::>(); - // An archive with no declared binaries is incomplete, even if the bin directory exists. + // An archive with no declared binaries is incomplete, even if the bin directory exists if names.is_empty() { return false; } - // Every declared binary must have a safe filename and exist as a regular file in bin. + // Every declared binary must have a safe filename and exist as a regular file in bin names .iter() .all(|binary| is_release_binary_name(binary) && release_bin_dir.join(binary).is_file()) @@ -240,7 +240,7 @@ pub(in crate::paths) fn is_unixnotis_release_archive(root: &Path) -> bool { fn is_release_binary_name(binary: &str) -> bool { // Only allow plain file names. Empty names, current/parent directory references, - // and path separators are rejected to prevent escaping the release bin directory. + // and path separators are rejected to prevent escaping the release bin directory !binary.is_empty() && binary != "." && binary != ".." @@ -250,6 +250,6 @@ fn is_release_binary_name(binary: &str) -> bool { #[derive(serde::Deserialize)] struct ReleaseArchiveManifest { - // The manifest declares the runtime binary filenames expected inside RELEASE_BIN_DIR. + // The manifest declares the runtime binary filenames expected inside RELEASE_BIN_DIR binaries: Vec, } diff --git a/crates/unixnotis-installer/src/ui/confirm.rs b/crates/unixnotis-installer/src/ui/confirm.rs index 9f8be9db..6b89364f 100644 --- a/crates/unixnotis-installer/src/ui/confirm.rs +++ b/crates/unixnotis-installer/src/ui/confirm.rs @@ -12,7 +12,7 @@ use super::header::draw_header; use super::reset::describe_reset_action; pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { - // Confirmation screen keeps the user on one page before running actions. + // Confirmation screen keeps the user on one page before running actions let layout = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -24,7 +24,7 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { draw_header(frame, layout[0]); - // Content lines are built first, then rendered as a wrapped paragraph. + // Content lines are built first, then rendered as a wrapped paragraph let mut lines = Vec::new(); lines.push(Line::from(vec![Span::styled( format!("Confirm {}", app.action_label(mode)), @@ -42,7 +42,7 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { Span::raw(crate::actions::summarize_owner(&app.detection.owner)), ])); - // Blocked state is rendered inline so it is visible before execution. + // Blocked state is rendered inline so it is visible before execution if let Err(reason) = app.checks.ready_for(mode) { lines.push(Line::from("")); lines.push(Line::from(vec![ @@ -53,7 +53,7 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { Span::raw(reason), ])); } - // Warn about destructive actions before proceeding. + // Warn about destructive actions before proceeding if matches!(mode, ActionMode::Install) && app .install_state @@ -99,7 +99,7 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { } if matches!(mode, ActionMode::Reset) { lines.push(Line::from("")); - // Reset warning includes backup retention to avoid surprise data loss. + // Reset warning includes backup retention to avoid surprise data loss lines.push(Line::from(Span::styled( "Reset overwrites config.toml and theme files with defaults.", Style::default().fg(Color::Yellow), From 43d8323c89618edf01dd8a8394078b04b12550c5 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 14:44:54 -0500 Subject: [PATCH 64/65] fix(release): package runtime binaries from metadata Read installer binary metadata from Cargo's current workspace metadata field and capture metadata discovery failures before the release build starts. This prevents package-release.sh from writing a successful archive with an empty bin directory. --- scripts/package-release.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 139b5273..993d4bdd 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -5,13 +5,13 @@ set -euo pipefail main() { local tag="${1:-}" if [[ -z "$tag" ]]; then - printf 'usage: %s v1.0.0\n' "${0}" >&2 + printf 'usage: %s v1.1.0\n' "${0}" >&2 exit 2 fi if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then # Release artifacts and installer update checks both expect stable vMAJOR.MINOR.PATCH tags - printf 'release tag must look like v1.0.0: %s\n' "$tag" >&2 + printf 'release tag must look like v1.1.0: %s\n' "$tag" >&2 exit 2 fi @@ -19,9 +19,11 @@ main() { local target="x86_64-unknown-linux-gnu" local root local binaries=() + local binary_list root="$(repo_root)" cd "$root" - readarray -t binaries < <(managed_binaries) + binary_list="$(managed_binaries)" + readarray -t binaries <<< "$binary_list" assert_workspace_version "$version" # Build first so packaging never creates an archive around stale target artifacts @@ -129,7 +131,7 @@ import sys metadata = json.load(sys.stdin) binaries = ( - metadata.get("workspace_metadata", {}) + metadata.get("metadata", {}) .get("unixnotis", {}) .get("installer", {}) .get("binaries", []) From 938392378a1a29d50c1021646ff8192d1ec287c5 Mon Sep 17 00:00:00 2001 From: locainin Date: Mon, 6 Jul 2026 14:44:59 -0500 Subject: [PATCH 65/65] chore(release): prepare v1.1.0 metadata Bump the workspace packages and lockfile to 1.1.0, update release-package examples, and align the installer welcome-screen test with the new displayed version. --- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 4 ++-- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- README.md | 2 +- crates/unixnotis-installer/src/ui/tests/welcome.rs | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fcdf6e3..a8ecdf1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: tag: - description: Release tag to package, such as v1.0.0 + description: Release tag to package, such as v1.1.0 required: true type: string diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a36ec5da..0e9b39d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,7 +178,7 @@ If docs live in the Wiki and are not updated in the PR, note what needs to be up ## Releases -UnixNotis releases are cut from `master` with tags such as `v1.0.0`. +UnixNotis releases are cut from `master` with tags such as `v1.1.0`. Before tagging a release: @@ -192,7 +192,7 @@ Before tagging a release: Build the release archive manually before creating the GitHub release: ```sh -scripts/package-release.sh v1.0.0 +scripts/package-release.sh v1.1.0 ``` The same packaging step can be run from the manual **Release Package** GitHub Actions workflow. It diff --git a/Cargo.lock b/Cargo.lock index 5345e8e8..7c18fb48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "noticenterctl" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "blake3", @@ -3318,7 +3318,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unixnotis-center" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "async-channel", @@ -3347,7 +3347,7 @@ dependencies = [ [[package]] name = "unixnotis-core" -version = "1.0.0" +version = "1.1.0" dependencies = [ "chrono", "serde", @@ -3361,7 +3361,7 @@ dependencies = [ [[package]] name = "unixnotis-daemon" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "chrono", @@ -3381,7 +3381,7 @@ dependencies = [ [[package]] name = "unixnotis-installer" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "chrono", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "unixnotis-popups" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "async-channel", @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "unixnotis-ui" -version = "1.0.0" +version = "1.1.0" dependencies = [ "gtk4", "notify", diff --git a/Cargo.toml b/Cargo.toml index d32213f2..26e8b319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.0.0" +version = "1.1.0" edition = "2021" license = "MIT" diff --git a/README.md b/README.md index e6601a99..3fd8f22b 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ reports when a newer GitHub release is available. Maintainers can build a local release archive manually: ```sh -scripts/package-release.sh v1.0.0 +scripts/package-release.sh v1.1.0 ``` ## Development diff --git a/crates/unixnotis-installer/src/ui/tests/welcome.rs b/crates/unixnotis-installer/src/ui/tests/welcome.rs index c914013c..02d52754 100644 --- a/crates/unixnotis-installer/src/ui/tests/welcome.rs +++ b/crates/unixnotis-installer/src/ui/tests/welcome.rs @@ -18,7 +18,7 @@ fn draw_welcome_renders_status_and_action_menu() { assert!(screen.contains("System status")); assert!(screen.contains("Actions")); assert!(screen.contains("Release")); - assert!(screen.contains("Version: v1.0.0 installed")); + assert!(screen.contains("Version: v1.1.0 installed")); assert!(screen.contains("Compatibility")); assert!(screen.contains("[ok]")); assert!(screen.contains("test - ok"));