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