serlib/
systemd.rs

1use crate::ServiceDetails;
2use anyhow::{bail, Result};
3
4/// Comment added to generated service files to indicate they are managed by ser
5pub const MANAGED_BY_COMMENT: &str = "# Managed by ser";
6
7/// Generate a systemd timer file for scheduled execution.
8pub fn generate_timer_file(service: &ServiceDetails) -> Result<String> {
9    let schedule = service
10        .schedule
11        .as_ref()
12        .ok_or_else(|| anyhow::anyhow!("No schedule defined"))?;
13
14    let mut content = String::new();
15    content.push_str(MANAGED_BY_COMMENT);
16    content.push('\n');
17    content.push_str("[Unit]\n");
18    content.push_str(&format!("Description=Timer for {}\n", service.name));
19    content.push_str("\n[Timer]\n");
20    content.push_str(&format!(
21        "OnCalendar={}\n",
22        schedule.to_systemd_oncalendar()
23    ));
24    content.push_str("Persistent=true\n");
25    content.push_str("\n[Install]\n");
26    content.push_str("WantedBy=timers.target\n");
27
28    Ok(content)
29}
30
31pub fn parse_systemd(contents: &str) -> Result<ServiceDetails> {
32    // Basic parsing of systemd unit file
33    let mut name = None;
34    let mut program = None;
35    let mut arguments = Vec::new();
36    let mut working_directory = None;
37    let mut run_at_load = false;
38    let mut keep_alive = false;
39    let mut env_file = None;
40    let mut env_vars = Vec::new();
41    let mut after = Vec::new();
42
43    for line in contents.lines() {
44        let line = line.trim();
45        if line.starts_with("Description=") {
46            name = line.strip_prefix("Description=").map(|s| s.to_string());
47        }
48        if line.starts_with("ExecStart=") {
49            let exec_start = line.strip_prefix("ExecStart=").unwrap_or("");
50            let mut parts = exec_start.split_whitespace();
51            if let Some(prog) = parts.next() {
52                program = Some(prog.to_string());
53                arguments = parts.map(|s| s.to_string()).collect();
54            } else {
55                bail!("ExecStart line is empty in service file");
56            }
57        } else if line.starts_with("WorkingDirectory=") {
58            working_directory = line
59                .strip_prefix("WorkingDirectory=")
60                .map(|s| s.to_string());
61        } else if line == "WantedBy=multi-user.target" || line == "WantedBy=default.target" {
62            run_at_load = true;
63        } else if line.starts_with("Restart=") {
64            keep_alive = line != "Restart=no";
65        } else if line.starts_with("EnvironmentFile=") {
66            env_file = line.strip_prefix("EnvironmentFile=").map(|s| s.to_string());
67        } else if line.starts_with("Environment=") {
68            // Ignored for now
69            let env_line = line.strip_prefix("Environment=").unwrap();
70            let Some((a, b)) = env_line.split_once('=') else {
71                bail!("Environment line is empty in service file");
72            };
73            env_vars.push((a.to_string(), b.to_string()));
74        } else if line.starts_with("After=") {
75            let after_line = line.strip_prefix("After=").unwrap_or("");
76            after = after_line
77                .split_whitespace()
78                .map(|s| s.to_string())
79                .collect();
80        }
81    }
82    Ok(ServiceDetails {
83        name: name.expect("No name for service"),
84        program: program.expect("No program for service"),
85        arguments,
86        working_directory,
87        run_at_load,
88        keep_alive,
89        env_file,
90        env_vars,
91        after,
92        schedule: None, // Schedule is parsed from .timer file separately
93    })
94}
95
96pub fn generate_file(service: &ServiceDetails) -> Result<String> {
97    let mut unit_content = String::new();
98    unit_content.push_str(MANAGED_BY_COMMENT);
99    unit_content.push('\n');
100    unit_content.push_str("[Unit]\n");
101    unit_content.push_str(&format!("Description={}\n", service.name));
102    if !service.after.is_empty() {
103        unit_content.push_str("After=");
104        for after in &service.after {
105            unit_content.push_str(after);
106            unit_content.push(' ');
107        }
108        unit_content.pop(); // Remove trailing space
109        unit_content.push('\n');
110    }
111    unit_content.push_str("\n[Service]\n");
112
113    // For scheduled services, use Type=oneshot
114    if service.schedule.is_some() {
115        unit_content.push_str("Type=oneshot\n");
116    }
117
118    unit_content.push_str("ExecStart=");
119    unit_content.push_str(&service.program);
120    for arg in &service.arguments {
121        unit_content.push(' ');
122        unit_content.push_str(arg);
123    }
124    unit_content.push('\n');
125
126    if let Some(ref wd) = service.working_directory {
127        unit_content.push_str(&format!("WorkingDirectory={}\n", wd));
128    }
129
130    // Only add Restart for non-scheduled services
131    if service.schedule.is_none() && service.keep_alive {
132        unit_content.push_str("Restart=always\n");
133    }
134    if let Some(file) = &service.env_file {
135        unit_content.push_str(&format!("EnvironmentFile={}\n", file));
136    }
137    for (key, value) in &service.env_vars {
138        unit_content.push_str(&format!("Environment=\"{}={}\"\n", key, value));
139    }
140
141    // Only add [Install] section for non-scheduled services
142    if service.schedule.is_none() && service.run_at_load {
143        unit_content.push_str("\n[Install]\n");
144        unit_content.push_str("WantedBy=default.target\n");
145    }
146
147    Ok(unit_content)
148}