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/noticenterctl/src/app.rs b/crates/noticenterctl/src/app.rs new file mode 100644 index 00000000..2296d751 --- /dev/null +++ b/crates/noticenterctl/src/app.rs @@ -0,0 +1,95 @@ +//! 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(); + let command = args.command; + + if command.is_local_only() { + // Local commands must work even when the daemon is not running + handle_local_command(command, crate::css_check::run, crate::preset::run_preset)?; + 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, 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/cli/args.rs b/crates/noticenterctl/src/cli/args.rs new file mode 100644 index 00000000..9e3dcc4c --- /dev/null +++ b/crates/noticenterctl/src/cli/args.rs @@ -0,0 +1,89 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; + +use super::command::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, +} + +#[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/command.rs b/crates/noticenterctl/src/cli/command.rs new file mode 100644 index 00000000..1f1be61c --- /dev/null +++ b/crates/noticenterctl/src/cli/command.rs @@ -0,0 +1,69 @@ +use clap::Subcommand; + +use super::args::{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/mod.rs b/crates/noticenterctl/src/cli/mod.rs new file mode 100644 index 00000000..7370daef --- /dev/null +++ b/crates/noticenterctl/src/cli/mod.rs @@ -0,0 +1,12 @@ +//! Command-line argument types for noticenterctl + +mod args; +mod command; + +pub(crate) use args::{Args, DndState, PresetCommand}; +#[cfg(test)] +pub(crate) use args::{DebugLevelArg, InhibitScopeArg}; +pub(crate) use command::Command; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs new file mode 100644 index 00000000..e75ef51d --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -0,0 +1,182 @@ +use clap::Parser; +use unixnotis_core::{PanelDebugLevel, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; + +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:?}"), + } +} + +#[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/command.rs b/crates/noticenterctl/src/cli/tests/command.rs new file mode 100644 index 00000000..b3a4e112 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/command.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/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs new file mode 100644 index 00000000..5c448cf4 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -0,0 +1,2 @@ +mod args; +mod command; 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/main/main_css_check_cache/dependencies.rs b/crates/noticenterctl/src/css_check/cache/dependencies.rs similarity index 98% rename from crates/noticenterctl/src/main/main_css_check_cache/dependencies.rs rename to crates/noticenterctl/src/css_check/cache/dependencies.rs index c53e866a..39587061 100644 --- a/crates/noticenterctl/src/main/main_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/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 98% rename from crates/noticenterctl/src/main/main_css_check_cache/model.rs rename to crates/noticenterctl/src/css_check/cache/model.rs index e75a93fd..c444c31a 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_cache/parse.rs b/crates/noticenterctl/src/css_check/cache/parse.rs similarity index 94% rename from crates/noticenterctl/src/main/main_css_check_cache/parse.rs rename to crates/noticenterctl/src/css_check/cache/parse.rs index c70920e5..60e0f553 100644 --- a/crates/noticenterctl/src/main/main_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/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 98% rename from crates/noticenterctl/src/main/main_css_check_files.rs rename to crates/noticenterctl/src/css_check/files.rs index 503a7596..d9a14ac1 100644 --- a/crates/noticenterctl/src/main/main_css_check_files.rs +++ b/crates/noticenterctl/src/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_css_check_geometry.rs b/crates/noticenterctl/src/css_check/geometry.rs similarity index 90% rename from crates/noticenterctl/src/main/main_css_check_geometry.rs rename to crates/noticenterctl/src/css_check/geometry.rs index c9e2cd18..0cf06baf 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/model.rs b/crates/noticenterctl/src/css_check/geometry/model.rs similarity index 96% rename from crates/noticenterctl/src/main/main_css_check_geometry/model.rs rename to crates/noticenterctl/src/css_check/geometry/model.rs index 75bae346..ed646561 100644 --- a/crates/noticenterctl/src/main/main_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/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/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/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 95% rename from crates/noticenterctl/src/main/main_css_check_geometry/parse.rs rename to crates/noticenterctl/src/css_check/geometry/parse.rs index 80701b08..b98cdb90 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/parse/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/parse/custom_properties.rs similarity index 94% 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 index aeefbfab..997ac31f 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs similarity index 99% 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 index 4c06be02..aea6e73a 100644 --- a/crates/noticenterctl/src/main/main_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/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/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/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/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/main/main_css_check_geometry/stock/baselines.rs b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs similarity index 95% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/baselines.rs rename to crates/noticenterctl/src/css_check/geometry/stock/baselines.rs index 44441538..26053daa 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs similarity index 72% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/classes.rs rename to crates/noticenterctl/src/css_check/geometry/stock/classes.rs index 2bdccc38..d32d2bb7 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/stock/mod.rs b/crates/noticenterctl/src/css_check/geometry/stock/mod.rs similarity index 82% rename from crates/noticenterctl/src/main/main_css_check_geometry/stock/mod.rs rename to crates/noticenterctl/src/css_check/geometry/stock/mod.rs index 9eb8f042..7acd57bb 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_geometry/stock/size_rules.rs b/crates/noticenterctl/src/css_check/geometry/stock/size_rules.rs similarity index 95% 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 index d0500088..99c6c25c 100644 --- a/crates/noticenterctl/src/main/main_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/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 59% rename from crates/noticenterctl/src/main/main_css_check_geometry/tests/mod.rs rename to crates/noticenterctl/src/css_check/geometry/tests/mod.rs index 10ab4825..c2bac7c8 100644 --- a/crates/noticenterctl/src/main/main_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; 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 92% rename from crates/noticenterctl/src/main/main_css_check_lint/mod.rs rename to crates/noticenterctl/src/css_check/lint/mod.rs index 64abe87c..ac12502e 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs similarity index 99% rename from crates/noticenterctl/src/main/main_css_check_lint/scan.rs rename to crates/noticenterctl/src/css_check/lint/scan.rs index 2dd445a6..5362ec77 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_lint/values.rs b/crates/noticenterctl/src/css_check/lint/values.rs similarity index 96% rename from crates/noticenterctl/src/main/main_css_check_lint/values.rs rename to crates/noticenterctl/src/css_check/lint/values.rs index 077f9ecf..7ccd9cd5 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check.rs b/crates/noticenterctl/src/css_check/mod.rs similarity index 77% rename from crates/noticenterctl/src/main/main_css_check.rs rename to crates/noticenterctl/src/css_check/mod.rs index 861dce76..09116d5e 100644 --- a/crates/noticenterctl/src/main/main_css_check.rs +++ b/crates/noticenterctl/src/css_check/mod.rs @@ -1,40 +1,31 @@ //! 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<()> { +pub(crate) fn run() -> Result<()> { // GTK needs to be ready before CSS parsing starts gtk::init().context("initialize gtk")?; @@ -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/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 93% rename from crates/noticenterctl/src/main/main_css_check_parse/mod.rs rename to crates/noticenterctl/src/css_check/parse/mod.rs index 12bbf199..52c4a78b 100644 --- a/crates/noticenterctl/src/main/main_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/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/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/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 96% rename from crates/noticenterctl/src/main/main_css_check_runtime.rs rename to crates/noticenterctl/src/css_check/runtime.rs index 0d82843c..1f792062 100644 --- a/crates/noticenterctl/src/main/main_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/main/main_css_check_tests.rs b/crates/noticenterctl/src/css_check/tests/mod.rs similarity index 96% rename from crates/noticenterctl/src/main/main_css_check_tests.rs rename to crates/noticenterctl/src/css_check/tests/mod.rs index ae28679c..db373642 100644 --- a/crates/noticenterctl/src/main/main_css_check_tests.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()); +} diff --git a/crates/noticenterctl/src/main/main_css_check_theme/collect.rs b/crates/noticenterctl/src/css_check/theme/collect.rs similarity index 98% rename from crates/noticenterctl/src/main/main_css_check_theme/collect.rs rename to crates/noticenterctl/src/css_check/theme/collect.rs index c901d469..85a1fd16 100644 --- a/crates/noticenterctl/src/main/main_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/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 86% rename from crates/noticenterctl/src/main/main_css_check_theme/model.rs rename to crates/noticenterctl/src/css_check/theme/model.rs index 483e48a2..6f522c62 100644 --- a/crates/noticenterctl/src/main/main_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 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/client.rs b/crates/noticenterctl/src/dbus/client.rs new file mode 100644 index 00000000..c4cf459e --- /dev/null +++ b/crates/noticenterctl/src/dbus/client.rs @@ -0,0 +1,138 @@ +use std::future::Future; +use std::pin::Pin; + +use anyhow::Result; +use unixnotis_core::{ControlProxy, InhibitorInfo, NotificationView, PanelDebugLevel}; + +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>>; + +// 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))) + } +} diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs new file mode 100644 index 00000000..ae14b92f --- /dev/null +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use unixnotis_core::util; + +use crate::cli::{Command, DndState}; +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; + +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 => { + // 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_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/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/mod.rs b/crates/noticenterctl/src/dbus/mod.rs new file mode 100644 index 00000000..b7d18552 --- /dev/null +++ b/crates/noticenterctl/src/dbus/mod.rs @@ -0,0 +1,10 @@ +//! Control-plane D-Bus command execution + +mod client; +mod commands; +mod timeout; + +pub(crate) use commands::handle_command; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs new file mode 100644 index 00000000..fc2fd562 --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -0,0 +1,195 @@ +use anyhow::anyhow; +use unixnotis_core::PanelDebugLevel; + +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() { + 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 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 = [ + ( + 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..5a974dcf --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/mod.rs @@ -0,0 +1,3 @@ +mod commands; +mod support; +mod timeout; diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs new file mode 100644 index 00000000..a8b255b9 --- /dev/null +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -0,0 +1,132 @@ +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(Debug, PartialEq, Eq)] +pub(super) enum RecordedEvent { + Control(RecordedCall), + DebugLogFollow, +} + +#[derive(Default)] +pub(super) struct RecordingControlClient { + events: RefCell>, +} + +impl RecordingControlClient { + fn record<'a, T: 'a>(&'a self, call: RecordedCall, value: T) -> ControlFuture<'a, T> { + Box::pin(async move { + 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.take_events() + .into_iter() + .filter_map(|event| match event { + RecordedEvent::Control(call) => Some(call), + RecordedEvent::DebugLogFollow => None, + }) + .collect() + } +} + +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.events + .borrow_mut() + .push(RecordedEvent::Control(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/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 new file mode 100644 index 00000000..8d7f6d8d --- /dev/null +++ b/crates/noticenterctl/src/dbus/timeout.rs @@ -0,0 +1,30 @@ +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 { + 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(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", + timeout.as_secs() + )), + } +} 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 new file mode 100644 index 00000000..2e4ccda9 --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/mod.rs @@ -0,0 +1,9 @@ +//! Journalctl follower used when the panel opens in debug mode + +mod command; +mod journal; + +pub(crate) use command::follow_debug_logs; + +#[cfg(test)] +mod tests; 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/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..20ecc51e --- /dev/null +++ b/crates/noticenterctl/src/debug_logs/tests/mod.rs @@ -0,0 +1,2 @@ +mod command; +mod journal; diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index dd0cf4b8..3d7e7736 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -6,53 +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 cli_args; -#[path = "dbus/dbus_ops.rs"] -mod dbus_ops; -#[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 app; +mod cli; +mod css_check; +mod dbus; +mod debug_logs; +mod output; mod preset; -use anyhow::{Context, Result}; -use clap::Parser; -use cli_args::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_args::Command::CssCheck => { - main_css_check::run_css_check()?; - } - cli_args::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 operation layer - dbus_ops::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/main/main_log_follow.rs b/crates/noticenterctl/src/main/main_log_follow.rs deleted file mode 100644 index 3a50af70..00000000 --- a/crates/noticenterctl/src/main/main_log_follow.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! 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; - -const DEFAULT_DAEMON_UNIT: &str = "unixnotis-daemon.service"; - -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()) -} diff --git a/crates/noticenterctl/src/output/gate.rs b/crates/noticenterctl/src/output/gate.rs new file mode 100644 index 00000000..7ed11303 --- /dev/null +++ b/crates/noticenterctl/src/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/main/main_output.rs b/crates/noticenterctl/src/output/mod.rs similarity index 56% rename from crates/noticenterctl/src/main/main_output.rs rename to crates/noticenterctl/src/output/mod.rs index ea8d9a6a..94aefdf6 100644 --- a/crates/noticenterctl/src/main/main_output.rs +++ b/crates/noticenterctl/src/output/mod.rs @@ -1,7 +1,11 @@ -//! Output formatting helpers for noticenterctl. +//! 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 new file mode 100644 index 00000000..315b22bf --- /dev/null +++ b/crates/noticenterctl/src/output/tests.rs @@ -0,0 +1,65 @@ +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_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/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/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.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/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/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/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.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/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/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/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.rs b/crates/noticenterctl/src/preset/import.rs deleted file mode 100644 index 91af941c..00000000 --- a/crates/noticenterctl/src/preset/import.rs +++ /dev/null @@ -1,364 +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 - -#[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 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)] -#[path = "import/tests/mod.rs"] -mod tests; 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/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/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/exec_review.rs b/crates/noticenterctl/src/preset/import/exec_review.rs index 84be320d..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,139 +248,3 @@ impl ReviewStyle { self.paint(text, "37") } } - -#[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")); - } -} 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" + )) +} 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..6d0bf4ec --- /dev/null +++ b/crates/noticenterctl/src/preset/import/tests/commit.rs @@ -0,0 +1,227 @@ +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; + +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()); +} + +#[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" + ); +} 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/exec_review.rs b/crates/noticenterctl/src/preset/import/tests/exec_review.rs new file mode 100644 index 00000000..5af3fff9 --- /dev/null +++ b/crates/noticenterctl/src/preset/import/tests/exec_review.rs @@ -0,0 +1,130 @@ +use super::super::exec_review::{ + 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")); +} 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); 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", + ] + ); +} 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 0db4b437..9d2884a8 100644 --- a/crates/noticenterctl/src/preset/mod.rs +++ b/crates/noticenterctl/src/preset/mod.rs @@ -14,10 +14,13 @@ mod inspect; mod manifest; mod pathing; +#[cfg(test)] +mod tests; + 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 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")] + )); +} diff --git a/crates/noticenterctl/tests/errors.rs b/crates/noticenterctl/tests/errors.rs new file mode 100644 index 00000000..8010a891 --- /dev/null +++ b/crates/noticenterctl/tests/errors.rs @@ -0,0 +1,39 @@ +#![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 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() -> 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)?; + assert!(stderr.contains("invalid value")); + assert!(stderr.contains("maybe")); + Ok(()) + } +} diff --git a/crates/noticenterctl/tests/help.rs b/crates/noticenterctl/tests/help.rs new file mode 100644 index 00000000..f681fe5a --- /dev/null +++ b/crates/noticenterctl/tests/help.rs @@ -0,0 +1,55 @@ +#![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 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()?; + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("export")); + assert!(stdout.contains("import")); + assert!(stdout.contains("inspect")); + Ok(()) + } +} 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/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/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/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/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/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()); +} 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()); +} 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); +} 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, 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/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 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/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, + ] + ); +} 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 93% rename from crates/unixnotis-core/src/config/tests/runtime/layout.rs rename to crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 6eb62ec9..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(); @@ -220,16 +243,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] 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; 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; 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/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, } 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/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/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/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/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); +} 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/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, + }) + ); +} 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); +} 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<()>; 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..a79211f5 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/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 0fceda9a..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(), @@ -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(' '); } } @@ -250,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, @@ -261,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-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( 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()); 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/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..7cf63127 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -0,0 +1,19 @@ +//! 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(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; + +#[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 73% rename from crates/unixnotis-daemon/src/daemon/control.rs rename to crates/unixnotis-daemon/src/daemon/control/server.rs index 7a2a470c..8cf6ec84 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")] @@ -181,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, @@ -191,29 +183,42 @@ 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) } - 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?; - // 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(()) } + 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(()) + } + + 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; + 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 +268,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 +282,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..9ae383e1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -0,0 +1,3 @@ +mod clear; +mod sanitize; +mod server; 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() { 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/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..42dcfea6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server.rs @@ -0,0 +1,101 @@ +//! 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; +#[cfg(test)] +#[path = "tests/server.rs"] +mod tests; + +/// 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..15942834 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -0,0 +1,275 @@ +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 { + let _ = 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, + ) -> bool { + // Debug logging is guarded so normal operation keeps log volume small + if !tracing::enabled!(tracing::Level::DEBUG) { + return false; + } + 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"); + } + true + } + + 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..5cfc391b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs @@ -0,0 +1,334 @@ +use std::collections::HashMap; +use std::sync::Arc; +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, +}; +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); +} + +#[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; + 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 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()); +} diff --git a/crates/unixnotis-daemon/src/daemon/root.rs b/crates/unixnotis-daemon/src/daemon/root.rs index 70d57f67..4f10e573 100644 --- a/crates/unixnotis-daemon/src/daemon/root.rs +++ b/crates/unixnotis-daemon/src/daemon/root.rs @@ -1,15 +1,23 @@ //! 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}; -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; 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..0f39d757 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs @@ -0,0 +1,50 @@ +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.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 { + 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..a526c7c3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -0,0 +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/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/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()); +} 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()); -} 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/expire.rs b/crates/unixnotis-daemon/src/expire.rs index f3c4841d..d7e44979 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,15 +106,21 @@ 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"); } } + + #[cfg(test)] + pub(crate) fn channel_for_test() -> (Self, mpsc::UnboundedReceiver) { + let (sender, receiver) = mpsc::unbounded_channel(); + (Self { sender }, receiver) + } } #[derive(Debug, Copy, Clone)] @@ -141,7 +145,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 +157,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; } 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()), + } +} 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..4704ecdc 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; @@ -22,11 +22,17 @@ 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) => { - // 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" @@ -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<()> { @@ -106,7 +117,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 +134,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..adc9feeb 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; } } @@ -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/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/store.rs b/crates/unixnotis-daemon/src/store.rs deleted file mode 100644 index 49d4b23c..00000000 --- a/crates/unixnotis-daemon/src/store.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! 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}; - -// 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; - -#[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(); - } -} - -#[cfg(test)] -#[path = "store/store_tests/support.rs"] -mod store_tests; 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/store_history.rs b/crates/unixnotis-daemon/src/store/history.rs similarity index 94% rename from crates/unixnotis-daemon/src/store/store_history.rs rename to crates/unixnotis-daemon/src/store/history.rs index 54a7a8a8..6848c8bd 100644 --- a/crates/unixnotis-daemon/src/store/store_history.rs +++ b/crates/unixnotis-daemon/src/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_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 91% rename from crates/unixnotis-daemon/src/store/store_inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibit.rs index be7e7bfa..f6dea848 100644 --- a/crates/unixnotis-daemon/src/store/store_inhibit.rs +++ b/crates/unixnotis-daemon/src/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_inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitor_api.rs similarity index 97% rename from crates/unixnotis-daemon/src/store/store_inhibitor_api.rs rename to crates/unixnotis-daemon/src/store/inhibitor_api.rs index e84eeb8e..dae26ebb 100644 --- a/crates/unixnotis-daemon/src/store/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/store_lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs similarity index 98% rename from crates/unixnotis-daemon/src/store/store_lifecycle.rs rename to crates/unixnotis-daemon/src/store/lifecycle.rs index 09600668..77c4c829 100644 --- a/crates/unixnotis-daemon/src/store/store_lifecycle.rs +++ b/crates/unixnotis-daemon/src/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/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs new file mode 100644 index 00000000..fb7519c9 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -0,0 +1,26 @@ +//! Notification store with ordering, history, and suppression policies + +// Focused modules keep policy and lifecycle logic isolated and easier to test +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 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 rules::contains_ci; + +#[cfg(test)] +mod tests; 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 96% rename from crates/unixnotis-daemon/src/store/store_state.rs rename to crates/unixnotis-daemon/src/store/state.rs index c53afe1e..2b352cc3 100644 --- a/crates/unixnotis-daemon/src/store/store_state.rs +++ b/crates/unixnotis-daemon/src/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/tests/dnd.rs similarity index 96% rename from crates/unixnotis-daemon/src/store/store_tests/dnd.rs rename to crates/unixnotis-daemon/src/store/tests/dnd.rs index 74ddd79e..9a8ceb4a 100644 --- a/crates/unixnotis-daemon/src/store/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 @@ -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/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 95% rename from crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs rename to crates/unixnotis-daemon/src/store/tests/lifecycle.rs index c2a9e2da..f507ae4d 100644 --- a/crates/unixnotis-daemon/src/store/store_tests/lifecycle.rs +++ b/crates/unixnotis-daemon/src/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); diff --git a/crates/unixnotis-daemon/src/store/store_tests/support.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs similarity index 97% rename from crates/unixnotis-daemon/src/store/store_tests/support.rs rename to crates/unixnotis-daemon/src/store/tests/mod.rs index 1ba0c693..86f203d0 100644 --- a/crates/unixnotis-daemon/src/store/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/store_tests/ownership.rs b/crates/unixnotis-daemon/src/store/tests/ownership.rs similarity index 97% rename from crates/unixnotis-daemon/src/store/store_tests/ownership.rs rename to crates/unixnotis-daemon/src/store/tests/ownership.rs index 3af99be3..bf84f7ba 100644 --- a/crates/unixnotis-daemon/src/store/store_tests/ownership.rs +++ b/crates/unixnotis-daemon/src/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/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/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 + } +} 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/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 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 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(()) + } +} 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/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..b712bab7 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..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, shell_path_entry_exists, - shell_startup_files, + ensure_path_entry_in_file, format_path_for_shell_line, remove_path_entry_from_file, + 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() { @@ -13,6 +21,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 +53,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 +157,166 @@ 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_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"); + 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"); @@ -88,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/src/actions/install/service.rs b/crates/unixnotis-installer/src/actions/install/service.rs index b8d1ed6f..a65793c8 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/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..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"] @@ -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/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index 4b601e32..fda7d42c 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(); 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 } 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")); } diff --git a/crates/unixnotis-installer/src/ui/confirm.rs b/crates/unixnotis-installer/src/ui/confirm.rs index e7ffb320..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 @@ -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), @@ -100,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), 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")); 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(()) + } +} diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 3321c0db..993d4bdd 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -5,26 +5,30 @@ 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 local version="${tag#v}" local target="x86_64-unknown-linux-gnu" local root + local binaries=() + local binary_list root="$(repo_root)" cd "$root" + 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 - build_release_binaries - assemble_archive "$tag" "$version" "$target" + build_release_binaries "${binaries[@]}" + assemble_archive "$tag" "$version" "$target" "${binaries[@]}" } repo_root() { @@ -48,19 +52,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 +82,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 +103,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("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}"