Skip to main content

monitord/
varlink_units.rs

1//! # units module
2//!
3//! All main systemd unit statistics. Counts of types of units, unit states and
4//! queued jobs. We also house service specific statistics and system unit states.
5
6use std::collections::BTreeSet;
7use std::collections::HashMap;
8use std::collections::HashSet;
9use std::str::FromStr;
10use std::sync::Arc;
11use std::time::Instant;
12use std::time::SystemTime;
13use std::time::UNIX_EPOCH;
14
15use tokio::sync::RwLock;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18use tracing::debug;
19use tracing::error;
20
21use tracing::warn;
22
23use crate::timer::TimerStats;
24use crate::unit_constants::{
25    is_unit_unhealthy, is_unit_unhealthy_for_service, SystemdUnitActiveState, SystemdUnitLoadState,
26    SYSTEMD_SERVICE_SUFFIX, SYSTEMD_TIMER_SUFFIX,
27};
28use crate::units::SystemdUnitStats;
29use crate::units::UnitsCollectionTimings;
30use crate::varlink::metrics::{ListOutput, Metrics};
31use crate::MachineStats;
32use futures_util::stream::TryStreamExt;
33use zlink::unix;
34
35pub const METRICS_SOCKET_PATH: &str = "/run/systemd/report/io.systemd.Manager";
36
37/// Parse a string value from a metric into an enum type, warning on failure
38fn parse_metric_enum<T: FromStr>(metric: &ListOutput) -> Option<T> {
39    if !metric.value().is_string() {
40        warn!(
41            "Metric {} has non-string value: {:?}",
42            metric.name(),
43            metric.value()
44        );
45        return None;
46    }
47    let value_str = metric.value_as_string();
48    // Normalize hyphens to underscores to match enum variant names (e.g. "not-found" -> "not_found"),
49    // mirroring the same replacement done in the D-Bus path (units.rs::parse_state).
50    let normalized = value_str.replace('-', "_");
51    match T::from_str(&normalized) {
52        Ok(v) => Some(v),
53        Err(_) => {
54            warn!(
55                "Metric {} has unrecognized value: {:?}",
56                metric.name(),
57                value_str
58            );
59            None
60        }
61    }
62}
63
64/// Check if a unit name should be skipped based on allowlist/blocklist
65fn should_skip_unit(object_name: &str, config: &crate::config::UnitsConfig) -> bool {
66    if config.state_stats_blocklist.contains(object_name) {
67        debug!("Skipping state stats for {} due to blocklist", object_name);
68        return true;
69    }
70    if !config.state_stats_allowlist.is_empty()
71        && !config.state_stats_allowlist.contains(object_name)
72    {
73        return true;
74    }
75    false
76}
77
78/// Parse state of a unit into our unit_states hash
79///
80/// `services` is the `[services]` config list: per-service stats are tracked
81/// only for those units, mirroring the D-Bus path.
82///
83/// `has_load_state_totals` says whether this run's metrics include the exact
84/// `UnitsByLoadStateTotal` family. When they do, the per-unit `UnitLoadState`
85/// metrics must not also be counted into the load-state totals, or every unit
86/// would be counted twice; the metric stream has no guaranteed ordering, so
87/// the caller determines this up front rather than relying on which arrives
88/// first.
89pub fn parse_one_metric(
90    stats: &mut SystemdUnitStats,
91    metric: &ListOutput,
92    config: &crate::config::UnitsConfig,
93    services: &HashSet<String>,
94    has_load_state_totals: bool,
95) -> anyhow::Result<()> {
96    let metric_name_suffix = metric.name_suffix();
97    let object_name = metric.object_name();
98
99    match metric_name_suffix {
100        "UnitActiveState" => {
101            if !config.state_stats || should_skip_unit(&object_name, config) {
102                return Ok(());
103            }
104            let active_state: SystemdUnitActiveState = match parse_metric_enum(metric) {
105                Some(v) => v,
106                None => return Ok(()),
107            };
108            let unit_state = stats
109                .unit_states
110                .entry(object_name.to_string())
111                .or_default();
112            unit_state.active_state = active_state;
113            unit_state.unhealthy =
114                is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
115        }
116        "UnitLoadState" => {
117            let load_state: SystemdUnitLoadState = match parse_metric_enum(metric) {
118                Some(v) => v,
119                None => return Ok(()),
120            };
121            // Count aggregate load state totals, matching D-Bus parse_unit() behaviour
122            // which counts every unit regardless of the state_stats allowlist. Skipped
123            // when the exact UnitsByLoadStateTotal metric is available instead.
124            if !has_load_state_totals {
125                match load_state {
126                    SystemdUnitLoadState::loaded => stats.loaded_units += 1,
127                    SystemdUnitLoadState::masked => stats.masked_units += 1,
128                    SystemdUnitLoadState::not_found => stats.not_found_units += 1,
129                    _ => {}
130                }
131            }
132            // Per-unit state tracking is gated by config.
133            if !config.state_stats || should_skip_unit(&object_name, config) {
134                return Ok(());
135            }
136            let unit_state = stats
137                .unit_states
138                .entry(object_name.to_string())
139                .or_default();
140            unit_state.load_state = load_state;
141            unit_state.unhealthy =
142                is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
143        }
144        "NRestarts" => {
145            // Service stats follow the [services] list (like the D-Bus path),
146            // independent of the state_stats gating used for unit_states.
147            if !services.contains(&object_name) {
148                return Ok(());
149            }
150            if !metric.value().is_i64() {
151                warn!(
152                    "Metric {} has non-integer value: {:?}",
153                    metric.name(),
154                    metric.value()
155                );
156                return Ok(());
157            }
158            let value = metric.value_as_int();
159            let nrestarts: u32 = match value.try_into() {
160                Ok(v) => v,
161                Err(_) => {
162                    warn!(
163                        "Metric {} has out-of-range value for u32: {}",
164                        metric.name(),
165                        value
166                    );
167                    return Ok(());
168                }
169            };
170            stats
171                .service_stats
172                .entry(object_name.to_string())
173                .or_default()
174                .nrestarts = nrestarts;
175        }
176        "StatusErrno" => {
177            // Per-service like NRestarts above: tracked for the [services] list only.
178            if !services.contains(&object_name) {
179                return Ok(());
180            }
181            if !metric.value().is_i64() {
182                warn!(
183                    "Metric {} has non-integer value: {:?}",
184                    metric.name(),
185                    metric.value()
186                );
187                return Ok(());
188            }
189            let value = metric.value_as_int();
190            // systemd rejects a negative ERRNO= in sd_notify ("Numerical result
191            // out of range") and emits this metric unsigned, so the value is
192            // non-negative. It is still stored as i32 to match the D-Bus
193            // StatusErrno property that ServiceStats is typed from, which is
194            // what makes the conversion below worth checking.
195            let status_errno: i32 = match value.try_into() {
196                Ok(v) => v,
197                Err(_) => {
198                    warn!(
199                        "Metric {} has out-of-range value for i32: {}",
200                        metric.name(),
201                        value
202                    );
203                    return Ok(());
204                }
205            };
206            stats
207                .service_stats
208                .entry(object_name.to_string())
209                .or_default()
210                .status_errno = status_errno;
211        }
212        "UnitsByTypeTotal" => {
213            if let Some(type_str) = metric.get_field_as_str("type") {
214                if !metric.value().is_i64() {
215                    warn!(
216                        "Metric {} has non-integer value: {:?}",
217                        metric.name(),
218                        metric.value()
219                    );
220                    return Ok(());
221                }
222                let value = metric.value_as_int();
223                let value: u64 = match value.try_into() {
224                    Ok(v) => v,
225                    Err(_) => {
226                        warn!("Metric {} has negative value: {}", metric.name(), value);
227                        return Ok(());
228                    }
229                };
230                match type_str {
231                    "automount" => stats.automount_units = value,
232                    "device" => stats.device_units = value,
233                    "mount" => stats.mount_units = value,
234                    "path" => stats.path_units = value,
235                    "scope" => stats.scope_units = value,
236                    "service" => stats.service_units = value,
237                    "slice" => stats.slice_units = value,
238                    "socket" => stats.socket_units = value,
239                    "target" => stats.target_units = value,
240                    "timer" => stats.timer_units = value,
241                    _ => debug!("Found unhandled unit type: {:?}", type_str),
242                }
243            }
244        }
245        "UnitsByLoadStateTotal" => {
246            if let Some(load_state_str) = metric.get_field_as_str("load_state") {
247                if !metric.value().is_i64() {
248                    warn!(
249                        "Metric {} has non-integer value: {:?}",
250                        metric.name(),
251                        metric.value()
252                    );
253                    return Ok(());
254                }
255                let value = metric.value_as_int();
256                let value: u64 = match value.try_into() {
257                    Ok(v) => v,
258                    Err(_) => {
259                        warn!("Metric {} has negative value: {}", metric.name(), value);
260                        return Ok(());
261                    }
262                };
263                match load_state_str {
264                    "loaded" => stats.loaded_units = value,
265                    "masked" => stats.masked_units = value,
266                    "not-found" => stats.not_found_units = value,
267                    // The remaining load states (stub, merged, error, bad-setting)
268                    // have no counter, matching the D-Bus path.
269                    _ => debug!("Found unhandled unit load state: {:?}", load_state_str),
270                }
271            }
272        }
273        "UnitsByStateTotal" => {
274            if let Some(state_str) = metric.get_field_as_str("state") {
275                if !metric.value().is_i64() {
276                    warn!(
277                        "Metric {} has non-integer value: {:?}",
278                        metric.name(),
279                        metric.value()
280                    );
281                    return Ok(());
282                }
283                let value = metric.value_as_int();
284                let value: u64 = match value.try_into() {
285                    Ok(v) => v,
286                    Err(_) => {
287                        warn!("Metric {} has negative value: {}", metric.name(), value);
288                        return Ok(());
289                    }
290                };
291                match state_str {
292                    "activating" => stats.activating_units = value,
293                    "active" => stats.active_units = value,
294                    "failed" => stats.failed_units = value,
295                    "inactive" => stats.inactive_units = value,
296                    // Other states (reloading, deactivating, maintenance,
297                    // refreshing) have no counter, matching the D-Bus path
298                    // which also only counts the four states above.
299                    _ => debug!("Found unhandled unit state: {:?}", state_str),
300                }
301            }
302        }
303        "JobsQueued" => {
304            if !metric.value().is_i64() {
305                warn!(
306                    "Metric {} has non-integer value: {:?}",
307                    metric.name(),
308                    metric.value()
309                );
310                return Ok(());
311            }
312            let value = metric.value_as_int();
313            match value.try_into() {
314                Ok(value) => stats.jobs_queued = value,
315                Err(_) => {
316                    warn!("Metric {} has negative value: {}", metric.name(), value);
317                }
318            }
319        }
320        "UnitsTotal" => {
321            if !metric.value().is_i64() {
322                warn!(
323                    "Metric {} has non-integer value: {:?}",
324                    metric.name(),
325                    metric.value()
326                );
327                return Ok(());
328            }
329            let value = metric.value_as_int();
330            match value.try_into() {
331                Ok(value) => stats.total_units = value,
332                Err(_) => {
333                    warn!("Metric {} has negative value: {}", metric.name(), value);
334                }
335            }
336        }
337        "StateChangeTimestamp" => {
338            if !config.state_stats
339                || !config.state_stats_time_in_state
340                || should_skip_unit(&object_name, config)
341            {
342                return Ok(());
343            }
344            if !metric.value().is_i64() {
345                warn!(
346                    "Metric {} has non-integer value: {:?}",
347                    metric.name(),
348                    metric.value()
349                );
350                return Ok(());
351            }
352            let state_change_usec: u64 = match metric.value_as_int().try_into() {
353                Ok(value) => value,
354                Err(_) => {
355                    warn!(
356                        "Metric {} has out-of-range value: {}",
357                        metric.name(),
358                        metric.value_as_int()
359                    );
360                    return Ok(());
361                }
362            };
363            let now_usec = match SystemTime::now().duration_since(UNIX_EPOCH) {
364                Ok(elapsed) => elapsed.as_secs() * 1_000_000 + u64::from(elapsed.subsec_micros()),
365                Err(err) => {
366                    warn!("System clock error computing time in state: {:?}", err);
367                    return Ok(());
368                }
369            };
370            // A zero/unknown timestamp leaves the entry untouched: the shared
371            // helper returns None instead of a bogus huge elapsed time.
372            let Some(elapsed) = crate::units::compute_time_in_state(now_usec, state_change_usec)
373            else {
374                return Ok(());
375            };
376            stats
377                .unit_states
378                .entry(object_name.to_string())
379                .or_default()
380                .time_in_state_usecs = Some(elapsed);
381        }
382        _ => debug!("Found unhandled metric: {:?}", metric.name()),
383    }
384
385    Ok(())
386}
387
388/// Collect all metrics from the varlink socket.
389/// Runs on a blocking thread with a dedicated runtime because the zlink
390/// stream is !Send and cannot be held across await points in a Send future.
391pub(crate) async fn collect_metrics(socket_path: String) -> anyhow::Result<Vec<ListOutput>> {
392    tokio::task::spawn_blocking(move || {
393        let rt = tokio::runtime::Builder::new_current_thread()
394            .enable_all()
395            .build()?;
396        rt.block_on(async move {
397            let mut conn = unix::connect(&socket_path).await?;
398            let stream = conn.list().await?;
399            futures_util::pin_mut!(stream);
400
401            let mut metrics = Vec::new();
402            let mut count = 0;
403            while let Some(result) = stream.try_next().await? {
404                let result: std::result::Result<ListOutput, _> = result;
405                match result {
406                    Ok(metric) => {
407                        debug!("Metrics {}: {:?}", count, metric);
408                        count += 1;
409                        metrics.push(metric);
410                    }
411                    Err(e) => {
412                        debug!("Error deserializing metric {}: {:?}", count, e);
413                        return Err(anyhow::anyhow!(e));
414                    }
415                }
416            }
417            Ok(metrics)
418        })
419    })
420    .await?
421}
422
423pub async fn parse_metrics(
424    stats: &mut SystemdUnitStats,
425    socket_path: &str,
426    config: &crate::config::UnitsConfig,
427    services: &HashSet<String>,
428) -> anyhow::Result<Vec<String>> {
429    // Parity with the D-Bus path's UnitsCollectionTimings: list_units_ms is the
430    // bulk fetch (varlink List on io.systemd.Manager), per_unit_loop_ms is the
431    // local parse loop plus the io.systemd.Unit.List detail pass that follows
432    // it. All three *_dbus_fetches counters stay 0 on the host varlink path,
433    // which no longer touches D-Bus at all; they only move for containers,
434    // which cannot reach their own varlink sockets (#211), or when the unit
435    // socket is unusable and the whole collection is redone over D-Bus.
436    let bulk_fetch_start = Instant::now();
437    let metrics = collect_metrics(socket_path.to_string()).await?;
438    let bulk_fetch_elapsed = bulk_fetch_start.elapsed();
439    stats.collection_timings.list_units_ms = bulk_fetch_elapsed.as_secs_f64() * 1000.0;
440
441    let parse_loop_start = Instant::now();
442    let has_load_state_totals = metrics
443        .iter()
444        .any(|metric| metric.name_suffix() == "UnitsByLoadStateTotal");
445    for metric in &metrics {
446        parse_one_metric(stats, metric, config, services, has_load_state_totals)?;
447    }
448    // Timer units are enumerated from the metrics we already have rather than
449    // from a second bulk call: every unit appears here as a metric object, so
450    // this costs a scan of a vector we fetched anyway. Mirrors how the D-Bus
451    // path picks timers out of its ListUnits reply.
452    let timer_names: BTreeSet<String> = metrics
453        .iter()
454        .filter_map(|metric| metric.object())
455        .filter(|object| object.ends_with(SYSTEMD_TIMER_SUFFIX))
456        .map(|object| object.to_string())
457        .collect();
458
459    let parse_loop_elapsed = parse_loop_start.elapsed();
460    stats.collection_timings.per_unit_loop_ms = parse_loop_elapsed.as_secs_f64() * 1000.0;
461
462    Ok(timer_names.into_iter().collect())
463}
464
465/// Select unit names whose health needs a service-type check.
466///
467/// Mirrors the condition in `units::parse_state`: only inactive, loaded
468/// `.service` units can be rescued by the oneshot override, so only those
469/// need the `Service.Type` lookup. Pure function over already collected
470/// stats, so it runs under a read lock without any I/O.
471pub fn select_oneshot_candidates(
472    stats: &SystemdUnitStats,
473    config: &crate::config::UnitsConfig,
474) -> Vec<String> {
475    if !config.ignore_inactive_oneshot_services {
476        return Vec::new();
477    }
478    stats
479        .unit_states
480        .iter()
481        .filter(|(name, state)| {
482            name.ends_with(SYSTEMD_SERVICE_SUFFIX)
483                && matches!(state.active_state, SystemdUnitActiveState::inactive)
484                && matches!(state.load_state, SystemdUnitLoadState::loaded)
485        })
486        .map(|(name, _)| name.clone())
487        .collect()
488}
489
490/// Look up `Service.Type` for each candidate over D-Bus.
491///
492/// Service type is not exposed via the varlink metrics API, so the varlink
493/// path resolves it here to keep `unhealthy` in parity with the D-Bus path.
494/// (`io.systemd.Unit.List` does expose it as `context.Service.Type`, so this
495/// lookup can go away once monitord adopts that API — see #37.)
496/// Concurrency is bounded by `per_unit_concurrency`, like the D-Bus per-unit
497/// loop. A lookup failure for one unit is logged and omitted from the map
498/// (which `apply_oneshot_types` treats as "not oneshot") rather than failing
499/// the whole collection, mirroring `units::parse_state`.
500///
501/// Returns the resolved types plus the number of successful lookups, so the
502/// caller can account this phase in `UnitsCollectionTimings` like any other
503/// per-service D-Bus work.
504pub async fn fetch_oneshot_types(
505    connection: &zbus::Connection,
506    candidates: Vec<String>,
507    per_unit_concurrency: u64,
508) -> (HashMap<String, bool>, u64) {
509    let semaphore = Arc::new(Semaphore::new(per_unit_concurrency.max(1) as usize));
510    let mut join_set: JoinSet<(String, Option<bool>)> = JoinSet::new();
511    for name in candidates {
512        let semaphore = Arc::clone(&semaphore);
513        let connection = connection.clone();
514        join_set.spawn(async move {
515            let _permit = semaphore
516                .acquire()
517                .await
518                .expect("semaphore closed unexpectedly");
519            let is_oneshot =
520                match crate::units::is_oneshot_service_by_name(&connection, &name).await {
521                    Ok(is_oneshot) => Some(is_oneshot),
522                    Err(err) => {
523                        warn!(
524                            "Unable to get Service.Type for {} (assuming not oneshot): {:?}",
525                            name, err
526                        );
527                        None
528                    }
529                };
530            (name, is_oneshot)
531        });
532    }
533    let mut types = HashMap::new();
534    let mut successful_fetches: u64 = 0;
535    while let Some(res) = join_set.join_next().await {
536        match res {
537            Ok((name, Some(is_oneshot))) => {
538                types.insert(name, is_oneshot);
539                successful_fetches += 1;
540            }
541            Ok((_, None)) => {}
542            Err(err) => {
543                warn!("Oneshot type lookup task failed to join: {:?}", err);
544            }
545        }
546    }
547    (types, successful_fetches)
548}
549
550/// Account a completed oneshot lookup phase in the collection timings.
551///
552/// Successful `Service.Type` resolutions are per-service D-Bus property
553/// fetches, so they count toward `service_dbus_fetches`; the phase duration
554/// folds into `per_unit_loop_ms`, the same bucket the D-Bus path uses for
555/// its own per-unit work (including its oneshot checks).
556fn record_oneshot_lookup_timings(
557    timings: &mut UnitsCollectionTimings,
558    elapsed_ms: f64,
559    successful_fetches: u64,
560) {
561    timings.per_unit_loop_ms += elapsed_ms;
562    timings.service_dbus_fetches += successful_fetches;
563}
564
565/// Recompute `unhealthy` for tracked units given resolved service types.
566///
567/// Units missing from `oneshot_types` (lookup failed or never a candidate)
568/// are treated as "not oneshot", matching the D-Bus path's assumption.
569pub fn apply_oneshot_types(
570    stats: &mut SystemdUnitStats,
571    oneshot_types: &HashMap<String, bool>,
572    config: &crate::config::UnitsConfig,
573) {
574    if !config.ignore_inactive_oneshot_services {
575        return;
576    }
577    for (unit_name, unit_state) in stats.unit_states.iter_mut() {
578        let is_oneshot = oneshot_types.get(unit_name).copied().unwrap_or(false);
579        unit_state.unhealthy = is_unit_unhealthy_for_service(
580            unit_state.active_state,
581            unit_state.load_state,
582            is_oneshot,
583            config.ignore_inactive_oneshot_services,
584        );
585    }
586}
587
588/// Apply the oneshot health override to varlink-collected unit stats.
589///
590/// Runs candidate selection under a read lock, resolves service types over
591/// D-Bus without holding any lock, then applies the results under a write
592/// lock — so D-Bus round trips never block other collectors on the shared
593/// `MachineStats` lock.
594pub async fn apply_oneshot_dbus_override(
595    connection: &zbus::Connection,
596    locked_machine_stats: &Arc<RwLock<MachineStats>>,
597    config: &crate::config::UnitsConfig,
598) {
599    let candidates = {
600        let machine_stats = locked_machine_stats.read().await;
601        select_oneshot_candidates(&machine_stats.units, config)
602    };
603    if candidates.is_empty() {
604        return;
605    }
606    let fetch_start = Instant::now();
607    let (oneshot_types, successful_fetches) =
608        fetch_oneshot_types(connection, candidates, config.per_unit_concurrency).await;
609    let fetch_elapsed_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
610    let mut machine_stats = locked_machine_stats.write().await;
611    apply_oneshot_types(&mut machine_stats.units, &oneshot_types, config);
612    record_oneshot_lookup_timings(
613        &mut machine_stats.units.collection_timings,
614        fetch_elapsed_ms,
615        successful_fetches,
616    );
617}
618
619/// Refuse a `Unit.List` reply that carries no per-type context section.
620///
621/// systemd v258 through v260 answer `Unit.List` successfully but without the
622/// per-type context sections: `src/core/varlink-service.c` and
623/// `src/core/varlink-timer.c` both land in v261. Without this check every field
624/// would map to its default on those versions, the call would look like a
625/// success, and the D-Bus fallback would never run — silently wrong numbers
626/// rather than a visible failure.
627///
628/// Only the unit types monitord maps are checked, so an unexpected type listed
629/// in `[services]` is treated as a config mistake rather than an old systemd.
630fn require_unit_context(
631    name: &str,
632    output: &crate::varlink::unit::ListOutput,
633) -> anyhow::Result<()> {
634    let context = output.context.as_ref();
635    let (section, present) = if name.ends_with(SYSTEMD_SERVICE_SUFFIX) {
636        (
637            "Service",
638            context
639                .and_then(|context| context.service.as_ref())
640                .is_some(),
641        )
642    } else if name.ends_with(SYSTEMD_TIMER_SUFFIX) {
643        (
644            "Timer",
645            context.and_then(|context| context.timer.as_ref()).is_some(),
646        )
647    } else {
648        return Ok(());
649    };
650    if !present {
651        anyhow::bail!(
652            "{} came back with no context.{}: this systemd is older than v261, \
653             where io.systemd.Unit gained the per-type context sections",
654            name,
655            section
656        );
657    }
658    Ok(())
659}
660
661/// Pick the timers to collect, applying the same `[timers]` gating as the
662/// D-Bus path in `timer::collect_all_timers_dbus`.
663fn select_timers(timer_names: &[String], config: &crate::config::TimersConfig) -> Vec<String> {
664    if !config.enabled {
665        return Vec::new();
666    }
667    timer_names
668        .iter()
669        .filter(|name| {
670            if config.blocklist.contains(*name) {
671                debug!("Skipping timer stats for {} due to blocklist", name);
672                return false;
673            }
674            config.allowlist.is_empty() || config.allowlist.contains(*name)
675        })
676        .cloned()
677        .collect()
678}
679
680/// Collect per-unit detail over varlink: `ServiceStats` for the `[services]`
681/// list, the tracked timers, and the service types the oneshot health override
682/// needs.
683///
684/// Both come from `io.systemd.Unit.List`, through one connection and one cache,
685/// so a unit wanted by both is fetched once. This replaces the per-service and
686/// oneshot-type D-Bus fetches the varlink path used to fall back to.
687///
688/// Selection runs under a read lock and the varlink round trips hold no lock at
689/// all, matching how the D-Bus override behaved.
690pub async fn apply_unit_details(
691    socket_path: &str,
692    locked_machine_stats: &Arc<RwLock<MachineStats>>,
693    config: &crate::config::Config,
694    fs_root: &str,
695    timer_names: &[String],
696) -> anyhow::Result<()> {
697    let candidates = {
698        let machine_stats = locked_machine_stats.read().await;
699        select_oneshot_candidates(&machine_stats.units, &config.units)
700    };
701    let timers = select_timers(timer_names, &config.timers);
702    if candidates.is_empty() && config.services.is_empty() && timers.is_empty() {
703        return Ok(());
704    }
705
706    let fetch_start = Instant::now();
707    let mut lookup = crate::varlink_unit::UnitLookup::connect(socket_path).await?;
708
709    let mut service_stats: HashMap<String, crate::units::ServiceStats> = HashMap::new();
710    for name in &config.services {
711        let Some(output) = lookup.get(name).await? else {
712            continue;
713        };
714        // Cloned so the cache entry stays available to the oneshot pass below.
715        let output = output.clone();
716        require_unit_context(name, &output)?;
717        let processes = crate::varlink_unit::count_cgroup_processes(fs_root, &output).await;
718        service_stats.insert(
719            name.clone(),
720            crate::varlink_unit::map_service_stats(&output, processes),
721        );
722    }
723
724    let mut oneshot_types: HashMap<String, bool> = HashMap::new();
725    for name in candidates {
726        if let Some(output) = lookup.get(&name).await? {
727            let output = output.clone();
728            require_unit_context(&name, &output)?;
729            oneshot_types.insert(name, crate::varlink_unit::is_oneshot(&output));
730        }
731    }
732
733    // Timers need a second unit each — the one they trigger — for its state
734    // change timestamps. That unit is often already in [services], which is
735    // where the lookup cache earns its place.
736    let mut timer_stats: HashMap<String, TimerStats> = HashMap::new();
737    for name in timers {
738        let Some(output) = lookup.get(&name).await? else {
739            continue;
740        };
741        let output = output.clone();
742        require_unit_context(&name, &output)?;
743        // If the triggered unit cannot be resolved the timer is still reported,
744        // with its own fields intact and the two trigger timestamps at 0. The
745        // D-Bus path instead drops the timer entirely, because its GetUnit call
746        // errors and the whole per-timer fetch is abandoned. Keeping it is the
747        // more useful of the two: a timer whose target is unloaded still has a
748        // real accuracy, next elapse and last trigger worth reporting.
749        let triggered = match crate::varlink_unit::timer_triggered_unit(&output) {
750            Some(triggered) => lookup.get(triggered).await?.cloned(),
751            None => {
752                error!("{}: No service unit name found for timer.", name);
753                None
754            }
755        };
756        timer_stats.insert(
757            name,
758            crate::varlink_unit::map_timer_stats(&output, triggered.as_ref()),
759        );
760    }
761
762    let fetch_elapsed_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
763    debug!(
764        "Varlink unit details: {} unit(s) fetched for {} service(s) + {} oneshot candidate(s) in {:.2}ms",
765        lookup.fetches(),
766        config.services.len(),
767        oneshot_types.len(),
768        fetch_elapsed_ms
769    );
770    let mut machine_stats = locked_machine_stats.write().await;
771    machine_stats.units.timer_persistent_units = timer_stats
772        .values()
773        .filter(|timer| timer.persistent)
774        .count() as u64;
775    machine_stats.units.timer_remain_after_elapse = timer_stats
776        .values()
777        .filter(|timer| timer.remain_after_elapse)
778        .count() as u64;
779    machine_stats.units.timer_stats = timer_stats;
780    machine_stats.units.service_stats.extend(service_stats);
781    apply_oneshot_types(&mut machine_stats.units, &oneshot_types, &config.units);
782    // The phase still folds into per_unit_loop_ms like its D-Bus predecessor,
783    // but nothing here touches D-Bus, so the *_dbus_fetches counters stay put.
784    machine_stats.units.collection_timings.per_unit_loop_ms += fetch_elapsed_ms;
785    Ok(())
786}
787
788/// Sum per-type counts as a fallback total for systemd versions whose metrics
789/// lack `UnitsTotal`. Mirrors what the D-Bus path computes as `units.len()`,
790/// except unmapped types (e.g. swap) are missed.
791fn sum_units_by_type(stats: &SystemdUnitStats) -> u64 {
792    stats.automount_units
793        + stats.device_units
794        + stats.mount_units
795        + stats.path_units
796        + stats.scope_units
797        + stats.service_units
798        + stats.slice_units
799        + stats.socket_units
800        + stats.target_units
801        + stats.timer_units
802}
803
804pub async fn get_unit_stats(
805    config: &crate::config::Config,
806    socket_path: &str,
807) -> anyhow::Result<(SystemdUnitStats, Vec<String>)> {
808    if !config.units.state_stats_allowlist.is_empty() {
809        debug!(
810            "Using unit state allowlist: {:?}",
811            config.units.state_stats_allowlist
812        );
813    }
814
815    if !config.units.state_stats_blocklist.is_empty() {
816        debug!(
817            "Using unit state blocklist: {:?}",
818            config.units.state_stats_blocklist,
819        );
820    }
821
822    let mut stats = SystemdUnitStats::default();
823
824    // Always collect metrics to get aggregate counts (UnitsByTypeTotal, UnitsByStateTotal)
825    // as well as per-unit state data when config.units.state_stats is enabled.
826    let timer_names =
827        parse_metrics(&mut stats, socket_path, &config.units, &config.services).await?;
828
829    // Prefer the UnitsTotal metric when present: it is exact, including unit
830    // types we do not map (e.g. swap). Fall back to summing per-type counts
831    // on systemd versions whose metrics lack it.
832    if stats.total_units == 0 {
833        stats.total_units = sum_units_by_type(&stats);
834    }
835
836    debug!("unit stats: {:?}", stats);
837    Ok((stats, timer_names))
838}
839
840/// Async wrapper that can update unit stats when passed a locked struct.
841pub async fn update_unit_stats(
842    config: Arc<crate::config::Config>,
843    locked_machine_stats: Arc<RwLock<MachineStats>>,
844    socket_path: String,
845) -> anyhow::Result<Vec<String>> {
846    let (units_stats, timer_names) = get_unit_stats(&config, &socket_path).await?;
847    let mut machine_stats = locked_machine_stats.write().await;
848    machine_stats.units = units_stats;
849    Ok(timer_names)
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use std::collections::HashSet;
856
857    fn string_value(s: &str) -> serde_json::Value {
858        serde_json::json!(s)
859    }
860
861    fn int_value(i: i64) -> serde_json::Value {
862        serde_json::json!(i)
863    }
864
865    fn empty_value() -> serde_json::Value {
866        serde_json::Value::Null
867    }
868
869    fn default_units_config() -> crate::config::UnitsConfig {
870        crate::config::UnitsConfig {
871            enabled: true,
872            state_stats: true,
873            state_stats_allowlist: HashSet::new(),
874            state_stats_blocklist: HashSet::new(),
875            state_stats_time_in_state: false,
876            ignore_inactive_oneshot_services: true,
877            unit_files: true,
878            ..Default::default()
879        }
880    }
881
882    #[tokio::test]
883    async fn test_parse_one_metric_unit_active_state() {
884        let mut stats = SystemdUnitStats::default();
885        let config = default_units_config();
886
887        let metric = ListOutput {
888            name: "io.systemd.Manager.UnitActiveState".to_string(),
889            value: string_value("active"),
890            object: Some("my-service.service".to_string()),
891            fields: None,
892        };
893
894        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
895            .expect("metric should parse successfully");
896
897        assert_eq!(
898            stats
899                .unit_states
900                .get("my-service.service")
901                .expect("my-service.service should have a unit_states entry")
902                .active_state,
903            SystemdUnitActiveState::active
904        );
905    }
906
907    #[tokio::test]
908    async fn test_parse_one_metric_unit_load_state() {
909        let mut stats = SystemdUnitStats::default();
910        let config = default_units_config();
911
912        // systemd sends "not-found" with a hyphen over both D-Bus and varlink;
913        // parse_metric_enum must normalize it to "not_found" before enum parsing.
914        let metric = ListOutput {
915            name: "io.systemd.Manager.UnitLoadState".to_string(),
916            value: string_value("not-found"),
917            object: Some("missing.service".to_string()),
918            fields: None,
919        };
920
921        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
922            .expect("metric should parse successfully");
923
924        assert_eq!(
925            stats
926                .unit_states
927                .get("missing.service")
928                .expect("missing.service should have a unit_states entry")
929                .load_state,
930            SystemdUnitLoadState::not_found
931        );
932    }
933
934    #[test]
935    fn test_parse_one_metric_nrestarts() {
936        let mut stats = SystemdUnitStats::default();
937        let config = default_units_config();
938        // NRestarts is tracked per the [services] list, mirroring the D-Bus path.
939        let services = HashSet::from(["my-service.service".to_string()]);
940
941        let metric = ListOutput {
942            name: "io.systemd.Manager.NRestarts".to_string(),
943            value: int_value(5),
944            object: Some("my-service.service".to_string()),
945            fields: None,
946        };
947
948        parse_one_metric(&mut stats, &metric, &config, &services, false)
949            .expect("metric should parse successfully");
950
951        assert_eq!(
952            stats
953                .service_stats
954                .get("my-service.service")
955                .expect("my-service.service should have a service_stats entry")
956                .nrestarts,
957            5
958        );
959
960        // Units outside [services] get no service_stats entry even with data present.
961        let other_metric = ListOutput {
962            name: "io.systemd.Manager.NRestarts".to_string(),
963            value: int_value(7),
964            object: Some("other.service".to_string()),
965            fields: None,
966        };
967        parse_one_metric(&mut stats, &other_metric, &config, &services, false)
968            .expect("other_metric should parse successfully");
969        assert!(!stats.service_stats.contains_key("other.service"));
970    }
971
972    #[test]
973    fn test_parse_one_metric_status_errno() {
974        let mut stats = SystemdUnitStats::default();
975        let config = default_units_config();
976        let services = HashSet::from(["my-service.service".to_string()]);
977
978        // A service reporting EACCES via sd_notify's ERRNO=13, as seen live.
979        let metric = ListOutput {
980            name: "io.systemd.Manager.StatusErrno".to_string(),
981            value: int_value(13),
982            object: Some("my-service.service".to_string()),
983            fields: None,
984        };
985        parse_one_metric(&mut stats, &metric, &config, &services, false)
986            .expect("metric should parse successfully");
987        assert_eq!(
988            stats
989                .service_stats
990                .get("my-service.service")
991                .expect("my-service.service should have a service_stats entry")
992                .status_errno,
993            13
994        );
995
996        // Out of i32 range: warn and leave the previous value alone rather than
997        // wrapping, since the metric is serialized unsigned.
998        let out_of_range = ListOutput {
999            name: "io.systemd.Manager.StatusErrno".to_string(),
1000            value: int_value(i64::from(i32::MAX) + 1),
1001            object: Some("my-service.service".to_string()),
1002            fields: None,
1003        };
1004        parse_one_metric(&mut stats, &out_of_range, &config, &services, false)
1005            .expect("out_of_range should parse successfully");
1006        assert_eq!(
1007            stats
1008                .service_stats
1009                .get("my-service.service")
1010                .expect("my-service.service should have a service_stats entry")
1011                .status_errno,
1012            13
1013        );
1014
1015        // Units outside [services] get no service_stats entry even with data present.
1016        let other_metric = ListOutput {
1017            name: "io.systemd.Manager.StatusErrno".to_string(),
1018            value: int_value(1),
1019            object: Some("other.service".to_string()),
1020            fields: None,
1021        };
1022        parse_one_metric(&mut stats, &other_metric, &config, &services, false)
1023            .expect("other_metric should parse successfully");
1024        assert!(!stats.service_stats.contains_key("other.service"));
1025    }
1026
1027    #[tokio::test]
1028    async fn test_parse_aggregated_metrics() {
1029        let mut stats = SystemdUnitStats::default();
1030        let config = default_units_config();
1031
1032        // Test UnitsByTypeTotal
1033        let type_metric = ListOutput {
1034            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1035            value: int_value(42),
1036            object: None,
1037            fields: Some(std::collections::HashMap::from([(
1038                "type".to_string(),
1039                serde_json::json!("service"),
1040            )])),
1041        };
1042        parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1043            .expect("type_metric should parse successfully");
1044        assert_eq!(stats.service_units, 42);
1045
1046        // Test UnitsByStateTotal
1047        let state_metric = ListOutput {
1048            name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1049            value: int_value(10),
1050            object: None,
1051            fields: Some(std::collections::HashMap::from([(
1052                "state".to_string(),
1053                serde_json::json!("active"),
1054            )])),
1055        };
1056        parse_one_metric(&mut stats, &state_metric, &config, &HashSet::new(), false)
1057            .expect("state_metric should parse successfully");
1058        assert_eq!(stats.active_units, 10);
1059
1060        // Test UnitsByStateTotal with activating state
1061        let activating_metric = ListOutput {
1062            name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1063            value: int_value(1),
1064            object: None,
1065            fields: Some(std::collections::HashMap::from([(
1066                "state".to_string(),
1067                serde_json::json!("activating"),
1068            )])),
1069        };
1070        parse_one_metric(
1071            &mut stats,
1072            &activating_metric,
1073            &config,
1074            &HashSet::new(),
1075            false,
1076        )
1077        .expect("activating_metric should parse successfully");
1078        assert_eq!(stats.activating_units, 1);
1079
1080        // Test JobsQueued
1081        let jobs_metric = ListOutput {
1082            name: "io.systemd.Manager.JobsQueued".to_string(),
1083            value: int_value(3),
1084            object: None,
1085            fields: None,
1086        };
1087        parse_one_metric(&mut stats, &jobs_metric, &config, &HashSet::new(), false)
1088            .expect("jobs_metric should parse successfully");
1089        assert_eq!(stats.jobs_queued, 3);
1090
1091        // Test UnitsTotal
1092        let total_metric = ListOutput {
1093            name: "io.systemd.Manager.UnitsTotal".to_string(),
1094            value: int_value(196),
1095            object: None,
1096            fields: None,
1097        };
1098        parse_one_metric(&mut stats, &total_metric, &config, &HashSet::new(), false)
1099            .expect("total_metric should parse successfully");
1100        assert_eq!(stats.total_units, 196);
1101    }
1102
1103    #[test]
1104    fn test_require_unit_context_rejects_pre_v261_replies() {
1105        use crate::varlink::unit::{
1106            ListOutput as UnitListOutput, ServiceContext, TimerContext, UnitContext,
1107        };
1108
1109        // systemd v258-v260 answer Unit.List successfully but omit the
1110        // per-type context sections, so a reply that parses fine still carries
1111        // nothing we can map. CI only ever runs Rawhide, so this shape cannot
1112        // be reached there and has to be pinned here instead.
1113        let bare = UnitListOutput {
1114            context: Some(UnitContext {
1115                service: None,
1116                exec: None,
1117                timer: None,
1118            }),
1119            runtime: None,
1120        };
1121        assert!(require_unit_context("foo.service", &bare).is_err());
1122        // Timers need the same guard: without it a v260 host with timers
1123        // enabled and no [services] would report every timer as all zeroes
1124        // and never fall back.
1125        assert!(require_unit_context("foo.timer", &bare).is_err());
1126        // Nothing at all is equally unusable.
1127        let empty = UnitListOutput {
1128            context: None,
1129            runtime: None,
1130        };
1131        assert!(require_unit_context("foo.service", &empty).is_err());
1132        assert!(require_unit_context("foo.timer", &empty).is_err());
1133
1134        // v261+ replies pass.
1135        let with_service = UnitListOutput {
1136            context: Some(UnitContext {
1137                service: Some(ServiceContext {
1138                    r#type: Some("simple".to_string()),
1139                    restart_usec: None,
1140                    watchdog_usec: None,
1141                }),
1142                exec: None,
1143                timer: None,
1144            }),
1145            runtime: None,
1146        };
1147        assert!(require_unit_context("foo.service", &with_service).is_ok());
1148        let with_timer = UnitListOutput {
1149            context: Some(UnitContext {
1150                service: None,
1151                exec: None,
1152                timer: Some(TimerContext {
1153                    unit: Some("foo.service".to_string()),
1154                    accuracy_usec: Some(60_000_000),
1155                    randomized_delay_usec: None,
1156                    fixed_random_delay: None,
1157                    persistent: None,
1158                    remain_after_elapse: None,
1159                }),
1160            }),
1161            runtime: None,
1162        };
1163        assert!(require_unit_context("foo.timer", &with_timer).is_ok());
1164
1165        // A type monitord does not map is a config mistake, not an old
1166        // systemd, so it must not trigger a version fallback.
1167        assert!(require_unit_context("foo.socket", &bare).is_ok());
1168    }
1169
1170    #[test]
1171    fn test_sum_units_by_type_fallback() {
1172        // The fallback sums mapped per-type counts; unmapped types like swap
1173        // are missed, which is why the UnitsTotal metric is preferred.
1174        let stats = SystemdUnitStats {
1175            service_units: 86,
1176            timer_units: 2,
1177            ..Default::default()
1178        };
1179        assert_eq!(sum_units_by_type(&stats), 88);
1180        assert_eq!(sum_units_by_type(&SystemdUnitStats::default()), 0);
1181    }
1182
1183    #[test]
1184    fn test_state_change_timestamp_sets_time_in_state() {
1185        let mut stats = SystemdUnitStats::default();
1186        let mut config = default_units_config();
1187        config.state_stats_time_in_state = true;
1188        let now_usec = SystemTime::now()
1189            .duration_since(UNIX_EPOCH)
1190            .expect("test clock should work")
1191            .as_secs()
1192            * 1_000_000;
1193        let metric = ListOutput {
1194            name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1195            value: int_value((now_usec - 5_000_000) as i64),
1196            object: Some("test.service".to_string()),
1197            fields: None,
1198        };
1199
1200        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1201            .expect("timestamp metric should parse successfully");
1202
1203        let elapsed = stats
1204            .unit_states
1205            .get("test.service")
1206            .expect("test.service should have a unit_states entry")
1207            .time_in_state_usecs
1208            .expect("time in state should be set");
1209        assert!(
1210            (5_000_000..6_000_000).contains(&elapsed),
1211            "expected ~5s in state, got {}us",
1212            elapsed
1213        );
1214    }
1215
1216    #[test]
1217    fn test_state_change_timestamp_zero_stays_unknown() {
1218        // 0 means no state change has occurred: no entry is created for the
1219        // timestamp alone, so no bogus huge elapsed time is ever reported.
1220        // (In practice the Active/LoadState arms create the entry with
1221        // time_in_state_usecs left as None.)
1222        let mut stats = SystemdUnitStats::default();
1223        let mut config = default_units_config();
1224        config.state_stats_time_in_state = true;
1225        let metric = ListOutput {
1226            name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1227            value: int_value(0),
1228            object: Some("test.service".to_string()),
1229            fields: None,
1230        };
1231
1232        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1233            .expect("zero timestamp should parse successfully");
1234
1235        assert!(stats.unit_states.get("test.service").is_none());
1236    }
1237
1238    #[test]
1239    fn test_state_change_timestamp_respects_gating() {
1240        let metric = ListOutput {
1241            name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1242            value: int_value(1_700_000_000_000_000),
1243            object: Some("test.service".to_string()),
1244            fields: None,
1245        };
1246
1247        // Disabled via state_stats_time_in_state.
1248        let mut config = default_units_config();
1249        config.state_stats_time_in_state = false;
1250        let mut stats = SystemdUnitStats::default();
1251        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1252            .expect("metric should parse successfully");
1253        assert!(stats.unit_states.get("test.service").is_none());
1254
1255        // Disabled via state_stats.
1256        let mut config = default_units_config();
1257        config.state_stats = false;
1258        let mut stats = SystemdUnitStats::default();
1259        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1260            .expect("metric should parse successfully");
1261        assert!(stats.unit_states.get("test.service").is_none());
1262
1263        // Excluded via blocklist.
1264        let mut config = default_units_config();
1265        config.state_stats_blocklist = HashSet::from(["test.service".to_string()]);
1266        let mut stats = SystemdUnitStats::default();
1267        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1268            .expect("metric should parse successfully");
1269        assert!(stats.unit_states.get("test.service").is_none());
1270
1271        // Negative and non-integer values are skipped, not stored. Tracking
1272        // must be enabled here so these calls reach validation instead of
1273        // returning at the gating checks above.
1274        let mut config = default_units_config();
1275        config.state_stats_time_in_state = true;
1276        let mut stats = SystemdUnitStats::default();
1277        let negative = ListOutput {
1278            name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1279            value: int_value(-1),
1280            object: Some("test.service".to_string()),
1281            fields: None,
1282        };
1283        parse_one_metric(&mut stats, &negative, &config, &HashSet::new(), false)
1284            .expect("negative timestamp should parse successfully");
1285        let null_value = ListOutput {
1286            name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1287            value: empty_value(),
1288            object: Some("test.service".to_string()),
1289            fields: None,
1290        };
1291        parse_one_metric(&mut stats, &null_value, &config, &HashSet::new(), false)
1292            .expect("null timestamp should parse successfully");
1293        assert!(stats.unit_states.get("test.service").is_none());
1294    }
1295
1296    #[tokio::test]
1297    async fn test_parse_multiple_units() {
1298        let mut stats = SystemdUnitStats::default();
1299        let config = default_units_config();
1300
1301        let metrics = vec![
1302            ListOutput {
1303                name: "io.systemd.Manager.UnitActiveState".to_string(),
1304                value: string_value("active"),
1305                object: Some("service1.service".to_string()),
1306                fields: None,
1307            },
1308            ListOutput {
1309                name: "io.systemd.Manager.UnitLoadState".to_string(),
1310                value: string_value("loaded"),
1311                object: Some("service1.service".to_string()),
1312                fields: None,
1313            },
1314            ListOutput {
1315                name: "io.systemd.Manager.UnitActiveState".to_string(),
1316                value: string_value("failed"),
1317                object: Some("service-2.service".to_string()),
1318                fields: None,
1319            },
1320        ];
1321
1322        for metric in metrics {
1323            parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1324                .expect("metric should parse successfully");
1325        }
1326
1327        assert_eq!(stats.unit_states.len(), 2);
1328        assert_eq!(
1329            stats
1330                .unit_states
1331                .get("service1.service")
1332                .expect("service1.service should have a unit_states entry")
1333                .active_state,
1334            SystemdUnitActiveState::active
1335        );
1336        assert_eq!(
1337            stats
1338                .unit_states
1339                .get("service1.service")
1340                .expect("service1.service should have a unit_states entry")
1341                .load_state,
1342            SystemdUnitLoadState::loaded
1343        );
1344        assert_eq!(
1345            stats
1346                .unit_states
1347                .get("service-2.service")
1348                .expect("service-2.service should have a unit_states entry")
1349                .active_state,
1350            SystemdUnitActiveState::failed
1351        );
1352    }
1353
1354    #[test]
1355    fn test_parse_unknown_and_missing_values() {
1356        let mut stats = SystemdUnitStats::default();
1357        let config = default_units_config();
1358
1359        // Unknown active state is skipped (not silently defaulted)
1360        let metric1 = ListOutput {
1361            name: "io.systemd.Manager.UnitActiveState".to_string(),
1362            value: string_value("invalid_state"),
1363            object: Some("test.service".to_string()),
1364            fields: None,
1365        };
1366        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1367            .expect("metric1 should parse successfully");
1368        assert!(
1369            !stats.unit_states.contains_key("test.service"),
1370            "invalid state should be skipped"
1371        );
1372
1373        // Missing nrestarts value (null) is skipped. The service must be in
1374        // [services] so the null-value path (not the gating) is what skips it.
1375        let metric2 = ListOutput {
1376            name: "io.systemd.Manager.NRestarts".to_string(),
1377            value: empty_value(),
1378            object: Some("test2.service".to_string()),
1379            fields: None,
1380        };
1381        let services = HashSet::from(["test2.service".to_string()]);
1382        parse_one_metric(&mut stats, &metric2, &config, &services, false)
1383            .expect("metric2 should parse successfully");
1384        assert!(
1385            !stats.service_stats.contains_key("test2.service"),
1386            "null value should be skipped"
1387        );
1388    }
1389
1390    #[tokio::test]
1391    async fn test_parse_edge_cases() {
1392        let mut stats = SystemdUnitStats::default();
1393        let config = default_units_config();
1394
1395        // Unknown unit type is ignored gracefully
1396        let metric1 = ListOutput {
1397            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1398            value: int_value(999),
1399            object: None,
1400            fields: Some(std::collections::HashMap::from([(
1401                "type".to_string(),
1402                serde_json::json!("unknown_type"),
1403            )])),
1404        };
1405        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1406            .expect("metric1 should parse successfully");
1407        assert_eq!(stats.service_units, 0);
1408
1409        // Metric with no fields is handled gracefully
1410        let metric2 = ListOutput {
1411            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1412            value: int_value(42),
1413            object: None,
1414            fields: None,
1415        };
1416        parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1417            .expect("metric2 should parse successfully");
1418
1419        // Non-string field value is ignored
1420        let metric3 = ListOutput {
1421            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1422            value: int_value(42),
1423            object: None,
1424            fields: Some(std::collections::HashMap::from([(
1425                "type".to_string(),
1426                serde_json::json!(123),
1427            )])),
1428        };
1429        parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1430            .expect("metric3 should parse successfully");
1431
1432        // Unhandled metric name is ignored
1433        let metric4 = ListOutput {
1434            name: "io.systemd.Manager.UnknownMetric".to_string(),
1435            value: int_value(999),
1436            object: Some("test.service".to_string()),
1437            fields: None,
1438        };
1439        parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1440            .expect("metric4 should parse successfully");
1441    }
1442
1443    #[test]
1444    fn test_state_stats_disabled_skips_unit_states_only() {
1445        // When state_stats=false, UnitActiveState / UnitLoadState are skipped so
1446        // unit_states remains empty. NRestarts follows the [services] list
1447        // instead (like the D-Bus path) and is unaffected by state_stats.
1448        let config = crate::config::UnitsConfig {
1449            enabled: true,
1450            state_stats: false,
1451            state_stats_allowlist: HashSet::new(),
1452            state_stats_blocklist: HashSet::new(),
1453            state_stats_time_in_state: true,
1454            ignore_inactive_oneshot_services: true,
1455            unit_files: true,
1456            ..Default::default()
1457        };
1458        let services = HashSet::from(["test.service".to_string()]);
1459        let mut stats = SystemdUnitStats::default();
1460
1461        let active_state_metric = ListOutput {
1462            name: "io.systemd.Manager.UnitActiveState".to_string(),
1463            value: string_value("active"),
1464            object: Some("test.service".to_string()),
1465            fields: None,
1466        };
1467        parse_one_metric(&mut stats, &active_state_metric, &config, &services, false)
1468            .expect("active_state_metric should parse successfully");
1469
1470        let load_state_metric = ListOutput {
1471            name: "io.systemd.Manager.UnitLoadState".to_string(),
1472            value: string_value("loaded"),
1473            object: Some("test.service".to_string()),
1474            fields: None,
1475        };
1476        parse_one_metric(&mut stats, &load_state_metric, &config, &services, false)
1477            .expect("load_state_metric should parse successfully");
1478
1479        let nrestarts_metric = ListOutput {
1480            name: "io.systemd.Manager.NRestarts".to_string(),
1481            value: int_value(3),
1482            object: Some("test.service".to_string()),
1483            fields: None,
1484        };
1485        parse_one_metric(&mut stats, &nrestarts_metric, &config, &services, false)
1486            .expect("nrestarts_metric should parse successfully");
1487
1488        // Per-unit state data must be absent when state_stats=false, but the
1489        // [services]-listed unit still gets its restart count.
1490        assert_eq!(stats.unit_states.len(), 0);
1491        assert_eq!(
1492            stats
1493                .service_stats
1494                .get("test.service")
1495                .expect("test.service should have a service_stats entry")
1496                .nrestarts,
1497            3
1498        );
1499
1500        // But aggregate type/state counts must still be processed (they are not gated on state_stats)
1501        let type_metric = ListOutput {
1502            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1503            value: int_value(10),
1504            object: None,
1505            fields: Some(std::collections::HashMap::from([(
1506                "type".to_string(),
1507                serde_json::json!("service"),
1508            )])),
1509        };
1510        parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1511            .expect("type_metric should parse successfully");
1512        assert_eq!(stats.service_units, 10);
1513    }
1514
1515    #[test]
1516    fn test_parse_metric_enum() {
1517        let metric_active = ListOutput {
1518            name: "io.systemd.Manager.UnitActiveState".to_string(),
1519            value: string_value("active"),
1520            object: Some("test.service".to_string()),
1521            fields: None,
1522        };
1523        assert_eq!(
1524            parse_metric_enum::<SystemdUnitActiveState>(&metric_active),
1525            Some(SystemdUnitActiveState::active)
1526        );
1527
1528        let metric_loaded = ListOutput {
1529            name: "io.systemd.Manager.UnitLoadState".to_string(),
1530            value: string_value("loaded"),
1531            object: Some("test.service".to_string()),
1532            fields: None,
1533        };
1534        assert_eq!(
1535            parse_metric_enum::<SystemdUnitLoadState>(&metric_loaded),
1536            Some(SystemdUnitLoadState::loaded)
1537        );
1538
1539        // Invalid value returns None
1540        let metric_invalid = ListOutput {
1541            name: "io.systemd.Manager.UnitActiveState".to_string(),
1542            value: string_value("invalid"),
1543            object: Some("test.service".to_string()),
1544            fields: None,
1545        };
1546        assert_eq!(
1547            parse_metric_enum::<SystemdUnitActiveState>(&metric_invalid),
1548            None
1549        );
1550
1551        // Null value returns None
1552        let metric_empty = ListOutput {
1553            name: "io.systemd.Manager.UnitActiveState".to_string(),
1554            value: empty_value(),
1555            object: Some("test.service".to_string()),
1556            fields: None,
1557        };
1558        assert_eq!(
1559            parse_metric_enum::<SystemdUnitActiveState>(&metric_empty),
1560            None
1561        );
1562    }
1563
1564    #[test]
1565    fn test_parse_metric_enum_all_states() {
1566        // Test all active states
1567        let active_states = vec![
1568            ("active", SystemdUnitActiveState::active),
1569            ("reloading", SystemdUnitActiveState::reloading),
1570            ("inactive", SystemdUnitActiveState::inactive),
1571            ("failed", SystemdUnitActiveState::failed),
1572            ("activating", SystemdUnitActiveState::activating),
1573            ("deactivating", SystemdUnitActiveState::deactivating),
1574        ];
1575
1576        for (state_str, expected) in active_states {
1577            let metric = ListOutput {
1578                name: "io.systemd.Manager.UnitActiveState".to_string(),
1579                value: string_value(state_str),
1580                object: Some("test.service".to_string()),
1581                fields: None,
1582            };
1583            assert_eq!(
1584                parse_metric_enum::<SystemdUnitActiveState>(&metric),
1585                Some(expected)
1586            );
1587        }
1588
1589        // Test all load states
1590        let load_states = vec![
1591            ("loaded", SystemdUnitLoadState::loaded),
1592            ("error", SystemdUnitLoadState::error),
1593            ("masked", SystemdUnitLoadState::masked),
1594            ("not_found", SystemdUnitLoadState::not_found),
1595        ];
1596
1597        for (state_str, expected) in load_states {
1598            let metric = ListOutput {
1599                name: "io.systemd.Manager.UnitLoadState".to_string(),
1600                value: string_value(state_str),
1601                object: Some("test.service".to_string()),
1602                fields: None,
1603            };
1604            assert_eq!(
1605                parse_metric_enum::<SystemdUnitLoadState>(&metric),
1606                Some(expected)
1607            );
1608        }
1609    }
1610
1611    #[tokio::test]
1612    async fn test_parse_state_updates() {
1613        let mut stats = SystemdUnitStats::default();
1614        let config = default_units_config();
1615
1616        // Parse initial state
1617        let metric1 = ListOutput {
1618            name: "io.systemd.Manager.UnitActiveState".to_string(),
1619            value: string_value("inactive"),
1620            object: Some("test.service".to_string()),
1621            fields: None,
1622        };
1623        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1624            .expect("metric1 should parse successfully");
1625        assert_eq!(
1626            stats
1627                .unit_states
1628                .get("test.service")
1629                .expect("test.service should have a unit_states entry")
1630                .active_state,
1631            SystemdUnitActiveState::inactive
1632        );
1633
1634        // Update to active state
1635        let metric2 = ListOutput {
1636            name: "io.systemd.Manager.UnitActiveState".to_string(),
1637            value: string_value("active"),
1638            object: Some("test.service".to_string()),
1639            fields: None,
1640        };
1641        parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1642            .expect("metric2 should parse successfully");
1643        assert_eq!(
1644            stats
1645                .unit_states
1646                .get("test.service")
1647                .expect("test.service should have a unit_states entry")
1648                .active_state,
1649            SystemdUnitActiveState::active
1650        );
1651    }
1652
1653    #[tokio::test]
1654    async fn test_unhealthy_computed() {
1655        let mut stats = SystemdUnitStats::default();
1656        let config = default_units_config();
1657
1658        // Set active state to failed
1659        let metric1 = ListOutput {
1660            name: "io.systemd.Manager.UnitActiveState".to_string(),
1661            value: string_value("failed"),
1662            object: Some("broken.service".to_string()),
1663            fields: None,
1664        };
1665        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1666            .expect("metric1 should parse successfully");
1667
1668        // Set load state to loaded
1669        let metric2 = ListOutput {
1670            name: "io.systemd.Manager.UnitLoadState".to_string(),
1671            value: string_value("loaded"),
1672            object: Some("broken.service".to_string()),
1673            fields: None,
1674        };
1675        parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1676            .expect("metric2 should parse successfully");
1677
1678        // Should be unhealthy: loaded + failed
1679        assert!(
1680            stats
1681                .unit_states
1682                .get("broken.service")
1683                .expect("broken.service should have a unit_states entry")
1684                .unhealthy
1685        );
1686
1687        // Set active state to active
1688        let metric3 = ListOutput {
1689            name: "io.systemd.Manager.UnitActiveState".to_string(),
1690            value: string_value("active"),
1691            object: Some("healthy.service".to_string()),
1692            fields: None,
1693        };
1694        parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1695            .expect("metric3 should parse successfully");
1696
1697        // Set load state to loaded
1698        let metric4 = ListOutput {
1699            name: "io.systemd.Manager.UnitLoadState".to_string(),
1700            value: string_value("loaded"),
1701            object: Some("healthy.service".to_string()),
1702            fields: None,
1703        };
1704        parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1705            .expect("metric4 should parse successfully");
1706
1707        // Should be healthy: loaded + active
1708        assert!(
1709            !stats
1710                .unit_states
1711                .get("healthy.service")
1712                .expect("healthy.service should have a unit_states entry")
1713                .unhealthy
1714        );
1715    }
1716
1717    #[test]
1718    fn test_oneshot_inactive_service_is_candidate() {
1719        let mut stats = SystemdUnitStats::default();
1720        let config = default_units_config();
1721        let metrics = vec![
1722            ListOutput {
1723                name: "io.systemd.Manager.UnitActiveState".to_string(),
1724                value: string_value("inactive"),
1725                object: Some("done.service".to_string()),
1726                fields: None,
1727            },
1728            ListOutput {
1729                name: "io.systemd.Manager.UnitLoadState".to_string(),
1730                value: string_value("loaded"),
1731                object: Some("done.service".to_string()),
1732                fields: None,
1733            },
1734        ];
1735
1736        for metric in &metrics {
1737            parse_one_metric(&mut stats, metric, &config, &HashSet::new(), false)
1738                .expect("metric should parse successfully");
1739        }
1740
1741        assert_eq!(
1742            select_oneshot_candidates(&stats, &config),
1743            vec!["done.service".to_string()]
1744        );
1745    }
1746
1747    #[test]
1748    fn test_oneshot_candidate_selection_skips_non_candidates() {
1749        let mut stats = SystemdUnitStats::default();
1750        let config = default_units_config();
1751        // Active service: healthy already, no type lookup needed.
1752        stats.unit_states.insert(
1753            "running.service".to_string(),
1754            crate::units::UnitStates {
1755                active_state: SystemdUnitActiveState::active,
1756                load_state: SystemdUnitLoadState::loaded,
1757                unhealthy: false,
1758                time_in_state_usecs: None,
1759            },
1760        );
1761        // Non-service unit: service type does not apply.
1762        stats.unit_states.insert(
1763            "waiting.timer".to_string(),
1764            crate::units::UnitStates {
1765                active_state: SystemdUnitActiveState::inactive,
1766                load_state: SystemdUnitLoadState::loaded,
1767                unhealthy: true,
1768                time_in_state_usecs: None,
1769            },
1770        );
1771        // Masked service: never unhealthy, no type lookup needed.
1772        stats.unit_states.insert(
1773            "masked.service".to_string(),
1774            crate::units::UnitStates {
1775                active_state: SystemdUnitActiveState::inactive,
1776                load_state: SystemdUnitLoadState::masked,
1777                unhealthy: false,
1778                time_in_state_usecs: None,
1779            },
1780        );
1781        // The only real candidate.
1782        stats.unit_states.insert(
1783            "done.service".to_string(),
1784            crate::units::UnitStates {
1785                active_state: SystemdUnitActiveState::inactive,
1786                load_state: SystemdUnitLoadState::loaded,
1787                unhealthy: true,
1788                time_in_state_usecs: None,
1789            },
1790        );
1791
1792        assert_eq!(
1793            select_oneshot_candidates(&stats, &config),
1794            vec!["done.service".to_string()]
1795        );
1796    }
1797
1798    #[test]
1799    fn test_oneshot_override_marks_oneshot_healthy() {
1800        let mut stats = SystemdUnitStats::default();
1801        let config = default_units_config();
1802        stats.unit_states.insert(
1803            "done.service".to_string(),
1804            crate::units::UnitStates {
1805                active_state: SystemdUnitActiveState::inactive,
1806                load_state: SystemdUnitLoadState::loaded,
1807                unhealthy: true,
1808                time_in_state_usecs: None,
1809            },
1810        );
1811        stats.unit_states.insert(
1812            "simple.service".to_string(),
1813            crate::units::UnitStates {
1814                active_state: SystemdUnitActiveState::inactive,
1815                load_state: SystemdUnitLoadState::loaded,
1816                unhealthy: true,
1817                time_in_state_usecs: None,
1818            },
1819        );
1820        // failed.service has no type entry (lookup failed): stays unhealthy.
1821        stats.unit_states.insert(
1822            "failed.service".to_string(),
1823            crate::units::UnitStates {
1824                active_state: SystemdUnitActiveState::inactive,
1825                load_state: SystemdUnitLoadState::loaded,
1826                unhealthy: true,
1827                time_in_state_usecs: None,
1828            },
1829        );
1830        let types = std::collections::HashMap::from([
1831            ("done.service".to_string(), true),
1832            ("simple.service".to_string(), false),
1833        ]);
1834
1835        apply_oneshot_types(&mut stats, &types, &config);
1836
1837        assert!(
1838            !stats
1839                .unit_states
1840                .get("done.service")
1841                .expect("done.service should have a unit_states entry")
1842                .unhealthy
1843        );
1844        assert!(
1845            stats
1846                .unit_states
1847                .get("simple.service")
1848                .expect("simple.service should have a unit_states entry")
1849                .unhealthy
1850        );
1851        assert!(
1852            stats
1853                .unit_states
1854                .get("failed.service")
1855                .expect("failed.service should have a unit_states entry")
1856                .unhealthy
1857        );
1858    }
1859
1860    #[test]
1861    fn test_oneshot_lookup_timings_accumulate() {
1862        // Lookup accounting adds to (never overwrites) the parse-phase values
1863        // already recorded by parse_metrics.
1864        let mut timings = UnitsCollectionTimings {
1865            per_unit_loop_ms: 10.0,
1866            service_dbus_fetches: 2,
1867            ..Default::default()
1868        };
1869
1870        record_oneshot_lookup_timings(&mut timings, 5.0, 3);
1871
1872        assert_eq!(timings.per_unit_loop_ms, 15.0);
1873        assert_eq!(timings.service_dbus_fetches, 5);
1874        // Untouched counters stay zero.
1875        assert_eq!(timings.state_dbus_fetches, 0);
1876        assert_eq!(timings.timer_dbus_fetches, 0);
1877    }
1878
1879    #[test]
1880    fn test_oneshot_override_can_be_disabled() {
1881        let mut stats = SystemdUnitStats::default();
1882        let mut config = default_units_config();
1883        config.ignore_inactive_oneshot_services = false;
1884        stats.unit_states.insert(
1885            "done.service".to_string(),
1886            crate::units::UnitStates {
1887                active_state: SystemdUnitActiveState::inactive,
1888                load_state: SystemdUnitLoadState::loaded,
1889                unhealthy: true,
1890                time_in_state_usecs: None,
1891            },
1892        );
1893
1894        assert!(select_oneshot_candidates(&stats, &config).is_empty());
1895
1896        let types = std::collections::HashMap::from([("done.service".to_string(), true)]);
1897        apply_oneshot_types(&mut stats, &types, &config);
1898
1899        assert!(
1900            stats
1901                .unit_states
1902                .get("done.service")
1903                .expect("done.service should have a unit_states entry")
1904                .unhealthy
1905        );
1906    }
1907
1908    #[tokio::test]
1909    async fn test_allowlist_filtering() {
1910        let mut stats = SystemdUnitStats::default();
1911        let config = crate::config::UnitsConfig {
1912            enabled: true,
1913            state_stats: true,
1914            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
1915            state_stats_blocklist: HashSet::new(),
1916            state_stats_time_in_state: false,
1917            ignore_inactive_oneshot_services: true,
1918            unit_files: true,
1919            ..Default::default()
1920        };
1921
1922        // Allowed unit should be tracked
1923        let metric1 = ListOutput {
1924            name: "io.systemd.Manager.UnitActiveState".to_string(),
1925            value: string_value("active"),
1926            object: Some("allowed.service".to_string()),
1927            fields: None,
1928        };
1929        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1930            .expect("metric1 should parse successfully");
1931        assert!(stats.unit_states.contains_key("allowed.service"));
1932
1933        // Non-allowed unit should be skipped
1934        let metric2 = ListOutput {
1935            name: "io.systemd.Manager.UnitActiveState".to_string(),
1936            value: string_value("active"),
1937            object: Some("not-allowed.service".to_string()),
1938            fields: None,
1939        };
1940        parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1941            .expect("metric2 should parse successfully");
1942        assert!(!stats.unit_states.contains_key("not-allowed.service"));
1943    }
1944
1945    #[tokio::test]
1946    async fn test_blocklist_filtering() {
1947        let mut stats = SystemdUnitStats::default();
1948        let config = crate::config::UnitsConfig {
1949            enabled: true,
1950            state_stats: true,
1951            state_stats_allowlist: HashSet::new(),
1952            state_stats_blocklist: HashSet::from(["blocked.service".to_string()]),
1953            state_stats_time_in_state: false,
1954            ignore_inactive_oneshot_services: true,
1955            unit_files: true,
1956            ..Default::default()
1957        };
1958
1959        // Blocked unit should be skipped
1960        let metric1 = ListOutput {
1961            name: "io.systemd.Manager.UnitActiveState".to_string(),
1962            value: string_value("active"),
1963            object: Some("blocked.service".to_string()),
1964            fields: None,
1965        };
1966        parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1967            .expect("metric1 should parse successfully");
1968        assert!(!stats.unit_states.contains_key("blocked.service"));
1969
1970        // Non-blocked unit should be tracked
1971        let metric2 = ListOutput {
1972            name: "io.systemd.Manager.UnitActiveState".to_string(),
1973            value: string_value("active"),
1974            object: Some("ok.service".to_string()),
1975            fields: None,
1976        };
1977        parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1978            .expect("metric2 should parse successfully");
1979        assert!(stats.unit_states.contains_key("ok.service"));
1980    }
1981
1982    #[tokio::test]
1983    async fn test_blocklist_overrides_allowlist() {
1984        let mut stats = SystemdUnitStats::default();
1985        let config = crate::config::UnitsConfig {
1986            enabled: true,
1987            state_stats: true,
1988            state_stats_allowlist: HashSet::from(["both.service".to_string()]),
1989            state_stats_blocklist: HashSet::from(["both.service".to_string()]),
1990            state_stats_time_in_state: false,
1991            ignore_inactive_oneshot_services: true,
1992            unit_files: true,
1993            ..Default::default()
1994        };
1995
1996        // Unit in both lists should be blocked (blocklist takes priority)
1997        let metric = ListOutput {
1998            name: "io.systemd.Manager.UnitActiveState".to_string(),
1999            value: string_value("active"),
2000            object: Some("both.service".to_string()),
2001            fields: None,
2002        };
2003        parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
2004            .expect("metric should parse successfully");
2005        assert!(!stats.unit_states.contains_key("both.service"));
2006    }
2007
2008    #[test]
2009    fn test_load_state_counts_bypass_allowlist() {
2010        // Counting path used when UnitsByLoadStateTotal is absent (systemd v260):
2011        // loaded_units/masked_units/not_found_units must be counted for every unit,
2012        // regardless of the state_stats allowlist (matching D-Bus parse_unit() behaviour).
2013        let config = crate::config::UnitsConfig {
2014            enabled: true,
2015            state_stats: true,
2016            // Only "allowed.service" is in the allowlist
2017            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
2018            state_stats_blocklist: HashSet::new(),
2019            state_stats_time_in_state: false,
2020            ignore_inactive_oneshot_services: true,
2021            unit_files: true,
2022            ..Default::default()
2023        };
2024        let mut stats = SystemdUnitStats::default();
2025
2026        let metrics = vec![
2027            // allowed unit → counts AND stored in unit_states
2028            ListOutput {
2029                name: "io.systemd.Manager.UnitLoadState".to_string(),
2030                value: string_value("loaded"),
2031                object: Some("allowed.service".to_string()),
2032                fields: None,
2033            },
2034            // non-allowed unit → only counted, NOT stored in unit_states
2035            ListOutput {
2036                name: "io.systemd.Manager.UnitLoadState".to_string(),
2037                value: string_value("loaded"),
2038                object: Some("other.service".to_string()),
2039                fields: None,
2040            },
2041            ListOutput {
2042                name: "io.systemd.Manager.UnitLoadState".to_string(),
2043                value: string_value("not-found"), // systemd sends hyphenated form over the wire
2044                object: Some("missing.service".to_string()),
2045                fields: None,
2046            },
2047            ListOutput {
2048                name: "io.systemd.Manager.UnitLoadState".to_string(),
2049                value: string_value("masked"),
2050                object: Some("masked.service".to_string()),
2051                fields: None,
2052            },
2053        ];
2054        for m in metrics {
2055            parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2056                .expect("m should parse successfully");
2057        }
2058
2059        // Aggregate counts include ALL units regardless of allowlist
2060        assert_eq!(stats.loaded_units, 2);
2061        assert_eq!(stats.not_found_units, 1);
2062        assert_eq!(stats.masked_units, 1);
2063        // per-unit state only tracked for the allowed unit
2064        assert_eq!(stats.unit_states.len(), 1);
2065        assert!(stats.unit_states.contains_key("allowed.service"));
2066    }
2067
2068    #[test]
2069    fn test_load_state_counts_when_state_stats_disabled() {
2070        // Even when state_stats=false, aggregate load state counts must be populated.
2071        let config = crate::config::UnitsConfig {
2072            enabled: true,
2073            state_stats: false,
2074            state_stats_allowlist: HashSet::new(),
2075            state_stats_blocklist: HashSet::new(),
2076            state_stats_time_in_state: false,
2077            ignore_inactive_oneshot_services: true,
2078            unit_files: true,
2079            ..Default::default()
2080        };
2081        let mut stats = SystemdUnitStats::default();
2082
2083        let metrics = vec![
2084            ListOutput {
2085                name: "io.systemd.Manager.UnitLoadState".to_string(),
2086                value: string_value("loaded"),
2087                object: Some("svc1.service".to_string()),
2088                fields: None,
2089            },
2090            ListOutput {
2091                name: "io.systemd.Manager.UnitLoadState".to_string(),
2092                value: string_value("not-found"), // systemd sends hyphenated form over the wire
2093                object: Some("svc2.service".to_string()),
2094                fields: None,
2095            },
2096        ];
2097        for m in metrics {
2098            parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2099                .expect("m should parse successfully");
2100        }
2101
2102        assert_eq!(stats.loaded_units, 1);
2103        assert_eq!(stats.not_found_units, 1);
2104        // No per-unit state tracking when state_stats=false
2105        assert_eq!(stats.unit_states.len(), 0);
2106    }
2107
2108    #[test]
2109    fn test_units_by_load_state_total() {
2110        let mut stats = SystemdUnitStats::default();
2111        let config = default_units_config();
2112
2113        let totals = [
2114            ("loaded", 191),
2115            ("not-found", 6),
2116            ("masked", 2),
2117            ("stub", 3),
2118        ];
2119        for (load_state, count) in totals {
2120            let metric = ListOutput {
2121                name: "io.systemd.Manager.UnitsByLoadStateTotal".to_string(),
2122                value: int_value(count),
2123                object: None,
2124                fields: Some(std::collections::HashMap::from([(
2125                    "load_state".to_string(),
2126                    serde_json::json!(load_state),
2127                )])),
2128            };
2129            parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), true)
2130                .expect("load state total should parse successfully");
2131        }
2132
2133        assert_eq!(stats.loaded_units, 191);
2134        assert_eq!(stats.not_found_units, 6);
2135        assert_eq!(stats.masked_units, 2);
2136
2137        // Per-unit metrics must not add to the totals once the aggregate is in
2138        // play, but per-unit state tracking carries on as before.
2139        let per_unit = ListOutput {
2140            name: "io.systemd.Manager.UnitLoadState".to_string(),
2141            value: string_value("loaded"),
2142            object: Some("my-service.service".to_string()),
2143            fields: None,
2144        };
2145        parse_one_metric(&mut stats, &per_unit, &config, &HashSet::new(), true)
2146            .expect("per-unit load state should parse successfully");
2147
2148        assert_eq!(stats.loaded_units, 191);
2149        assert_eq!(
2150            stats
2151                .unit_states
2152                .get("my-service.service")
2153                .expect("my-service.service should have a unit_states entry")
2154                .load_state,
2155            SystemdUnitLoadState::loaded
2156        );
2157    }
2158}