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, 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, Default, serde::Serialize, serde::Deserialize)]
45struct PitchforkTomlRaw {
46 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub namespace: Option<String>,
48 #[serde(default)]
49 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
50 #[serde(default)]
51 pub settings: Option<SettingsPartial>,
52 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
55 pub slugs: IndexMap<String, SlugEntryRaw>,
56}
57
58#[derive(Debug, serde::Serialize, serde::Deserialize)]
65struct PitchforkTomlDaemonRaw {
66 pub run: String,
67 #[serde(skip_serializing_if = "Vec::is_empty", default)]
68 pub auto: Vec<PitchforkTomlAuto>,
69 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub cron: Option<PitchforkTomlCron>,
71 #[serde(default)]
72 pub retry: Retry,
73 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub ready_delay: Option<u64>,
75 #[serde(skip_serializing_if = "Option::is_none", default)]
76 pub ready_output: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none", default)]
78 pub ready_http: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none", default)]
80 pub ready_port: Option<u16>,
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub ready_cmd: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none", default)]
85 pub port: Option<PortConfig>,
86 #[serde(skip_serializing_if = "Vec::is_empty", default)]
88 pub expected_port: Vec<u16>,
89 #[serde(skip_serializing_if = "Option::is_none", default)]
91 pub auto_bump_port: Option<bool>,
92 #[serde(skip_serializing_if = "Option::is_none", default)]
94 pub port_bump_attempts: Option<u32>,
95 #[serde(skip_serializing_if = "Option::is_none", default)]
96 pub boot_start: Option<bool>,
97 #[serde(skip_serializing_if = "Vec::is_empty", default)]
98 pub depends: Vec<String>,
99 #[serde(skip_serializing_if = "Vec::is_empty", default)]
100 pub watch: Vec<String>,
101 #[serde(skip_serializing_if = "Option::is_none", default)]
102 pub watch_mode: Option<WatchMode>,
103 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub dir: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none", default)]
106 pub env: Option<IndexMap<String, String>>,
107 #[serde(skip_serializing_if = "Option::is_none", default)]
108 pub hooks: Option<PitchforkTomlHooks>,
109 #[serde(skip_serializing_if = "Option::is_none", default)]
110 pub mise: Option<bool>,
111 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub user: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none", default)]
116 pub memory_limit: Option<MemoryLimit>,
117 #[serde(skip_serializing_if = "Option::is_none", default)]
119 pub cpu_limit: Option<CpuLimit>,
120 #[serde(skip_serializing_if = "Option::is_none", default)]
122 pub stop_signal: Option<StopConfig>,
123 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub pty: Option<bool>,
126}
127
128#[derive(Debug, Default, JsonSchema)]
133#[schemars(title = "Pitchfork Configuration")]
134pub struct PitchforkToml {
135 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
137 pub namespace: Option<String>,
142 #[serde(default)]
151 pub(crate) settings: SettingsPartial,
152 #[schemars(skip)]
157 pub slugs: IndexMap<String, SlugEntry>,
158 #[schemars(skip)]
159 pub path: Option<PathBuf>,
160}
161
162pub(crate) fn is_global_config(path: &Path) -> bool {
163 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
164}
165
166fn is_local_config(path: &Path) -> bool {
167 path.file_name()
168 .map(|n| n == "pitchfork.local.toml")
169 .unwrap_or(false)
170}
171
172pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
173 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
174}
175
176fn sibling_base_config(path: &Path) -> Option<PathBuf> {
177 if !is_local_config(path) {
178 return None;
179 }
180 path.parent().map(|p| p.join("pitchfork.toml"))
181}
182
183fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
184 use toml::Value;
185
186 let doc: Value = toml::from_str(content)
187 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
188 let Some(value) = doc.get("namespace") else {
189 return Ok(None);
190 };
191
192 match value {
193 Value::String(s) => Ok(Some(s.clone())),
194 _ => Err(ConfigParseError::InvalidNamespace {
195 path: path.to_path_buf(),
196 namespace: value.to_string(),
197 reason: "top-level 'namespace' must be a string".to_string(),
198 }
199 .into()),
200 }
201}
202
203fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
204 if !path.exists() {
205 return Ok(None);
206 }
207 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
208 path: path.to_path_buf(),
209 source: e,
210 })?;
211 parse_namespace_override_from_content(path, &content)
212}
213
214fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
215 if let Err(e) = DaemonId::try_new(namespace, "probe") {
216 return Err(ConfigParseError::InvalidNamespace {
217 path: path.to_path_buf(),
218 namespace: namespace.to_string(),
219 reason: e.to_string(),
220 }
221 .into());
222 }
223 Ok(namespace.to_string())
224}
225
226fn derive_namespace_from_dir(path: &Path) -> Result<String> {
227 let dir_for_namespace = if is_dot_config_pitchfork(path) {
228 path.parent().and_then(|p| p.parent())
229 } else {
230 path.parent()
231 };
232
233 let raw_namespace = dir_for_namespace
234 .and_then(|p| p.file_name())
235 .and_then(|n| n.to_str())
236 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
237 .to_string();
238
239 validate_namespace(path, &raw_namespace).map_err(|e| {
240 ConfigParseError::InvalidNamespace {
241 path: path.to_path_buf(),
242 namespace: raw_namespace,
243 reason: format!(
244 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
245 ),
246 }
247 .into()
248 })
249}
250
251fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
252 if is_global_config(path) {
253 if let Some(ns) = explicit
254 && ns != "global"
255 {
256 return Err(ConfigParseError::InvalidNamespace {
257 path: path.to_path_buf(),
258 namespace: ns.to_string(),
259 reason: "global config files must use namespace 'global'".to_string(),
260 }
261 .into());
262 }
263 return Ok("global".to_string());
264 }
265
266 if let Some(ns) = explicit {
267 return validate_namespace(path, ns);
268 }
269
270 derive_namespace_from_dir(path)
271}
272
273fn namespace_from_file(path: &Path) -> Result<String> {
274 let explicit = read_namespace_override_from_file(path)?;
275 let base_explicit = sibling_base_config(path)
276 .and_then(|p| if p.exists() { Some(p) } else { None })
277 .map(|p| read_namespace_override_from_file(&p))
278 .transpose()?
279 .flatten();
280
281 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
282 && local_ns != base_ns
283 {
284 return Err(ConfigParseError::InvalidNamespace {
285 path: path.to_path_buf(),
286 namespace: local_ns.to_string(),
287 reason: format!(
288 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
289 ),
290 }
291 .into());
292 }
293
294 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
295 namespace_from_path_with_override(path, effective_explicit)
296}
297
298pub fn namespace_from_path(path: &Path) -> Result<String> {
311 namespace_from_file(path)
312}
313
314impl PitchforkToml {
315 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
328 if user_id.contains('/') {
330 return match DaemonId::parse(user_id) {
331 Ok(id) => Ok(vec![id]),
332 Err(e) => Err(e), };
334 }
335
336 let global_slugs = Self::read_global_slugs();
338 if let Some(entry) = global_slugs.get(user_id) {
339 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
341 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
342 let matches: Vec<DaemonId> = project_config
344 .daemons
345 .keys()
346 .filter(|id| id.name() == daemon_name)
347 .cloned()
348 .collect();
349 match matches.as_slice() {
350 [] => {}
351 [id] => return Ok(vec![id.clone()]),
352 _ => {
353 let mut candidates: Vec<String> =
354 matches.iter().map(|id| id.qualified()).collect();
355 candidates.sort();
356 return Err(miette::miette!(
357 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
358 user_id,
359 daemon_name,
360 candidates.join(", ")
361 ));
362 }
363 }
364 }
365 }
366
367 let matches: Vec<DaemonId> = self
369 .daemons
370 .keys()
371 .filter(|id| id.name() == user_id)
372 .cloned()
373 .collect();
374
375 if matches.is_empty() {
376 let _ = DaemonId::try_new("global", user_id)?;
378 }
379 Ok(matches)
380 }
381
382 #[allow(dead_code)]
403 pub fn resolve_daemon_id_prefer_local(
404 &self,
405 user_id: &str,
406 current_dir: &Path,
407 ) -> Result<DaemonId> {
408 if user_id.contains('/') {
410 return DaemonId::parse(user_id);
411 }
412
413 let current_namespace = Self::namespace_for_dir(current_dir)?;
417
418 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
419 }
420
421 fn resolve_daemon_id_with_namespace(
424 &self,
425 user_id: &str,
426 current_namespace: &str,
427 ) -> Result<DaemonId> {
428 let global_slugs = Self::read_global_slugs();
430 if let Some(entry) = global_slugs.get(user_id) {
431 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
432 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
433 let matches: Vec<DaemonId> = project_config
434 .daemons
435 .keys()
436 .filter(|id| id.name() == daemon_name)
437 .cloned()
438 .collect();
439 match matches.as_slice() {
440 [] => {}
441 [id] => return Ok(id.clone()),
442 _ => {
443 let mut candidates: Vec<String> =
444 matches.iter().map(|id| id.qualified()).collect();
445 candidates.sort();
446 return Err(miette::miette!(
447 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
448 user_id,
449 daemon_name,
450 candidates.join(", ")
451 ));
452 }
453 }
454 }
455 }
456
457 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
460 if self.daemons.contains_key(&preferred_id) {
461 return Ok(preferred_id);
462 }
463
464 let matches = self.resolve_daemon_id(user_id)?;
466
467 if matches.len() > 1 {
469 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
470 candidates.sort();
471 return Err(miette::miette!(
472 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
473 user_id,
474 candidates.join(", ")
475 ));
476 }
477
478 if let Some(id) = matches.into_iter().next() {
479 return Ok(id);
480 }
481
482 let global_id = DaemonId::try_new("global", user_id)?;
485 if self.daemons.contains_key(&global_id) {
486 return Ok(global_id);
487 }
488
489 if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
493 && state.daemons.contains_key(&global_id)
494 {
495 return Ok(global_id);
496 }
497
498 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
499 Err(DependencyError::DaemonNotFound {
500 name: user_id.to_string(),
501 suggestion,
502 }
503 .into())
504 }
505
506 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
509 Ok(Self::list_paths_from(dir)
510 .iter()
511 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
513 .transpose()?
514 .unwrap_or_else(|| "global".to_string()))
515 }
516
517 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
527 if user_id.contains('/') {
528 return DaemonId::parse(user_id);
529 }
530
531 let config = Self::all_merged()?;
534 let ns = Self::namespace_for_dir(&env::CWD)?;
535 config.resolve_daemon_id_with_namespace(user_id, &ns)
536 }
537
538 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
544 if user_id.contains('/') {
545 return DaemonId::parse(user_id);
546 }
547
548 let config = Self::all_merged()?;
549 let ns = Self::namespace_for_dir(&env::CWD)?;
550
551 let preferred_id = DaemonId::try_new(&ns, user_id)?;
552 if config.daemons.contains_key(&preferred_id) {
553 return Ok(preferred_id);
554 }
555
556 let matches = config.resolve_daemon_id(user_id)?;
557 if matches.len() > 1 {
558 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
559 candidates.sort();
560 return Err(miette::miette!(
561 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
562 user_id,
563 candidates.join(", ")
564 ));
565 }
566 if let Some(id) = matches.into_iter().next() {
567 return Ok(id);
568 }
569
570 DaemonId::try_new("global", user_id)
571 }
572
573 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
584 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
586 return user_ids
587 .iter()
588 .map(|s| DaemonId::parse(s.as_ref()))
589 .collect();
590 }
591
592 let config = Self::all_merged()?;
593 let ns = Self::namespace_for_dir(&env::CWD)?;
595 user_ids
596 .iter()
597 .map(|s| {
598 let id = s.as_ref();
599 if id.contains('/') {
600 DaemonId::parse(id)
601 } else {
602 config.resolve_daemon_id_with_namespace(id, &ns)
603 }
604 })
605 .collect()
606 }
607
608 pub fn list_paths() -> Vec<PathBuf> {
611 Self::list_paths_from(&env::CWD)
612 }
613
614 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
625 let mut paths = Vec::new();
626 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
627 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
628
629 let mut project_paths = xx::file::find_up_all(
633 cwd,
634 &[
635 "pitchfork.local.toml",
636 "pitchfork.toml",
637 ".config/pitchfork.local.toml",
638 ".config/pitchfork.toml",
639 ],
640 );
641 project_paths.reverse();
642 paths.extend(project_paths);
643
644 paths
645 }
646
647 pub fn all_merged() -> Result<PitchforkToml> {
650 Self::all_merged_from(&env::CWD)
651 }
652
653 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
667 use std::collections::HashMap;
668
669 let paths = Self::list_paths_from(cwd);
670 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
671
672 let mut pt = Self::default();
673 for p in paths {
674 match Self::read(&p) {
675 Ok(pt2) => {
676 if p.exists() && !is_global_config(&p) {
680 let ns = namespace_from_path(&p)?;
681 let origin_dir = if is_dot_config_pitchfork(&p) {
682 p.parent().and_then(|d| d.parent())
683 } else {
684 p.parent()
685 }
686 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
687 .unwrap_or_else(|| p.clone());
688
689 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
690 && *other_dir != origin_dir
691 {
692 return Err(crate::error::ConfigParseError::NamespaceCollision {
693 path_a: other_path.clone(),
694 path_b: p.clone(),
695 ns,
696 }
697 .into());
698 }
699 ns_to_origin.insert(ns, (p.clone(), origin_dir));
700 }
701
702 pt.merge(pt2)
703 }
704 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
705 }
706 }
707 Ok(pt)
708 }
709}
710
711impl PitchforkToml {
712 pub fn new(path: PathBuf) -> Self {
713 Self {
714 daemons: Default::default(),
715 namespace: None,
716 settings: SettingsPartial::default(),
717 slugs: IndexMap::new(),
718 path: Some(path),
719 }
720 }
721
722 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
730 let raw_config: PitchforkTomlRaw = toml::from_str(content)
731 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
732
733 let namespace = {
734 let base_explicit = sibling_base_config(path)
735 .and_then(|p| if p.exists() { Some(p) } else { None })
736 .map(|p| read_namespace_override_from_file(&p))
737 .transpose()?
738 .flatten();
739
740 if is_local_config(path)
741 && let (Some(local_ns), Some(base_ns)) =
742 (raw_config.namespace.as_deref(), base_explicit.as_deref())
743 && local_ns != base_ns
744 {
745 return Err(ConfigParseError::InvalidNamespace {
746 path: path.to_path_buf(),
747 namespace: local_ns.to_string(),
748 reason: format!(
749 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
750 ),
751 }
752 .into());
753 }
754
755 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
756 namespace_from_path_with_override(path, explicit)?
757 };
758 let mut pt = Self::new(path.to_path_buf());
759 pt.namespace = raw_config.namespace.clone();
760
761 for (short_name, raw_daemon) in raw_config.daemons {
762 let id = match DaemonId::try_new(&namespace, &short_name) {
763 Ok(id) => id,
764 Err(e) => {
765 return Err(ConfigParseError::InvalidDaemonName {
766 name: short_name,
767 path: path.to_path_buf(),
768 reason: e.to_string(),
769 }
770 .into());
771 }
772 };
773
774 let mut depends = Vec::new();
775 for dep in raw_daemon.depends {
776 let dep_id = if dep.contains('/') {
777 match DaemonId::parse(&dep) {
778 Ok(id) => id,
779 Err(e) => {
780 return Err(ConfigParseError::InvalidDependency {
781 daemon: short_name.clone(),
782 dependency: dep,
783 path: path.to_path_buf(),
784 reason: e.to_string(),
785 }
786 .into());
787 }
788 }
789 } else {
790 match DaemonId::try_new(&namespace, &dep) {
791 Ok(id) => id,
792 Err(e) => {
793 return Err(ConfigParseError::InvalidDependency {
794 daemon: short_name.clone(),
795 dependency: dep,
796 path: path.to_path_buf(),
797 reason: e.to_string(),
798 }
799 .into());
800 }
801 }
802 };
803 depends.push(dep_id);
804 }
805
806 let has_deprecated = !raw_daemon.expected_port.is_empty()
808 || raw_daemon.auto_bump_port.is_some()
809 || raw_daemon.port_bump_attempts.is_some();
810 let port = if let Some(port) = raw_daemon.port {
811 if has_deprecated {
812 warn!(
813 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
814 );
815 }
816 Some(port)
817 } else if has_deprecated {
818 warn!(
819 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
820 );
821 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
822 PortBump(
823 raw_daemon
824 .port_bump_attempts
825 .unwrap_or_else(|| settings().default_port_bump_attempts()),
826 )
827 } else {
828 PortBump(0)
829 };
830 Some(PortConfig {
831 expect: raw_daemon.expected_port,
832 bump,
833 })
834 } else {
835 None
836 };
837
838 let daemon = PitchforkTomlDaemon {
839 run: raw_daemon.run,
840 auto: raw_daemon.auto,
841 cron: raw_daemon.cron,
842 retry: raw_daemon.retry,
843 ready_delay: raw_daemon.ready_delay,
844 ready_output: raw_daemon.ready_output,
845 ready_http: raw_daemon.ready_http,
846 ready_port: raw_daemon.ready_port,
847 ready_cmd: raw_daemon.ready_cmd,
848 port,
849 boot_start: raw_daemon.boot_start,
850 depends,
851 watch: raw_daemon.watch,
852 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
853 dir: raw_daemon.dir,
854 env: raw_daemon.env,
855 hooks: raw_daemon.hooks,
856 mise: raw_daemon.mise,
857 user: raw_daemon.user,
858 memory_limit: raw_daemon.memory_limit,
859 cpu_limit: raw_daemon.cpu_limit,
860 stop_signal: raw_daemon.stop_signal,
861 pty: raw_daemon.pty,
862 path: Some(path.to_path_buf()),
863 };
864 pt.daemons.insert(id, daemon);
865 }
866
867 if let Some(settings) = raw_config.settings {
869 pt.settings = settings;
870 }
871
872 for (slug, entry) in raw_config.slugs {
874 pt.slugs.insert(
875 slug,
876 SlugEntry {
877 dir: PathBuf::from(entry.dir),
878 daemon: entry.daemon,
879 },
880 );
881 }
882
883 Ok(pt)
884 }
885
886 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
887 let path = path.as_ref();
888 if !path.exists() {
889 return Ok(Self::new(path.to_path_buf()));
890 }
891 let _lock = xx::fslock::get(path, false)
892 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
893 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
894 path: path.to_path_buf(),
895 source: e,
896 })?;
897 Self::parse_str(&raw, path)
898 }
899
900 pub fn write(&self) -> Result<()> {
901 if let Some(path) = &self.path {
902 let _lock = xx::fslock::get(path, false)
903 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
904 self.write_unlocked()
905 } else {
906 Err(FileError::NoPath.into())
907 }
908 }
909
910 fn write_unlocked(&self) -> Result<()> {
916 if let Some(path) = &self.path {
917 let config_namespace = if path.exists() {
919 namespace_from_path(path)?
920 } else {
921 namespace_from_path_with_override(path, self.namespace.as_deref())?
922 };
923
924 let mut raw = PitchforkTomlRaw {
926 namespace: self.namespace.clone(),
927 ..PitchforkTomlRaw::default()
928 };
929 for (id, daemon) in &self.daemons {
930 if id.namespace() != config_namespace {
931 return Err(miette::miette!(
932 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
933 id,
934 path.display(),
935 id.namespace(),
936 config_namespace
937 ));
938 }
939 let port = daemon.port.as_ref();
940 let raw_daemon = PitchforkTomlDaemonRaw {
941 run: daemon.run.clone(),
942 auto: daemon.auto.clone(),
943 cron: daemon.cron.clone(),
944 retry: daemon.retry,
945 ready_delay: daemon.ready_delay,
946 ready_output: daemon.ready_output.clone(),
947 ready_http: daemon.ready_http.clone(),
948 ready_port: daemon.ready_port,
949 ready_cmd: daemon.ready_cmd.clone(),
950 port: port.cloned(),
951 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
953 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
954 port_bump_attempts: port
955 .filter(|p| p.auto_bump())
956 .map(|p| p.max_bump_attempts()),
957 boot_start: daemon.boot_start,
958 depends: daemon
961 .depends
962 .iter()
963 .map(|d| {
964 if d.namespace() == config_namespace {
965 d.name().to_string()
966 } else {
967 d.qualified()
968 }
969 })
970 .collect(),
971 watch: daemon.watch.clone(),
972 watch_mode: match daemon.watch_mode {
973 WatchMode::Native => None,
974 mode => Some(mode),
975 },
976 dir: daemon.dir.clone(),
977 env: daemon.env.clone(),
978 hooks: daemon.hooks.clone(),
979 mise: daemon.mise,
980 user: daemon.user.clone(),
981 memory_limit: daemon.memory_limit,
982 cpu_limit: daemon.cpu_limit,
983 stop_signal: daemon.stop_signal,
984 pty: daemon.pty,
985 };
986 raw.daemons.insert(id.name().to_string(), raw_daemon);
987 }
988
989 for (slug, entry) in &self.slugs {
991 raw.slugs.insert(
992 slug.clone(),
993 SlugEntryRaw {
994 dir: entry.dir.to_string_lossy().to_string(),
995 daemon: entry.daemon.clone(),
996 },
997 );
998 }
999
1000 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1001 path: path.clone(),
1002 source: e,
1003 })?;
1004 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1005 path: path.clone(),
1006 details: Some(e.to_string()),
1007 })?;
1008 Ok(())
1009 } else {
1010 Err(FileError::NoPath.into())
1011 }
1012 }
1013
1014 pub fn merge(&mut self, pt: Self) {
1019 for (id, d) in pt.daemons {
1020 self.daemons.insert(id, d);
1021 }
1022 for (slug, entry) in pt.slugs {
1024 self.slugs.insert(slug, entry);
1025 }
1026 self.settings.merge_from(&pt.settings);
1028 }
1029
1030 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1035 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1036 Ok(pt) => pt.slugs,
1037 Err(_) => IndexMap::new(),
1038 }
1039 }
1040
1041 #[allow(dead_code)]
1043 pub fn is_slug_registered(slug: &str) -> bool {
1044 Self::read_global_slugs().contains_key(slug)
1045 }
1046
1047 pub fn add_slug(slug: &str, dir: &Path, daemon: Option<&str>) -> Result<()> {
1051 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1052
1053 if let Some(parent) = global_path.parent() {
1055 std::fs::create_dir_all(parent).map_err(|e| {
1056 miette::miette!(
1057 "Failed to create config directory {}: {e}",
1058 parent.display()
1059 )
1060 })?;
1061 }
1062
1063 let _lock = xx::fslock::get(global_path, false)
1067 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1068
1069 let mut pt = if global_path.exists() {
1070 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1071 path: global_path.to_path_buf(),
1072 source: e,
1073 })?;
1074 Self::parse_str(&raw, global_path)?
1075 } else {
1076 Self::new(global_path.to_path_buf())
1077 };
1078
1079 pt.slugs.insert(
1080 slug.to_string(),
1081 SlugEntry {
1082 dir: dir.to_path_buf(),
1083 daemon: daemon.map(str::to_string),
1084 },
1085 );
1086 pt.write_unlocked()
1087 }
1088
1089 pub fn remove_slug(slug: &str) -> Result<bool> {
1091 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1092 if !global_path.exists() {
1093 return Ok(false);
1094 }
1095
1096 let _lock = xx::fslock::get(global_path, false)
1097 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1098
1099 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1100 path: global_path.to_path_buf(),
1101 source: e,
1102 })?;
1103 let mut pt = Self::parse_str(&raw, global_path)?;
1104
1105 let removed = pt.slugs.shift_remove(slug).is_some();
1106 if removed {
1107 pt.write_unlocked()?;
1108 }
1109 Ok(removed)
1110 }
1111}
1112
1113#[derive(Debug, Clone, JsonSchema, Default)]
1115pub struct PitchforkTomlDaemon {
1116 #[schemars(example = example_run_command())]
1118 pub run: String,
1119 #[schemars(default)]
1121 pub auto: Vec<PitchforkTomlAuto>,
1122 pub cron: Option<PitchforkTomlCron>,
1124 #[schemars(default)]
1127 pub retry: Retry,
1128 pub ready_delay: Option<u64>,
1130 pub ready_output: Option<String>,
1132 pub ready_http: Option<String>,
1134 #[schemars(range(min = 1, max = 65535))]
1136 pub ready_port: Option<u16>,
1137 pub ready_cmd: Option<String>,
1139 pub port: Option<PortConfig>,
1141 pub boot_start: Option<bool>,
1143 #[schemars(default)]
1145 pub depends: Vec<DaemonId>,
1146 #[schemars(default)]
1148 pub watch: Vec<String>,
1149 #[schemars(default)]
1155 pub watch_mode: WatchMode,
1156 pub dir: Option<String>,
1158 pub env: Option<IndexMap<String, String>>,
1160 pub hooks: Option<PitchforkTomlHooks>,
1162 pub mise: Option<bool>,
1165 pub user: Option<String>,
1167 pub memory_limit: Option<MemoryLimit>,
1170 pub cpu_limit: Option<CpuLimit>,
1173 pub stop_signal: Option<StopConfig>,
1176 pub pty: Option<bool>,
1178 #[schemars(skip)]
1179 pub path: Option<PathBuf>,
1180}
1181
1182impl PitchforkTomlDaemon {
1183 pub fn to_run_options(
1188 &self,
1189 id: &crate::daemon_id::DaemonId,
1190 cmd: Vec<String>,
1191 ) -> crate::daemon::RunOptions {
1192 use crate::daemon::RunOptions;
1193
1194 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1195
1196 RunOptions {
1197 id: id.clone(),
1198 cmd,
1199 force: false,
1200 shell_pid: None,
1201 dir: Dir(dir),
1202 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1203 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1204 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1205 retry: self.retry,
1206 retry_count: 0,
1207 ready_delay: self.ready_delay,
1208 ready_output: self.ready_output.clone(),
1209 ready_http: self.ready_http.clone(),
1210 ready_port: self.ready_port,
1211 ready_cmd: self.ready_cmd.clone(),
1212 port: self.port.clone(),
1213 wait_ready: false,
1214 depends: self.depends.clone(),
1215 env: self.env.clone(),
1216 watch: self.watch.clone(),
1217 watch_mode: self.watch_mode,
1218 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1219 self.path.as_deref(),
1220 )),
1221 mise: self.mise,
1222 slug: None,
1223 proxy: None,
1224 user: self.user.clone(),
1225 memory_limit: self.memory_limit,
1226 cpu_limit: self.cpu_limit,
1227 stop_signal: self.stop_signal,
1228 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1229 pty: self.pty,
1230 }
1231 }
1232}
1233fn example_run_command() -> &'static str {
1234 "exec node server.js"
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use std::path::Path;
1241
1242 #[test]
1243 fn test_daemon_user_parses_and_flows_to_run_options() {
1244 let pt = PitchforkToml::parse_str(
1245 r#"
1246[daemons.api]
1247run = "node server.js"
1248user = "postgres"
1249"#,
1250 Path::new("/tmp/my-project/pitchfork.toml"),
1251 )
1252 .unwrap();
1253
1254 let id = DaemonId::new("my-project", "api");
1255 let daemon = pt.daemons.get(&id).unwrap();
1256 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1257
1258 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1259 assert_eq!(opts.user.as_deref(), Some("postgres"));
1260 }
1261
1262 #[test]
1263 fn test_daemon_user_write_roundtrip() {
1264 let temp = tempfile::tempdir().unwrap();
1265 let path = temp.path().join("pitchfork.toml");
1266 let mut pt = PitchforkToml::new(path.clone());
1267 pt.namespace = Some("test-project".to_string());
1268 pt.daemons.insert(
1269 DaemonId::new("test-project", "api"),
1270 PitchforkTomlDaemon {
1271 run: "node server.js".to_string(),
1272 user: Some("postgres".to_string()),
1273 ..PitchforkTomlDaemon::default()
1274 },
1275 );
1276
1277 pt.write().unwrap();
1278
1279 let raw = std::fs::read_to_string(&path).unwrap();
1280 assert!(raw.contains("user = \"postgres\""));
1281
1282 let parsed = PitchforkToml::read(&path).unwrap();
1283 let daemon = parsed
1284 .daemons
1285 .get(&DaemonId::new("test-project", "api"))
1286 .unwrap();
1287 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1288 }
1289}