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