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