Skip to main content

pitchfork_cli/
pitchfork_toml.rs

1use crate::daemon_id::DaemonId;
2use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
3use crate::settings::SettingsPartial;
4use crate::settings::settings;
5use crate::state_file::StateFile;
6use crate::{Result, env};
7use humanbyte::HumanByte;
8use indexmap::IndexMap;
9use miette::Context;
10use schemars::JsonSchema;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::path::{Path, PathBuf};
13
14/// A byte-size type that accepts human-readable strings like "50MB", "1GiB", etc.
15///
16/// Backed by `u64` and uses the `humanbyte` crate for parsing and display.
17/// Used for `memory_limit` configuration in daemon definitions.
18#[derive(Clone, Copy, PartialEq, Eq, HumanByte)]
19pub struct MemoryLimit(pub u64);
20
21impl JsonSchema for MemoryLimit {
22    fn schema_name() -> std::borrow::Cow<'static, str> {
23        std::borrow::Cow::Borrowed("MemoryLimit")
24    }
25
26    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
27        schemars::json_schema!({
28            "type": "string",
29            "description": "Memory limit in human-readable format, e.g. '50MB', '1GiB', '512KB'"
30        })
31    }
32}
33
34/// A CPU usage limit expressed as a percentage (e.g. `80.0` means 80% of one CPU core).
35///
36/// The supervisor periodically checks each daemon's CPU usage and kills processes
37/// that exceed this limit. Values above 100% are valid on multi-core systems
38/// (e.g. `200.0` allows up to 2 full cores).
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct CpuLimit(pub f32);
41
42impl std::fmt::Display for CpuLimit {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "{}%", self.0)
45    }
46}
47
48impl Serialize for CpuLimit {
49    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
50    where
51        S: Serializer,
52    {
53        serializer.serialize_f64(self.0 as f64)
54    }
55}
56
57impl<'de> Deserialize<'de> for CpuLimit {
58    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
59    where
60        D: Deserializer<'de>,
61    {
62        let v = f64::deserialize(deserializer)?;
63        if v <= 0.0 {
64            return Err(serde::de::Error::custom("cpu_limit must be positive"));
65        }
66        Ok(CpuLimit(v as f32))
67    }
68}
69
70impl JsonSchema for CpuLimit {
71    fn schema_name() -> std::borrow::Cow<'static, str> {
72        std::borrow::Cow::Borrowed("CpuLimit")
73    }
74
75    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
76        schemars::json_schema!({
77            "type": "number",
78            "description": "CPU usage limit as a percentage (e.g. 80 for 80% of one core, 200 for 2 cores)",
79            "exclusiveMinimum": 0
80        })
81    }
82}
83
84/// Raw slug entry as read from TOML (uses String for dir path).
85/// Format in global config:
86/// ```toml
87/// [slugs]
88/// api = { dir = "/home/user/my-api", daemon = "server" }
89/// docs = { dir = "/home/user/docs-site" }  # daemon defaults to slug name
90/// ```
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct SlugEntryRaw {
93    /// Project directory containing the pitchfork.toml
94    pub dir: String,
95    /// Daemon name within that project (defaults to slug name if omitted)
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub daemon: Option<String>,
98}
99
100/// Resolved slug entry with PathBuf.
101#[derive(Debug, Clone)]
102pub struct SlugEntry {
103    /// Project directory containing the pitchfork.toml
104    pub dir: PathBuf,
105    /// Daemon name within that project (defaults to slug name if omitted)
106    pub daemon: Option<String>,
107}
108
109/// Internal structure for reading config files (uses String keys for short daemon names)
110#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
111struct PitchforkTomlRaw {
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub namespace: Option<String>,
114    #[serde(default)]
115    pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
116    #[serde(default)]
117    pub settings: Option<SettingsPartial>,
118    /// Slug registry (only meaningful in global config).
119    /// Maps slug names to their configuration (dir + optional daemon name).
120    #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
121    pub slugs: IndexMap<String, SlugEntryRaw>,
122}
123
124/// Internal daemon config for reading (uses String for depends).
125///
126/// Note: This struct mirrors `PitchforkTomlDaemon` but uses `Vec<String>` for `depends`
127/// (before namespace resolution) and has serde attributes for TOML serialization.
128/// When adding new fields, remember to update both structs and the conversion code
129/// in `read()` and `write()`.
130#[derive(Debug, serde::Serialize, serde::Deserialize)]
131struct PitchforkTomlDaemonRaw {
132    pub run: String,
133    #[serde(skip_serializing_if = "Vec::is_empty", default)]
134    pub auto: Vec<PitchforkTomlAuto>,
135    #[serde(skip_serializing_if = "Option::is_none", default)]
136    pub cron: Option<PitchforkTomlCron>,
137    #[serde(default)]
138    pub retry: Retry,
139    #[serde(skip_serializing_if = "Option::is_none", default)]
140    pub ready_delay: Option<u64>,
141    #[serde(skip_serializing_if = "Option::is_none", default)]
142    pub ready_output: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub ready_http: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub ready_port: Option<u16>,
147    #[serde(skip_serializing_if = "Option::is_none", default)]
148    pub ready_cmd: Option<String>,
149    #[serde(skip_serializing_if = "Vec::is_empty", default)]
150    pub expected_port: Vec<u16>,
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub auto_bump_port: Option<bool>,
153    #[serde(skip_serializing_if = "Option::is_none", default)]
154    pub port_bump_attempts: Option<u32>,
155    #[serde(skip_serializing_if = "Option::is_none", default)]
156    pub boot_start: Option<bool>,
157    #[serde(skip_serializing_if = "Vec::is_empty", default)]
158    pub depends: Vec<String>,
159    #[serde(skip_serializing_if = "Vec::is_empty", default)]
160    pub watch: Vec<String>,
161    #[serde(skip_serializing_if = "Option::is_none", default)]
162    pub dir: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none", default)]
164    pub env: Option<IndexMap<String, String>>,
165    #[serde(skip_serializing_if = "Option::is_none", default)]
166    pub hooks: Option<PitchforkTomlHooks>,
167    #[serde(skip_serializing_if = "Option::is_none", default)]
168    pub mise: Option<bool>,
169    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
170    #[serde(skip_serializing_if = "Option::is_none", default)]
171    pub memory_limit: Option<MemoryLimit>,
172    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
173    #[serde(skip_serializing_if = "Option::is_none", default)]
174    pub cpu_limit: Option<CpuLimit>,
175}
176
177/// Configuration schema for pitchfork.toml daemon supervisor configuration files.
178///
179/// Note: When read from a file, daemon keys are short names (e.g., "api").
180/// After merging, keys become qualified DaemonIds (e.g., "project/api").
181#[derive(Debug, Default, JsonSchema)]
182#[schemars(title = "Pitchfork Configuration")]
183pub struct PitchforkToml {
184    /// Map of daemon IDs to their configurations
185    pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
186    /// Optional explicit namespace declared in this file.
187    ///
188    /// This applies to per-file read/write flows. Merged configs may contain
189    /// daemons from multiple namespaces and leave this as `None`.
190    pub namespace: Option<String>,
191    /// Settings configuration (merged from all config files).
192    ///
193    /// **Note:** This field exists for serialization round-trips and for
194    /// `PitchforkToml::merge()` to collect per-file overrides.  It is **not**
195    /// consumed by the global `settings()` singleton, which is populated
196    /// independently by `Settings::load()` to avoid a circular dependency
197    /// between `PitchforkToml` and `Settings`.  Do not rely on mutations to
198    /// this field being reflected in `settings()`.
199    #[serde(default)]
200    pub(crate) settings: SettingsPartial,
201    /// Slug registry (merged from global config files).
202    /// Maps slug names to their project directory and optional daemon name.
203    /// Only populated from global config files (`~/.config/pitchfork/config.toml`
204    /// or `/etc/pitchfork/config.toml`).
205    #[schemars(skip)]
206    pub slugs: IndexMap<String, SlugEntry>,
207    #[schemars(skip)]
208    pub path: Option<PathBuf>,
209}
210
211pub(crate) fn is_global_config(path: &Path) -> bool {
212    path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
213}
214
215fn is_local_config(path: &Path) -> bool {
216    path.file_name()
217        .map(|n| n == "pitchfork.local.toml")
218        .unwrap_or(false)
219}
220
221pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
222    path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
223}
224
225fn sibling_base_config(path: &Path) -> Option<PathBuf> {
226    if !is_local_config(path) {
227        return None;
228    }
229    path.parent().map(|p| p.join("pitchfork.toml"))
230}
231
232fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
233    use toml::Value;
234
235    let doc: Value = toml::from_str(content)
236        .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
237    let Some(value) = doc.get("namespace") else {
238        return Ok(None);
239    };
240
241    match value {
242        Value::String(s) => Ok(Some(s.clone())),
243        _ => Err(ConfigParseError::InvalidNamespace {
244            path: path.to_path_buf(),
245            namespace: value.to_string(),
246            reason: "top-level 'namespace' must be a string".to_string(),
247        }
248        .into()),
249    }
250}
251
252fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
253    if !path.exists() {
254        return Ok(None);
255    }
256    let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
257        path: path.to_path_buf(),
258        source: e,
259    })?;
260    parse_namespace_override_from_content(path, &content)
261}
262
263fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
264    if let Err(e) = DaemonId::try_new(namespace, "probe") {
265        return Err(ConfigParseError::InvalidNamespace {
266            path: path.to_path_buf(),
267            namespace: namespace.to_string(),
268            reason: e.to_string(),
269        }
270        .into());
271    }
272    Ok(namespace.to_string())
273}
274
275fn derive_namespace_from_dir(path: &Path) -> Result<String> {
276    let dir_for_namespace = if is_dot_config_pitchfork(path) {
277        path.parent().and_then(|p| p.parent())
278    } else {
279        path.parent()
280    };
281
282    let raw_namespace = dir_for_namespace
283        .and_then(|p| p.file_name())
284        .and_then(|n| n.to_str())
285        .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
286        .to_string();
287
288    validate_namespace(path, &raw_namespace).map_err(|e| {
289        ConfigParseError::InvalidNamespace {
290            path: path.to_path_buf(),
291            namespace: raw_namespace,
292            reason: format!(
293                "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
294            ),
295        }
296        .into()
297    })
298}
299
300fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
301    if is_global_config(path) {
302        if let Some(ns) = explicit
303            && ns != "global"
304        {
305            return Err(ConfigParseError::InvalidNamespace {
306                path: path.to_path_buf(),
307                namespace: ns.to_string(),
308                reason: "global config files must use namespace 'global'".to_string(),
309            }
310            .into());
311        }
312        return Ok("global".to_string());
313    }
314
315    if let Some(ns) = explicit {
316        return validate_namespace(path, ns);
317    }
318
319    derive_namespace_from_dir(path)
320}
321
322fn namespace_from_file(path: &Path) -> Result<String> {
323    let explicit = read_namespace_override_from_file(path)?;
324    let base_explicit = sibling_base_config(path)
325        .and_then(|p| if p.exists() { Some(p) } else { None })
326        .map(|p| read_namespace_override_from_file(&p))
327        .transpose()?
328        .flatten();
329
330    if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
331        && local_ns != base_ns
332    {
333        return Err(ConfigParseError::InvalidNamespace {
334            path: path.to_path_buf(),
335            namespace: local_ns.to_string(),
336            reason: format!(
337                "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
338            ),
339        }
340        .into());
341    }
342
343    let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
344    namespace_from_path_with_override(path, effective_explicit)
345}
346
347/// Extracts a namespace from a config file path.
348///
349/// - For user global config (`~/.config/pitchfork/config.toml`): returns "global"
350/// - For system global config (`/etc/pitchfork/config.toml`): returns "global"
351/// - For project configs: uses top-level `namespace` if present, otherwise parent directory name
352///
353/// Examples:
354/// - `~/.config/pitchfork/config.toml` → `"global"`
355/// - `/etc/pitchfork/config.toml` → `"global"`
356/// - `/home/user/project-a/pitchfork.toml` → `"project-a"`
357/// - `/home/user/project-b/sub/pitchfork.toml` → `"sub"`
358/// - `/home/user/中文目录/pitchfork.toml` → error unless `namespace = "..."` is set
359pub fn namespace_from_path(path: &Path) -> Result<String> {
360    namespace_from_file(path)
361}
362
363impl PitchforkToml {
364    /// Resolves a user-provided daemon ID to qualified DaemonIds.
365    ///
366    /// If the ID is already qualified (contains '/'), parses and returns it.
367    /// Otherwise, looks up the short ID in the config and returns
368    /// matching qualified IDs.
369    ///
370    /// # Arguments
371    /// * `user_id` - The daemon ID provided by the user
372    ///
373    /// # Returns
374    /// A Result containing a vector of matching DaemonIds (usually one, but could be multiple
375    /// if the same short ID exists in multiple namespaces), or an error if the ID is invalid.
376    pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
377        // If already qualified, parse and return
378        if user_id.contains('/') {
379            return match DaemonId::parse(user_id) {
380                Ok(id) => Ok(vec![id]),
381                Err(e) => Err(e), // Invalid format - propagate error
382            };
383        }
384
385        // Check for slug match in global slugs registry
386        let global_slugs = Self::read_global_slugs();
387        if let Some(entry) = global_slugs.get(user_id) {
388            // Load the project's config from the slug's dir to find the daemon ID
389            let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
390            if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
391                // Find daemon by short name in that project
392                let matches: Vec<DaemonId> = project_config
393                    .daemons
394                    .keys()
395                    .filter(|id| id.name() == daemon_name)
396                    .cloned()
397                    .collect();
398                match matches.as_slice() {
399                    [] => {}
400                    [id] => return Ok(vec![id.clone()]),
401                    _ => {
402                        let mut candidates: Vec<String> =
403                            matches.iter().map(|id| id.qualified()).collect();
404                        candidates.sort();
405                        return Err(miette::miette!(
406                            "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
407                            user_id,
408                            daemon_name,
409                            candidates.join(", ")
410                        ));
411                    }
412                }
413            }
414        }
415
416        // Look for matching qualified IDs in the config
417        let matches: Vec<DaemonId> = self
418            .daemons
419            .keys()
420            .filter(|id| id.name() == user_id)
421            .cloned()
422            .collect();
423
424        if matches.is_empty() {
425            // No config matches. Validate short ID format and return no matches.
426            let _ = DaemonId::try_new("global", user_id)?;
427        }
428        Ok(matches)
429    }
430
431    /// Resolves a user-provided daemon ID to a qualified DaemonId, preferring the current directory's namespace.
432    ///
433    /// If the ID is already qualified (contains '/'), parses and returns it.
434    /// Otherwise, tries to find a daemon in the current directory's namespace first.
435    /// Falls back to any matching daemon if not found in current namespace.
436    ///
437    /// # Arguments
438    /// * `user_id` - The daemon ID provided by the user
439    /// * `current_dir` - The current working directory (used to determine namespace preference)
440    ///
441    /// # Returns
442    /// The resolved DaemonId, or an error if the ID format is invalid
443    ///
444    /// # Errors
445    /// Returns an error if `user_id` contains '/' but is not a valid qualified ID
446    /// (e.g., "foo/bar/baz" with multiple slashes), or if `user_id` contains invalid characters.
447    ///
448    /// # Warnings
449    /// If multiple daemons match the short name and none is in the current namespace,
450    /// a warning is logged to stderr indicating the ambiguity.
451    #[allow(dead_code)]
452    pub fn resolve_daemon_id_prefer_local(
453        &self,
454        user_id: &str,
455        current_dir: &Path,
456    ) -> Result<DaemonId> {
457        // If already qualified, parse and return (or error if invalid)
458        if user_id.contains('/') {
459            return DaemonId::parse(user_id);
460        }
461
462        // Determine the current directory's namespace by finding the nearest
463        // pitchfork.toml. Cache the namespace in the caller when resolving
464        // multiple IDs to avoid repeated filesystem traversal.
465        let current_namespace = Self::namespace_for_dir(current_dir)?;
466
467        self.resolve_daemon_id_with_namespace(user_id, &current_namespace)
468    }
469
470    /// Like `resolve_daemon_id_prefer_local` but accepts a pre-computed namespace,
471    /// avoiding redundant filesystem traversal when resolving multiple IDs.
472    fn resolve_daemon_id_with_namespace(
473        &self,
474        user_id: &str,
475        current_namespace: &str,
476    ) -> Result<DaemonId> {
477        // Check for slug match in global slugs registry
478        let global_slugs = Self::read_global_slugs();
479        if let Some(entry) = global_slugs.get(user_id) {
480            let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
481            if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
482                let matches: Vec<DaemonId> = project_config
483                    .daemons
484                    .keys()
485                    .filter(|id| id.name() == daemon_name)
486                    .cloned()
487                    .collect();
488                match matches.as_slice() {
489                    [] => {}
490                    [id] => return Ok(id.clone()),
491                    _ => {
492                        let mut candidates: Vec<String> =
493                            matches.iter().map(|id| id.qualified()).collect();
494                        candidates.sort();
495                        return Err(miette::miette!(
496                            "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
497                            user_id,
498                            daemon_name,
499                            candidates.join(", ")
500                        ));
501                    }
502                }
503            }
504        }
505
506        // Try to find the daemon in the current namespace first
507        // Use try_new to validate user input
508        let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
509        if self.daemons.contains_key(&preferred_id) {
510            return Ok(preferred_id);
511        }
512
513        // Fall back to any matching daemon
514        let matches = self.resolve_daemon_id(user_id)?;
515
516        // Error on ambiguity instead of implicitly preferring global.
517        if matches.len() > 1 {
518            let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
519            candidates.sort();
520            return Err(miette::miette!(
521                "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
522                user_id,
523                candidates.join(", ")
524            ));
525        }
526
527        if let Some(id) = matches.into_iter().next() {
528            return Ok(id);
529        }
530
531        // If not found in current namespace or merged config matches, only fall back
532        // to global when it is explicitly configured.
533        let global_id = DaemonId::try_new("global", user_id)?;
534        if self.daemons.contains_key(&global_id) {
535            return Ok(global_id);
536        }
537
538        // Also allow existing ad-hoc daemons (persisted in state file) to be
539        // referenced by short ID. This keeps commands like status/restart/stop
540        // working for daemons started via `pitchfork run`.
541        if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
542            && state.daemons.contains_key(&global_id)
543        {
544            return Ok(global_id);
545        }
546
547        let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
548        Err(DependencyError::DaemonNotFound {
549            name: user_id.to_string(),
550            suggestion,
551        }
552        .into())
553    }
554
555    /// Returns the effective namespace for the given directory by finding
556    /// the nearest config file. Traverses the filesystem at most once per call.
557    pub fn namespace_for_dir(dir: &Path) -> Result<String> {
558        Ok(Self::list_paths_from(dir)
559            .iter()
560            .rfind(|p| p.exists()) // most specific (closest) config
561            .map(|p| namespace_from_path(p))
562            .transpose()?
563            .unwrap_or_else(|| "global".to_string()))
564    }
565
566    /// Convenience method: resolves a single user ID using the merged config and current directory.
567    ///
568    /// Equivalent to:
569    /// ```ignore
570    /// PitchforkToml::all_merged().resolve_daemon_id_prefer_local(user_id, &env::CWD)
571    /// ```
572    ///
573    /// # Errors
574    /// Returns an error if `user_id` contains '/' but is not a valid qualified ID
575    pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
576        if user_id.contains('/') {
577            return DaemonId::parse(user_id);
578        }
579
580        // Compute the namespace once and reuse it — avoids a second traversal
581        // inside resolve_daemon_id_prefer_local.
582        let config = Self::all_merged()?;
583        let ns = Self::namespace_for_dir(&env::CWD)?;
584        config.resolve_daemon_id_with_namespace(user_id, &ns)
585    }
586
587    /// Like `resolve_id`, but allows ad-hoc short IDs by falling back to
588    /// `global/<id>` when no configured daemon matches.
589    ///
590    /// This is intended for commands such as `pitchfork run` that create
591    /// managed daemons without requiring prior config entries.
592    pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
593        if user_id.contains('/') {
594            return DaemonId::parse(user_id);
595        }
596
597        let config = Self::all_merged()?;
598        let ns = Self::namespace_for_dir(&env::CWD)?;
599
600        let preferred_id = DaemonId::try_new(&ns, user_id)?;
601        if config.daemons.contains_key(&preferred_id) {
602            return Ok(preferred_id);
603        }
604
605        let matches = config.resolve_daemon_id(user_id)?;
606        if matches.len() > 1 {
607            let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
608            candidates.sort();
609            return Err(miette::miette!(
610                "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
611                user_id,
612                candidates.join(", ")
613            ));
614        }
615        if let Some(id) = matches.into_iter().next() {
616            return Ok(id);
617        }
618
619        DaemonId::try_new("global", user_id)
620    }
621
622    /// Convenience method: resolves multiple user IDs using the merged config and current directory.
623    ///
624    /// Equivalent to:
625    /// ```ignore
626    /// let config = PitchforkToml::all_merged();
627    /// ids.iter().map(|s| config.resolve_daemon_id_prefer_local(s, &env::CWD)).collect()
628    /// ```
629    ///
630    /// # Errors
631    /// Returns an error if any ID is malformed
632    pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
633        // Fast path: all IDs are already qualified and can be parsed directly.
634        if user_ids.iter().all(|s| s.as_ref().contains('/')) {
635            return user_ids
636                .iter()
637                .map(|s| DaemonId::parse(s.as_ref()))
638                .collect();
639        }
640
641        let config = Self::all_merged()?;
642        // Compute namespace once for all IDs
643        let ns = Self::namespace_for_dir(&env::CWD)?;
644        user_ids
645            .iter()
646            .map(|s| {
647                let id = s.as_ref();
648                if id.contains('/') {
649                    DaemonId::parse(id)
650                } else {
651                    config.resolve_daemon_id_with_namespace(id, &ns)
652                }
653            })
654            .collect()
655    }
656
657    /// List all configuration file paths from the current working directory.
658    /// See `list_paths_from` for details on the search order.
659    pub fn list_paths() -> Vec<PathBuf> {
660        Self::list_paths_from(&env::CWD)
661    }
662
663    /// List all configuration file paths starting from a given directory.
664    ///
665    /// Returns paths in order of precedence (lowest to highest):
666    /// 1. System-level: /etc/pitchfork/config.toml
667    /// 2. User-level: ~/.config/pitchfork/config.toml
668    /// 3. Project-level: .config/pitchfork.toml, .config/pitchfork.local.toml, pitchfork.toml and pitchfork.local.toml files
669    ///    from filesystem root to the given directory
670    ///
671    /// Within each directory, .config/ comes before pitchfork.toml,
672    /// which comes before pitchfork.local.toml, so local.toml values override base config.
673    pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
674        let mut paths = Vec::new();
675        paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
676        paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
677
678        // Find all project config files. Order is reversed so after .reverse():
679        // - each directory has: .config/pitchfork.toml < .config/pitchfork.local.toml < pitchfork.toml < pitchfork.local.toml
680        // - directories go from root to cwd (later configs override earlier)
681        let mut project_paths = xx::file::find_up_all(
682            cwd,
683            &[
684                "pitchfork.local.toml",
685                "pitchfork.toml",
686                ".config/pitchfork.local.toml",
687                ".config/pitchfork.toml",
688            ],
689        );
690        project_paths.reverse();
691        paths.extend(project_paths);
692
693        paths
694    }
695
696    /// Merge all configuration files from the current working directory.
697    /// See `all_merged_from` for details.
698    pub fn all_merged() -> Result<PitchforkToml> {
699        Self::all_merged_from(&env::CWD)
700    }
701
702    /// Merge all configuration files starting from a given directory.
703    ///
704    /// Reads and merges configuration files in precedence order.
705    /// Each daemon ID is qualified with a namespace based on its config file location:
706    /// - Global configs (`~/.config/pitchfork/config.toml`) use namespace "global"
707    /// - Project configs use the parent directory name as namespace
708    ///
709    /// This prevents ID conflicts when multiple projects define daemons with the same name.
710    ///
711    /// # Errors
712    /// Prints (but does not abort) if a config file cannot be read. Aborts with an error
713    /// if two *different* project config files produce the same namespace (e.g. two
714    /// `pitchfork.toml` files in separate directories that share the same directory name).
715    pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
716        use std::collections::HashMap;
717
718        let paths = Self::list_paths_from(cwd);
719        let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
720
721        let mut pt = Self::default();
722        for p in paths {
723            match Self::read(&p) {
724                Ok(pt2) => {
725                    // Detect collisions for all existing project configs, including
726                    // pitchfork.local.toml. Allow sibling base/local files in the same
727                    // directory to share a namespace, including siblings via .config subfolder
728                    if p.exists() && !is_global_config(&p) {
729                        let ns = namespace_from_path(&p)?;
730                        let origin_dir = if is_dot_config_pitchfork(&p) {
731                            p.parent().and_then(|d| d.parent())
732                        } else {
733                            p.parent()
734                        }
735                        .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
736                        .unwrap_or_else(|| p.clone());
737
738                        if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
739                            && *other_dir != origin_dir
740                        {
741                            return Err(crate::error::ConfigParseError::NamespaceCollision {
742                                path_a: other_path.clone(),
743                                path_b: p.clone(),
744                                ns,
745                            }
746                            .into());
747                        }
748                        ns_to_origin.insert(ns, (p.clone(), origin_dir));
749                    }
750
751                    pt.merge(pt2)
752                }
753                Err(e) => eprintln!("error reading {}: {}", p.display(), e),
754            }
755        }
756        Ok(pt)
757    }
758}
759
760impl PitchforkToml {
761    pub fn new(path: PathBuf) -> Self {
762        Self {
763            daemons: Default::default(),
764            namespace: None,
765            settings: SettingsPartial::default(),
766            slugs: IndexMap::new(),
767            path: Some(path),
768        }
769    }
770
771    /// Parse TOML content as a [`PitchforkToml`] without touching the filesystem.
772    ///
773    /// Applies the same namespace derivation and daemon validation as [`read()`] but
774    /// uses the provided `content` directly instead of reading from disk.  `path` is
775    /// used only for namespace derivation and error messages.
776    ///
777    /// This is useful for validating user-edited content before saving it.
778    pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
779        let raw_config: PitchforkTomlRaw = toml::from_str(content)
780            .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
781
782        let namespace = {
783            let base_explicit = sibling_base_config(path)
784                .and_then(|p| if p.exists() { Some(p) } else { None })
785                .map(|p| read_namespace_override_from_file(&p))
786                .transpose()?
787                .flatten();
788
789            if is_local_config(path)
790                && let (Some(local_ns), Some(base_ns)) =
791                    (raw_config.namespace.as_deref(), base_explicit.as_deref())
792                && local_ns != base_ns
793            {
794                return Err(ConfigParseError::InvalidNamespace {
795                    path: path.to_path_buf(),
796                    namespace: local_ns.to_string(),
797                    reason: format!(
798                        "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
799                    ),
800                }
801                .into());
802            }
803
804            let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
805            namespace_from_path_with_override(path, explicit)?
806        };
807        let mut pt = Self::new(path.to_path_buf());
808        pt.namespace = raw_config.namespace.clone();
809
810        for (short_name, raw_daemon) in raw_config.daemons {
811            let id = match DaemonId::try_new(&namespace, &short_name) {
812                Ok(id) => id,
813                Err(e) => {
814                    return Err(ConfigParseError::InvalidDaemonName {
815                        name: short_name,
816                        path: path.to_path_buf(),
817                        reason: e.to_string(),
818                    }
819                    .into());
820                }
821            };
822
823            let mut depends = Vec::new();
824            for dep in raw_daemon.depends {
825                let dep_id = if dep.contains('/') {
826                    match DaemonId::parse(&dep) {
827                        Ok(id) => id,
828                        Err(e) => {
829                            return Err(ConfigParseError::InvalidDependency {
830                                daemon: short_name.clone(),
831                                dependency: dep,
832                                path: path.to_path_buf(),
833                                reason: e.to_string(),
834                            }
835                            .into());
836                        }
837                    }
838                } else {
839                    match DaemonId::try_new(&namespace, &dep) {
840                        Ok(id) => id,
841                        Err(e) => {
842                            return Err(ConfigParseError::InvalidDependency {
843                                daemon: short_name.clone(),
844                                dependency: dep,
845                                path: path.to_path_buf(),
846                                reason: e.to_string(),
847                            }
848                            .into());
849                        }
850                    }
851                };
852                depends.push(dep_id);
853            }
854
855            let daemon = PitchforkTomlDaemon {
856                run: raw_daemon.run,
857                auto: raw_daemon.auto,
858                cron: raw_daemon.cron,
859                retry: raw_daemon.retry,
860                ready_delay: raw_daemon.ready_delay,
861                ready_output: raw_daemon.ready_output,
862                ready_http: raw_daemon.ready_http,
863                ready_port: raw_daemon.ready_port,
864                ready_cmd: raw_daemon.ready_cmd,
865                expected_port: raw_daemon.expected_port,
866                auto_bump_port: raw_daemon.auto_bump_port.unwrap_or(false),
867                port_bump_attempts: raw_daemon
868                    .port_bump_attempts
869                    .unwrap_or_else(|| settings().default_port_bump_attempts()),
870                boot_start: raw_daemon.boot_start,
871                depends,
872                watch: raw_daemon.watch,
873                dir: raw_daemon.dir,
874                env: raw_daemon.env,
875                hooks: raw_daemon.hooks,
876                mise: raw_daemon.mise,
877                memory_limit: raw_daemon.memory_limit,
878                cpu_limit: raw_daemon.cpu_limit,
879                path: Some(path.to_path_buf()),
880            };
881            pt.daemons.insert(id, daemon);
882        }
883
884        // Copy settings if present
885        if let Some(settings) = raw_config.settings {
886            pt.settings = settings;
887        }
888
889        // Copy slugs registry (only meaningful in global config files)
890        for (slug, entry) in raw_config.slugs {
891            pt.slugs.insert(
892                slug,
893                SlugEntry {
894                    dir: PathBuf::from(entry.dir),
895                    daemon: entry.daemon,
896                },
897            );
898        }
899
900        Ok(pt)
901    }
902
903    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
904        let path = path.as_ref();
905        if !path.exists() {
906            return Ok(Self::new(path.to_path_buf()));
907        }
908        let _lock = xx::fslock::get(path, false)
909            .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
910        let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
911            path: path.to_path_buf(),
912            source: e,
913        })?;
914        Self::parse_str(&raw, path)
915    }
916
917    pub fn write(&self) -> Result<()> {
918        if let Some(path) = &self.path {
919            let _lock = xx::fslock::get(path, false)
920                .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
921            self.write_unlocked()
922        } else {
923            Err(FileError::NoPath.into())
924        }
925    }
926
927    /// Write the config file without acquiring a file lock.
928    ///
929    /// The caller MUST hold the file lock (via `xx::fslock::get`) before
930    /// calling this method. This is used by `register_slug` which needs to
931    /// hold a single lock across a read-modify-write cycle.
932    fn write_unlocked(&self) -> Result<()> {
933        if let Some(path) = &self.path {
934            // Determine the namespace for this config file
935            let config_namespace = if path.exists() {
936                namespace_from_path(path)?
937            } else {
938                namespace_from_path_with_override(path, self.namespace.as_deref())?
939            };
940
941            // Convert back to raw format for writing (use short names as keys)
942            let mut raw = PitchforkTomlRaw {
943                namespace: self.namespace.clone(),
944                ..PitchforkTomlRaw::default()
945            };
946            for (id, daemon) in &self.daemons {
947                if id.namespace() != config_namespace {
948                    return Err(miette::miette!(
949                        "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
950                        id,
951                        path.display(),
952                        id.namespace(),
953                        config_namespace
954                    ));
955                }
956                let raw_daemon = PitchforkTomlDaemonRaw {
957                    run: daemon.run.clone(),
958                    auto: daemon.auto.clone(),
959                    cron: daemon.cron.clone(),
960                    retry: daemon.retry,
961                    ready_delay: daemon.ready_delay,
962                    ready_output: daemon.ready_output.clone(),
963                    ready_http: daemon.ready_http.clone(),
964                    ready_port: daemon.ready_port,
965                    ready_cmd: daemon.ready_cmd.clone(),
966                    expected_port: daemon.expected_port.clone(),
967                    auto_bump_port: Some(daemon.auto_bump_port),
968                    port_bump_attempts: Some(daemon.port_bump_attempts),
969                    boot_start: daemon.boot_start,
970                    // Preserve cross-namespace dependencies: use qualified ID if namespace differs,
971                    // otherwise use short name
972                    depends: daemon
973                        .depends
974                        .iter()
975                        .map(|d| {
976                            if d.namespace() == config_namespace {
977                                d.name().to_string()
978                            } else {
979                                d.qualified()
980                            }
981                        })
982                        .collect(),
983                    watch: daemon.watch.clone(),
984                    dir: daemon.dir.clone(),
985                    env: daemon.env.clone(),
986                    hooks: daemon.hooks.clone(),
987                    mise: daemon.mise,
988                    memory_limit: daemon.memory_limit,
989                    cpu_limit: daemon.cpu_limit,
990                };
991                raw.daemons.insert(id.name().to_string(), raw_daemon);
992            }
993
994            // Copy slugs registry to raw format
995            for (slug, entry) in &self.slugs {
996                raw.slugs.insert(
997                    slug.clone(),
998                    SlugEntryRaw {
999                        dir: entry.dir.to_string_lossy().to_string(),
1000                        daemon: entry.daemon.clone(),
1001                    },
1002                );
1003            }
1004
1005            let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1006                path: path.clone(),
1007                source: e,
1008            })?;
1009            xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1010                path: path.clone(),
1011                details: Some(e.to_string()),
1012            })?;
1013            Ok(())
1014        } else {
1015            Err(FileError::NoPath.into())
1016        }
1017    }
1018
1019    /// Simple merge without namespace re-qualification.
1020    /// Used primarily for testing or when merging configs from the same namespace.
1021    /// Since read() already qualifies daemon IDs with namespace, this just inserts them.
1022    /// Settings are also merged - later values override earlier ones.
1023    pub fn merge(&mut self, pt: Self) {
1024        for (id, d) in pt.daemons {
1025            self.daemons.insert(id, d);
1026        }
1027        // Merge slugs - pt's values override self's values
1028        for (slug, entry) in pt.slugs {
1029            self.slugs.insert(slug, entry);
1030        }
1031        // Merge settings - pt's values override self's values
1032        self.settings.merge_from(&pt.settings);
1033    }
1034
1035    /// Read the global slug registry from the user-level global config.
1036    ///
1037    /// Returns a map of slug → SlugEntry from `[slugs]` in
1038    /// `~/.config/pitchfork/config.toml`.
1039    pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1040        match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1041            Ok(pt) => pt.slugs,
1042            Err(_) => IndexMap::new(),
1043        }
1044    }
1045
1046    /// Check if a slug is registered in the global config's `[slugs]` section.
1047    #[allow(dead_code)]
1048    pub fn is_slug_registered(slug: &str) -> bool {
1049        Self::read_global_slugs().contains_key(slug)
1050    }
1051
1052    /// Add a slug entry to the global config's `[slugs]` section.
1053    ///
1054    /// Reads the global config, adds/updates the slug entry, and writes it back.
1055    pub fn add_slug(slug: &str, dir: &Path, daemon: Option<&str>) -> Result<()> {
1056        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1057
1058        // Ensure the config directory exists
1059        if let Some(parent) = global_path.parent() {
1060            std::fs::create_dir_all(parent).map_err(|e| {
1061                miette::miette!(
1062                    "Failed to create config directory {}: {e}",
1063                    parent.display()
1064                )
1065            })?;
1066        }
1067
1068        // Hold a single file lock across the entire read-modify-write cycle to
1069        // prevent TOCTOU races. Without this, another process could modify the
1070        // file between our read() and write() calls, and we'd overwrite its changes.
1071        let _lock = xx::fslock::get(global_path, false)
1072            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1073
1074        let mut pt = if global_path.exists() {
1075            let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1076                path: global_path.to_path_buf(),
1077                source: e,
1078            })?;
1079            Self::parse_str(&raw, global_path)?
1080        } else {
1081            Self::new(global_path.to_path_buf())
1082        };
1083
1084        pt.slugs.insert(
1085            slug.to_string(),
1086            SlugEntry {
1087                dir: dir.to_path_buf(),
1088                daemon: daemon.map(str::to_string),
1089            },
1090        );
1091        pt.write_unlocked()
1092    }
1093
1094    /// Remove a slug from the global config's `[slugs]` section.
1095    pub fn remove_slug(slug: &str) -> Result<bool> {
1096        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1097        if !global_path.exists() {
1098            return Ok(false);
1099        }
1100
1101        let _lock = xx::fslock::get(global_path, false)
1102            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1103
1104        let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1105            path: global_path.to_path_buf(),
1106            source: e,
1107        })?;
1108        let mut pt = Self::parse_str(&raw, global_path)?;
1109
1110        let removed = pt.slugs.shift_remove(slug).is_some();
1111        if removed {
1112            pt.write_unlocked()?;
1113        }
1114        Ok(removed)
1115    }
1116}
1117
1118/// Lifecycle hooks for a daemon
1119#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1120pub struct PitchforkTomlHooks {
1121    /// Command to run when the daemon becomes ready
1122    #[serde(skip_serializing_if = "Option::is_none", default)]
1123    pub on_ready: Option<String>,
1124    /// Command to run when the daemon fails and all retries are exhausted
1125    #[serde(skip_serializing_if = "Option::is_none", default)]
1126    pub on_fail: Option<String>,
1127    /// Command to run before each retry attempt
1128    #[serde(skip_serializing_if = "Option::is_none", default)]
1129    pub on_retry: Option<String>,
1130    /// Command to run when the daemon is explicitly stopped by pitchfork
1131    #[serde(skip_serializing_if = "Option::is_none", default)]
1132    pub on_stop: Option<String>,
1133    /// Command to run on any daemon termination (clean exit, crash, or stop)
1134    #[serde(skip_serializing_if = "Option::is_none", default)]
1135    pub on_exit: Option<String>,
1136}
1137
1138/// Configuration for a single daemon (internal representation with DaemonId)
1139#[derive(Debug, Clone, JsonSchema)]
1140pub struct PitchforkTomlDaemon {
1141    /// The command to run. Prepend with 'exec' to avoid shell process overhead.
1142    #[schemars(example = example_run_command())]
1143    pub run: String,
1144    /// Automatic start/stop behavior based on shell hooks
1145    #[schemars(default)]
1146    pub auto: Vec<PitchforkTomlAuto>,
1147    /// Cron scheduling configuration for periodic execution
1148    pub cron: Option<PitchforkTomlCron>,
1149    /// Number of times to retry if the daemon fails.
1150    /// Can be a number (e.g., `3`) or `true` for infinite retries.
1151    #[schemars(default)]
1152    pub retry: Retry,
1153    /// Delay in seconds before considering the daemon ready
1154    pub ready_delay: Option<u64>,
1155    /// Regex pattern to match in stdout/stderr to determine readiness
1156    pub ready_output: Option<String>,
1157    /// HTTP URL to poll for readiness (expects 2xx response)
1158    pub ready_http: Option<String>,
1159    /// TCP port to check for readiness (connection success = ready)
1160    #[schemars(range(min = 1, max = 65535))]
1161    pub ready_port: Option<u16>,
1162    /// Shell command to poll for readiness (exit code 0 = ready)
1163    pub ready_cmd: Option<String>,
1164    /// TCP ports the daemon is expected to bind to
1165    #[serde(skip_serializing_if = "Vec::is_empty", default)]
1166    pub expected_port: Vec<u16>,
1167    /// Automatically find an available port if the specified port is in use
1168    #[serde(default)]
1169    pub auto_bump_port: bool,
1170    /// Maximum number of port bump attempts when auto_bump_port is enabled (default: 10)
1171    #[serde(default = "default_port_bump_attempts")]
1172    pub port_bump_attempts: u32,
1173    /// Whether to start this daemon automatically on system boot
1174    pub boot_start: Option<bool>,
1175    /// List of daemon IDs that must be started before this one
1176    #[schemars(default)]
1177    pub depends: Vec<DaemonId>,
1178    /// File patterns to watch for changes
1179    #[schemars(default)]
1180    pub watch: Vec<String>,
1181    /// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
1182    pub dir: Option<String>,
1183    /// Environment variables to set for the daemon process
1184    pub env: Option<IndexMap<String, String>>,
1185    /// Lifecycle hooks (on_ready, on_fail, on_retry)
1186    pub hooks: Option<PitchforkTomlHooks>,
1187    /// Wrap this daemon's command with `mise x --` for tool/env setup.
1188    /// Overrides the global `settings.general.mise` when set.
1189    pub mise: Option<bool>,
1190    /// Memory limit for the daemon process (e.g. "50MB", "1GiB").
1191    /// The supervisor periodically monitors RSS and kills the process if it exceeds the limit.
1192    pub memory_limit: Option<MemoryLimit>,
1193    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores).
1194    /// The supervisor periodically monitors CPU usage and kills the process if it exceeds the limit.
1195    pub cpu_limit: Option<CpuLimit>,
1196    #[schemars(skip)]
1197    pub path: Option<PathBuf>,
1198}
1199
1200impl Default for PitchforkTomlDaemon {
1201    fn default() -> Self {
1202        Self {
1203            run: String::new(),
1204            auto: Vec::new(),
1205            cron: None,
1206            retry: Retry::default(),
1207            ready_delay: None,
1208            ready_output: None,
1209            ready_http: None,
1210            ready_port: None,
1211            ready_cmd: None,
1212            expected_port: Vec::new(),
1213            auto_bump_port: false,
1214            port_bump_attempts: 10,
1215            boot_start: None,
1216            depends: Vec::new(),
1217            watch: Vec::new(),
1218            dir: None,
1219            env: None,
1220            hooks: None,
1221            mise: None,
1222            memory_limit: None,
1223            cpu_limit: None,
1224            path: None,
1225        }
1226    }
1227}
1228
1229impl PitchforkTomlDaemon {
1230    /// Build RunOptions from this daemon configuration.
1231    ///
1232    /// Carries over all config fields and resolves the working directory.
1233    /// Callers can override specific fields on the returned value.
1234    pub fn to_run_options(
1235        &self,
1236        id: &crate::daemon_id::DaemonId,
1237        cmd: Vec<String>,
1238    ) -> crate::daemon::RunOptions {
1239        use crate::daemon::RunOptions;
1240
1241        let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1242
1243        RunOptions {
1244            id: id.clone(),
1245            cmd,
1246            force: false,
1247            shell_pid: None,
1248            dir,
1249            autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1250            cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1251            cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1252            retry: self.retry.count(),
1253            retry_count: 0,
1254            ready_delay: self.ready_delay,
1255            ready_output: self.ready_output.clone(),
1256            ready_http: self.ready_http.clone(),
1257            ready_port: self.ready_port,
1258            ready_cmd: self.ready_cmd.clone(),
1259            expected_port: self.expected_port.clone(),
1260            auto_bump_port: self.auto_bump_port,
1261            port_bump_attempts: self.port_bump_attempts,
1262            wait_ready: false,
1263            depends: self.depends.clone(),
1264            env: self.env.clone(),
1265            watch: self.watch.clone(),
1266            watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1267                self.path.as_deref(),
1268            )),
1269            mise: self.mise,
1270            slug: None,
1271            proxy: None,
1272            memory_limit: self.memory_limit,
1273            cpu_limit: self.cpu_limit,
1274        }
1275    }
1276}
1277fn example_run_command() -> &'static str {
1278    "exec node server.js"
1279}
1280
1281fn default_port_bump_attempts() -> u32 {
1282    // Return a hardcoded default to avoid calling settings() during serde
1283    // deserialization, which could cause a OnceLock re-entrancy deadlock.
1284    // The runtime value from settings is applied later at each call site.
1285    10
1286}
1287
1288/// Cron scheduling configuration
1289#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1290pub struct PitchforkTomlCron {
1291    /// Cron expression (e.g., '0 * * * *' for hourly, '*/5 * * * *' for every 5 minutes)
1292    #[schemars(example = example_cron_schedule())]
1293    pub schedule: String,
1294    /// Behavior when cron triggers while previous run is still active
1295    #[serde(default = "default_retrigger")]
1296    pub retrigger: CronRetrigger,
1297}
1298
1299fn default_retrigger() -> CronRetrigger {
1300    CronRetrigger::Finish
1301}
1302
1303fn example_cron_schedule() -> &'static str {
1304    "0 * * * *"
1305}
1306
1307/// Retrigger behavior for cron-scheduled daemons
1308#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
1309#[serde(rename_all = "snake_case")]
1310pub enum CronRetrigger {
1311    /// Retrigger only if the previous run has finished (success or error)
1312    Finish,
1313    /// Always retrigger, stopping the previous run if still active
1314    Always,
1315    /// Retrigger only if the previous run succeeded
1316    Success,
1317    /// Retrigger only if the previous run failed
1318    Fail,
1319}
1320
1321/// Auto start/stop configuration
1322#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
1323#[serde(rename_all = "snake_case")]
1324pub enum PitchforkTomlAuto {
1325    Start,
1326    Stop,
1327}
1328
1329/// Retry configuration that accepts either a boolean or a count.
1330/// - `true` means retry indefinitely (u32::MAX)
1331/// - `false` or `0` means no retries
1332/// - A number means retry that many times
1333#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
1334pub struct Retry(pub u32);
1335
1336impl std::fmt::Display for Retry {
1337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338        if self.is_infinite() {
1339            write!(f, "infinite")
1340        } else {
1341            write!(f, "{}", self.0)
1342        }
1343    }
1344}
1345
1346impl Retry {
1347    pub const INFINITE: Retry = Retry(u32::MAX);
1348
1349    pub fn count(&self) -> u32 {
1350        self.0
1351    }
1352
1353    pub fn is_infinite(&self) -> bool {
1354        self.0 == u32::MAX
1355    }
1356}
1357
1358impl From<u32> for Retry {
1359    fn from(n: u32) -> Self {
1360        Retry(n)
1361    }
1362}
1363
1364impl From<bool> for Retry {
1365    fn from(b: bool) -> Self {
1366        if b { Retry::INFINITE } else { Retry(0) }
1367    }
1368}
1369
1370impl Serialize for Retry {
1371    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1372    where
1373        S: Serializer,
1374    {
1375        // Serialize infinite as true, otherwise as number
1376        if self.is_infinite() {
1377            serializer.serialize_bool(true)
1378        } else {
1379            serializer.serialize_u32(self.0)
1380        }
1381    }
1382}
1383
1384impl<'de> Deserialize<'de> for Retry {
1385    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1386    where
1387        D: Deserializer<'de>,
1388    {
1389        use serde::de::{self, Visitor};
1390
1391        struct RetryVisitor;
1392
1393        impl Visitor<'_> for RetryVisitor {
1394            type Value = Retry;
1395
1396            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1397                formatter.write_str("a boolean or non-negative integer")
1398            }
1399
1400            fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
1401            where
1402                E: de::Error,
1403            {
1404                Ok(Retry::from(v))
1405            }
1406
1407            fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
1408            where
1409                E: de::Error,
1410            {
1411                if v < 0 {
1412                    Err(de::Error::custom("retry count cannot be negative"))
1413                } else if v > u32::MAX as i64 {
1414                    Ok(Retry::INFINITE)
1415                } else {
1416                    Ok(Retry(v as u32))
1417                }
1418            }
1419
1420            fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
1421            where
1422                E: de::Error,
1423            {
1424                if v > u32::MAX as u64 {
1425                    Ok(Retry::INFINITE)
1426                } else {
1427                    Ok(Retry(v as u32))
1428                }
1429            }
1430        }
1431
1432        deserializer.deserialize_any(RetryVisitor)
1433    }
1434}