Skip to main content

pitchfork_cli/
pitchfork_toml.rs

1use crate::error::{ConfigParseError, FileError};
2use crate::{Result, env};
3use indexmap::IndexMap;
4use miette::Context;
5use schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::path::{Path, PathBuf};
8
9/// Configuration schema for pitchfork.toml daemon supervisor configuration files
10#[derive(Debug, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
11#[schemars(title = "Pitchfork Configuration")]
12pub struct PitchforkToml {
13    /// Map of daemon names to their configurations
14    pub daemons: IndexMap<String, PitchforkTomlDaemon>,
15    #[serde(skip)]
16    #[schemars(skip)]
17    pub path: Option<PathBuf>,
18}
19
20impl PitchforkToml {
21    /// List all configuration file paths from the current working directory.
22    /// See `list_paths_from` for details on the search order.
23    pub fn list_paths() -> Vec<PathBuf> {
24        Self::list_paths_from(&env::CWD)
25    }
26
27    /// List all configuration file paths starting from a given directory.
28    ///
29    /// Returns paths in order of precedence (lowest to highest):
30    /// 1. System-level: /etc/pitchfork/config.toml
31    /// 2. User-level: ~/.config/pitchfork/config.toml
32    /// 3. Project-level: pitchfork.toml and pitchfork.local.toml files
33    ///    from filesystem root to the given directory
34    ///
35    /// Within each directory, pitchfork.toml comes before pitchfork.local.toml,
36    /// so local.toml values override the base config.
37    pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
38        let mut paths = Vec::new();
39        paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
40        paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
41
42        // Find both files in one call. Order is reversed so after .reverse():
43        // - each directory has pitchfork.toml before pitchfork.local.toml
44        // - directories go from root to cwd (later configs override earlier)
45        let mut project_paths =
46            xx::file::find_up_all(cwd, &["pitchfork.local.toml", "pitchfork.toml"]);
47        project_paths.reverse();
48        paths.extend(project_paths);
49
50        paths
51    }
52
53    /// Merge all configuration files from the current working directory.
54    /// See `all_merged_from` for details.
55    pub fn all_merged() -> PitchforkToml {
56        Self::all_merged_from(&env::CWD)
57    }
58
59    /// Merge all configuration files starting from a given directory.
60    ///
61    /// Reads and merges configuration files in precedence order.
62    /// Later files override values from earlier files.
63    pub fn all_merged_from(cwd: &Path) -> PitchforkToml {
64        let mut pt = Self::default();
65        for p in Self::list_paths_from(cwd) {
66            match Self::read(&p) {
67                Ok(pt2) => pt.merge(pt2),
68                Err(e) => eprintln!("error reading {}: {}", p.display(), e),
69            }
70        }
71        pt
72    }
73}
74
75impl PitchforkToml {
76    pub fn new(path: PathBuf) -> Self {
77        Self {
78            daemons: Default::default(),
79            path: Some(path),
80        }
81    }
82
83    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
84        let path = path.as_ref();
85        if !path.exists() {
86            return Ok(Self::new(path.to_path_buf()));
87        }
88        let _lock = xx::fslock::get(path, false)
89            .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
90        let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
91            path: path.to_path_buf(),
92            source: e,
93        })?;
94        let mut pt: Self = toml::from_str(&raw)
95            .map_err(|e| ConfigParseError::from_toml_error(path, raw.clone(), e))?;
96        pt.path = Some(path.to_path_buf());
97        for (_id, d) in pt.daemons.iter_mut() {
98            d.path = pt.path.clone();
99        }
100        Ok(pt)
101    }
102
103    pub fn write(&self) -> Result<()> {
104        if let Some(path) = &self.path {
105            let _lock = xx::fslock::get(path, false)
106                .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
107            let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
108                path: path.clone(),
109                source: e,
110            })?;
111            xx::file::write(path, &raw).map_err(|e| FileError::WriteError {
112                path: path.clone(),
113                details: Some(e.to_string()),
114            })?;
115            Ok(())
116        } else {
117            Err(FileError::NoPath.into())
118        }
119    }
120
121    pub fn merge(&mut self, pt: Self) {
122        for (id, d) in pt.daemons {
123            self.daemons.insert(id, d);
124        }
125    }
126}
127
128/// Configuration for a single daemon
129#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
130pub struct PitchforkTomlDaemon {
131    /// The command to run. Prepend with 'exec' to avoid shell process overhead.
132    #[schemars(example = example_run_command())]
133    pub run: String,
134    /// Automatic start/stop behavior based on shell hooks
135    #[serde(skip_serializing_if = "Vec::is_empty", default)]
136    pub auto: Vec<PitchforkTomlAuto>,
137    /// Cron scheduling configuration for periodic execution
138    #[serde(skip_serializing_if = "Option::is_none", default)]
139    pub cron: Option<PitchforkTomlCron>,
140    /// Number of times to retry if the daemon fails.
141    /// Can be a number (e.g., `3`) or `true` for infinite retries.
142    #[serde(default)]
143    pub retry: Retry,
144    /// Delay in milliseconds before considering the daemon ready
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub ready_delay: Option<u64>,
147    /// Regex pattern to match in stdout/stderr to determine readiness
148    #[serde(skip_serializing_if = "Option::is_none", default)]
149    pub ready_output: Option<String>,
150    /// HTTP URL to poll for readiness (expects 2xx response)
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub ready_http: Option<String>,
153    /// TCP port to check for readiness (connection success = ready)
154    #[serde(skip_serializing_if = "Option::is_none", default)]
155    #[schemars(range(min = 1, max = 65535))]
156    pub ready_port: Option<u16>,
157    /// Shell command to poll for readiness (exit code 0 = ready)
158    #[serde(skip_serializing_if = "Option::is_none", default)]
159    pub ready_cmd: Option<String>,
160    /// Whether to start this daemon automatically on system boot
161    #[serde(skip_serializing_if = "Option::is_none", default)]
162    pub boot_start: Option<bool>,
163    /// List of daemon names that must be started before this one
164    #[serde(skip_serializing_if = "Vec::is_empty", default)]
165    pub depends: Vec<String>,
166    #[serde(skip_serializing_if = "Vec::is_empty", default)]
167    pub watch: Vec<String>,
168    /// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
169    #[serde(skip_serializing_if = "Option::is_none", default)]
170    pub dir: Option<String>,
171    /// Environment variables to set for the daemon process
172    #[serde(skip_serializing_if = "Option::is_none", default)]
173    pub env: Option<IndexMap<String, String>>,
174    #[serde(skip)]
175    #[schemars(skip)]
176    pub path: Option<PathBuf>,
177}
178
179fn example_run_command() -> &'static str {
180    "exec node server.js"
181}
182
183/// Cron scheduling configuration
184#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
185pub struct PitchforkTomlCron {
186    /// Cron expression (e.g., '0 * * * *' for hourly, '*/5 * * * *' for every 5 minutes)
187    #[schemars(example = example_cron_schedule())]
188    pub schedule: String,
189    /// Behavior when cron triggers while previous run is still active
190    #[serde(default = "default_retrigger")]
191    pub retrigger: CronRetrigger,
192}
193
194fn default_retrigger() -> CronRetrigger {
195    CronRetrigger::Finish
196}
197
198fn example_cron_schedule() -> &'static str {
199    "0 * * * *"
200}
201
202/// Retrigger behavior for cron-scheduled daemons
203#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
204#[serde(rename_all = "snake_case")]
205pub enum CronRetrigger {
206    /// Retrigger only if the previous run has finished (success or error)
207    Finish,
208    /// Always retrigger, stopping the previous run if still active
209    Always,
210    /// Retrigger only if the previous run succeeded
211    Success,
212    /// Retrigger only if the previous run failed
213    Fail,
214}
215
216/// Automatic behavior triggered by shell hooks
217#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
218#[serde(rename_all = "snake_case")]
219pub enum PitchforkTomlAuto {
220    /// Automatically start when entering the directory
221    Start,
222    /// Automatically stop when leaving the directory
223    Stop,
224}
225
226/// Retry configuration that accepts either a boolean or a count.
227/// - `true` means retry indefinitely (u32::MAX)
228/// - `false` or `0` means no retries
229/// - A number means retry that many times
230#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
231pub struct Retry(pub u32);
232
233impl std::fmt::Display for Retry {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        if self.is_infinite() {
236            write!(f, "infinite")
237        } else {
238            write!(f, "{}", self.0)
239        }
240    }
241}
242
243impl Retry {
244    pub const INFINITE: Retry = Retry(u32::MAX);
245
246    pub fn count(&self) -> u32 {
247        self.0
248    }
249
250    pub fn is_infinite(&self) -> bool {
251        self.0 == u32::MAX
252    }
253}
254
255impl From<u32> for Retry {
256    fn from(n: u32) -> Self {
257        Retry(n)
258    }
259}
260
261impl From<bool> for Retry {
262    fn from(b: bool) -> Self {
263        if b { Retry::INFINITE } else { Retry(0) }
264    }
265}
266
267impl Serialize for Retry {
268    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
269    where
270        S: Serializer,
271    {
272        // Serialize infinite as true, otherwise as number
273        if self.is_infinite() {
274            serializer.serialize_bool(true)
275        } else {
276            serializer.serialize_u32(self.0)
277        }
278    }
279}
280
281impl<'de> Deserialize<'de> for Retry {
282    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
283    where
284        D: Deserializer<'de>,
285    {
286        use serde::de::{self, Visitor};
287
288        struct RetryVisitor;
289
290        impl Visitor<'_> for RetryVisitor {
291            type Value = Retry;
292
293            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
294                formatter.write_str("a boolean or non-negative integer")
295            }
296
297            fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
298            where
299                E: de::Error,
300            {
301                Ok(Retry::from(v))
302            }
303
304            fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
305            where
306                E: de::Error,
307            {
308                if v < 0 {
309                    Err(de::Error::custom("retry count cannot be negative"))
310                } else if v > u32::MAX as i64 {
311                    Ok(Retry::INFINITE)
312                } else {
313                    Ok(Retry(v as u32))
314                }
315            }
316
317            fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
318            where
319                E: de::Error,
320            {
321                if v > u32::MAX as u64 {
322                    Ok(Retry::INFINITE)
323                } else {
324                    Ok(Retry(v as u32))
325                }
326            }
327        }
328
329        deserializer.deserialize_any(RetryVisitor)
330    }
331}