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