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