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}
204
205#[derive(Debug, Default, JsonSchema)]
210#[schemars(title = "Pitchfork Configuration")]
211pub struct PitchforkToml {
212 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
214 pub namespace: Option<String>,
219 #[serde(default)]
228 pub(crate) settings: SettingsPartial,
229 #[schemars(skip)]
234 pub slugs: IndexMap<String, SlugEntry>,
235 #[schemars(skip)]
237 pub groups: IndexMap<String, GroupEntry>,
238 #[schemars(skip)]
241 pub namespaces: IndexMap<String, NamespaceEntry>,
242 #[schemars(skip)]
243 pub path: Option<PathBuf>,
244}
245
246pub(crate) fn is_global_config(path: &Path) -> bool {
247 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
248}
249
250fn is_local_config(path: &Path) -> bool {
251 path.file_name()
252 .map(|n| n == "pitchfork.local.toml")
253 .unwrap_or(false)
254}
255
256pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
257 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
258}
259
260fn sibling_base_config(path: &Path) -> Option<PathBuf> {
261 if !is_local_config(path) {
262 return None;
263 }
264 path.parent().map(|p| p.join("pitchfork.toml"))
265}
266
267fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
268 use toml::Value;
269
270 let doc: Value = toml::from_str(content)
271 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
272 let Some(value) = doc.get("namespace") else {
273 return Ok(None);
274 };
275
276 match value {
277 Value::String(s) => Ok(Some(s.clone())),
278 _ => Err(ConfigParseError::InvalidNamespace {
279 path: path.to_path_buf(),
280 namespace: value.to_string(),
281 reason: "top-level 'namespace' must be a string".to_string(),
282 }
283 .into()),
284 }
285}
286
287fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
288 if !path.exists() {
289 return Ok(None);
290 }
291 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
292 path: path.to_path_buf(),
293 source: e,
294 })?;
295 parse_namespace_override_from_content(path, &content)
296}
297
298fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
299 if let Err(e) = DaemonId::try_new(namespace, "probe") {
300 return Err(ConfigParseError::InvalidNamespace {
301 path: path.to_path_buf(),
302 namespace: namespace.to_string(),
303 reason: e.to_string(),
304 }
305 .into());
306 }
307 Ok(namespace.to_string())
308}
309
310fn derive_namespace_from_dir(path: &Path) -> Result<String> {
311 let dir_for_namespace = if is_dot_config_pitchfork(path) {
312 path.parent().and_then(|p| p.parent())
313 } else {
314 path.parent()
315 };
316
317 let raw_namespace = dir_for_namespace
318 .and_then(|p| p.file_name())
319 .and_then(|n| n.to_str())
320 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
321 .to_string();
322
323 validate_namespace(path, &raw_namespace).map_err(|e| {
324 ConfigParseError::InvalidNamespace {
325 path: path.to_path_buf(),
326 namespace: raw_namespace,
327 reason: format!(
328 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
329 ),
330 }
331 .into()
332 })
333}
334
335fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
336 if is_global_config(path) {
337 if let Some(ns) = explicit
338 && ns != "global"
339 {
340 return Err(ConfigParseError::InvalidNamespace {
341 path: path.to_path_buf(),
342 namespace: ns.to_string(),
343 reason: "global config files must use namespace 'global'".to_string(),
344 }
345 .into());
346 }
347 return Ok("global".to_string());
348 }
349
350 if let Some(ns) = explicit {
351 return validate_namespace(path, ns);
352 }
353
354 derive_namespace_from_dir(path)
355}
356
357fn namespace_from_file(path: &Path) -> Result<String> {
358 let explicit = read_namespace_override_from_file(path)?;
359 let base_explicit = sibling_base_config(path)
360 .and_then(|p| if p.exists() { Some(p) } else { None })
361 .map(|p| read_namespace_override_from_file(&p))
362 .transpose()?
363 .flatten();
364
365 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
366 && local_ns != base_ns
367 {
368 return Err(ConfigParseError::InvalidNamespace {
369 path: path.to_path_buf(),
370 namespace: local_ns.to_string(),
371 reason: format!(
372 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
373 ),
374 }
375 .into());
376 }
377
378 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
379 namespace_from_path_with_override(path, effective_explicit)
380}
381
382pub fn namespace_from_path(path: &Path) -> Result<String> {
395 namespace_from_file(path)
396}
397
398impl PitchforkToml {
399 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
412 if user_id.contains('/') {
414 return match DaemonId::parse(user_id) {
415 Ok(id) => Ok(vec![id]),
416 Err(e) => Err(e), };
418 }
419
420 let global_slugs = Self::read_global_slugs();
422 if let Some(entry) = global_slugs.get(user_id) {
423 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
425 if let Some(dir) = entry.resolve_dir() {
426 if let Ok(project_config) = Self::all_merged_from(&dir) {
427 let matches: Vec<DaemonId> = project_config
429 .daemons
430 .keys()
431 .filter(|id| id.name() == daemon_name)
432 .cloned()
433 .collect();
434 match matches.as_slice() {
435 [] => {}
436 [id] => return Ok(vec![id.clone()]),
437 _ => {
438 let mut candidates: Vec<String> =
439 matches.iter().map(|id| id.qualified()).collect();
440 candidates.sort();
441 return Err(miette::miette!(
442 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
443 user_id,
444 daemon_name,
445 candidates.join(", ")
446 ));
447 }
448 }
449 }
450 }
451 }
452
453 let matches: Vec<DaemonId> = self
455 .daemons
456 .keys()
457 .filter(|id| id.name() == user_id)
458 .cloned()
459 .collect();
460
461 if matches.is_empty() {
462 let state_matches = Self::find_in_state_file(user_id);
464 match state_matches.as_slice() {
465 [] => {}
466 [id] => return Ok(vec![id.clone()]),
467 _ => {
468 let mut candidates: Vec<String> =
469 state_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 let _ = DaemonId::try_new("global", user_id)?;
480 }
481 Ok(matches)
482 }
483
484 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
490 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
491 Ok(state) => state
492 .daemons
493 .keys()
494 .filter(|id| id.name() == short_name)
495 .cloned()
496 .collect(),
497 Err(e) => {
498 warn!("cannot read state file: {e}");
499 Vec::new()
500 }
501 }
502 }
503
504 #[allow(dead_code)]
525 pub fn resolve_daemon_id_prefer_local(
526 &self,
527 user_id: &str,
528 current_dir: &Path,
529 ) -> Result<DaemonId> {
530 if user_id.contains('/') {
532 return DaemonId::parse(user_id);
533 }
534
535 let current_namespace = Self::namespace_for_dir(current_dir)?;
539
540 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
541 }
542
543 fn resolve_daemon_id_with_namespace(
546 &self,
547 user_id: &str,
548 current_namespace: &str,
549 ) -> Result<DaemonId> {
550 let global_slugs = Self::read_global_slugs();
552 if let Some(entry) = global_slugs.get(user_id) {
553 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
554 if let Some(dir) = entry.resolve_dir() {
555 if let Ok(project_config) = Self::all_merged_from(&dir) {
556 let matches: Vec<DaemonId> = project_config
557 .daemons
558 .keys()
559 .filter(|id| id.name() == daemon_name)
560 .cloned()
561 .collect();
562 match matches.as_slice() {
563 [] => {}
564 [id] => return Ok(id.clone()),
565 _ => {
566 let mut candidates: Vec<String> =
567 matches.iter().map(|id| id.qualified()).collect();
568 candidates.sort();
569 return Err(miette::miette!(
570 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
571 user_id,
572 daemon_name,
573 candidates.join(", ")
574 ));
575 }
576 }
577 }
578 }
579 }
580
581 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
584 if self.daemons.contains_key(&preferred_id) {
585 return Ok(preferred_id);
586 }
587
588 let matches = self.resolve_daemon_id(user_id)?;
590
591 if matches.len() > 1 {
593 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
594 candidates.sort();
595 return Err(miette::miette!(
596 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
597 user_id,
598 candidates.join(", ")
599 ));
600 }
601
602 if let Some(id) = matches.into_iter().next() {
603 return Ok(id);
604 }
605
606 let global_id = DaemonId::try_new("global", user_id)?;
609 if self.daemons.contains_key(&global_id) {
610 return Ok(global_id);
611 }
612
613 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
614 Err(DependencyError::DaemonNotFound {
615 name: user_id.to_string(),
616 suggestion,
617 }
618 .into())
619 }
620
621 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
624 Ok(Self::list_paths_from(dir)
625 .iter()
626 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
628 .transpose()?
629 .unwrap_or_else(|| "global".to_string()))
630 }
631
632 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
642 if user_id.contains('/') {
643 return DaemonId::parse(user_id);
644 }
645
646 let config = Self::all_merged()?;
649 let ns = Self::namespace_for_dir(&env::CWD)?;
650 config.resolve_daemon_id_with_namespace(user_id, &ns)
651 }
652
653 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
659 if user_id.contains('/') {
660 return DaemonId::parse(user_id);
661 }
662
663 let config = Self::all_merged()?;
664 let ns = Self::namespace_for_dir(&env::CWD)?;
665
666 let preferred_id = DaemonId::try_new(&ns, user_id)?;
667 if config.daemons.contains_key(&preferred_id) {
668 return Ok(preferred_id);
669 }
670
671 let matches = config.resolve_daemon_id(user_id)?;
672 if matches.len() > 1 {
673 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
674 candidates.sort();
675 return Err(miette::miette!(
676 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
677 user_id,
678 candidates.join(", ")
679 ));
680 }
681 if let Some(id) = matches.into_iter().next() {
682 return Ok(id);
683 }
684
685 DaemonId::try_new("global", user_id)
686 }
687
688 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
699 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
701 return user_ids
702 .iter()
703 .map(|s| DaemonId::parse(s.as_ref()))
704 .collect();
705 }
706
707 let config = Self::all_merged()?;
708 let ns = Self::namespace_for_dir(&env::CWD)?;
710 user_ids
711 .iter()
712 .map(|s| {
713 let id = s.as_ref();
714 if id.contains('/') {
715 DaemonId::parse(id)
716 } else {
717 config.resolve_daemon_id_with_namespace(id, &ns)
718 }
719 })
720 .collect()
721 }
722
723 pub fn resolve_ids_and_group<S: AsRef<str>>(
728 user_ids: &[S],
729 group_name: Option<&str>,
730 ) -> Result<Vec<DaemonId>> {
731 let config = Self::all_merged()?;
732 let ns = Self::namespace_for_dir(&env::CWD)?;
733 let mut ids = Vec::new();
734 let mut seen = std::collections::HashSet::new();
735
736 for id in user_ids {
737 let id_str = id.as_ref();
738 let daemon_id = if id_str.contains('/') {
739 DaemonId::parse(id_str)?
740 } else {
741 config.resolve_daemon_id_with_namespace(id_str, &ns)?
742 };
743 if seen.insert(daemon_id.clone()) {
744 ids.push(daemon_id);
745 }
746 }
747
748 if let Some(name) = group_name {
749 match config.groups.get(name) {
750 Some(group) => {
751 let missing: Vec<String> = group
752 .daemons
753 .iter()
754 .filter(|id| !config.daemons.contains_key(*id))
755 .map(|id| id.qualified())
756 .collect();
757 if !missing.is_empty() {
758 return Err(miette::miette!(
759 "group '{}' references undefined daemon{}: {}",
760 name,
761 if missing.len() > 1 { "s" } else { "" },
762 missing.join(", ")
763 ));
764 }
765 for daemon_id in &group.daemons {
766 if seen.insert(daemon_id.clone()) {
767 ids.push(daemon_id.clone());
768 }
769 }
770 }
771 None => {
772 let suggestion =
773 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
774 return Err(miette::miette!(
775 "group '{}' not found in configuration{}",
776 name,
777 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
778 ));
779 }
780 }
781 }
782
783 Ok(ids)
784 }
785
786 pub fn list_paths() -> Vec<PathBuf> {
789 Self::list_paths_from(&env::CWD)
790 }
791
792 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
803 let mut paths = Vec::new();
804 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
805 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
806
807 let mut project_paths = xx::file::find_up_all(
811 cwd,
812 &[
813 "pitchfork.local.toml",
814 "pitchfork.toml",
815 ".config/pitchfork.local.toml",
816 ".config/pitchfork.toml",
817 ],
818 );
819 project_paths.reverse();
820 paths.extend(project_paths);
821
822 paths
823 }
824
825 pub fn all_merged() -> Result<PitchforkToml> {
828 Self::all_merged_from(&env::CWD)
829 }
830 pub fn all_merged_all_namespaces() -> Result<Self> {
837 let mut pt = Self::all_merged_from(&env::CWD)?;
838
839 let namespaces = Self::read_global_namespaces();
840 for (ns_name, entry) in namespaces {
841 match Self::all_merged_from(&entry.dir) {
842 Ok(ns_config) => {
843 for (daemon_id, daemon_config) in ns_config.daemons {
844 if !pt.daemons.contains_key(&daemon_id) {
845 pt.daemons.insert(daemon_id, daemon_config);
846 }
847 }
848 pt.settings.merge_from(&ns_config.settings);
851 }
852 Err(e) => {
853 log::warn!(
854 "Failed to load namespace '{ns_name}' from {}: {e}",
855 entry.dir.display()
856 );
857 }
858 }
859 }
860
861 Ok(pt)
862 }
863
864 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
878 use std::collections::HashMap;
879
880 let paths = Self::list_paths_from(cwd);
881 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
882
883 let mut pt = Self::default();
884 for p in paths {
885 match Self::read(&p) {
886 Ok(pt2) => {
887 if p.exists() && !is_global_config(&p) {
891 let ns = namespace_from_path(&p)?;
892 let origin_dir = if is_dot_config_pitchfork(&p) {
893 p.parent().and_then(|d| d.parent())
894 } else {
895 p.parent()
896 }
897 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
898 .unwrap_or_else(|| p.clone());
899
900 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
901 && *other_dir != origin_dir
902 {
903 return Err(crate::error::ConfigParseError::NamespaceCollision {
904 path_a: other_path.clone(),
905 path_b: p.clone(),
906 ns,
907 }
908 .into());
909 }
910 ns_to_origin.insert(ns, (p.clone(), origin_dir));
911 }
912
913 pt.merge(pt2)
914 }
915 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
916 }
917 }
918 Ok(pt)
919 }
920}
921
922impl PitchforkToml {
923 pub fn new(path: PathBuf) -> Self {
924 Self {
925 daemons: Default::default(),
926 namespace: None,
927 settings: SettingsPartial::default(),
928 slugs: IndexMap::new(),
929 groups: IndexMap::new(),
930 namespaces: IndexMap::new(),
931 path: Some(path),
932 }
933 }
934
935 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
943 let raw_config: PitchforkTomlRaw = toml::from_str(content)
944 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
945
946 let namespace = {
947 let base_explicit = sibling_base_config(path)
948 .and_then(|p| if p.exists() { Some(p) } else { None })
949 .map(|p| read_namespace_override_from_file(&p))
950 .transpose()?
951 .flatten();
952
953 if is_local_config(path)
954 && let (Some(local_ns), Some(base_ns)) =
955 (raw_config.namespace.as_deref(), base_explicit.as_deref())
956 && local_ns != base_ns
957 {
958 return Err(ConfigParseError::InvalidNamespace {
959 path: path.to_path_buf(),
960 namespace: local_ns.to_string(),
961 reason: format!(
962 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
963 ),
964 }
965 .into());
966 }
967
968 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
969 namespace_from_path_with_override(path, explicit)?
970 };
971 let mut pt = Self::new(path.to_path_buf());
972 pt.namespace = raw_config.namespace.clone();
973
974 for (short_name, raw_daemon) in raw_config.daemons {
975 let id = match DaemonId::try_new(&namespace, &short_name) {
976 Ok(id) => id,
977 Err(e) => {
978 return Err(ConfigParseError::InvalidDaemonName {
979 name: short_name,
980 path: path.to_path_buf(),
981 reason: e.to_string(),
982 }
983 .into());
984 }
985 };
986
987 let mut depends = Vec::new();
988 for dep in raw_daemon.depends {
989 let dep_id = if dep.contains('/') {
990 match DaemonId::parse(&dep) {
991 Ok(id) => id,
992 Err(e) => {
993 return Err(ConfigParseError::InvalidDependency {
994 daemon: short_name.clone(),
995 dependency: dep,
996 path: path.to_path_buf(),
997 reason: e.to_string(),
998 }
999 .into());
1000 }
1001 }
1002 } else {
1003 match DaemonId::try_new(&namespace, &dep) {
1004 Ok(id) => id,
1005 Err(e) => {
1006 return Err(ConfigParseError::InvalidDependency {
1007 daemon: short_name.clone(),
1008 dependency: dep,
1009 path: path.to_path_buf(),
1010 reason: e.to_string(),
1011 }
1012 .into());
1013 }
1014 }
1015 };
1016 depends.push(dep_id);
1017 }
1018
1019 let has_deprecated = !raw_daemon.expected_port.is_empty()
1021 || raw_daemon.auto_bump_port.is_some()
1022 || raw_daemon.port_bump_attempts.is_some();
1023 let port = if let Some(port) = raw_daemon.port {
1024 if has_deprecated {
1025 warn!(
1026 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1027 );
1028 }
1029 Some(port)
1030 } else if has_deprecated {
1031 warn!(
1032 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1033 );
1034 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1035 PortBump(
1036 raw_daemon
1037 .port_bump_attempts
1038 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1039 )
1040 } else {
1041 PortBump(0)
1042 };
1043 Some(PortConfig {
1044 expect: raw_daemon.expected_port,
1045 bump,
1046 })
1047 } else {
1048 None
1049 };
1050
1051 let daemon = PitchforkTomlDaemon {
1052 run: raw_daemon.run,
1053 auto: raw_daemon.auto,
1054 cron: raw_daemon.cron,
1055 retry: raw_daemon.retry,
1056 ready_delay: raw_daemon.ready_delay,
1057 ready_output: raw_daemon.ready_output,
1058 ready_http: raw_daemon.ready_http,
1059 ready_port: raw_daemon.ready_port,
1060 ready_cmd: raw_daemon.ready_cmd,
1061 port,
1062 boot_start: raw_daemon.boot_start,
1063 depends,
1064 watch: raw_daemon.watch,
1065 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1066 dir: raw_daemon.dir,
1067 env: raw_daemon.env,
1068 hooks: raw_daemon.hooks,
1069 mise: raw_daemon.mise,
1070 user: raw_daemon.user,
1071 memory_limit: raw_daemon.memory_limit,
1072 cpu_limit: raw_daemon.cpu_limit,
1073 stop_signal: raw_daemon.stop_signal,
1074 pty: raw_daemon.pty,
1075 time_retention: raw_daemon.time_retention,
1076 line_retention: raw_daemon.line_retention,
1077 path: Some(path.to_path_buf()),
1078 };
1079 pt.daemons.insert(id, daemon);
1080 }
1081
1082 if let Some(settings) = raw_config.settings {
1084 pt.settings = settings;
1085 }
1086
1087 for (slug, entry) in raw_config.slugs {
1089 pt.slugs.insert(
1090 slug,
1091 SlugEntry {
1092 dir: entry.dir.map(PathBuf::from),
1093 namespace: entry.namespace,
1094 daemon: entry.daemon,
1095 },
1096 );
1097 }
1098
1099 for (name, entry) in raw_config.namespaces {
1101 pt.namespaces.insert(
1102 name,
1103 NamespaceEntry {
1104 dir: PathBuf::from(entry.dir),
1105 },
1106 );
1107 }
1108
1109 for (group_name, raw_group) in raw_config.groups {
1111 let mut daemons = Vec::new();
1112 for daemon_name in &raw_group.daemons {
1113 let id = if daemon_name.contains('/') {
1114 DaemonId::parse(daemon_name).map_err(|e| {
1115 ConfigParseError::InvalidDependency {
1116 daemon: group_name.clone(),
1117 dependency: daemon_name.clone(),
1118 path: path.to_path_buf(),
1119 reason: e.to_string(),
1120 }
1121 })?
1122 } else {
1123 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1124 ConfigParseError::InvalidDaemonName {
1125 name: daemon_name.clone(),
1126 path: path.to_path_buf(),
1127 reason: e.to_string(),
1128 }
1129 })?
1130 };
1131 daemons.push(id);
1132 }
1133 pt.groups.insert(group_name, GroupEntry { daemons });
1134 }
1135
1136 Ok(pt)
1137 }
1138
1139 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1140 let path = path.as_ref();
1141 if !path.exists() {
1142 return Ok(Self::new(path.to_path_buf()));
1143 }
1144 let _lock = xx::fslock::get(path, false)
1145 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1146 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1147 path: path.to_path_buf(),
1148 source: e,
1149 })?;
1150 Self::parse_str(&raw, path)
1151 }
1152
1153 pub fn write(&self) -> Result<()> {
1154 if let Some(path) = &self.path {
1155 let _lock = xx::fslock::get(path, false)
1156 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1157 self.write_unlocked()
1158 } else {
1159 Err(FileError::NoPath.into())
1160 }
1161 }
1162
1163 fn write_unlocked(&self) -> Result<()> {
1169 if let Some(path) = &self.path {
1170 let config_namespace = if path.exists() {
1172 namespace_from_path(path)?
1173 } else {
1174 namespace_from_path_with_override(path, self.namespace.as_deref())?
1175 };
1176
1177 let mut raw = PitchforkTomlRaw {
1179 namespace: self.namespace.clone(),
1180 ..PitchforkTomlRaw::default()
1181 };
1182 for (id, daemon) in &self.daemons {
1183 if id.namespace() != config_namespace {
1184 return Err(miette::miette!(
1185 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1186 id,
1187 path.display(),
1188 id.namespace(),
1189 config_namespace
1190 ));
1191 }
1192 let port = daemon.port.as_ref();
1193 let raw_daemon = PitchforkTomlDaemonRaw {
1194 run: daemon.run.clone(),
1195 auto: daemon.auto.clone(),
1196 cron: daemon.cron.clone(),
1197 retry: daemon.retry,
1198 ready_delay: daemon.ready_delay,
1199 ready_output: daemon.ready_output.clone(),
1200 ready_http: daemon.ready_http.clone(),
1201 ready_port: daemon.ready_port,
1202 ready_cmd: daemon.ready_cmd.clone(),
1203 port: port.cloned(),
1204 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1206 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1207 port_bump_attempts: port
1208 .filter(|p| p.auto_bump())
1209 .map(|p| p.max_bump_attempts()),
1210 boot_start: daemon.boot_start,
1211 depends: daemon
1214 .depends
1215 .iter()
1216 .map(|d| {
1217 if d.namespace() == config_namespace {
1218 d.name().to_string()
1219 } else {
1220 d.qualified()
1221 }
1222 })
1223 .collect(),
1224 watch: daemon.watch.clone(),
1225 watch_mode: match daemon.watch_mode {
1226 WatchMode::Native => None,
1227 mode => Some(mode),
1228 },
1229 dir: daemon.dir.clone(),
1230 env: daemon.env.clone(),
1231 hooks: daemon.hooks.clone(),
1232 mise: daemon.mise,
1233 user: daemon.user.clone(),
1234 memory_limit: daemon.memory_limit,
1235 cpu_limit: daemon.cpu_limit,
1236 stop_signal: daemon.stop_signal,
1237 pty: daemon.pty,
1238 time_retention: daemon.time_retention.clone(),
1239 line_retention: daemon.line_retention,
1240 };
1241 raw.daemons.insert(id.name().to_string(), raw_daemon);
1242 }
1243
1244 for (slug, entry) in &self.slugs {
1246 raw.slugs.insert(
1247 slug.clone(),
1248 SlugEntryRaw {
1249 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1250 namespace: entry.namespace.clone(),
1251 daemon: entry.daemon.clone(),
1252 },
1253 );
1254 }
1255
1256 for (name, group) in &self.groups {
1258 let raw_daemons: Vec<String> = group
1259 .daemons
1260 .iter()
1261 .map(|id| {
1262 if id.namespace() == config_namespace {
1263 id.name().to_string()
1264 } else {
1265 id.qualified()
1266 }
1267 })
1268 .collect();
1269 raw.groups.insert(
1270 name.clone(),
1271 GroupEntryRaw {
1272 daemons: raw_daemons,
1273 },
1274 );
1275 }
1276
1277 for (name, entry) in &self.namespaces {
1279 raw.namespaces.insert(
1280 name.clone(),
1281 NamespaceEntryRaw {
1282 dir: entry.dir.to_string_lossy().to_string(),
1283 },
1284 );
1285 }
1286
1287 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1288 path: path.clone(),
1289 source: e,
1290 })?;
1291 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1292 path: path.clone(),
1293 details: Some(e.to_string()),
1294 })?;
1295 Ok(())
1296 } else {
1297 Err(FileError::NoPath.into())
1298 }
1299 }
1300
1301 pub fn merge(&mut self, pt: Self) {
1306 for (id, d) in pt.daemons {
1307 self.daemons.insert(id, d);
1308 }
1309 for (slug, entry) in pt.slugs {
1311 self.slugs.insert(slug, entry);
1312 }
1313 for (name, group) in pt.groups {
1315 self.groups.insert(name, group);
1316 }
1317 for (name, entry) in pt.namespaces {
1319 self.namespaces.insert(name, entry);
1320 }
1321 self.settings.merge_from(&pt.settings);
1323 }
1324
1325 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1330 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1331 Ok(pt) => pt.slugs,
1332 Err(_) => IndexMap::new(),
1333 }
1334 }
1335
1336 pub fn find_slug_for_daemon_in_registry(
1338 daemon_id: &DaemonId,
1339 global_slugs: &IndexMap<String, SlugEntry>,
1340 ) -> Option<String> {
1341 global_slugs
1342 .iter()
1343 .find(|(slug, entry)| {
1344 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1345 if daemon_id.name() != daemon_name {
1346 return false;
1347 }
1348
1349 match entry.resolve_namespace() {
1350 Some(namespace) => daemon_id.namespace() == namespace,
1351 None => false,
1352 }
1353 })
1354 .map(|(slug, _)| slug.clone())
1355 }
1356
1357 #[allow(dead_code)]
1359 pub fn is_slug_registered(slug: &str) -> bool {
1360 Self::read_global_slugs().contains_key(slug)
1361 }
1362
1363 pub fn add_slug_with_namespace(
1369 slug: &str,
1370 namespace: Option<&str>,
1371 daemon: Option<&str>,
1372 ) -> Result<()> {
1373 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1374
1375 if let Some(parent) = global_path.parent() {
1377 std::fs::create_dir_all(parent).map_err(|e| {
1378 miette::miette!(
1379 "Failed to create config directory {}: {e}",
1380 parent.display()
1381 )
1382 })?;
1383 }
1384
1385 let _lock = xx::fslock::get(global_path, false)
1386 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1387
1388 let mut pt = if global_path.exists() {
1389 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1390 path: global_path.to_path_buf(),
1391 source: e,
1392 })?;
1393 Self::parse_str(&raw, global_path)?
1394 } else {
1395 Self::new(global_path.to_path_buf())
1396 };
1397
1398 if let Some(ns) = namespace {
1402 if !pt.namespaces.contains_key(ns) {
1403 let dir = pt
1404 .slugs
1405 .get(slug)
1406 .and_then(|e| e.resolve_dir())
1407 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1408 if let Some(ref d) = dir {
1409 pt.namespaces
1410 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1411 }
1412 }
1413 }
1414
1415 pt.slugs.insert(
1416 slug.to_string(),
1417 SlugEntry {
1418 dir: None,
1419 namespace: namespace.map(str::to_string),
1420 daemon: daemon.map(str::to_string),
1421 },
1422 );
1423 pt.write_unlocked()?;
1424 crate::proxy::hosts::sync_hosts_from_settings();
1425 Ok(())
1426 }
1427
1428 pub fn remove_slug(slug: &str) -> Result<bool> {
1430 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1431 if !global_path.exists() {
1432 return Ok(false);
1433 }
1434
1435 let _lock = xx::fslock::get(global_path, false)
1436 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1437
1438 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1439 path: global_path.to_path_buf(),
1440 source: e,
1441 })?;
1442 let mut pt = Self::parse_str(&raw, global_path)?;
1443
1444 let removed = pt.slugs.shift_remove(slug).is_some();
1445 if removed {
1446 pt.write_unlocked()?;
1447 crate::proxy::hosts::sync_hosts_from_settings();
1448 }
1449 Ok(removed)
1450 }
1451 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1454 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1455 Ok(pt) => pt.namespaces,
1456 Err(_) => IndexMap::new(),
1457 }
1458 }
1459
1460 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1464 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1465
1466 if let Some(parent) = global_path.parent() {
1468 std::fs::create_dir_all(parent).map_err(|e| {
1469 miette::miette!(
1470 "Failed to create config directory {}: {e}",
1471 parent.display()
1472 )
1473 })?;
1474 }
1475
1476 let _lock = xx::fslock::get(global_path, false)
1477 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1478
1479 let mut pt = if global_path.exists() {
1480 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1481 crate::error::FileError::ReadError {
1482 path: global_path.to_path_buf(),
1483 source: e,
1484 }
1485 })?;
1486 Self::parse_str(&raw, global_path)?
1487 } else {
1488 Self::new(global_path.to_path_buf())
1489 };
1490
1491 pt.namespaces.insert(
1492 name.to_string(),
1493 NamespaceEntry {
1494 dir: PathBuf::from(dir),
1495 },
1496 );
1497 pt.write_unlocked()?;
1498 Ok(())
1499 }
1500
1501 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1503 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1504 if !global_path.exists() {
1505 return Ok(false);
1506 }
1507
1508 let _lock = xx::fslock::get(global_path, false)
1509 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1510
1511 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1512 crate::error::FileError::ReadError {
1513 path: global_path.to_path_buf(),
1514 source: e,
1515 }
1516 })?;
1517 let mut pt = Self::parse_str(&raw, global_path)?;
1518
1519 let removed = pt.namespaces.shift_remove(name).is_some();
1520 if removed {
1521 pt.write_unlocked()?;
1522 }
1523 Ok(removed)
1524 }
1525}
1526
1527#[derive(Debug, Clone, JsonSchema, Default)]
1529pub struct PitchforkTomlDaemon {
1530 #[schemars(example = example_run_command())]
1532 pub run: String,
1533 #[schemars(default)]
1535 pub auto: Vec<PitchforkTomlAuto>,
1536 pub cron: Option<PitchforkTomlCron>,
1538 #[schemars(default)]
1541 pub retry: Retry,
1542 pub ready_delay: Option<u64>,
1544 pub ready_output: Option<String>,
1546 pub ready_http: Option<ReadyHttp>,
1548 #[schemars(range(min = 1, max = 65535))]
1550 pub ready_port: Option<u16>,
1551 pub ready_cmd: Option<String>,
1553 pub port: Option<PortConfig>,
1555 pub boot_start: Option<bool>,
1557 #[schemars(default)]
1559 pub depends: Vec<DaemonId>,
1560 #[schemars(default)]
1562 pub watch: Vec<String>,
1563 #[schemars(default)]
1569 pub watch_mode: WatchMode,
1570 pub dir: Option<String>,
1572 pub env: Option<IndexMap<String, String>>,
1574 pub hooks: Option<PitchforkTomlHooks>,
1576 pub mise: Option<bool>,
1579 pub user: Option<String>,
1581 pub memory_limit: Option<MemoryLimit>,
1584 pub cpu_limit: Option<CpuLimit>,
1587 pub stop_signal: Option<StopConfig>,
1590 pub pty: Option<bool>,
1592 pub time_retention: Option<String>,
1595 pub line_retention: Option<i64>,
1598 #[schemars(skip)]
1599 pub path: Option<PathBuf>,
1600}
1601
1602impl PitchforkTomlDaemon {
1603 pub fn to_run_options(
1608 &self,
1609 id: &crate::daemon_id::DaemonId,
1610 cmd: Vec<String>,
1611 ) -> crate::daemon::RunOptions {
1612 use crate::daemon::RunOptions;
1613
1614 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1615 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1616 .into_iter()
1617 .find(|(slug, entry)| {
1618 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1619 if daemon_name != id.name() {
1620 return false;
1621 }
1622
1623 match entry.resolve_namespace() {
1624 Some(namespace) => namespace == id.namespace(),
1625 None => false,
1626 }
1627 })
1628 .map(|(slug, _)| slug);
1629
1630 RunOptions {
1631 id: id.clone(),
1632 cmd,
1633 force: false,
1634 shell_pid: None,
1635 dir: Dir(dir),
1636 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1637 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1638 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1639 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1640 retry: self.retry,
1641 retry_count: 0,
1642 ready_delay: self.ready_delay,
1643 ready_output: self.ready_output.clone(),
1644 ready_http: self.ready_http.clone(),
1645 ready_port: self.ready_port,
1646 ready_cmd: self.ready_cmd.clone(),
1647 port: self.port.clone(),
1648 wait_ready: false,
1649 depends: self.depends.clone(),
1650 env: self.env.clone(),
1651 watch: self.watch.clone(),
1652 watch_mode: self.watch_mode,
1653 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1654 self.path.as_deref(),
1655 )),
1656 mise: self.mise,
1657 slug,
1658 proxy: None,
1659 user: self.user.clone(),
1660 memory_limit: self.memory_limit,
1661 cpu_limit: self.cpu_limit,
1662 stop_signal: self.stop_signal,
1663 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1664 pty: self.pty,
1665 }
1666 }
1667}
1668fn example_run_command() -> &'static str {
1669 "exec node server.js"
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use std::path::Path;
1676
1677 #[test]
1678 fn test_daemon_user_parses_and_flows_to_run_options() {
1679 let pt = PitchforkToml::parse_str(
1680 r#"
1681[daemons.api]
1682run = "node server.js"
1683user = "postgres"
1684"#,
1685 Path::new("/tmp/my-project/pitchfork.toml"),
1686 )
1687 .unwrap();
1688
1689 let id = DaemonId::new("my-project", "api");
1690 let daemon = pt.daemons.get(&id).unwrap();
1691 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1692
1693 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1694 assert_eq!(opts.user.as_deref(), Some("postgres"));
1695 }
1696
1697 #[test]
1698 fn test_daemon_user_write_roundtrip() {
1699 let temp = tempfile::tempdir().unwrap();
1700 let path = temp.path().join("pitchfork.toml");
1701 let mut pt = PitchforkToml::new(path.clone());
1702 pt.namespace = Some("test-project".to_string());
1703 pt.daemons.insert(
1704 DaemonId::new("test-project", "api"),
1705 PitchforkTomlDaemon {
1706 run: "node server.js".to_string(),
1707 user: Some("postgres".to_string()),
1708 ..PitchforkTomlDaemon::default()
1709 },
1710 );
1711
1712 pt.write().unwrap();
1713
1714 let raw = std::fs::read_to_string(&path).unwrap();
1715 assert!(raw.contains("user = \"postgres\""));
1716
1717 let parsed = PitchforkToml::read(&path).unwrap();
1718 let daemon = parsed
1719 .daemons
1720 .get(&DaemonId::new("test-project", "api"))
1721 .unwrap();
1722 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1723 }
1724}