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, services: &[String]) -> 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 =
167      match manager_proxy.list_unit_files_by_patterns(vec![], services.iter().map(|s| s.to_string()).collect()).await {
168        Ok(files) => {
169          info!("get_unit_files: got {} {:?} unit files", files.len(), unit_scope);
170          files
171        },
172        Err(e) => {
173          error!("get_unit_files: list_unit_files_by_patterns failed for {:?}: {:?}", unit_scope, e);
174          if is_root && unit_scope == UnitScope::User {
175            info!("get_unit_files: ignoring user scope error because we're root");
176            vec![]
177          } else {
178            return Err(e.into());
179          }
180        },
181      };
182
183    let services = unit_files
184      .into_iter()
185      .filter_map(|(path, state)| {
186        let rust_path = std::path::Path::new(&path);
187        let file_name = rust_path.file_name()?.to_str()?;
188        Some(UnitFile { name: file_name.to_string(), scope: unit_scope, enablement_state: state, path })
189      })
190      .collect::<Vec<_>>();
191    ret.extend(services);
192  }
193
194  info!("Loaded {} unit files in {:?}", ret.len(), start.elapsed());
195  Ok(ret)
196}
197
198// this takes like 5-10 ms on 13th gen Intel i7 (scope=all)
199pub async fn get_all_services(scope: Scope, services: &[String]) -> Result<Vec<UnitWithStatus>> {
200  let start = std::time::Instant::now();
201
202  let mut units = vec![];
203
204  let is_root = nix::unistd::geteuid().is_root();
205
206  match scope {
207    Scope::Global => {
208      let system_units = get_services(UnitScope::Global, services).await?;
209      units.extend(system_units);
210    },
211    Scope::User => {
212      let user_units = get_services(UnitScope::User, services).await?;
213      units.extend(user_units);
214    },
215    Scope::All => {
216      let (system_units, user_units) =
217        tokio::join!(get_services(UnitScope::Global, services), get_services(UnitScope::User, services));
218      units.extend(system_units?);
219
220      // Should always be able to get user units, but it may fail when running as root
221      if let Ok(user_units) = user_units {
222        units.extend(user_units);
223      } else if is_root {
224        error!("Failed to get user units, ignoring because we're running as root")
225      } else {
226        user_units?;
227      }
228    },
229  }
230
231  // sort by name case-insensitive
232  units.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
233
234  info!("Loaded systemd services in {:?}", start.elapsed());
235
236  Ok(units)
237}
238
239async fn get_services(scope: UnitScope, services: &[String]) -> Result<Vec<UnitWithStatus>, anyhow::Error> {
240  let connection = get_connection(scope).await?;
241  let manager_proxy = ManagerProxy::new(&connection).await?;
242  let units = manager_proxy.list_units_by_patterns(vec![], services.to_vec()).await?;
243  let units: Vec<_> = units.into_iter().map(|u| to_unit_status(u, scope)).collect();
244  Ok(units)
245}
246
247pub fn get_unit_file_location(service: &UnitId) -> Result<String> {
248  // show -P FragmentPath reitunes.service
249  let mut args = vec!["--quiet", "show", "-P", "FragmentPath"];
250  args.push(&service.name);
251
252  if service.scope == UnitScope::User {
253    args.insert(0, "--user");
254  }
255
256  let output = Command::new("systemctl").args(&args).output()?;
257
258  if output.status.success() {
259    let path = str::from_utf8(&output.stdout)?.trim();
260    if path.is_empty() {
261      bail!("No unit file found for {}", service.name);
262    }
263    Ok(path.trim().to_string())
264  } else {
265    let stderr = String::from_utf8(output.stderr)?;
266    bail!(stderr);
267  }
268}
269
270pub async fn start_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
271  async fn start_service(service: UnitId) -> Result<()> {
272    let connection = get_connection(service.scope).await?;
273    let manager_proxy = ManagerProxy::new(&connection).await?;
274    manager_proxy.start_unit(service.name.clone(), "replace".into()).await?;
275    Ok(())
276  }
277
278  // god these select macros are ugly, is there really no better way to select?
279  tokio::select! {
280    _ = cancel_token.cancelled() => {
281        anyhow::bail!("cancelled");
282    }
283    result = start_service(service) => {
284        result
285    }
286  }
287}
288
289pub async fn stop_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
290  async fn stop_service(service: UnitId) -> Result<()> {
291    let connection = get_connection(service.scope).await?;
292    let manager_proxy = ManagerProxy::new(&connection).await?;
293    manager_proxy.stop_unit(service.name, "replace".into()).await?;
294    Ok(())
295  }
296
297  // god these select macros are ugly, is there really no better way to select?
298  tokio::select! {
299    _ = cancel_token.cancelled() => {
300        anyhow::bail!("cancelled");
301    }
302    result = stop_service(service) => {
303        result
304    }
305  }
306}
307
308pub async fn reload(scope: UnitScope, cancel_token: CancellationToken) -> Result<()> {
309  async fn reload_(scope: UnitScope) -> Result<()> {
310    let connection = get_connection(scope).await?;
311    let manager_proxy: ManagerProxy<'_> = ManagerProxy::new(&connection).await?;
312    let error_message = match scope {
313      UnitScope::Global => "Failed to reload units, probably because superuser permissions are needed. Try running `sudo systemctl daemon-reload`",
314      UnitScope::User => "Failed to reload units. Try running `systemctl --user daemon-reload`",
315    };
316    manager_proxy.reload().await.context(error_message)?;
317    Ok(())
318  }
319
320  // god these select macros are ugly, is there really no better way to select?
321  tokio::select! {
322    _ = cancel_token.cancelled() => {
323        anyhow::bail!("cancelled");
324    }
325    result = reload_(scope) => {
326        result
327    }
328  }
329}
330
331async fn get_connection(scope: UnitScope) -> Result<Connection, anyhow::Error> {
332  match scope {
333    UnitScope::Global => Ok(Connection::system().await?),
334    UnitScope::User => Ok(Connection::session().await?),
335  }
336}
337
338pub async fn restart_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
339  async fn restart(service: UnitId) -> Result<()> {
340    let connection = get_connection(service.scope).await?;
341    let manager_proxy = ManagerProxy::new(&connection).await?;
342    manager_proxy.restart_unit(service.name, "replace".into()).await?;
343    Ok(())
344  }
345
346  // god these select macros are ugly, is there really no better way to select?
347  tokio::select! {
348    _ = cancel_token.cancelled() => {
349        // The token was cancelled
350        anyhow::bail!("cancelled");
351    }
352    result = restart(service) => {
353        result
354    }
355  }
356}
357
358pub async fn enable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
359  async fn enable(service: UnitId) -> Result<()> {
360    let connection = get_connection(service.scope).await?;
361    let manager_proxy = ManagerProxy::new(&connection).await?;
362    let files = vec![service.name];
363    let (_, changes) = manager_proxy.enable_unit_files(files, false, false).await?;
364
365    for (change_type, name, destination) in changes {
366      info!("{}: {} -> {}", change_type, name, destination);
367    }
368    // Enabling without reloading puts things in a weird state where `systemctl status foo` tells you to run daemon-reload
369    manager_proxy.reload().await?;
370    Ok(())
371  }
372
373  tokio::select! {
374    _ = cancel_token.cancelled() => {
375        anyhow::bail!("cancelled");
376    }
377    result = enable(service) => {
378        result
379    }
380  }
381}
382
383pub async fn disable_service(service: UnitId, cancel_token: CancellationToken) -> Result<()> {
384  async fn disable(service: UnitId) -> Result<()> {
385    let connection = get_connection(service.scope).await?;
386    let manager_proxy = ManagerProxy::new(&connection).await?;
387    let files = vec![service.name];
388    let changes = manager_proxy.disable_unit_files(files, false).await?;
389
390    for (change_type, name, destination) in changes {
391      info!("{}: {} -> {}", change_type, name, destination);
392    }
393    manager_proxy.reload().await?;
394    Ok(())
395  }
396
397  tokio::select! {
398    _ = cancel_token.cancelled() => {
399        anyhow::bail!("cancelled");
400    }
401    result = disable(service) => {
402        result
403    }
404  }
405}
406
407// useless function only added to test that cancellation works
408pub async fn sleep_test(_service: String, cancel_token: CancellationToken) -> Result<()> {
409  // god these select macros are ugly, is there really no better way to select?
410  tokio::select! {
411      _ = cancel_token.cancelled() => {
412          // The token was cancelled
413          anyhow::bail!("cancelled");
414      }
415      _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
416          Ok(())
417      }
418  }
419}
420
421pub async fn kill_service(service: UnitId, signal: String, cancel_token: CancellationToken) -> Result<()> {
422  async fn kill(service: UnitId, signal: String) -> Result<()> {
423    let mut args = vec!["kill", "--signal", &signal];
424    if service.scope == UnitScope::User {
425      args.push("--user");
426    }
427    args.push(&service.name);
428
429    let output = Command::new("systemctl").args(&args).output()?;
430
431    if output.status.success() {
432      info!("Successfully sent signal {} to srvice {}", signal, service.name);
433      Ok(())
434    } else {
435      let stderr = String::from_utf8(output.stderr)?;
436      bail!("Failed to send signal {} to service {}: {}", signal, service.name, stderr);
437    }
438  }
439
440  tokio::select! {
441      _ = cancel_token.cancelled() => {
442          bail!("cancelled");
443      }
444      result = kill(service, signal) => {
445          result
446      }
447  }
448}
449
450/// Proxy object for `org.freedesktop.systemd1.Manager`.
451/// Partially taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
452#[proxy(
453  interface = "org.freedesktop.systemd1.Manager",
454  default_service = "org.freedesktop.systemd1",
455  default_path = "/org/freedesktop/systemd1",
456  gen_blocking = false
457)]
458pub trait Manager {
459  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#StartUnit()) Call interface method `StartUnit`.
460  #[zbus(name = "StartUnit")]
461  fn start_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
462
463  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#StopUnit()) Call interface method `StopUnit`.
464  #[zbus(name = "StopUnit")]
465  fn stop_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
466
467  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ReloadUnit()) Call interface method `ReloadUnit`.
468  #[zbus(name = "ReloadUnit")]
469  fn reload_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
470
471  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#RestartUnit()) Call interface method `RestartUnit`.
472  #[zbus(name = "RestartUnit")]
473  fn restart_unit(&self, name: String, mode: String) -> zbus::Result<zvariant::OwnedObjectPath>;
474
475  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#EnableUnitFiles()) Call interface method `EnableUnitFiles`.
476  #[zbus(name = "EnableUnitFiles")]
477  fn enable_unit_files(
478    &self,
479    files: Vec<String>,
480    runtime: bool,
481    force: bool,
482  ) -> zbus::Result<(bool, Vec<(String, String, String)>)>;
483
484  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#DisableUnitFiles()) Call interface method `DisableUnitFiles`.
485  #[zbus(name = "DisableUnitFiles")]
486  fn disable_unit_files(&self, files: Vec<String>, runtime: bool) -> zbus::Result<Vec<(String, String, String)>>;
487
488  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ListUnits()) Call interface method `ListUnits`.
489  #[zbus(name = "ListUnits")]
490  fn list_units(
491    &self,
492  ) -> zbus::Result<
493    Vec<(
494      String,
495      String,
496      String,
497      String,
498      String,
499      String,
500      zvariant::OwnedObjectPath,
501      u32,
502      String,
503      zvariant::OwnedObjectPath,
504    )>,
505  >;
506
507  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#ListUnitsByPatterns()) Call interface method `ListUnitsByPatterns`.
508  #[zbus(name = "ListUnitsByPatterns")]
509  fn list_units_by_patterns(
510    &self,
511    states: Vec<String>,
512    patterns: Vec<String>,
513  ) -> zbus::Result<
514    Vec<(
515      String,
516      String,
517      String,
518      String,
519      String,
520      String,
521      zvariant::OwnedObjectPath,
522      u32,
523      String,
524      zvariant::OwnedObjectPath,
525    )>,
526  >;
527
528  /// [📖](https://www.freedesktop.org/software/systemd/man/systemd.directives.html#Reload()) Call interface method `Reload`.
529  #[zbus(name = "Reload")]
530  fn reload(&self) -> zbus::Result<()>;
531
532  /// [📖](https://www.freedesktop.org/software/systemd/man/latest/systemd.directives.html#ListUnitFilesByPatterns()) Call interface method `ListUnitFilesByPatterns`.
533  #[zbus(name = "ListUnitFilesByPatterns")]
534  fn list_unit_files_by_patterns(
535    &self,
536    states: Vec<String>,
537    patterns: Vec<String>,
538  ) -> zbus::Result<Vec<(String, String)>>;
539}
540
541/// Proxy object for `org.freedesktop.systemd1.Unit`.
542/// Taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
543#[proxy(
544  interface = "org.freedesktop.systemd1.Unit",
545  default_service = "org.freedesktop.systemd1",
546  assume_defaults = false,
547  gen_blocking = false
548)]
549pub trait Unit {
550  /// Get property `ActiveState`.
551  #[zbus(property)]
552  fn active_state(&self) -> zbus::Result<String>;
553
554  /// Get property `LoadState`.
555  #[zbus(property)]
556  fn load_state(&self) -> zbus::Result<String>;
557
558  /// Get property `UnitFileState`.
559  #[zbus(property)]
560  fn unit_file_state(&self) -> zbus::Result<String>;
561}
562
563/// Proxy object for `org.freedesktop.systemd1.Service`.
564/// Taken from https://github.com/lucab/zbus_systemd/blob/main/src/systemd1/generated.rs
565#[proxy(
566  interface = "org.freedesktop.systemd1.Service",
567  default_service = "org.freedesktop.systemd1",
568  assume_defaults = false,
569  gen_blocking = false
570)]
571trait Service {
572  /// Get property `MainPID`.
573  #[zbus(property, name = "MainPID")]
574  fn main_pid(&self) -> zbus::Result<u32>;
575}
576
577/// Returns the load state of a systemd unit
578///
579/// Returns `invalid-unit-path` if the path is invalid
580///
581/// # Arguments
582///
583/// * `connection`: zbus connection
584/// * `full_service_name`: Full name of the service name with '.service' in the end
585///
586pub async fn get_active_state(connection: &Connection, full_service_name: &str) -> String {
587  let object_path = get_unit_path(full_service_name);
588
589  match zvariant::ObjectPath::try_from(object_path) {
590    Ok(path) => {
591      let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
592      unit_proxy.active_state().await.unwrap_or("invalid-unit-path".into())
593    },
594    Err(_) => "invalid-unit-path".to_string(),
595  }
596}
597
598/// Returns the unit file state of a systemd unit. If the state is `enabled`, the unit loads on every boot
599///
600/// Returns `invalid-unit-path` if the path is invalid
601///
602/// # Arguments
603///
604/// * `connection`: zbus connection
605/// * `full_service_name`: Full name of the service name with '.service' in the end
606///
607pub async fn get_unit_file_state(connection: &Connection, full_service_name: &str) -> String {
608  let object_path = get_unit_path(full_service_name);
609
610  match zvariant::ObjectPath::try_from(object_path) {
611    Ok(path) => {
612      let unit_proxy = UnitProxy::new(connection, path).await.unwrap();
613      unit_proxy.unit_file_state().await.unwrap_or("invalid-unit-path".into())
614    },
615    Err(_) => "invalid-unit-path".to_string(),
616  }
617}
618
619/// Returns the PID of a systemd service
620///
621/// # Arguments
622///
623/// * `connection`: zbus connection
624/// * `full_service_name`: Full name of the service name with '.service' in the end
625///
626pub async fn get_main_pid(connection: &Connection, full_service_name: &str) -> Result<u32, zbus::Error> {
627  let object_path = get_unit_path(full_service_name);
628
629  let validated_object_path = zvariant::ObjectPath::try_from(object_path).unwrap();
630
631  let service_proxy = ServiceProxy::new(connection, validated_object_path).await.unwrap();
632  service_proxy.main_pid().await
633}
634
635/// Encode into a valid dbus string
636///
637/// # Arguments
638///
639/// * `input_string`
640///
641fn encode_as_dbus_object_path(input_string: &str) -> String {
642  input_string
643    .chars()
644    .map(|c| if c.is_ascii_alphanumeric() || c == '/' || c == '_' { c.to_string() } else { format!("_{:x}", c as u32) })
645    .collect()
646}
647
648/// Unit file path for a service
649///
650/// # Arguments
651///
652/// * `full_service_name`
653///
654pub fn get_unit_path(full_service_name: &str) -> String {
655  format!("/org/freedesktop/systemd1/unit/{}", encode_as_dbus_object_path(full_service_name))
656}
657
658/// Diagnostic result explaining why logs might be missing
659#[derive(Debug, Clone)]
660pub enum LogDiagnostic {
661  /// Unit has never been activated (ActiveEnterTimestamp is 0)
662  NeverRun { unit_name: String },
663  /// Journal is not accessible (likely permissions)
664  JournalInaccessible { error: String },
665  /// Unit-specific permission issue
666  PermissionDenied { error: String },
667  /// Journal is available but no logs exist for this unit
668  NoLogsRecorded { unit_name: String },
669  /// journalctl command failed with an error
670  JournalctlError { stderr: String },
671}
672
673impl LogDiagnostic {
674  /// Returns a human-readable message for display
675  pub fn message(&self) -> String {
676    match self {
677      Self::NeverRun { unit_name } => format!("No logs: {} has never been started", unit_name),
678      Self::JournalInaccessible { error } => {
679        format!("Cannot access journal: {}\n\nCheck that systemd-journald is running", error)
680      },
681      Self::PermissionDenied { error } => format!("Permission denied: {}\n\nTry: sudo systemctl-tui", error),
682      Self::NoLogsRecorded { unit_name } => {
683        format!("No logs recorded for {} (unit has run but produced no journal output)", unit_name)
684      },
685      Self::JournalctlError { stderr } => format!("journalctl error: {}", stderr),
686    }
687  }
688}
689
690/// Check if a unit has ever been activated using systemctl show
691pub fn check_unit_has_run(unit: &UnitId) -> bool {
692  let mut args = vec!["show", "-P", "ActiveEnterTimestampMonotonic"];
693  if unit.scope == UnitScope::User {
694    args.insert(0, "--user");
695  }
696  args.push(&unit.name);
697
698  Command::new("systemctl")
699    .args(&args)
700    .output()
701    .ok()
702    .and_then(
703      |output| if output.status.success() { std::str::from_utf8(&output.stdout).ok().map(String::from) } else { None },
704    )
705    .map(|s| s.trim().parse::<u64>().unwrap_or(0) > 0)
706    .unwrap_or(false)
707}
708
709/// Check if the journal is accessible at all (tests general read access)
710fn can_access_journal(scope: UnitScope) -> Result<(), String> {
711  let mut args = vec!["--lines=1", "--quiet"];
712  if scope == UnitScope::User {
713    args.push("--user");
714  }
715
716  match Command::new("journalctl").args(&args).output() {
717    Ok(output) => {
718      if output.status.success() {
719        Ok(())
720      } else {
721        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
722      }
723    },
724    Err(e) => Err(e.to_string()),
725  }
726}
727
728/// Parse journalctl stderr to determine the specific error type
729pub fn parse_journalctl_error(stderr: &str) -> LogDiagnostic {
730  let stderr_lower = stderr.to_lowercase();
731
732  if stderr_lower.contains("permission denied") || stderr_lower.contains("access denied") {
733    LogDiagnostic::PermissionDenied { error: stderr.trim().to_string() }
734  } else if stderr_lower.contains("no such file") || stderr_lower.contains("failed to open") {
735    LogDiagnostic::JournalInaccessible { error: stderr.trim().to_string() }
736  } else {
737    LogDiagnostic::JournalctlError { stderr: stderr.trim().to_string() }
738  }
739}
740
741/// Diagnose why logs are missing for a unit
742pub fn diagnose_missing_logs(unit: &UnitId) -> LogDiagnostic {
743  // Check 1: Has unit ever run?
744  if !check_unit_has_run(unit) {
745    return LogDiagnostic::NeverRun { unit_name: unit.name.clone() };
746  }
747
748  // Check 2: Can we access the journal at all?
749  if let Err(error) = can_access_journal(unit.scope) {
750    return parse_journalctl_error(&error);
751  }
752
753  // If we get here, journal is accessible but no logs for this specific unit
754  LogDiagnostic::NoLogsRecorded { unit_name: unit.name.clone() }
755}
756
757#[cfg(test)]
758mod tests {
759  use super::*;
760
761  #[test]
762  fn test_get_unit_path() {
763    assert_eq!(get_unit_path("test.service"), "/org/freedesktop/systemd1/unit/test_2eservice");
764  }
765
766  #[test]
767  fn test_encode_as_dbus_object_path() {
768    assert_eq!(encode_as_dbus_object_path("test.service"), "test_2eservice");
769    assert_eq!(encode_as_dbus_object_path("test-with-hyphen.service"), "test_2dwith_2dhyphen_2eservice");
770  }
771
772  #[test]
773  fn test_parse_journalctl_error_permission() {
774    let diagnostic = parse_journalctl_error("Failed to get journal access: Permission denied");
775    assert!(matches!(diagnostic, LogDiagnostic::PermissionDenied { .. }));
776  }
777
778  #[test]
779  fn test_parse_journalctl_error_no_file() {
780    let diagnostic = parse_journalctl_error("No such file or directory");
781    assert!(matches!(diagnostic, LogDiagnostic::JournalInaccessible { .. }));
782  }
783
784  #[test]
785  fn test_parse_journalctl_error_generic() {
786    let diagnostic = parse_journalctl_error("Something unexpected happened");
787    assert!(matches!(diagnostic, LogDiagnostic::JournalctlError { .. }));
788  }
789}