Skip to main content

systemctl_tui/
systemd.rs

1// File initially taken from https://github.com/servicer-labs/servicer/blob/master/src/utils/systemd.rs, since modified
2
3use core::str;
4use std::process::Command;
5
6use anyhow::{bail, Context, Result};
7use log::error;
8use tokio_util::sync::CancellationToken;
9use tracing::info;
10use zbus::{proxy, zvariant, Connection};
11
12#[derive(Debug, Clone)]
13pub struct UnitWithStatus {
14  pub name: String,                              // The primary unit name as string
15  pub scope: UnitScope,                          // System or user?
16  pub description: String,                       // The human readable description string
17  pub file_path: Option<Result<String, String>>, // The unit file path - populated later on demand
18
19  pub load_state: String, // The load state (i.e. whether the unit file has been loaded successfully)
20
21  // Some comments re: state from this helpful comment: https://www.reddit.com/r/linuxquestions/comments/r58dvz/comment/hmlemfk/
22  /// One state, called the "activation state", essentially describes what the unit is doing now. The two most common values for this state are active and inactive, though there are a few other possibilities. (Each unit type has its own set of "substates" that map to these activation states. For instance, service units can be running or stopped. Again, there's a variety of other substates, and the list differs for each unit type.)
23  pub activation_state: String,
24  /// The sub state (a more fine-grained version of the active state that is specific to the unit type, which the active state is not)
25  pub sub_state: String,
26
27  /// The other state all units have is called the "enablement state". It describes how the unit might be automatically started in the future. A unit is enabled if it has been added to the requirements list of any other unit though symlinks in the filesystem. The set of symlinks to be created when enabling a unit is described by the unit's [Install] section. A unit is disabled if no symlinks are present. Again there's a variety of other values other than these two (e.g. not all units even have [Install] sections).
28  /// Only populated when needed b/c this is much slower to get
29  pub enablement_state: Option<String>,
30  // We don't use any of these right now, might as well skip'em so there's less data to clone
31  // pub followed: String, // A unit that is being followed in its state by this unit, if there is any, otherwise the empty string.
32  // pub path: String,     // The unit object path
33  // pub job_id: u32,      // If there is a job queued for the job unit the numeric job id, 0 otherwise
34  // pub job_type: String, // The job type as string
35  // pub job_path: String, // The job object path
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum UnitScope {
40  Global,
41  User,
42}
43
44/// Just enough info to fully identify a unit
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct UnitId {
47  pub name: String,
48  pub scope: UnitScope,
49}
50
51impl UnitWithStatus {
52  pub fn is_active(&self) -> bool {
53    self.activation_state == "active"
54  }
55
56  pub fn is_failed(&self) -> bool {
57    self.activation_state == "failed"
58  }
59
60  pub fn is_not_found(&self) -> bool {
61    self.load_state == "not-found"
62  }
63
64  pub fn is_enabled(&self) -> bool {
65    self.load_state == "loaded" && self.activation_state == "active"
66  }
67
68  pub fn short_name(&self) -> &str {
69    if self.name.ends_with(".service") {
70      &self.name[..self.name.len() - 8]
71    } else {
72      &self.name
73    }
74  }
75
76  // TODO: should we have a non-allocating version of this?
77  pub fn id(&self) -> UnitId {
78    UnitId { name: self.name.clone(), scope: self.scope }
79  }
80
81  // useful for updating without wiping out the file path
82  pub fn update(&mut self, other: UnitWithStatus) {
83    self.description = other.description;
84    self.load_state = other.load_state;
85    self.activation_state = other.activation_state;
86    self.sub_state = other.sub_state;
87  }
88}
89
90type RawUnit =
91  (String, String, String, String, String, String, zvariant::OwnedObjectPath, u32, String, zvariant::OwnedObjectPath);
92
93fn to_unit_status(raw_unit: RawUnit, scope: UnitScope) -> UnitWithStatus {
94  let (name, description, load_state, active_state, sub_state, _followed, _path, _job_id, _job_type, _job_path) =
95    raw_unit;
96
97  UnitWithStatus {
98    name,
99    scope,
100    description,
101    file_path: None,
102    enablement_state: None,
103    load_state,
104    activation_state: active_state,
105    sub_state,
106  }
107}
108
109// Different from UnitScope in that this is not for 1 specific unit (i.e. it can include multiple scopes)
110#[derive(Clone, Copy, Default, Debug)]
111pub enum Scope {
112  Global,
113  User,
114  #[default]
115  All,
116}
117
118/// Represents a unit file from ListUnitFiles (includes disabled units not returned by ListUnits)
119#[derive(Debug, Clone)]
120pub struct UnitFile {
121  pub name: String,
122  pub scope: UnitScope,
123  pub enablement_state: String,
124  pub path: String,
125}
126
127impl UnitFile {
128  pub fn id(&self) -> UnitId {
129    UnitId { name: self.name.clone(), scope: self.scope }
130  }
131}
132
133/// Get unit files for all services, INCLUDING DISABLED ONES (ListUnits doesn't include those)
134/// This is slower than get_all_services. Takes about 100ms (user) and 300ms (global) on 13th gen Intel i7
135pub async fn get_unit_files(scope: Scope) -> Result<Vec<UnitFile>> {
136  let start = std::time::Instant::now();
137
138  let mut unit_scopes = vec![];
139  match scope {
140    Scope::Global => unit_scopes.push(UnitScope::Global),
141    Scope::User => unit_scopes.push(UnitScope::User),
142    Scope::All => {
143      unit_scopes.push(UnitScope::Global);
144      unit_scopes.push(UnitScope::User);
145    },
146  }
147
148  let mut ret = vec![];
149  let is_root = nix::unistd::geteuid().is_root();
150  info!("get_unit_files: is_root={}, scope={:?}", is_root, scope);
151
152  for unit_scope in unit_scopes {
153    info!("get_unit_files: fetching {:?} unit files", unit_scope);
154    let connection = match get_connection(unit_scope).await {
155      Ok(conn) => conn,
156      Err(e) => {
157        error!("get_unit_files: failed to get {:?} connection: {:?}", unit_scope, e);
158        if is_root && unit_scope == UnitScope::User {
159          info!("get_unit_files: skipping user scope because we're root");
160          continue;
161        }
162        return Err(e);
163      },
164    };
165    let manager_proxy = ManagerProxy::new(&connection).await?;
166    let unit_files = match manager_proxy.list_unit_files_by_patterns(vec![], vec!["*.service".into()]).await {
167      Ok(files) => {
168        info!("get_unit_files: got {} {:?} unit files", files.len(), unit_scope);
169        files
170      },
171      Err(e) => {
172        error!("get_unit_files: list_unit_files_by_patterns failed for {:?}: {:?}", unit_scope, e);
173        if is_root && unit_scope == UnitScope::User {
174          info!("get_unit_files: ignoring user scope error because we're root");
175          vec![]
176        } else {
177          return Err(e.into());
178        }
179      },
180    };
181
182    let services = unit_files
183      .into_iter()
184      .filter_map(|(path, state)| {
185        let rust_path = std::path::Path::new(&path);
186        let file_name = rust_path.file_name()?.to_str()?;
187        Some(UnitFile { name: file_name.to_string(), scope: unit_scope, enablement_state: state, path })
188      })
189      .collect::<Vec<_>>();
190    ret.extend(services);
191  }
192
193  info!("Loaded {} unit files in {:?}", ret.len(), start.elapsed());
194  Ok(ret)
195}
196
197// this takes like 5-10 ms on 13th gen Intel i7 (scope=all)
198pub async fn get_all_services(scope: Scope, services: &[String]) -> Result<Vec<UnitWithStatus>> {
199  let start = std::time::Instant::now();
200
201  let mut units = vec![];
202
203  let is_root = nix::unistd::geteuid().is_root();
204
205  match scope {
206    Scope::Global => {
207      let system_units = get_services(UnitScope::Global, services).await?;
208      units.extend(system_units);
209    },
210    Scope::User => {
211      let user_units = get_services(UnitScope::User, services).await?;
212      units.extend(user_units);
213    },
214    Scope::All => {
215      let (system_units, user_units) =
216        tokio::join!(get_services(UnitScope::Global, services), get_services(UnitScope::User, services));
217      units.extend(system_units?);
218
219      // Should always be able to get user units, but it may fail when running as root
220      if let Ok(user_units) = user_units {
221        units.extend(user_units);
222      } else if is_root {
223        error!("Failed to get user units, ignoring because we're running as root")
224      } else {
225        user_units?;
226      }
227    },
228  }
229
230  // sort by name case-insensitive
231  units.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
232
233  info!("Loaded systemd services in {:?}", start.elapsed());
234
235  Ok(units)
236}
237
238async fn get_services(scope: UnitScope, services: &[String]) -> Result<Vec<UnitWithStatus>, anyhow::Error> {
239  let connection = get_connection(scope).await?;
240  let manager_proxy = ManagerProxy::new(&connection).await?;
241  let units = manager_proxy.list_units_by_patterns(vec![], services.to_vec()).await?;
242  let units: Vec<_> = units.into_iter().map(|u| to_unit_status(u, scope)).collect();
243  Ok(units)
244}
245
246pub fn get_unit_file_location(service: &UnitId) -> Result<String> {
247  // show -P FragmentPath reitunes.service
248  let mut args = vec!["--quiet", "show", "-P", "FragmentPath"];
249  args.push(&service.name);
250
251  if service.scope == UnitScope::User {
252    args.insert(0, "--user");
253  }
254
255  let output = Command::new("systemctl").args(&args).output()?;
256
257  if output.status.success() {
258    let path = str::from_utf8(&output.stdout)?.trim();
259    if path.is_empty() {
260      bail!("No unit file found for {}", service.name);
261    }
262    Ok(path.trim().to_string())
263  } else {
264    let stderr = String::from_utf8(output.stderr)?;
265    bail!(stderr);
266  }
267}
268
269pub async fn start_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
270  async fn start_service(service: UnitId) -> Result<()> {
271    let connection = get_connection(service.scope).await?;
272    let manager_proxy = ManagerProxy::new(&connection).await?;
273    manager_proxy.start_unit(service.name.clone(), "replace".into()).await?;
274    Ok(())
275  }
276
277  // god these select macros are ugly, is there really no better way to select?
278  tokio::select! {
279    _ = cancel_token.cancelled() => {
280        anyhow::bail!("cancelled");
281    }
282    result = start_service(service) => {
283        result
284    }
285  }
286}
287
288pub async fn stop_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
289  async fn stop_service(service: UnitId) -> Result<()> {
290    let connection = get_connection(service.scope).await?;
291    let manager_proxy = ManagerProxy::new(&connection).await?;
292    manager_proxy.stop_unit(service.name, "replace".into()).await?;
293    Ok(())
294  }
295
296  // god these select macros are ugly, is there really no better way to select?
297  tokio::select! {
298    _ = cancel_token.cancelled() => {
299        anyhow::bail!("cancelled");
300    }
301    result = stop_service(service) => {
302        result
303    }
304  }
305}
306
307pub async fn reload(scope: UnitScope, cancel_token: CancellationToken) -> Result<()> {
308  async fn reload_(scope: UnitScope) -> Result<()> {
309    let connection = get_connection(scope).await?;
310    let manager_proxy: ManagerProxy<'_> = ManagerProxy::new(&connection).await?;
311    let error_message = match scope {
312      UnitScope::Global => "Failed to reload units, probably because superuser permissions are needed. Try running `sudo systemctl daemon-reload`",
313      UnitScope::User => "Failed to reload units. Try running `systemctl --user daemon-reload`",
314    };
315    manager_proxy.reload().await.context(error_message)?;
316    Ok(())
317  }
318
319  // god these select macros are ugly, is there really no better way to select?
320  tokio::select! {
321    _ = cancel_token.cancelled() => {
322        anyhow::bail!("cancelled");
323    }
324    result = reload_(scope) => {
325        result
326    }
327  }
328}
329
330async fn get_connection(scope: UnitScope) -> Result<Connection, anyhow::Error> {
331  match scope {
332    UnitScope::Global => Ok(Connection::system().await?),
333    UnitScope::User => Ok(Connection::session().await?),
334  }
335}
336
337pub async fn restart_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
338  async fn restart(service: UnitId) -> Result<()> {
339    let connection = get_connection(service.scope).await?;
340    let manager_proxy = ManagerProxy::new(&connection).await?;
341    manager_proxy.restart_unit(service.name, "replace".into()).await?;
342    Ok(())
343  }
344
345  // god these select macros are ugly, is there really no better way to select?
346  tokio::select! {
347    _ = cancel_token.cancelled() => {
348        // The token was cancelled
349        anyhow::bail!("cancelled");
350    }
351    result = restart(service) => {
352        result
353    }
354  }
355}
356
357pub async fn enable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
358  async fn enable(service: UnitId) -> Result<()> {
359    let connection = get_connection(service.scope).await?;
360    let manager_proxy = ManagerProxy::new(&connection).await?;
361    let files = vec![service.name];
362    let (_, changes) = manager_proxy.enable_unit_files(files, false, false).await?;
363
364    for (change_type, name, destination) in changes {
365      info!("{}: {} -> {}", change_type, name, destination);
366    }
367    // Enabling without reloading puts things in a weird state where `systemctl status foo` tells you to run daemon-reload
368    manager_proxy.reload().await?;
369    Ok(())
370  }
371
372  tokio::select! {
373    _ = cancel_token.cancelled() => {
374        anyhow::bail!("cancelled");
375    }
376    result = enable(service) => {
377        result
378    }
379  }
380}
381
382pub async fn disable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
383  async fn disable(service: UnitId) -> Result<()> {
384    let connection = get_connection(service.scope).await?;
385    let manager_proxy = ManagerProxy::new(&connection).await?;
386    let files = vec![service.name];
387    let changes = manager_proxy.disable_unit_files(files, false).await?;
388
389    for (change_type, name, destination) in changes {
390      info!("{}: {} -> {}", change_type, name, destination);
391    }
392    manager_proxy.reload().await?;
393    Ok(())
394  }
395
396  tokio::select! {
397    _ = cancel_token.cancelled() => {
398        anyhow::bail!("cancelled");
399    }
400    result = disable(service) => {
401        result
402    }
403  }
404}
405
406// useless function only added to test that cancellation works
407pub async fn sleep_test(_service: String, cancel_token: CancellationToken) -> Result<()> {
408  // god these select macros are ugly, is there really no better way to select?
409  tokio::select! {
410      _ = cancel_token.cancelled() => {
411          // The token was cancelled
412          anyhow::bail!("cancelled");
413      }
414      _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
415          Ok(())
416      }
417  }
418}
419
420pub async fn kill_service(service: UnitId, signal: String, cancel_token: CancellationToken) -> Result<()> {
421  async fn kill(service: UnitId, signal: String) -> Result<()> {
422    let mut args = vec!["kill", "--signal", &signal];
423    if service.scope == UnitScope::User {
424      args.push("--user");
425    }
426    args.push(&service.name);
427
428    let output = Command::new("systemctl").args(&args).output()?;
429
430    if output.status.success() {
431      info!("Successfully sent signal {} to srvice {}", signal, service.name);
432      Ok(())
433    } else {
434      let stderr = String::from_utf8(output.stderr)?;
435      bail!("Failed to send signal {} to service {}: {}", signal, service.name, stderr);
436    }
437  }
438
439  tokio::select! {
440      _ = cancel_token.cancelled() => {
441          bail!("cancelled");
442      }
443      result = kill(service, signal) => {
444          result
445      }
446  }
447}
448
449/// Proxy object for `org.freedesktop.systemd1.Manager`.
450/// Partially taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
451#[proxy(
452  interface = "org.freedesktop.systemd1.Manager",
453  default_service = "org.freedesktop.systemd1",
454  default_path = "/org/freedesktop/systemd1",
455  gen_blocking = false
456)]
457pub trait Manager {
458  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#StartUnit()) Call interface method `StartUnit`.
459  #[zbus(name = "StartUnit")]
460  fn start_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
461
462  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#StopUnit()) Call interface method `StopUnit`.
463  #[zbus(name = "StopUnit")]
464  fn stop_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
465
466  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ReloadUnit()) Call interface method `ReloadUnit`.
467  #[zbus(name = "ReloadUnit")]
468  fn reload_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
469
470  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#RestartUnit()) Call interface method `RestartUnit`.
471  #[zbus(name = "RestartUnit")]
472  fn restart_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
473
474  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#EnableUnitFiles()) Call interface method `EnableUnitFiles`.
475  #[zbus(name = "EnableUnitFiles")]
476  fn enable_unit_files(
477    &self,
478    files: Vec<String>,
479    runtime: bool,
480    force: bool,
481  ) -> zbus::Result<(bool, Vec<(String, String, String)>)>;
482
483  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#DisableUnitFiles()) Call interface method `DisableUnitFiles`.
484  #[zbus(name = "DisableUnitFiles")]
485  fn disable_unit_files(&self, files: Vec<String>, runtime: bool) -> zbus::Result<Vec<(String, String, String)>>;
486
487  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ListUnits()) Call interface method `ListUnits`.
488  #[zbus(name = "ListUnits")]
489  fn list_units(
490    &self,
491  ) -> zbus::Result<
492    Vec<(
493      String,
494      String,
495      String,
496      String,
497      String,
498      String,
499      zvariant::OwnedObjectPath,
500      u32,
501      String,
502      zvariant::OwnedObjectPath,
503    )>,
504  >;
505
506  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ListUnitsByPatterns()) Call interface method `ListUnitsByPatterns`.
507  #[zbus(name = "ListUnitsByPatterns")]
508  fn list_units_by_patterns(
509    &self,
510    states: Vec<String>,
511    patterns: Vec<String>,
512  ) -> zbus::Result<
513    Vec<(
514      String,
515      String,
516      String,
517      String,
518      String,
519      String,
520      zvariant::OwnedObjectPath,
521      u32,
522      String,
523      zvariant::OwnedObjectPath,
524    )>,
525  >;
526
527  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#Reload()) Call interface method `Reload`.
528  #[zbus(name = "Reload")]
529  fn reload(&self) -> zbus::Result<()>;
530
531  /// [📖](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ListUnitFilesByPatterns()) Call interface method `ListUnitFilesByPatterns`.
532  #[zbus(name = "ListUnitFilesByPatterns")]
533  fn list_unit_files_by_patterns(
534    &self,
535    states: Vec<String>,
536    patterns: Vec<String>,
537  ) -> zbus::Result<Vec<(String, String)>>;
538}
539
540/// Proxy object for `org.freedesktop.systemd1.Unit`.
541/// Taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
542#[proxy(
543  interface = "org.freedesktop.systemd1.Unit",
544  default_service = "org.freedesktop.systemd1",
545  assume_defaults = false,
546  gen_blocking = false
547)]
548pub trait Unit {
549  /// Get property `ActiveState`.
550  #[zbus(property)]
551  fn active_state(&self) -> zbus::Result<String>;
552
553  /// Get property `LoadState`.
554  #[zbus(property)]
555  fn load_state(&self) -> zbus::Result<String>;
556
557  /// Get property `UnitFileState`.
558  #[zbus(property)]
559  fn unit_file_state(&self) -> zbus::Result<String>;
560}
561
562/// Proxy object for `org.freedesktop.systemd1.Service`.
563/// Taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
564#[proxy(
565  interface = "org.freedesktop.systemd1.Service",
566  default_service = "org.freedesktop.systemd1",
567  assume_defaults = false,
568  gen_blocking = false
569)]
570trait Service {
571  /// Get property `MainPID`.
572  #[zbus(property, name = "MainPID")]
573  fn main_pid(&self) -> zbus::Result<u32>;
574}
575
576/// Returns the load state of a systemd unit
577///
578/// Returns `invalid-unit-path` if the path is invalid
579///
580/// # Arguments
581///
582/// * `connection`: zbus connection
583/// * `full_service_name`: Full name of the service name with '.service' in the end
584///
585pub async fn get_active_state(connection: &Connection, full_service_name: &str) -> String {
586  let object_path = get_unit_path(full_service_name);
587
588  match zvariant::ObjectPath::try_from(object_path) {
589    Ok(path) => {
590      let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
591      unit_proxy.active_state().await.unwrap_or("invalid-unit-path".into())
592    },
593    Err(_) => "invalid-unit-path".to_string(),
594  }
595}
596
597/// Returns the unit file state of a systemd unit. If the state is `enabled`, the unit loads on every boot
598///
599/// Returns `invalid-unit-path` if the path is invalid
600///
601/// # Arguments
602///
603/// * `connection`: zbus connection
604/// * `full_service_name`: Full name of the service name with '.service' in the end
605///
606pub async fn get_unit_file_state(connection: &Connection, full_service_name: &str) -> String {
607  let object_path = get_unit_path(full_service_name);
608
609  match zvariant::ObjectPath::try_from(object_path) {
610    Ok(path) => {
611      let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
612      unit_proxy.unit_file_state().await.unwrap_or("invalid-unit-path".into())
613    },
614    Err(_) => "invalid-unit-path".to_string(),
615  }
616}
617
618/// Returns the PID of a systemd service
619///
620/// # Arguments
621///
622/// * `connection`: zbus connection
623/// * `full_service_name`: Full name of the service name with '.service' in the end
624///
625pub async fn get_main_pid(connection: &Connection, full_service_name: &str) -> Result<u32, zbus::Error> {
626  let object_path = get_unit_path(full_service_name);
627
628  let validated_object_path = zvariant::ObjectPath::try_from(object_path).unwrap();
629
630  let service_proxy = ServiceProxy::new(connection, validated_object_path).await.unwrap();
631  service_proxy.main_pid().await
632}
633
634/// Encode into a valid dbus string
635///
636/// # Arguments
637///
638/// * `input_string`
639///
640fn encode_as_dbus_object_path(input_string: &str) -> String {
641  input_string
642    .chars()
643    .map(|c| if c.is_ascii_alphanumeric() || c == '/' || c == '_' { c.to_string() } else { format!("_{:x}", c as u32) })
644    .collect()
645}
646
647/// Unit file path for a service
648///
649/// # Arguments
650///
651/// * `full_service_name`
652///
653pub fn get_unit_path(full_service_name: &str) -> String {
654  format!("/org/freedesktop/systemd1/unit/{}", encode_as_dbus_object_path(full_service_name))
655}
656
657/// Diagnostic result explaining why logs might be missing
658#[derive(Debug, Clone)]
659pub enum LogDiagnostic {
660  /// Unit has never been activated (ActiveEnterTimestamp is 0)
661  NeverRun { unit_name: String },
662  /// Journal is not accessible (likely permissions)
663  JournalInaccessible { error: String },
664  /// Unit-specific permission issue
665  PermissionDenied { error: String },
666  /// Journal is available but no logs exist for this unit
667  NoLogsRecorded { unit_name: String },
668  /// journalctl command failed with an error
669  JournalctlError { stderr: String },
670}
671
672impl LogDiagnostic {
673  /// Returns a human-readable message for display
674  pub fn message(&self) -> String {
675    match self {
676      Self::NeverRun { unit_name } => format!("No logs: {} has never been started", unit_name),
677      Self::JournalInaccessible { error } => {
678        format!("Cannot access journal: {}\n\nCheck that systemd-journald is running", error)
679      },
680      Self::PermissionDenied { error } => format!("Permission denied: {}\n\nTry: sudo systemctl-tui", error),
681      Self::NoLogsRecorded { unit_name } => {
682        format!("No logs recorded for {} (unit has run but produced no journal output)", unit_name)
683      },
684      Self::JournalctlError { stderr } => format!("journalctl error: {}", stderr),
685    }
686  }
687}
688
689/// Check if a unit has ever been activated using systemctl show
690pub fn check_unit_has_run(unit: &UnitId) -> bool {
691  let mut args = vec!["show", "-P", "ActiveEnterTimestampMonotonic"];
692  if unit.scope == UnitScope::User {
693    args.insert(0, "--user");
694  }
695  args.push(&unit.name);
696
697  Command::new("systemctl")
698    .args(&args)
699    .output()
700    .ok()
701    .and_then(
702      |output| if output.status.success() { std::str::from_utf8(&output.stdout).ok().map(String::from) } else { None },
703    )
704    .map(|s| s.trim().parse::<u64>().unwrap_or(0) > 0)
705    .unwrap_or(false)
706}
707
708/// Check if the journal is accessible at all (tests general read access)
709fn can_access_journal(scope: UnitScope) -> Result<(), String> {
710  let mut args = vec!["--lines=1", "--quiet"];
711  if scope == UnitScope::User {
712    args.push("--user");
713  }
714
715  match Command::new("journalctl").args(&args).output() {
716    Ok(output) => {
717      if output.status.success() {
718        Ok(())
719      } else {
720        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
721      }
722    },
723    Err(e) => Err(e.to_string()),
724  }
725}
726
727/// Parse journalctl stderr to determine the specific error type
728pub fn parse_journalctl_error(stderr: &str) -> LogDiagnostic {
729  let stderr_lower = stderr.to_lowercase();
730
731  if stderr_lower.contains("permission denied") || stderr_lower.contains("access denied") {
732    LogDiagnostic::PermissionDenied { error: stderr.trim().to_string() }
733  } else if stderr_lower.contains("no such file") || stderr_lower.contains("failed to open") {
734    LogDiagnostic::JournalInaccessible { error: stderr.trim().to_string() }
735  } else {
736    LogDiagnostic::JournalctlError { stderr: stderr.trim().to_string() }
737  }
738}
739
740/// Diagnose why logs are missing for a unit
741pub fn diagnose_missing_logs(unit: &UnitId) -> LogDiagnostic {
742  // Check 1: Has unit ever run?
743  if !check_unit_has_run(unit) {
744    return LogDiagnostic::NeverRun { unit_name: unit.name.clone() };
745  }
746
747  // Check 2: Can we access the journal at all?
748  if let Err(error) = can_access_journal(unit.scope) {
749    return parse_journalctl_error(&error);
750  }
751
752  // If we get here, journal is accessible but no logs for this specific unit
753  LogDiagnostic::NoLogsRecorded { unit_name: unit.name.clone() }
754}
755
756#[cfg(test)]
757mod tests {
758  use super::*;
759
760  #[test]
761  fn test_get_unit_path() {
762    assert_eq!(get_unit_path("test.service"), "/org/freedesktop/systemd1/unit/test_2eservice");
763  }
764
765  #[test]
766  fn test_encode_as_dbus_object_path() {
767    assert_eq!(encode_as_dbus_object_path("test.service"), "test_2eservice");
768    assert_eq!(encode_as_dbus_object_path("test-with-hyphen.service"), "test_2dwith_2dhyphen_2eservice");
769  }
770
771  #[test]
772  fn test_parse_journalctl_error_permission() {
773    let diagnostic = parse_journalctl_error("Failed to get journal access: Permission denied");
774    assert!(matches!(diagnostic, LogDiagnostic::PermissionDenied { .. }));
775  }
776
777  #[test]
778  fn test_parse_journalctl_error_no_file() {
779    let diagnostic = parse_journalctl_error("No such file or directory");
780    assert!(matches!(diagnostic, LogDiagnostic::JournalInaccessible { .. }));
781  }
782
783  #[test]
784  fn test_parse_journalctl_error_generic() {
785    let diagnostic = parse_journalctl_error("Something unexpected happened");
786    assert!(matches!(diagnostic, LogDiagnostic::JournalctlError { .. }));
787  }
788}