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 {
1186 namespace: self.namespace.clone(),
1187 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1188 ..PitchforkTomlRaw::default()
1189 };
1190 for (id, daemon) in &self.daemons {
1191 if id.namespace() != config_namespace {
1192 return Err(miette::miette!(
1193 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1194 id,
1195 path.display(),
1196 id.namespace(),
1197 config_namespace
1198 ));
1199 }
1200 let port = daemon.port.as_ref();
1201 let raw_daemon = PitchforkTomlDaemonRaw {
1202 run: daemon.run.clone(),
1203 auto: daemon.auto.clone(),
1204 cron: daemon.cron.clone(),
1205 retry: daemon.retry,
1206 ready_delay: daemon.ready_delay,
1207 ready_output: daemon.ready_output.clone(),
1208 ready_http: daemon.ready_http.clone(),
1209 ready_port: daemon.ready_port,
1210 ready_cmd: daemon.ready_cmd.clone(),
1211 port: port.cloned(),
1212 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1214 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1215 port_bump_attempts: port
1216 .filter(|p| p.auto_bump())
1217 .map(|p| p.max_bump_attempts()),
1218 boot_start: daemon.boot_start,
1219 depends: daemon
1222 .depends
1223 .iter()
1224 .map(|d| {
1225 if d.namespace() == config_namespace {
1226 d.name().to_string()
1227 } else {
1228 d.qualified()
1229 }
1230 })
1231 .collect(),
1232 watch: daemon.watch.clone(),
1233 watch_mode: match daemon.watch_mode {
1234 WatchMode::Native => None,
1235 mode => Some(mode),
1236 },
1237 dir: daemon.dir.clone(),
1238 env: daemon.env.clone(),
1239 hooks: daemon.hooks.clone(),
1240 mise: daemon.mise,
1241 user: daemon.user.clone(),
1242 memory_limit: daemon.memory_limit,
1243 cpu_limit: daemon.cpu_limit,
1244 stop_signal: daemon.stop_signal,
1245 pty: daemon.pty,
1246 time_retention: daemon.time_retention.clone(),
1247 line_retention: daemon.line_retention,
1248 archive_hook: daemon.archive_hook.clone(),
1249 };
1250 raw.daemons.insert(id.name().to_string(), raw_daemon);
1251 }
1252
1253 for (slug, entry) in &self.slugs {
1255 raw.slugs.insert(
1256 slug.clone(),
1257 SlugEntryRaw {
1258 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1259 namespace: entry.namespace.clone(),
1260 daemon: entry.daemon.clone(),
1261 },
1262 );
1263 }
1264
1265 for (name, group) in &self.groups {
1267 let raw_daemons: Vec<String> = group
1268 .daemons
1269 .iter()
1270 .map(|id| {
1271 if id.namespace() == config_namespace {
1272 id.name().to_string()
1273 } else {
1274 id.qualified()
1275 }
1276 })
1277 .collect();
1278 raw.groups.insert(
1279 name.clone(),
1280 GroupEntryRaw {
1281 daemons: raw_daemons,
1282 },
1283 );
1284 }
1285
1286 for (name, entry) in &self.namespaces {
1288 raw.namespaces.insert(
1289 name.clone(),
1290 NamespaceEntryRaw {
1291 dir: entry.dir.to_string_lossy().to_string(),
1292 },
1293 );
1294 }
1295
1296 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1297 path: path.clone(),
1298 source: e,
1299 })?;
1300 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1301 path: path.clone(),
1302 details: Some(e.to_string()),
1303 })?;
1304 Ok(())
1305 } else {
1306 Err(FileError::NoPath.into())
1307 }
1308 }
1309
1310 pub fn merge(&mut self, pt: Self) {
1315 for (id, d) in pt.daemons {
1316 self.daemons.insert(id, d);
1317 }
1318 for (slug, entry) in pt.slugs {
1320 self.slugs.insert(slug, entry);
1321 }
1322 for (name, group) in pt.groups {
1324 self.groups.insert(name, group);
1325 }
1326 for (name, entry) in pt.namespaces {
1328 self.namespaces.insert(name, entry);
1329 }
1330 self.settings.merge_from(&pt.settings);
1332 }
1333
1334 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1339 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1340 Ok(pt) => pt.slugs,
1341 Err(_) => IndexMap::new(),
1342 }
1343 }
1344
1345 pub fn find_slug_for_daemon_in_registry(
1347 daemon_id: &DaemonId,
1348 global_slugs: &IndexMap<String, SlugEntry>,
1349 ) -> Option<String> {
1350 global_slugs
1351 .iter()
1352 .find(|(slug, entry)| {
1353 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1354 if daemon_id.name() != daemon_name {
1355 return false;
1356 }
1357
1358 match entry.resolve_namespace() {
1359 Some(namespace) => daemon_id.namespace() == namespace,
1360 None => false,
1361 }
1362 })
1363 .map(|(slug, _)| slug.clone())
1364 }
1365
1366 #[allow(dead_code)]
1368 pub fn is_slug_registered(slug: &str) -> bool {
1369 Self::read_global_slugs().contains_key(slug)
1370 }
1371
1372 pub fn add_slug_with_namespace(
1378 slug: &str,
1379 namespace: Option<&str>,
1380 daemon: Option<&str>,
1381 ) -> Result<()> {
1382 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1383
1384 if let Some(parent) = global_path.parent() {
1386 std::fs::create_dir_all(parent).map_err(|e| {
1387 miette::miette!(
1388 "Failed to create config directory {}: {e}",
1389 parent.display()
1390 )
1391 })?;
1392 }
1393
1394 let _lock = xx::fslock::get(global_path, false)
1395 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1396
1397 let mut pt = if global_path.exists() {
1398 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1399 path: global_path.to_path_buf(),
1400 source: e,
1401 })?;
1402 Self::parse_str(&raw, global_path)?
1403 } else {
1404 Self::new(global_path.to_path_buf())
1405 };
1406
1407 if let Some(ns) = namespace {
1411 if !pt.namespaces.contains_key(ns) {
1412 let dir = pt
1413 .slugs
1414 .get(slug)
1415 .and_then(|e| e.resolve_dir())
1416 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1417 if let Some(ref d) = dir {
1418 pt.namespaces
1419 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1420 }
1421 }
1422 }
1423
1424 pt.slugs.insert(
1425 slug.to_string(),
1426 SlugEntry {
1427 dir: None,
1428 namespace: namespace.map(str::to_string),
1429 daemon: daemon.map(str::to_string),
1430 },
1431 );
1432 pt.write_unlocked()?;
1433 crate::proxy::hosts::sync_hosts_from_settings();
1434 Ok(())
1435 }
1436
1437 pub fn remove_slug(slug: &str) -> Result<bool> {
1439 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1440 if !global_path.exists() {
1441 return Ok(false);
1442 }
1443
1444 let _lock = xx::fslock::get(global_path, false)
1445 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1446
1447 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1448 path: global_path.to_path_buf(),
1449 source: e,
1450 })?;
1451 let mut pt = Self::parse_str(&raw, global_path)?;
1452
1453 let removed = pt.slugs.shift_remove(slug).is_some();
1454 if removed {
1455 pt.write_unlocked()?;
1456 crate::proxy::hosts::sync_hosts_from_settings();
1457 }
1458 Ok(removed)
1459 }
1460 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1463 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1464 Ok(pt) => pt.namespaces,
1465 Err(_) => IndexMap::new(),
1466 }
1467 }
1468
1469 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1473 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1474
1475 if let Some(parent) = global_path.parent() {
1477 std::fs::create_dir_all(parent).map_err(|e| {
1478 miette::miette!(
1479 "Failed to create config directory {}: {e}",
1480 parent.display()
1481 )
1482 })?;
1483 }
1484
1485 let _lock = xx::fslock::get(global_path, false)
1486 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1487
1488 let mut pt = if global_path.exists() {
1489 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1490 crate::error::FileError::ReadError {
1491 path: global_path.to_path_buf(),
1492 source: e,
1493 }
1494 })?;
1495 Self::parse_str(&raw, global_path)?
1496 } else {
1497 Self::new(global_path.to_path_buf())
1498 };
1499
1500 pt.namespaces.insert(
1501 name.to_string(),
1502 NamespaceEntry {
1503 dir: PathBuf::from(dir),
1504 },
1505 );
1506 pt.write_unlocked()?;
1507 Ok(())
1508 }
1509
1510 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1512 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1513 if !global_path.exists() {
1514 return Ok(false);
1515 }
1516
1517 let _lock = xx::fslock::get(global_path, false)
1518 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1519
1520 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1521 crate::error::FileError::ReadError {
1522 path: global_path.to_path_buf(),
1523 source: e,
1524 }
1525 })?;
1526 let mut pt = Self::parse_str(&raw, global_path)?;
1527
1528 let removed = pt.namespaces.shift_remove(name).is_some();
1529 if removed {
1530 pt.write_unlocked()?;
1531 }
1532 Ok(removed)
1533 }
1534}
1535
1536#[derive(Debug, Clone, JsonSchema, Default)]
1538pub struct PitchforkTomlDaemon {
1539 #[schemars(example = example_run_command())]
1541 pub run: String,
1542 #[schemars(default)]
1544 pub auto: Vec<PitchforkTomlAuto>,
1545 pub cron: Option<PitchforkTomlCron>,
1547 #[schemars(default)]
1550 pub retry: Retry,
1551 pub ready_delay: Option<u64>,
1553 pub ready_output: Option<String>,
1555 pub ready_http: Option<ReadyHttp>,
1557 #[schemars(range(min = 1, max = 65535))]
1559 pub ready_port: Option<u16>,
1560 pub ready_cmd: Option<String>,
1562 pub port: Option<PortConfig>,
1564 pub boot_start: Option<bool>,
1566 #[schemars(default)]
1568 pub depends: Vec<DaemonId>,
1569 #[schemars(default)]
1571 pub watch: Vec<String>,
1572 #[schemars(default)]
1578 pub watch_mode: WatchMode,
1579 pub dir: Option<String>,
1581 pub env: Option<IndexMap<String, String>>,
1583 pub hooks: Option<PitchforkTomlHooks>,
1585 pub mise: Option<bool>,
1588 pub user: Option<String>,
1590 pub memory_limit: Option<MemoryLimit>,
1593 pub cpu_limit: Option<CpuLimit>,
1596 pub stop_signal: Option<StopConfig>,
1599 pub pty: Option<bool>,
1601 pub time_retention: Option<String>,
1604 pub line_retention: Option<i64>,
1607 pub archive_hook: Option<String>,
1610 #[schemars(skip)]
1611 pub path: Option<PathBuf>,
1612}
1613
1614impl PitchforkTomlDaemon {
1615 pub fn to_run_options(
1620 &self,
1621 id: &crate::daemon_id::DaemonId,
1622 cmd: Vec<String>,
1623 ) -> crate::daemon::RunOptions {
1624 use crate::daemon::RunOptions;
1625
1626 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1627 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1628 .into_iter()
1629 .find(|(slug, entry)| {
1630 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1631 if daemon_name != id.name() {
1632 return false;
1633 }
1634
1635 match entry.resolve_namespace() {
1636 Some(namespace) => namespace == id.namespace(),
1637 None => false,
1638 }
1639 })
1640 .map(|(slug, _)| slug);
1641
1642 RunOptions {
1643 id: id.clone(),
1644 cmd,
1645 run: Some(self.run.clone()),
1646 force: false,
1647 shell_pid: None,
1648 dir: Dir(dir),
1649 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1650 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1651 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1652 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1653 retry: self.retry,
1654 retry_count: 0,
1655 ready_delay: self.ready_delay,
1656 ready_output: self.ready_output.clone(),
1657 ready_http: self.ready_http.clone(),
1658 ready_port: self.ready_port,
1659 ready_cmd: self.ready_cmd.clone(),
1660 port: self.port.clone(),
1661 wait_ready: false,
1662 depends: self.depends.clone(),
1663 env: self.env.clone(),
1664 watch: self.watch.clone(),
1665 watch_mode: self.watch_mode,
1666 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1667 self.path.as_deref(),
1668 )),
1669 mise: self.mise,
1670 slug,
1671 proxy: None,
1672 user: self.user.clone(),
1673 memory_limit: self.memory_limit,
1674 cpu_limit: self.cpu_limit,
1675 stop_signal: self.stop_signal,
1676 archive_hook: self.archive_hook.clone(),
1677 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1678 pty: self.pty,
1679 }
1680 }
1681}
1682fn example_run_command() -> &'static str {
1683 "exec node server.js"
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688 use super::*;
1689 use std::path::Path;
1690
1691 #[test]
1692 fn test_daemon_user_parses_and_flows_to_run_options() {
1693 let pt = PitchforkToml::parse_str(
1694 r#"
1695[daemons.api]
1696run = "node server.js"
1697user = "postgres"
1698"#,
1699 Path::new("/tmp/my-project/pitchfork.toml"),
1700 )
1701 .unwrap();
1702
1703 let id = DaemonId::new("my-project", "api");
1704 let daemon = pt.daemons.get(&id).unwrap();
1705 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1706
1707 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1708 assert_eq!(opts.user.as_deref(), Some("postgres"));
1709 }
1710
1711 #[test]
1712 fn test_daemon_user_write_roundtrip() {
1713 let temp = tempfile::tempdir().unwrap();
1714 let path = temp.path().join("pitchfork.toml");
1715 let mut pt = PitchforkToml::new(path.clone());
1716 pt.namespace = Some("test-project".to_string());
1717 pt.daemons.insert(
1718 DaemonId::new("test-project", "api"),
1719 PitchforkTomlDaemon {
1720 run: "node server.js".to_string(),
1721 user: Some("postgres".to_string()),
1722 ..PitchforkTomlDaemon::default()
1723 },
1724 );
1725
1726 pt.write().unwrap();
1727
1728 let raw = std::fs::read_to_string(&path).unwrap();
1729 assert!(raw.contains("user = \"postgres\""));
1730
1731 let parsed = PitchforkToml::read(&path).unwrap();
1732 let daemon = parsed
1733 .daemons
1734 .get(&DaemonId::new("test-project", "api"))
1735 .unwrap();
1736 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1737 }
1738
1739 #[test]
1740 fn test_settings_write_roundtrip() {
1741 let temp = tempfile::tempdir().unwrap();
1742 let path = temp.path().join("pitchfork.toml");
1743 let mut pt = PitchforkToml::new(path.clone());
1744 pt.namespace = Some("test-project".to_string());
1745 pt.settings.web.auto_start = Some(true);
1746 pt.settings.general.log_level = Some("debug".to_string());
1747
1748 pt.write().unwrap();
1749
1750 let raw = std::fs::read_to_string(&path).unwrap();
1751 assert!(
1752 raw.contains("[settings.web]"),
1753 "settings.web section should be written, got:\n{raw}"
1754 );
1755 assert!(raw.contains("auto_start = true"));
1756 assert!(raw.contains("log_level = \"debug\""));
1757
1758 let parsed = PitchforkToml::read(&path).unwrap();
1759 assert_eq!(parsed.settings.web.auto_start, Some(true));
1760 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
1761 }
1762
1763 #[test]
1764 fn test_settings_preserved_on_unrelated_write() {
1765 let temp = tempfile::tempdir().unwrap();
1768 let path = temp.path().join("pitchfork.toml");
1769 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
1770
1771 let mut pt = PitchforkToml::read(&path).unwrap();
1772 pt.slugs.insert(
1773 "api".to_string(),
1774 SlugEntry {
1775 dir: None,
1776 namespace: Some("myproject".to_string()),
1777 daemon: None,
1778 },
1779 );
1780 pt.namespaces.insert(
1781 "myproject".to_string(),
1782 NamespaceEntry {
1783 dir: PathBuf::from("/tmp/myproject"),
1784 },
1785 );
1786 pt.write().unwrap();
1787
1788 let raw = std::fs::read_to_string(&path).unwrap();
1789 assert!(
1790 raw.contains("[settings.web]"),
1791 "existing settings must be preserved, got:\n{raw}"
1792 );
1793 assert!(raw.contains("auto_start = true"));
1794 assert!(raw.contains("[slugs.api]"));
1795
1796 let parsed = PitchforkToml::read(&path).unwrap();
1797 assert_eq!(parsed.settings.web.auto_start, Some(true));
1798 assert!(parsed.slugs.contains_key("api"));
1799 }
1800}