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
12pub use crate::config_types::{
14 CpuLimit, CronRetrigger, Dir, MemoryLimit, OnOutputHook, PitchforkTomlAuto, PitchforkTomlCron,
15 PitchforkTomlHooks, PortBump, PortConfig, ReadyHttp, Retry, StopConfig, StopSignal, WatchMode,
16};
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct SlugEntryRaw {
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub dir: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub namespace: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none", default)]
35 pub daemon: Option<String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct SlugEntry {
41 pub dir: Option<PathBuf>,
43 pub namespace: Option<String>,
45 pub daemon: Option<String>,
47}
48
49impl SlugEntry {
50 pub fn resolve_dir(&self) -> Option<PathBuf> {
53 self.dir.clone().or_else(|| {
54 self.namespace.as_ref().and_then(|ns| {
55 let namespaces = PitchforkToml::read_global_namespaces();
56 namespaces.get(ns).map(|entry| entry.dir.clone())
57 })
58 })
59 }
60
61 pub fn resolve_namespace(&self) -> Option<String> {
64 self.namespace.clone().or_else(|| {
65 self.resolve_dir()
66 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
67 })
68 }
69}
70
71#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct GroupEntryRaw {
78 pub daemons: Vec<String>,
79}
80
81#[derive(Debug, Clone)]
83pub struct GroupEntry {
84 pub daemons: Vec<DaemonId>,
85}
86
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct NamespaceEntryRaw {
94 pub dir: String,
96}
97
98#[derive(Debug, Clone)]
100pub struct NamespaceEntry {
101 pub dir: PathBuf,
103}
104
105#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
107struct PitchforkTomlRaw {
108 #[serde(skip_serializing_if = "Option::is_none", default)]
109 pub namespace: Option<String>,
110 #[serde(default)]
111 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
112 #[serde(default)]
113 pub settings: Option<SettingsPartial>,
114 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
117 pub slugs: IndexMap<String, SlugEntryRaw>,
118 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
120 pub groups: IndexMap<String, GroupEntryRaw>,
121 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
124 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
125}
126
127#[derive(Debug, serde::Serialize, serde::Deserialize)]
134struct PitchforkTomlDaemonRaw {
135 pub run: String,
136 #[serde(skip_serializing_if = "Vec::is_empty", default)]
137 pub auto: Vec<PitchforkTomlAuto>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
139 pub cron: Option<PitchforkTomlCron>,
140 #[serde(default)]
141 pub retry: Retry,
142 #[serde(skip_serializing_if = "Option::is_none", default)]
143 pub ready_delay: Option<u64>,
144 #[serde(skip_serializing_if = "Option::is_none", default)]
145 pub ready_output: Option<String>,
146 #[serde(skip_serializing_if = "Option::is_none", default)]
147 pub ready_http: Option<ReadyHttp>,
148 #[serde(skip_serializing_if = "Option::is_none", default)]
149 pub ready_port: Option<u16>,
150 #[serde(skip_serializing_if = "Option::is_none", default)]
151 pub ready_cmd: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub port: Option<PortConfig>,
155 #[serde(skip_serializing_if = "Vec::is_empty", default)]
157 pub expected_port: Vec<u16>,
158 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub auto_bump_port: Option<bool>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub port_bump_attempts: Option<u32>,
164 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub boot_start: Option<bool>,
166 #[serde(skip_serializing_if = "Vec::is_empty", default)]
167 pub depends: Vec<String>,
168 #[serde(skip_serializing_if = "Vec::is_empty", default)]
169 pub watch: Vec<String>,
170 #[serde(skip_serializing_if = "Option::is_none", default)]
171 pub watch_mode: Option<WatchMode>,
172 #[serde(skip_serializing_if = "Option::is_none", default)]
173 pub dir: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none", default)]
175 pub env: Option<IndexMap<String, String>>,
176 #[serde(skip_serializing_if = "Option::is_none", default)]
177 pub hooks: Option<PitchforkTomlHooks>,
178 #[serde(skip_serializing_if = "Option::is_none", default)]
179 pub mise: Option<bool>,
180 #[serde(skip_serializing_if = "Option::is_none", default)]
182 pub user: Option<String>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub memory_limit: Option<MemoryLimit>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub cpu_limit: Option<CpuLimit>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub stop_signal: Option<StopConfig>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub pty: Option<bool>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub time_retention: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub line_retention: Option<i64>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub archive_hook: Option<String>,
207}
208
209#[derive(Debug, Default, JsonSchema)]
214#[schemars(title = "Pitchfork Configuration")]
215pub struct PitchforkToml {
216 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
218 pub namespace: Option<String>,
223 #[serde(default)]
232 pub(crate) settings: SettingsPartial,
233 #[schemars(skip)]
238 pub slugs: IndexMap<String, SlugEntry>,
239 #[schemars(skip)]
241 pub groups: IndexMap<String, GroupEntry>,
242 #[schemars(skip)]
245 pub namespaces: IndexMap<String, NamespaceEntry>,
246 #[schemars(skip)]
247 pub path: Option<PathBuf>,
248}
249
250pub(crate) fn is_global_config(path: &Path) -> bool {
251 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
252}
253
254fn is_local_config(path: &Path) -> bool {
255 path.file_name()
256 .map(|n| n == "pitchfork.local.toml")
257 .unwrap_or(false)
258}
259
260pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
261 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
262}
263
264fn sibling_base_config(path: &Path) -> Option<PathBuf> {
265 if !is_local_config(path) {
266 return None;
267 }
268 path.parent().map(|p| p.join("pitchfork.toml"))
269}
270
271fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
272 use toml::Value;
273
274 let doc: Value = toml::from_str(content)
275 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
276 let Some(value) = doc.get("namespace") else {
277 return Ok(None);
278 };
279
280 match value {
281 Value::String(s) => Ok(Some(s.clone())),
282 _ => Err(ConfigParseError::InvalidNamespace {
283 path: path.to_path_buf(),
284 namespace: value.to_string(),
285 reason: "top-level 'namespace' must be a string".to_string(),
286 }
287 .into()),
288 }
289}
290
291fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
292 if !path.exists() {
293 return Ok(None);
294 }
295 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
296 path: path.to_path_buf(),
297 source: e,
298 })?;
299 parse_namespace_override_from_content(path, &content)
300}
301
302fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
303 if let Err(e) = DaemonId::try_new(namespace, "probe") {
304 return Err(ConfigParseError::InvalidNamespace {
305 path: path.to_path_buf(),
306 namespace: namespace.to_string(),
307 reason: e.to_string(),
308 }
309 .into());
310 }
311 Ok(namespace.to_string())
312}
313
314fn derive_namespace_from_dir(path: &Path) -> Result<String> {
315 let dir_for_namespace = if is_dot_config_pitchfork(path) {
316 path.parent().and_then(|p| p.parent())
317 } else {
318 path.parent()
319 };
320
321 let raw_namespace = dir_for_namespace
322 .and_then(|p| p.file_name())
323 .and_then(|n| n.to_str())
324 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
325 .to_string();
326
327 validate_namespace(path, &raw_namespace).map_err(|e| {
328 ConfigParseError::InvalidNamespace {
329 path: path.to_path_buf(),
330 namespace: raw_namespace,
331 reason: format!(
332 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
333 ),
334 }
335 .into()
336 })
337}
338
339fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
340 if is_global_config(path) {
341 if let Some(ns) = explicit
342 && ns != "global"
343 {
344 return Err(ConfigParseError::InvalidNamespace {
345 path: path.to_path_buf(),
346 namespace: ns.to_string(),
347 reason: "global config files must use namespace 'global'".to_string(),
348 }
349 .into());
350 }
351 return Ok("global".to_string());
352 }
353
354 if let Some(ns) = explicit {
355 return validate_namespace(path, ns);
356 }
357
358 derive_namespace_from_dir(path)
359}
360
361fn namespace_from_file(path: &Path) -> Result<String> {
362 let explicit = read_namespace_override_from_file(path)?;
363 let base_explicit = sibling_base_config(path)
364 .and_then(|p| if p.exists() { Some(p) } else { None })
365 .map(|p| read_namespace_override_from_file(&p))
366 .transpose()?
367 .flatten();
368
369 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
370 && local_ns != base_ns
371 {
372 return Err(ConfigParseError::InvalidNamespace {
373 path: path.to_path_buf(),
374 namespace: local_ns.to_string(),
375 reason: format!(
376 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
377 ),
378 }
379 .into());
380 }
381
382 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
383 namespace_from_path_with_override(path, effective_explicit)
384}
385
386pub fn namespace_from_path(path: &Path) -> Result<String> {
399 namespace_from_file(path)
400}
401
402impl PitchforkToml {
403 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
416 if user_id.contains('/') {
418 return match DaemonId::parse(user_id) {
419 Ok(id) => Ok(vec![id]),
420 Err(e) => Err(e), };
422 }
423
424 let global_slugs = Self::read_global_slugs();
426 if let Some(entry) = global_slugs.get(user_id) {
427 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
429 if let Some(dir) = entry.resolve_dir() {
430 if let Ok(project_config) = Self::all_merged_from(&dir) {
431 let matches: Vec<DaemonId> = project_config
433 .daemons
434 .keys()
435 .filter(|id| id.name() == daemon_name)
436 .cloned()
437 .collect();
438 match matches.as_slice() {
439 [] => {}
440 [id] => return Ok(vec![id.clone()]),
441 _ => {
442 let mut candidates: Vec<String> =
443 matches.iter().map(|id| id.qualified()).collect();
444 candidates.sort();
445 return Err(miette::miette!(
446 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
447 user_id,
448 daemon_name,
449 candidates.join(", ")
450 ));
451 }
452 }
453 }
454 }
455 }
456
457 let matches: Vec<DaemonId> = self
459 .daemons
460 .keys()
461 .filter(|id| id.name() == user_id)
462 .cloned()
463 .collect();
464
465 if matches.is_empty() {
466 let state_matches = Self::find_in_state_file(user_id);
468 match state_matches.as_slice() {
469 [] => {}
470 [id] => return Ok(vec![id.clone()]),
471 _ => {
472 let mut candidates: Vec<String> =
473 state_matches.iter().map(|id| id.qualified()).collect();
474 candidates.sort();
475 return Err(miette::miette!(
476 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
477 user_id,
478 candidates.join(", ")
479 ));
480 }
481 }
482 let _ = DaemonId::try_new("global", user_id)?;
484 }
485 Ok(matches)
486 }
487
488 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
494 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
495 Ok(state) => state
496 .daemons
497 .keys()
498 .filter(|id| id.name() == short_name)
499 .cloned()
500 .collect(),
501 Err(e) => {
502 warn!("cannot read state file: {e}");
503 Vec::new()
504 }
505 }
506 }
507
508 #[allow(dead_code)]
529 pub fn resolve_daemon_id_prefer_local(
530 &self,
531 user_id: &str,
532 current_dir: &Path,
533 ) -> Result<DaemonId> {
534 if user_id.contains('/') {
536 return DaemonId::parse(user_id);
537 }
538
539 let current_namespace = Self::namespace_for_dir(current_dir)?;
543
544 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
545 }
546
547 fn resolve_daemon_id_with_namespace(
550 &self,
551 user_id: &str,
552 current_namespace: &str,
553 ) -> Result<DaemonId> {
554 let global_slugs = Self::read_global_slugs();
556 if let Some(entry) = global_slugs.get(user_id) {
557 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
558 if let Some(dir) = entry.resolve_dir() {
559 if let Ok(project_config) = Self::all_merged_from(&dir) {
560 let matches: Vec<DaemonId> = project_config
561 .daemons
562 .keys()
563 .filter(|id| id.name() == daemon_name)
564 .cloned()
565 .collect();
566 match matches.as_slice() {
567 [] => {}
568 [id] => return Ok(id.clone()),
569 _ => {
570 let mut candidates: Vec<String> =
571 matches.iter().map(|id| id.qualified()).collect();
572 candidates.sort();
573 return Err(miette::miette!(
574 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
575 user_id,
576 daemon_name,
577 candidates.join(", ")
578 ));
579 }
580 }
581 }
582 }
583 }
584
585 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
588 if self.daemons.contains_key(&preferred_id) {
589 return Ok(preferred_id);
590 }
591
592 let matches = self.resolve_daemon_id(user_id)?;
594
595 if matches.len() > 1 {
597 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
598 candidates.sort();
599 return Err(miette::miette!(
600 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
601 user_id,
602 candidates.join(", ")
603 ));
604 }
605
606 if let Some(id) = matches.into_iter().next() {
607 return Ok(id);
608 }
609
610 let global_id = DaemonId::try_new("global", user_id)?;
613 if self.daemons.contains_key(&global_id) {
614 return Ok(global_id);
615 }
616
617 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
618 Err(DependencyError::DaemonNotFound {
619 name: user_id.to_string(),
620 suggestion,
621 }
622 .into())
623 }
624
625 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
628 Ok(Self::list_paths_from(dir)
629 .iter()
630 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
632 .transpose()?
633 .unwrap_or_else(|| "global".to_string()))
634 }
635
636 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
646 if user_id.contains('/') {
647 return DaemonId::parse(user_id);
648 }
649
650 let config = Self::all_merged()?;
653 let ns = Self::namespace_for_dir(&env::CWD)?;
654 config.resolve_daemon_id_with_namespace(user_id, &ns)
655 }
656
657 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
663 if user_id.contains('/') {
664 return DaemonId::parse(user_id);
665 }
666
667 let config = Self::all_merged()?;
668 let ns = Self::namespace_for_dir(&env::CWD)?;
669
670 let preferred_id = DaemonId::try_new(&ns, user_id)?;
671 if config.daemons.contains_key(&preferred_id) {
672 return Ok(preferred_id);
673 }
674
675 let matches = config.resolve_daemon_id(user_id)?;
676 if matches.len() > 1 {
677 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
678 candidates.sort();
679 return Err(miette::miette!(
680 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
681 user_id,
682 candidates.join(", ")
683 ));
684 }
685 if let Some(id) = matches.into_iter().next() {
686 return Ok(id);
687 }
688
689 DaemonId::try_new("global", user_id)
690 }
691
692 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
703 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
705 return user_ids
706 .iter()
707 .map(|s| DaemonId::parse(s.as_ref()))
708 .collect();
709 }
710
711 let config = Self::all_merged()?;
712 let ns = Self::namespace_for_dir(&env::CWD)?;
714 user_ids
715 .iter()
716 .map(|s| {
717 let id = s.as_ref();
718 if id.contains('/') {
719 DaemonId::parse(id)
720 } else {
721 config.resolve_daemon_id_with_namespace(id, &ns)
722 }
723 })
724 .collect()
725 }
726
727 pub fn resolve_ids_and_group<S: AsRef<str>>(
732 user_ids: &[S],
733 group_name: Option<&str>,
734 ) -> Result<Vec<DaemonId>> {
735 let config = Self::all_merged()?;
736 let ns = Self::namespace_for_dir(&env::CWD)?;
737 let mut ids = Vec::new();
738 let mut seen = std::collections::HashSet::new();
739
740 for id in user_ids {
741 let id_str = id.as_ref();
742 let daemon_id = if id_str.contains('/') {
743 DaemonId::parse(id_str)?
744 } else {
745 config.resolve_daemon_id_with_namespace(id_str, &ns)?
746 };
747 if seen.insert(daemon_id.clone()) {
748 ids.push(daemon_id);
749 }
750 }
751
752 if let Some(name) = group_name {
753 match config.groups.get(name) {
754 Some(group) => {
755 let missing: Vec<String> = group
756 .daemons
757 .iter()
758 .filter(|id| !config.daemons.contains_key(*id))
759 .map(|id| id.qualified())
760 .collect();
761 if !missing.is_empty() {
762 return Err(miette::miette!(
763 "group '{}' references undefined daemon{}: {}",
764 name,
765 if missing.len() > 1 { "s" } else { "" },
766 missing.join(", ")
767 ));
768 }
769 for daemon_id in &group.daemons {
770 if seen.insert(daemon_id.clone()) {
771 ids.push(daemon_id.clone());
772 }
773 }
774 }
775 None => {
776 let suggestion =
777 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
778 return Err(miette::miette!(
779 "group '{}' not found in configuration{}",
780 name,
781 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
782 ));
783 }
784 }
785 }
786
787 Ok(ids)
788 }
789
790 pub fn list_paths() -> Vec<PathBuf> {
793 Self::list_paths_from(&env::CWD)
794 }
795
796 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
807 let mut paths = Vec::new();
808 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
809 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
810
811 let mut project_paths = xx::file::find_up_all(
815 cwd,
816 &[
817 "pitchfork.local.toml",
818 "pitchfork.toml",
819 ".config/pitchfork.local.toml",
820 ".config/pitchfork.toml",
821 ],
822 );
823 project_paths.reverse();
824 paths.extend(project_paths);
825
826 paths
827 }
828
829 pub fn all_merged() -> Result<PitchforkToml> {
832 Self::all_merged_from(&env::CWD)
833 }
834 pub fn all_merged_all_namespaces() -> Result<Self> {
841 let mut pt = Self::all_merged_from(&env::CWD)?;
842
843 let namespaces = Self::read_global_namespaces();
844 for (ns_name, entry) in namespaces {
845 match Self::all_merged_from(&entry.dir) {
846 Ok(ns_config) => {
847 for (daemon_id, daemon_config) in ns_config.daemons {
848 if !pt.daemons.contains_key(&daemon_id) {
849 pt.daemons.insert(daemon_id, daemon_config);
850 }
851 }
852 pt.settings.merge_from(&ns_config.settings);
855 }
856 Err(e) => {
857 log::warn!(
858 "Failed to load namespace '{ns_name}' from {}: {e}",
859 entry.dir.display()
860 );
861 }
862 }
863 }
864
865 Ok(pt)
866 }
867
868 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
882 use std::collections::HashMap;
883
884 let paths = Self::list_paths_from(cwd);
885 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
886
887 let mut pt = Self::default();
888 for p in paths {
889 match Self::read(&p) {
890 Ok(pt2) => {
891 if p.exists() && !is_global_config(&p) {
895 let ns = namespace_from_path(&p)?;
896 let origin_dir = if is_dot_config_pitchfork(&p) {
897 p.parent().and_then(|d| d.parent())
898 } else {
899 p.parent()
900 }
901 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
902 .unwrap_or_else(|| p.clone());
903
904 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
905 && *other_dir != origin_dir
906 {
907 return Err(crate::error::ConfigParseError::NamespaceCollision {
908 path_a: other_path.clone(),
909 path_b: p.clone(),
910 ns,
911 }
912 .into());
913 }
914 ns_to_origin.insert(ns, (p.clone(), origin_dir));
915 }
916
917 pt.merge(pt2)
918 }
919 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
920 }
921 }
922 Ok(pt)
923 }
924}
925
926impl PitchforkToml {
927 pub fn new(path: PathBuf) -> Self {
928 Self {
929 daemons: Default::default(),
930 namespace: None,
931 settings: SettingsPartial::default(),
932 slugs: IndexMap::new(),
933 groups: IndexMap::new(),
934 namespaces: IndexMap::new(),
935 path: Some(path),
936 }
937 }
938
939 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
947 let raw_config: PitchforkTomlRaw = toml::from_str(content)
948 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
949
950 let namespace = {
951 let base_explicit = sibling_base_config(path)
952 .and_then(|p| if p.exists() { Some(p) } else { None })
953 .map(|p| read_namespace_override_from_file(&p))
954 .transpose()?
955 .flatten();
956
957 if is_local_config(path)
958 && let (Some(local_ns), Some(base_ns)) =
959 (raw_config.namespace.as_deref(), base_explicit.as_deref())
960 && local_ns != base_ns
961 {
962 return Err(ConfigParseError::InvalidNamespace {
963 path: path.to_path_buf(),
964 namespace: local_ns.to_string(),
965 reason: format!(
966 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
967 ),
968 }
969 .into());
970 }
971
972 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
973 namespace_from_path_with_override(path, explicit)?
974 };
975 let mut pt = Self::new(path.to_path_buf());
976 pt.namespace = raw_config.namespace.clone();
977
978 for (short_name, raw_daemon) in raw_config.daemons {
979 let id = match DaemonId::try_new(&namespace, &short_name) {
980 Ok(id) => id,
981 Err(e) => {
982 return Err(ConfigParseError::InvalidDaemonName {
983 name: short_name,
984 path: path.to_path_buf(),
985 reason: e.to_string(),
986 }
987 .into());
988 }
989 };
990
991 let mut depends = Vec::new();
992 for dep in raw_daemon.depends {
993 let dep_id = if dep.contains('/') {
994 match DaemonId::parse(&dep) {
995 Ok(id) => id,
996 Err(e) => {
997 return Err(ConfigParseError::InvalidDependency {
998 daemon: short_name.clone(),
999 dependency: dep,
1000 path: path.to_path_buf(),
1001 reason: e.to_string(),
1002 }
1003 .into());
1004 }
1005 }
1006 } else {
1007 match DaemonId::try_new(&namespace, &dep) {
1008 Ok(id) => id,
1009 Err(e) => {
1010 return Err(ConfigParseError::InvalidDependency {
1011 daemon: short_name.clone(),
1012 dependency: dep,
1013 path: path.to_path_buf(),
1014 reason: e.to_string(),
1015 }
1016 .into());
1017 }
1018 }
1019 };
1020 depends.push(dep_id);
1021 }
1022
1023 let has_deprecated = !raw_daemon.expected_port.is_empty()
1025 || raw_daemon.auto_bump_port.is_some()
1026 || raw_daemon.port_bump_attempts.is_some();
1027 let port = if let Some(port) = raw_daemon.port {
1028 if has_deprecated {
1029 warn!(
1030 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1031 );
1032 }
1033 Some(port)
1034 } else if has_deprecated {
1035 warn!(
1036 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1037 );
1038 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1039 PortBump(
1040 raw_daemon
1041 .port_bump_attempts
1042 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1043 )
1044 } else {
1045 PortBump(0)
1046 };
1047 Some(PortConfig {
1048 expect: raw_daemon.expected_port,
1049 bump,
1050 })
1051 } else {
1052 None
1053 };
1054
1055 let daemon = PitchforkTomlDaemon {
1056 run: raw_daemon.run,
1057 auto: raw_daemon.auto,
1058 cron: raw_daemon.cron,
1059 retry: raw_daemon.retry,
1060 ready_delay: raw_daemon.ready_delay,
1061 ready_output: raw_daemon.ready_output,
1062 ready_http: raw_daemon.ready_http,
1063 ready_port: raw_daemon.ready_port,
1064 ready_cmd: raw_daemon.ready_cmd,
1065 port,
1066 boot_start: raw_daemon.boot_start,
1067 depends,
1068 watch: raw_daemon.watch,
1069 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1070 dir: raw_daemon.dir,
1071 env: raw_daemon.env,
1072 hooks: raw_daemon.hooks,
1073 mise: raw_daemon.mise,
1074 user: raw_daemon.user,
1075 memory_limit: raw_daemon.memory_limit,
1076 cpu_limit: raw_daemon.cpu_limit,
1077 stop_signal: raw_daemon.stop_signal,
1078 pty: raw_daemon.pty,
1079 time_retention: raw_daemon.time_retention,
1080 line_retention: raw_daemon.line_retention,
1081 archive_hook: raw_daemon.archive_hook,
1082 path: Some(path.to_path_buf()),
1083 };
1084 pt.daemons.insert(id, daemon);
1085 }
1086
1087 if let Some(settings) = raw_config.settings {
1089 pt.settings = settings;
1090 }
1091
1092 for (slug, entry) in raw_config.slugs {
1094 pt.slugs.insert(
1095 slug,
1096 SlugEntry {
1097 dir: entry.dir.map(PathBuf::from),
1098 namespace: entry.namespace,
1099 daemon: entry.daemon,
1100 },
1101 );
1102 }
1103
1104 for (name, entry) in raw_config.namespaces {
1106 pt.namespaces.insert(
1107 name,
1108 NamespaceEntry {
1109 dir: PathBuf::from(entry.dir),
1110 },
1111 );
1112 }
1113
1114 for (group_name, raw_group) in raw_config.groups {
1116 let mut daemons = Vec::new();
1117 for daemon_name in &raw_group.daemons {
1118 let id = if daemon_name.contains('/') {
1119 DaemonId::parse(daemon_name).map_err(|e| {
1120 ConfigParseError::InvalidDependency {
1121 daemon: group_name.clone(),
1122 dependency: daemon_name.clone(),
1123 path: path.to_path_buf(),
1124 reason: e.to_string(),
1125 }
1126 })?
1127 } else {
1128 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1129 ConfigParseError::InvalidDaemonName {
1130 name: daemon_name.clone(),
1131 path: path.to_path_buf(),
1132 reason: e.to_string(),
1133 }
1134 })?
1135 };
1136 daemons.push(id);
1137 }
1138 pt.groups.insert(group_name, GroupEntry { daemons });
1139 }
1140
1141 Ok(pt)
1142 }
1143
1144 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1145 let path = path.as_ref();
1146 if !path.exists() {
1147 return Ok(Self::new(path.to_path_buf()));
1148 }
1149 let _lock = xx::fslock::get(path, false)
1150 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1151 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1152 path: path.to_path_buf(),
1153 source: e,
1154 })?;
1155 Self::parse_str(&raw, path)
1156 }
1157
1158 pub fn write(&self) -> Result<()> {
1159 if let Some(path) = &self.path {
1160 let _lock = xx::fslock::get(path, false)
1161 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1162 self.write_unlocked()
1163 } else {
1164 Err(FileError::NoPath.into())
1165 }
1166 }
1167
1168 fn write_unlocked(&self) -> Result<()> {
1174 if let Some(path) = &self.path {
1175 let config_namespace = if path.exists() {
1177 namespace_from_path(path)?
1178 } else {
1179 namespace_from_path_with_override(path, self.namespace.as_deref())?
1180 };
1181
1182 let mut raw = PitchforkTomlRaw {
1184 namespace: self.namespace.clone(),
1185 ..PitchforkTomlRaw::default()
1186 };
1187 for (id, daemon) in &self.daemons {
1188 if id.namespace() != config_namespace {
1189 return Err(miette::miette!(
1190 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1191 id,
1192 path.display(),
1193 id.namespace(),
1194 config_namespace
1195 ));
1196 }
1197 let port = daemon.port.as_ref();
1198 let raw_daemon = PitchforkTomlDaemonRaw {
1199 run: daemon.run.clone(),
1200 auto: daemon.auto.clone(),
1201 cron: daemon.cron.clone(),
1202 retry: daemon.retry,
1203 ready_delay: daemon.ready_delay,
1204 ready_output: daemon.ready_output.clone(),
1205 ready_http: daemon.ready_http.clone(),
1206 ready_port: daemon.ready_port,
1207 ready_cmd: daemon.ready_cmd.clone(),
1208 port: port.cloned(),
1209 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1211 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1212 port_bump_attempts: port
1213 .filter(|p| p.auto_bump())
1214 .map(|p| p.max_bump_attempts()),
1215 boot_start: daemon.boot_start,
1216 depends: daemon
1219 .depends
1220 .iter()
1221 .map(|d| {
1222 if d.namespace() == config_namespace {
1223 d.name().to_string()
1224 } else {
1225 d.qualified()
1226 }
1227 })
1228 .collect(),
1229 watch: daemon.watch.clone(),
1230 watch_mode: match daemon.watch_mode {
1231 WatchMode::Native => None,
1232 mode => Some(mode),
1233 },
1234 dir: daemon.dir.clone(),
1235 env: daemon.env.clone(),
1236 hooks: daemon.hooks.clone(),
1237 mise: daemon.mise,
1238 user: daemon.user.clone(),
1239 memory_limit: daemon.memory_limit,
1240 cpu_limit: daemon.cpu_limit,
1241 stop_signal: daemon.stop_signal,
1242 pty: daemon.pty,
1243 time_retention: daemon.time_retention.clone(),
1244 line_retention: daemon.line_retention,
1245 archive_hook: daemon.archive_hook.clone(),
1246 };
1247 raw.daemons.insert(id.name().to_string(), raw_daemon);
1248 }
1249
1250 for (slug, entry) in &self.slugs {
1252 raw.slugs.insert(
1253 slug.clone(),
1254 SlugEntryRaw {
1255 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1256 namespace: entry.namespace.clone(),
1257 daemon: entry.daemon.clone(),
1258 },
1259 );
1260 }
1261
1262 for (name, group) in &self.groups {
1264 let raw_daemons: Vec<String> = group
1265 .daemons
1266 .iter()
1267 .map(|id| {
1268 if id.namespace() == config_namespace {
1269 id.name().to_string()
1270 } else {
1271 id.qualified()
1272 }
1273 })
1274 .collect();
1275 raw.groups.insert(
1276 name.clone(),
1277 GroupEntryRaw {
1278 daemons: raw_daemons,
1279 },
1280 );
1281 }
1282
1283 for (name, entry) in &self.namespaces {
1285 raw.namespaces.insert(
1286 name.clone(),
1287 NamespaceEntryRaw {
1288 dir: entry.dir.to_string_lossy().to_string(),
1289 },
1290 );
1291 }
1292
1293 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1294 path: path.clone(),
1295 source: e,
1296 })?;
1297 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1298 path: path.clone(),
1299 details: Some(e.to_string()),
1300 })?;
1301 Ok(())
1302 } else {
1303 Err(FileError::NoPath.into())
1304 }
1305 }
1306
1307 pub fn merge(&mut self, pt: Self) {
1312 for (id, d) in pt.daemons {
1313 self.daemons.insert(id, d);
1314 }
1315 for (slug, entry) in pt.slugs {
1317 self.slugs.insert(slug, entry);
1318 }
1319 for (name, group) in pt.groups {
1321 self.groups.insert(name, group);
1322 }
1323 for (name, entry) in pt.namespaces {
1325 self.namespaces.insert(name, entry);
1326 }
1327 self.settings.merge_from(&pt.settings);
1329 }
1330
1331 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1336 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1337 Ok(pt) => pt.slugs,
1338 Err(_) => IndexMap::new(),
1339 }
1340 }
1341
1342 pub fn find_slug_for_daemon_in_registry(
1344 daemon_id: &DaemonId,
1345 global_slugs: &IndexMap<String, SlugEntry>,
1346 ) -> Option<String> {
1347 global_slugs
1348 .iter()
1349 .find(|(slug, entry)| {
1350 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1351 if daemon_id.name() != daemon_name {
1352 return false;
1353 }
1354
1355 match entry.resolve_namespace() {
1356 Some(namespace) => daemon_id.namespace() == namespace,
1357 None => false,
1358 }
1359 })
1360 .map(|(slug, _)| slug.clone())
1361 }
1362
1363 #[allow(dead_code)]
1365 pub fn is_slug_registered(slug: &str) -> bool {
1366 Self::read_global_slugs().contains_key(slug)
1367 }
1368
1369 pub fn add_slug_with_namespace(
1375 slug: &str,
1376 namespace: Option<&str>,
1377 daemon: Option<&str>,
1378 ) -> Result<()> {
1379 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1380
1381 if let Some(parent) = global_path.parent() {
1383 std::fs::create_dir_all(parent).map_err(|e| {
1384 miette::miette!(
1385 "Failed to create config directory {}: {e}",
1386 parent.display()
1387 )
1388 })?;
1389 }
1390
1391 let _lock = xx::fslock::get(global_path, false)
1392 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1393
1394 let mut pt = if global_path.exists() {
1395 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1396 path: global_path.to_path_buf(),
1397 source: e,
1398 })?;
1399 Self::parse_str(&raw, global_path)?
1400 } else {
1401 Self::new(global_path.to_path_buf())
1402 };
1403
1404 if let Some(ns) = namespace {
1408 if !pt.namespaces.contains_key(ns) {
1409 let dir = pt
1410 .slugs
1411 .get(slug)
1412 .and_then(|e| e.resolve_dir())
1413 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1414 if let Some(ref d) = dir {
1415 pt.namespaces
1416 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1417 }
1418 }
1419 }
1420
1421 pt.slugs.insert(
1422 slug.to_string(),
1423 SlugEntry {
1424 dir: None,
1425 namespace: namespace.map(str::to_string),
1426 daemon: daemon.map(str::to_string),
1427 },
1428 );
1429 pt.write_unlocked()?;
1430 crate::proxy::hosts::sync_hosts_from_settings();
1431 Ok(())
1432 }
1433
1434 pub fn remove_slug(slug: &str) -> Result<bool> {
1436 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1437 if !global_path.exists() {
1438 return Ok(false);
1439 }
1440
1441 let _lock = xx::fslock::get(global_path, false)
1442 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1443
1444 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1445 path: global_path.to_path_buf(),
1446 source: e,
1447 })?;
1448 let mut pt = Self::parse_str(&raw, global_path)?;
1449
1450 let removed = pt.slugs.shift_remove(slug).is_some();
1451 if removed {
1452 pt.write_unlocked()?;
1453 crate::proxy::hosts::sync_hosts_from_settings();
1454 }
1455 Ok(removed)
1456 }
1457 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1460 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1461 Ok(pt) => pt.namespaces,
1462 Err(_) => IndexMap::new(),
1463 }
1464 }
1465
1466 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1470 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1471
1472 if let Some(parent) = global_path.parent() {
1474 std::fs::create_dir_all(parent).map_err(|e| {
1475 miette::miette!(
1476 "Failed to create config directory {}: {e}",
1477 parent.display()
1478 )
1479 })?;
1480 }
1481
1482 let _lock = xx::fslock::get(global_path, false)
1483 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1484
1485 let mut pt = if global_path.exists() {
1486 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1487 crate::error::FileError::ReadError {
1488 path: global_path.to_path_buf(),
1489 source: e,
1490 }
1491 })?;
1492 Self::parse_str(&raw, global_path)?
1493 } else {
1494 Self::new(global_path.to_path_buf())
1495 };
1496
1497 pt.namespaces.insert(
1498 name.to_string(),
1499 NamespaceEntry {
1500 dir: PathBuf::from(dir),
1501 },
1502 );
1503 pt.write_unlocked()?;
1504 Ok(())
1505 }
1506
1507 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1509 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1510 if !global_path.exists() {
1511 return Ok(false);
1512 }
1513
1514 let _lock = xx::fslock::get(global_path, false)
1515 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1516
1517 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1518 crate::error::FileError::ReadError {
1519 path: global_path.to_path_buf(),
1520 source: e,
1521 }
1522 })?;
1523 let mut pt = Self::parse_str(&raw, global_path)?;
1524
1525 let removed = pt.namespaces.shift_remove(name).is_some();
1526 if removed {
1527 pt.write_unlocked()?;
1528 }
1529 Ok(removed)
1530 }
1531}
1532
1533#[derive(Debug, Clone, JsonSchema, Default)]
1535pub struct PitchforkTomlDaemon {
1536 #[schemars(example = example_run_command())]
1538 pub run: String,
1539 #[schemars(default)]
1541 pub auto: Vec<PitchforkTomlAuto>,
1542 pub cron: Option<PitchforkTomlCron>,
1544 #[schemars(default)]
1547 pub retry: Retry,
1548 pub ready_delay: Option<u64>,
1550 pub ready_output: Option<String>,
1552 pub ready_http: Option<ReadyHttp>,
1554 #[schemars(range(min = 1, max = 65535))]
1556 pub ready_port: Option<u16>,
1557 pub ready_cmd: Option<String>,
1559 pub port: Option<PortConfig>,
1561 pub boot_start: Option<bool>,
1563 #[schemars(default)]
1565 pub depends: Vec<DaemonId>,
1566 #[schemars(default)]
1568 pub watch: Vec<String>,
1569 #[schemars(default)]
1575 pub watch_mode: WatchMode,
1576 pub dir: Option<String>,
1578 pub env: Option<IndexMap<String, String>>,
1580 pub hooks: Option<PitchforkTomlHooks>,
1582 pub mise: Option<bool>,
1585 pub user: Option<String>,
1587 pub memory_limit: Option<MemoryLimit>,
1590 pub cpu_limit: Option<CpuLimit>,
1593 pub stop_signal: Option<StopConfig>,
1596 pub pty: Option<bool>,
1598 pub time_retention: Option<String>,
1601 pub line_retention: Option<i64>,
1604 pub archive_hook: Option<String>,
1607 #[schemars(skip)]
1608 pub path: Option<PathBuf>,
1609}
1610
1611impl PitchforkTomlDaemon {
1612 pub fn to_run_options(
1617 &self,
1618 id: &crate::daemon_id::DaemonId,
1619 cmd: Vec<String>,
1620 ) -> crate::daemon::RunOptions {
1621 use crate::daemon::RunOptions;
1622
1623 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1624 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1625 .into_iter()
1626 .find(|(slug, entry)| {
1627 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1628 if daemon_name != id.name() {
1629 return false;
1630 }
1631
1632 match entry.resolve_namespace() {
1633 Some(namespace) => namespace == id.namespace(),
1634 None => false,
1635 }
1636 })
1637 .map(|(slug, _)| slug);
1638
1639 RunOptions {
1640 id: id.clone(),
1641 cmd,
1642 run: Some(self.run.clone()),
1643 force: false,
1644 shell_pid: None,
1645 dir: Dir(dir),
1646 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1647 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1648 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1649 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1650 retry: self.retry,
1651 retry_count: 0,
1652 ready_delay: self.ready_delay,
1653 ready_output: self.ready_output.clone(),
1654 ready_http: self.ready_http.clone(),
1655 ready_port: self.ready_port,
1656 ready_cmd: self.ready_cmd.clone(),
1657 port: self.port.clone(),
1658 wait_ready: false,
1659 depends: self.depends.clone(),
1660 env: self.env.clone(),
1661 watch: self.watch.clone(),
1662 watch_mode: self.watch_mode,
1663 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1664 self.path.as_deref(),
1665 )),
1666 mise: self.mise,
1667 slug,
1668 proxy: None,
1669 user: self.user.clone(),
1670 memory_limit: self.memory_limit,
1671 cpu_limit: self.cpu_limit,
1672 stop_signal: self.stop_signal,
1673 archive_hook: self.archive_hook.clone(),
1674 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1675 pty: self.pty,
1676 }
1677 }
1678}
1679fn example_run_command() -> &'static str {
1680 "exec node server.js"
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685 use super::*;
1686 use std::path::Path;
1687
1688 #[test]
1689 fn test_daemon_user_parses_and_flows_to_run_options() {
1690 let pt = PitchforkToml::parse_str(
1691 r#"
1692[daemons.api]
1693run = "node server.js"
1694user = "postgres"
1695"#,
1696 Path::new("/tmp/my-project/pitchfork.toml"),
1697 )
1698 .unwrap();
1699
1700 let id = DaemonId::new("my-project", "api");
1701 let daemon = pt.daemons.get(&id).unwrap();
1702 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1703
1704 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1705 assert_eq!(opts.user.as_deref(), Some("postgres"));
1706 }
1707
1708 #[test]
1709 fn test_daemon_user_write_roundtrip() {
1710 let temp = tempfile::tempdir().unwrap();
1711 let path = temp.path().join("pitchfork.toml");
1712 let mut pt = PitchforkToml::new(path.clone());
1713 pt.namespace = Some("test-project".to_string());
1714 pt.daemons.insert(
1715 DaemonId::new("test-project", "api"),
1716 PitchforkTomlDaemon {
1717 run: "node server.js".to_string(),
1718 user: Some("postgres".to_string()),
1719 ..PitchforkTomlDaemon::default()
1720 },
1721 );
1722
1723 pt.write().unwrap();
1724
1725 let raw = std::fs::read_to_string(&path).unwrap();
1726 assert!(raw.contains("user = \"postgres\""));
1727
1728 let parsed = PitchforkToml::read(&path).unwrap();
1729 let daemon = parsed
1730 .daemons
1731 .get(&DaemonId::new("test-project", "api"))
1732 .unwrap();
1733 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1734 }
1735}