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 pub dir: String,
29 #[serde(skip_serializing_if = "Option::is_none", default)]
31 pub daemon: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct SlugEntry {
37 pub dir: PathBuf,
39 pub daemon: Option<String>,
41}
42
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct GroupEntryRaw {
50 pub daemons: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct GroupEntry {
56 pub daemons: Vec<DaemonId>,
57}
58
59#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
61struct PitchforkTomlRaw {
62 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub namespace: Option<String>,
64 #[serde(default)]
65 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
66 #[serde(default)]
67 pub settings: Option<SettingsPartial>,
68 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
71 pub slugs: IndexMap<String, SlugEntryRaw>,
72 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
74 pub groups: IndexMap<String, GroupEntryRaw>,
75}
76
77#[derive(Debug, serde::Serialize, serde::Deserialize)]
84struct PitchforkTomlDaemonRaw {
85 pub run: String,
86 #[serde(skip_serializing_if = "Vec::is_empty", default)]
87 pub auto: Vec<PitchforkTomlAuto>,
88 #[serde(skip_serializing_if = "Option::is_none", default)]
89 pub cron: Option<PitchforkTomlCron>,
90 #[serde(default)]
91 pub retry: Retry,
92 #[serde(skip_serializing_if = "Option::is_none", default)]
93 pub ready_delay: Option<u64>,
94 #[serde(skip_serializing_if = "Option::is_none", default)]
95 pub ready_output: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none", default)]
97 pub ready_http: Option<ReadyHttp>,
98 #[serde(skip_serializing_if = "Option::is_none", default)]
99 pub ready_port: Option<u16>,
100 #[serde(skip_serializing_if = "Option::is_none", default)]
101 pub ready_cmd: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub port: Option<PortConfig>,
105 #[serde(skip_serializing_if = "Vec::is_empty", default)]
107 pub expected_port: Vec<u16>,
108 #[serde(skip_serializing_if = "Option::is_none", default)]
110 pub auto_bump_port: Option<bool>,
111 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub port_bump_attempts: Option<u32>,
114 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub boot_start: Option<bool>,
116 #[serde(skip_serializing_if = "Vec::is_empty", default)]
117 pub depends: Vec<String>,
118 #[serde(skip_serializing_if = "Vec::is_empty", default)]
119 pub watch: Vec<String>,
120 #[serde(skip_serializing_if = "Option::is_none", default)]
121 pub watch_mode: Option<WatchMode>,
122 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub dir: Option<String>,
124 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub env: Option<IndexMap<String, String>>,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub hooks: Option<PitchforkTomlHooks>,
128 #[serde(skip_serializing_if = "Option::is_none", default)]
129 pub mise: Option<bool>,
130 #[serde(skip_serializing_if = "Option::is_none", default)]
132 pub user: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none", default)]
135 pub memory_limit: Option<MemoryLimit>,
136 #[serde(skip_serializing_if = "Option::is_none", default)]
138 pub cpu_limit: Option<CpuLimit>,
139 #[serde(skip_serializing_if = "Option::is_none", default)]
141 pub stop_signal: Option<StopConfig>,
142 #[serde(skip_serializing_if = "Option::is_none", default)]
144 pub pty: Option<bool>,
145}
146
147#[derive(Debug, Default, JsonSchema)]
152#[schemars(title = "Pitchfork Configuration")]
153pub struct PitchforkToml {
154 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
156 pub namespace: Option<String>,
161 #[serde(default)]
170 pub(crate) settings: SettingsPartial,
171 #[schemars(skip)]
176 pub slugs: IndexMap<String, SlugEntry>,
177 #[schemars(skip)]
179 pub groups: IndexMap<String, GroupEntry>,
180 #[schemars(skip)]
181 pub path: Option<PathBuf>,
182}
183
184pub(crate) fn is_global_config(path: &Path) -> bool {
185 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
186}
187
188fn is_local_config(path: &Path) -> bool {
189 path.file_name()
190 .map(|n| n == "pitchfork.local.toml")
191 .unwrap_or(false)
192}
193
194pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
195 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
196}
197
198fn sibling_base_config(path: &Path) -> Option<PathBuf> {
199 if !is_local_config(path) {
200 return None;
201 }
202 path.parent().map(|p| p.join("pitchfork.toml"))
203}
204
205fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
206 use toml::Value;
207
208 let doc: Value = toml::from_str(content)
209 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
210 let Some(value) = doc.get("namespace") else {
211 return Ok(None);
212 };
213
214 match value {
215 Value::String(s) => Ok(Some(s.clone())),
216 _ => Err(ConfigParseError::InvalidNamespace {
217 path: path.to_path_buf(),
218 namespace: value.to_string(),
219 reason: "top-level 'namespace' must be a string".to_string(),
220 }
221 .into()),
222 }
223}
224
225fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
226 if !path.exists() {
227 return Ok(None);
228 }
229 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
230 path: path.to_path_buf(),
231 source: e,
232 })?;
233 parse_namespace_override_from_content(path, &content)
234}
235
236fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
237 if let Err(e) = DaemonId::try_new(namespace, "probe") {
238 return Err(ConfigParseError::InvalidNamespace {
239 path: path.to_path_buf(),
240 namespace: namespace.to_string(),
241 reason: e.to_string(),
242 }
243 .into());
244 }
245 Ok(namespace.to_string())
246}
247
248fn derive_namespace_from_dir(path: &Path) -> Result<String> {
249 let dir_for_namespace = if is_dot_config_pitchfork(path) {
250 path.parent().and_then(|p| p.parent())
251 } else {
252 path.parent()
253 };
254
255 let raw_namespace = dir_for_namespace
256 .and_then(|p| p.file_name())
257 .and_then(|n| n.to_str())
258 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
259 .to_string();
260
261 validate_namespace(path, &raw_namespace).map_err(|e| {
262 ConfigParseError::InvalidNamespace {
263 path: path.to_path_buf(),
264 namespace: raw_namespace,
265 reason: format!(
266 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
267 ),
268 }
269 .into()
270 })
271}
272
273fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
274 if is_global_config(path) {
275 if let Some(ns) = explicit
276 && ns != "global"
277 {
278 return Err(ConfigParseError::InvalidNamespace {
279 path: path.to_path_buf(),
280 namespace: ns.to_string(),
281 reason: "global config files must use namespace 'global'".to_string(),
282 }
283 .into());
284 }
285 return Ok("global".to_string());
286 }
287
288 if let Some(ns) = explicit {
289 return validate_namespace(path, ns);
290 }
291
292 derive_namespace_from_dir(path)
293}
294
295fn namespace_from_file(path: &Path) -> Result<String> {
296 let explicit = read_namespace_override_from_file(path)?;
297 let base_explicit = sibling_base_config(path)
298 .and_then(|p| if p.exists() { Some(p) } else { None })
299 .map(|p| read_namespace_override_from_file(&p))
300 .transpose()?
301 .flatten();
302
303 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
304 && local_ns != base_ns
305 {
306 return Err(ConfigParseError::InvalidNamespace {
307 path: path.to_path_buf(),
308 namespace: local_ns.to_string(),
309 reason: format!(
310 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
311 ),
312 }
313 .into());
314 }
315
316 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
317 namespace_from_path_with_override(path, effective_explicit)
318}
319
320pub fn namespace_from_path(path: &Path) -> Result<String> {
333 namespace_from_file(path)
334}
335
336impl PitchforkToml {
337 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
350 if user_id.contains('/') {
352 return match DaemonId::parse(user_id) {
353 Ok(id) => Ok(vec![id]),
354 Err(e) => Err(e), };
356 }
357
358 let global_slugs = Self::read_global_slugs();
360 if let Some(entry) = global_slugs.get(user_id) {
361 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
363 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
364 let matches: Vec<DaemonId> = project_config
366 .daemons
367 .keys()
368 .filter(|id| id.name() == daemon_name)
369 .cloned()
370 .collect();
371 match matches.as_slice() {
372 [] => {}
373 [id] => return Ok(vec![id.clone()]),
374 _ => {
375 let mut candidates: Vec<String> =
376 matches.iter().map(|id| id.qualified()).collect();
377 candidates.sort();
378 return Err(miette::miette!(
379 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
380 user_id,
381 daemon_name,
382 candidates.join(", ")
383 ));
384 }
385 }
386 }
387 }
388
389 let matches: Vec<DaemonId> = self
391 .daemons
392 .keys()
393 .filter(|id| id.name() == user_id)
394 .cloned()
395 .collect();
396
397 if matches.is_empty() {
398 let _ = DaemonId::try_new("global", user_id)?;
400 }
401 Ok(matches)
402 }
403
404 #[allow(dead_code)]
425 pub fn resolve_daemon_id_prefer_local(
426 &self,
427 user_id: &str,
428 current_dir: &Path,
429 ) -> Result<DaemonId> {
430 if user_id.contains('/') {
432 return DaemonId::parse(user_id);
433 }
434
435 let current_namespace = Self::namespace_for_dir(current_dir)?;
439
440 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
441 }
442
443 fn resolve_daemon_id_with_namespace(
446 &self,
447 user_id: &str,
448 current_namespace: &str,
449 ) -> Result<DaemonId> {
450 let global_slugs = Self::read_global_slugs();
452 if let Some(entry) = global_slugs.get(user_id) {
453 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
454 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
455 let matches: Vec<DaemonId> = project_config
456 .daemons
457 .keys()
458 .filter(|id| id.name() == daemon_name)
459 .cloned()
460 .collect();
461 match matches.as_slice() {
462 [] => {}
463 [id] => return Ok(id.clone()),
464 _ => {
465 let mut candidates: Vec<String> =
466 matches.iter().map(|id| id.qualified()).collect();
467 candidates.sort();
468 return Err(miette::miette!(
469 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
470 user_id,
471 daemon_name,
472 candidates.join(", ")
473 ));
474 }
475 }
476 }
477 }
478
479 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
482 if self.daemons.contains_key(&preferred_id) {
483 return Ok(preferred_id);
484 }
485
486 let matches = self.resolve_daemon_id(user_id)?;
488
489 if matches.len() > 1 {
491 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
492 candidates.sort();
493 return Err(miette::miette!(
494 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
495 user_id,
496 candidates.join(", ")
497 ));
498 }
499
500 if let Some(id) = matches.into_iter().next() {
501 return Ok(id);
502 }
503
504 let global_id = DaemonId::try_new("global", user_id)?;
507 if self.daemons.contains_key(&global_id) {
508 return Ok(global_id);
509 }
510
511 if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
515 && state.daemons.contains_key(&global_id)
516 {
517 return Ok(global_id);
518 }
519
520 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
521 Err(DependencyError::DaemonNotFound {
522 name: user_id.to_string(),
523 suggestion,
524 }
525 .into())
526 }
527
528 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
531 Ok(Self::list_paths_from(dir)
532 .iter()
533 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
535 .transpose()?
536 .unwrap_or_else(|| "global".to_string()))
537 }
538
539 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
549 if user_id.contains('/') {
550 return DaemonId::parse(user_id);
551 }
552
553 let config = Self::all_merged()?;
556 let ns = Self::namespace_for_dir(&env::CWD)?;
557 config.resolve_daemon_id_with_namespace(user_id, &ns)
558 }
559
560 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
566 if user_id.contains('/') {
567 return DaemonId::parse(user_id);
568 }
569
570 let config = Self::all_merged()?;
571 let ns = Self::namespace_for_dir(&env::CWD)?;
572
573 let preferred_id = DaemonId::try_new(&ns, user_id)?;
574 if config.daemons.contains_key(&preferred_id) {
575 return Ok(preferred_id);
576 }
577
578 let matches = config.resolve_daemon_id(user_id)?;
579 if matches.len() > 1 {
580 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
581 candidates.sort();
582 return Err(miette::miette!(
583 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
584 user_id,
585 candidates.join(", ")
586 ));
587 }
588 if let Some(id) = matches.into_iter().next() {
589 return Ok(id);
590 }
591
592 DaemonId::try_new("global", user_id)
593 }
594
595 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
606 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
608 return user_ids
609 .iter()
610 .map(|s| DaemonId::parse(s.as_ref()))
611 .collect();
612 }
613
614 let config = Self::all_merged()?;
615 let ns = Self::namespace_for_dir(&env::CWD)?;
617 user_ids
618 .iter()
619 .map(|s| {
620 let id = s.as_ref();
621 if id.contains('/') {
622 DaemonId::parse(id)
623 } else {
624 config.resolve_daemon_id_with_namespace(id, &ns)
625 }
626 })
627 .collect()
628 }
629
630 pub fn resolve_ids_and_group<S: AsRef<str>>(
635 user_ids: &[S],
636 group_name: Option<&str>,
637 ) -> Result<Vec<DaemonId>> {
638 let config = Self::all_merged()?;
639 let ns = Self::namespace_for_dir(&env::CWD)?;
640 let mut ids = Vec::new();
641 let mut seen = std::collections::HashSet::new();
642
643 for id in user_ids {
644 let id_str = id.as_ref();
645 let daemon_id = if id_str.contains('/') {
646 DaemonId::parse(id_str)?
647 } else {
648 config.resolve_daemon_id_with_namespace(id_str, &ns)?
649 };
650 if seen.insert(daemon_id.clone()) {
651 ids.push(daemon_id);
652 }
653 }
654
655 if let Some(name) = group_name {
656 match config.groups.get(name) {
657 Some(group) => {
658 let missing: Vec<String> = group
659 .daemons
660 .iter()
661 .filter(|id| !config.daemons.contains_key(*id))
662 .map(|id| id.qualified())
663 .collect();
664 if !missing.is_empty() {
665 return Err(miette::miette!(
666 "group '{}' references undefined daemon{}: {}",
667 name,
668 if missing.len() > 1 { "s" } else { "" },
669 missing.join(", ")
670 ));
671 }
672 for daemon_id in &group.daemons {
673 if seen.insert(daemon_id.clone()) {
674 ids.push(daemon_id.clone());
675 }
676 }
677 }
678 None => {
679 let suggestion =
680 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
681 return Err(miette::miette!(
682 "group '{}' not found in configuration{}",
683 name,
684 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
685 ));
686 }
687 }
688 }
689
690 Ok(ids)
691 }
692
693 pub fn list_paths() -> Vec<PathBuf> {
696 Self::list_paths_from(&env::CWD)
697 }
698
699 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
710 let mut paths = Vec::new();
711 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
712 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
713
714 let mut project_paths = xx::file::find_up_all(
718 cwd,
719 &[
720 "pitchfork.local.toml",
721 "pitchfork.toml",
722 ".config/pitchfork.local.toml",
723 ".config/pitchfork.toml",
724 ],
725 );
726 project_paths.reverse();
727 paths.extend(project_paths);
728
729 paths
730 }
731
732 pub fn all_merged() -> Result<PitchforkToml> {
735 Self::all_merged_from(&env::CWD)
736 }
737
738 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
752 use std::collections::HashMap;
753
754 let paths = Self::list_paths_from(cwd);
755 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
756
757 let mut pt = Self::default();
758 for p in paths {
759 match Self::read(&p) {
760 Ok(pt2) => {
761 if p.exists() && !is_global_config(&p) {
765 let ns = namespace_from_path(&p)?;
766 let origin_dir = if is_dot_config_pitchfork(&p) {
767 p.parent().and_then(|d| d.parent())
768 } else {
769 p.parent()
770 }
771 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
772 .unwrap_or_else(|| p.clone());
773
774 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
775 && *other_dir != origin_dir
776 {
777 return Err(crate::error::ConfigParseError::NamespaceCollision {
778 path_a: other_path.clone(),
779 path_b: p.clone(),
780 ns,
781 }
782 .into());
783 }
784 ns_to_origin.insert(ns, (p.clone(), origin_dir));
785 }
786
787 pt.merge(pt2)
788 }
789 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
790 }
791 }
792 Ok(pt)
793 }
794}
795
796impl PitchforkToml {
797 pub fn new(path: PathBuf) -> Self {
798 Self {
799 daemons: Default::default(),
800 namespace: None,
801 settings: SettingsPartial::default(),
802 slugs: IndexMap::new(),
803 groups: IndexMap::new(),
804 path: Some(path),
805 }
806 }
807
808 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
816 let raw_config: PitchforkTomlRaw = toml::from_str(content)
817 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
818
819 let namespace = {
820 let base_explicit = sibling_base_config(path)
821 .and_then(|p| if p.exists() { Some(p) } else { None })
822 .map(|p| read_namespace_override_from_file(&p))
823 .transpose()?
824 .flatten();
825
826 if is_local_config(path)
827 && let (Some(local_ns), Some(base_ns)) =
828 (raw_config.namespace.as_deref(), base_explicit.as_deref())
829 && local_ns != base_ns
830 {
831 return Err(ConfigParseError::InvalidNamespace {
832 path: path.to_path_buf(),
833 namespace: local_ns.to_string(),
834 reason: format!(
835 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
836 ),
837 }
838 .into());
839 }
840
841 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
842 namespace_from_path_with_override(path, explicit)?
843 };
844 let mut pt = Self::new(path.to_path_buf());
845 pt.namespace = raw_config.namespace.clone();
846
847 for (short_name, raw_daemon) in raw_config.daemons {
848 let id = match DaemonId::try_new(&namespace, &short_name) {
849 Ok(id) => id,
850 Err(e) => {
851 return Err(ConfigParseError::InvalidDaemonName {
852 name: short_name,
853 path: path.to_path_buf(),
854 reason: e.to_string(),
855 }
856 .into());
857 }
858 };
859
860 let mut depends = Vec::new();
861 for dep in raw_daemon.depends {
862 let dep_id = if dep.contains('/') {
863 match DaemonId::parse(&dep) {
864 Ok(id) => id,
865 Err(e) => {
866 return Err(ConfigParseError::InvalidDependency {
867 daemon: short_name.clone(),
868 dependency: dep,
869 path: path.to_path_buf(),
870 reason: e.to_string(),
871 }
872 .into());
873 }
874 }
875 } else {
876 match DaemonId::try_new(&namespace, &dep) {
877 Ok(id) => id,
878 Err(e) => {
879 return Err(ConfigParseError::InvalidDependency {
880 daemon: short_name.clone(),
881 dependency: dep,
882 path: path.to_path_buf(),
883 reason: e.to_string(),
884 }
885 .into());
886 }
887 }
888 };
889 depends.push(dep_id);
890 }
891
892 let has_deprecated = !raw_daemon.expected_port.is_empty()
894 || raw_daemon.auto_bump_port.is_some()
895 || raw_daemon.port_bump_attempts.is_some();
896 let port = if let Some(port) = raw_daemon.port {
897 if has_deprecated {
898 warn!(
899 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
900 );
901 }
902 Some(port)
903 } else if has_deprecated {
904 warn!(
905 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
906 );
907 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
908 PortBump(
909 raw_daemon
910 .port_bump_attempts
911 .unwrap_or_else(|| settings().default_port_bump_attempts()),
912 )
913 } else {
914 PortBump(0)
915 };
916 Some(PortConfig {
917 expect: raw_daemon.expected_port,
918 bump,
919 })
920 } else {
921 None
922 };
923
924 let daemon = PitchforkTomlDaemon {
925 run: raw_daemon.run,
926 auto: raw_daemon.auto,
927 cron: raw_daemon.cron,
928 retry: raw_daemon.retry,
929 ready_delay: raw_daemon.ready_delay,
930 ready_output: raw_daemon.ready_output,
931 ready_http: raw_daemon.ready_http,
932 ready_port: raw_daemon.ready_port,
933 ready_cmd: raw_daemon.ready_cmd,
934 port,
935 boot_start: raw_daemon.boot_start,
936 depends,
937 watch: raw_daemon.watch,
938 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
939 dir: raw_daemon.dir,
940 env: raw_daemon.env,
941 hooks: raw_daemon.hooks,
942 mise: raw_daemon.mise,
943 user: raw_daemon.user,
944 memory_limit: raw_daemon.memory_limit,
945 cpu_limit: raw_daemon.cpu_limit,
946 stop_signal: raw_daemon.stop_signal,
947 pty: raw_daemon.pty,
948 path: Some(path.to_path_buf()),
949 };
950 pt.daemons.insert(id, daemon);
951 }
952
953 if let Some(settings) = raw_config.settings {
955 pt.settings = settings;
956 }
957
958 for (slug, entry) in raw_config.slugs {
960 pt.slugs.insert(
961 slug,
962 SlugEntry {
963 dir: PathBuf::from(entry.dir),
964 daemon: entry.daemon,
965 },
966 );
967 }
968
969 for (group_name, raw_group) in raw_config.groups {
971 let mut daemons = Vec::new();
972 for daemon_name in &raw_group.daemons {
973 let id = if daemon_name.contains('/') {
974 DaemonId::parse(daemon_name).map_err(|e| {
975 ConfigParseError::InvalidDependency {
976 daemon: group_name.clone(),
977 dependency: daemon_name.clone(),
978 path: path.to_path_buf(),
979 reason: e.to_string(),
980 }
981 })?
982 } else {
983 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
984 ConfigParseError::InvalidDaemonName {
985 name: daemon_name.clone(),
986 path: path.to_path_buf(),
987 reason: e.to_string(),
988 }
989 })?
990 };
991 daemons.push(id);
992 }
993 pt.groups.insert(group_name, GroupEntry { daemons });
994 }
995
996 Ok(pt)
997 }
998
999 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1000 let path = path.as_ref();
1001 if !path.exists() {
1002 return Ok(Self::new(path.to_path_buf()));
1003 }
1004 let _lock = xx::fslock::get(path, false)
1005 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1006 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1007 path: path.to_path_buf(),
1008 source: e,
1009 })?;
1010 Self::parse_str(&raw, path)
1011 }
1012
1013 pub fn write(&self) -> Result<()> {
1014 if let Some(path) = &self.path {
1015 let _lock = xx::fslock::get(path, false)
1016 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1017 self.write_unlocked()
1018 } else {
1019 Err(FileError::NoPath.into())
1020 }
1021 }
1022
1023 fn write_unlocked(&self) -> Result<()> {
1029 if let Some(path) = &self.path {
1030 let config_namespace = if path.exists() {
1032 namespace_from_path(path)?
1033 } else {
1034 namespace_from_path_with_override(path, self.namespace.as_deref())?
1035 };
1036
1037 let mut raw = PitchforkTomlRaw {
1039 namespace: self.namespace.clone(),
1040 ..PitchforkTomlRaw::default()
1041 };
1042 for (id, daemon) in &self.daemons {
1043 if id.namespace() != config_namespace {
1044 return Err(miette::miette!(
1045 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1046 id,
1047 path.display(),
1048 id.namespace(),
1049 config_namespace
1050 ));
1051 }
1052 let port = daemon.port.as_ref();
1053 let raw_daemon = PitchforkTomlDaemonRaw {
1054 run: daemon.run.clone(),
1055 auto: daemon.auto.clone(),
1056 cron: daemon.cron.clone(),
1057 retry: daemon.retry,
1058 ready_delay: daemon.ready_delay,
1059 ready_output: daemon.ready_output.clone(),
1060 ready_http: daemon.ready_http.clone(),
1061 ready_port: daemon.ready_port,
1062 ready_cmd: daemon.ready_cmd.clone(),
1063 port: port.cloned(),
1064 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1066 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1067 port_bump_attempts: port
1068 .filter(|p| p.auto_bump())
1069 .map(|p| p.max_bump_attempts()),
1070 boot_start: daemon.boot_start,
1071 depends: daemon
1074 .depends
1075 .iter()
1076 .map(|d| {
1077 if d.namespace() == config_namespace {
1078 d.name().to_string()
1079 } else {
1080 d.qualified()
1081 }
1082 })
1083 .collect(),
1084 watch: daemon.watch.clone(),
1085 watch_mode: match daemon.watch_mode {
1086 WatchMode::Native => None,
1087 mode => Some(mode),
1088 },
1089 dir: daemon.dir.clone(),
1090 env: daemon.env.clone(),
1091 hooks: daemon.hooks.clone(),
1092 mise: daemon.mise,
1093 user: daemon.user.clone(),
1094 memory_limit: daemon.memory_limit,
1095 cpu_limit: daemon.cpu_limit,
1096 stop_signal: daemon.stop_signal,
1097 pty: daemon.pty,
1098 };
1099 raw.daemons.insert(id.name().to_string(), raw_daemon);
1100 }
1101
1102 for (slug, entry) in &self.slugs {
1104 raw.slugs.insert(
1105 slug.clone(),
1106 SlugEntryRaw {
1107 dir: entry.dir.to_string_lossy().to_string(),
1108 daemon: entry.daemon.clone(),
1109 },
1110 );
1111 }
1112
1113 for (name, group) in &self.groups {
1115 let raw_daemons: Vec<String> = group
1116 .daemons
1117 .iter()
1118 .map(|id| {
1119 if id.namespace() == config_namespace {
1120 id.name().to_string()
1121 } else {
1122 id.qualified()
1123 }
1124 })
1125 .collect();
1126 raw.groups.insert(
1127 name.clone(),
1128 GroupEntryRaw {
1129 daemons: raw_daemons,
1130 },
1131 );
1132 }
1133
1134 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1135 path: path.clone(),
1136 source: e,
1137 })?;
1138 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1139 path: path.clone(),
1140 details: Some(e.to_string()),
1141 })?;
1142 Ok(())
1143 } else {
1144 Err(FileError::NoPath.into())
1145 }
1146 }
1147
1148 pub fn merge(&mut self, pt: Self) {
1153 for (id, d) in pt.daemons {
1154 self.daemons.insert(id, d);
1155 }
1156 for (slug, entry) in pt.slugs {
1158 self.slugs.insert(slug, entry);
1159 }
1160 for (name, group) in pt.groups {
1162 self.groups.insert(name, group);
1163 }
1164 self.settings.merge_from(&pt.settings);
1166 }
1167
1168 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1173 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1174 Ok(pt) => pt.slugs,
1175 Err(_) => IndexMap::new(),
1176 }
1177 }
1178
1179 pub fn find_slug_for_daemon_in_registry(
1181 daemon_id: &DaemonId,
1182 global_slugs: &IndexMap<String, SlugEntry>,
1183 ) -> Option<String> {
1184 global_slugs
1185 .iter()
1186 .find(|(slug, entry)| {
1187 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1188 if daemon_id.name() != daemon_name {
1189 return false;
1190 }
1191
1192 match Self::namespace_for_dir(&entry.dir) {
1193 Ok(namespace) => daemon_id.namespace() == namespace,
1194 Err(_) => true,
1195 }
1196 })
1197 .map(|(slug, _)| slug.clone())
1198 }
1199
1200 #[allow(dead_code)]
1202 pub fn is_slug_registered(slug: &str) -> bool {
1203 Self::read_global_slugs().contains_key(slug)
1204 }
1205
1206 pub fn add_slug(slug: &str, dir: &Path, daemon: Option<&str>) -> Result<()> {
1210 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1211
1212 if let Some(parent) = global_path.parent() {
1214 std::fs::create_dir_all(parent).map_err(|e| {
1215 miette::miette!(
1216 "Failed to create config directory {}: {e}",
1217 parent.display()
1218 )
1219 })?;
1220 }
1221
1222 let _lock = xx::fslock::get(global_path, false)
1226 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1227
1228 let mut pt = if global_path.exists() {
1229 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1230 path: global_path.to_path_buf(),
1231 source: e,
1232 })?;
1233 Self::parse_str(&raw, global_path)?
1234 } else {
1235 Self::new(global_path.to_path_buf())
1236 };
1237
1238 pt.slugs.insert(
1239 slug.to_string(),
1240 SlugEntry {
1241 dir: dir.to_path_buf(),
1242 daemon: daemon.map(str::to_string),
1243 },
1244 );
1245 pt.write_unlocked()?;
1246 crate::proxy::hosts::sync_hosts_from_settings();
1247 Ok(())
1248 }
1249
1250 pub fn remove_slug(slug: &str) -> Result<bool> {
1252 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1253 if !global_path.exists() {
1254 return Ok(false);
1255 }
1256
1257 let _lock = xx::fslock::get(global_path, false)
1258 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1259
1260 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1261 path: global_path.to_path_buf(),
1262 source: e,
1263 })?;
1264 let mut pt = Self::parse_str(&raw, global_path)?;
1265
1266 let removed = pt.slugs.shift_remove(slug).is_some();
1267 if removed {
1268 pt.write_unlocked()?;
1269 crate::proxy::hosts::sync_hosts_from_settings();
1270 }
1271 Ok(removed)
1272 }
1273}
1274
1275#[derive(Debug, Clone, JsonSchema, Default)]
1277pub struct PitchforkTomlDaemon {
1278 #[schemars(example = example_run_command())]
1280 pub run: String,
1281 #[schemars(default)]
1283 pub auto: Vec<PitchforkTomlAuto>,
1284 pub cron: Option<PitchforkTomlCron>,
1286 #[schemars(default)]
1289 pub retry: Retry,
1290 pub ready_delay: Option<u64>,
1292 pub ready_output: Option<String>,
1294 pub ready_http: Option<ReadyHttp>,
1296 #[schemars(range(min = 1, max = 65535))]
1298 pub ready_port: Option<u16>,
1299 pub ready_cmd: Option<String>,
1301 pub port: Option<PortConfig>,
1303 pub boot_start: Option<bool>,
1305 #[schemars(default)]
1307 pub depends: Vec<DaemonId>,
1308 #[schemars(default)]
1310 pub watch: Vec<String>,
1311 #[schemars(default)]
1317 pub watch_mode: WatchMode,
1318 pub dir: Option<String>,
1320 pub env: Option<IndexMap<String, String>>,
1322 pub hooks: Option<PitchforkTomlHooks>,
1324 pub mise: Option<bool>,
1327 pub user: Option<String>,
1329 pub memory_limit: Option<MemoryLimit>,
1332 pub cpu_limit: Option<CpuLimit>,
1335 pub stop_signal: Option<StopConfig>,
1338 pub pty: Option<bool>,
1340 #[schemars(skip)]
1341 pub path: Option<PathBuf>,
1342}
1343
1344impl PitchforkTomlDaemon {
1345 pub fn to_run_options(
1350 &self,
1351 id: &crate::daemon_id::DaemonId,
1352 cmd: Vec<String>,
1353 ) -> crate::daemon::RunOptions {
1354 use crate::daemon::RunOptions;
1355
1356 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1357 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1358 .into_iter()
1359 .find(|(slug, entry)| {
1360 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1361 if daemon_name != id.name() {
1362 return false;
1363 }
1364
1365 match crate::pitchfork_toml::PitchforkToml::namespace_for_dir(&entry.dir).ok() {
1366 Some(namespace) => namespace == id.namespace(),
1367 None => false,
1368 }
1369 })
1370 .map(|(slug, _)| slug);
1371
1372 RunOptions {
1373 id: id.clone(),
1374 cmd,
1375 force: false,
1376 shell_pid: None,
1377 dir: Dir(dir),
1378 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1379 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1380 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1381 retry: self.retry,
1382 retry_count: 0,
1383 ready_delay: self.ready_delay,
1384 ready_output: self.ready_output.clone(),
1385 ready_http: self.ready_http.clone(),
1386 ready_port: self.ready_port,
1387 ready_cmd: self.ready_cmd.clone(),
1388 port: self.port.clone(),
1389 wait_ready: false,
1390 depends: self.depends.clone(),
1391 env: self.env.clone(),
1392 watch: self.watch.clone(),
1393 watch_mode: self.watch_mode,
1394 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1395 self.path.as_deref(),
1396 )),
1397 mise: self.mise,
1398 slug,
1399 proxy: None,
1400 user: self.user.clone(),
1401 memory_limit: self.memory_limit,
1402 cpu_limit: self.cpu_limit,
1403 stop_signal: self.stop_signal,
1404 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1405 pty: self.pty,
1406 }
1407 }
1408}
1409fn example_run_command() -> &'static str {
1410 "exec node server.js"
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415 use super::*;
1416 use std::path::Path;
1417
1418 #[test]
1419 fn test_daemon_user_parses_and_flows_to_run_options() {
1420 let pt = PitchforkToml::parse_str(
1421 r#"
1422[daemons.api]
1423run = "node server.js"
1424user = "postgres"
1425"#,
1426 Path::new("/tmp/my-project/pitchfork.toml"),
1427 )
1428 .unwrap();
1429
1430 let id = DaemonId::new("my-project", "api");
1431 let daemon = pt.daemons.get(&id).unwrap();
1432 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1433
1434 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1435 assert_eq!(opts.user.as_deref(), Some("postgres"));
1436 }
1437
1438 #[test]
1439 fn test_daemon_user_write_roundtrip() {
1440 let temp = tempfile::tempdir().unwrap();
1441 let path = temp.path().join("pitchfork.toml");
1442 let mut pt = PitchforkToml::new(path.clone());
1443 pt.namespace = Some("test-project".to_string());
1444 pt.daemons.insert(
1445 DaemonId::new("test-project", "api"),
1446 PitchforkTomlDaemon {
1447 run: "node server.js".to_string(),
1448 user: Some("postgres".to_string()),
1449 ..PitchforkTomlDaemon::default()
1450 },
1451 );
1452
1453 pt.write().unwrap();
1454
1455 let raw = std::fs::read_to_string(&path).unwrap();
1456 assert!(raw.contains("user = \"postgres\""));
1457
1458 let parsed = PitchforkToml::read(&path).unwrap();
1459 let daemon = parsed
1460 .daemons
1461 .get(&DaemonId::new("test-project", "api"))
1462 .unwrap();
1463 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1464 }
1465}