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 humanbyte::HumanByte;
8use indexmap::IndexMap;
9use miette::Context;
10use schemars::JsonSchema;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::path::{Path, PathBuf};
13
14#[derive(Clone, Copy, PartialEq, Eq, HumanByte)]
19pub struct MemoryLimit(pub u64);
20
21impl JsonSchema for MemoryLimit {
22 fn schema_name() -> std::borrow::Cow<'static, str> {
23 std::borrow::Cow::Borrowed("MemoryLimit")
24 }
25
26 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
27 schemars::json_schema!({
28 "type": "string",
29 "description": "Memory limit in human-readable format, e.g. '50MB', '1GiB', '512KB'"
30 })
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct CpuLimit(pub f32);
41
42impl std::fmt::Display for CpuLimit {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 write!(f, "{}%", self.0)
45 }
46}
47
48impl Serialize for CpuLimit {
49 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
50 where
51 S: Serializer,
52 {
53 serializer.serialize_f64(self.0 as f64)
54 }
55}
56
57impl<'de> Deserialize<'de> for CpuLimit {
58 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
59 where
60 D: Deserializer<'de>,
61 {
62 let v = f64::deserialize(deserializer)?;
63 if v <= 0.0 {
64 return Err(serde::de::Error::custom("cpu_limit must be positive"));
65 }
66 Ok(CpuLimit(v as f32))
67 }
68}
69
70impl JsonSchema for CpuLimit {
71 fn schema_name() -> std::borrow::Cow<'static, str> {
72 std::borrow::Cow::Borrowed("CpuLimit")
73 }
74
75 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
76 schemars::json_schema!({
77 "type": "number",
78 "description": "CPU usage limit as a percentage (e.g. 80 for 80% of one core, 200 for 2 cores)",
79 "exclusiveMinimum": 0
80 })
81 }
82}
83
84#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
86struct PitchforkTomlRaw {
87 #[serde(skip_serializing_if = "Option::is_none", default)]
88 pub namespace: Option<String>,
89 #[serde(default)]
90 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
91 #[serde(default)]
92 pub settings: Option<SettingsPartial>,
93}
94
95#[derive(Debug, serde::Serialize, serde::Deserialize)]
102struct PitchforkTomlDaemonRaw {
103 pub run: String,
104 #[serde(skip_serializing_if = "Vec::is_empty", default)]
105 pub auto: Vec<PitchforkTomlAuto>,
106 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub cron: Option<PitchforkTomlCron>,
108 #[serde(default)]
109 pub retry: Retry,
110 #[serde(skip_serializing_if = "Option::is_none", default)]
111 pub ready_delay: Option<u64>,
112 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub ready_output: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub ready_http: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none", default)]
117 pub ready_port: Option<u16>,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
119 pub ready_cmd: Option<String>,
120 #[serde(skip_serializing_if = "Vec::is_empty", default)]
121 pub expected_port: Vec<u16>,
122 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub auto_bump_port: Option<bool>,
124 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub port_bump_attempts: Option<u32>,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub boot_start: Option<bool>,
128 #[serde(skip_serializing_if = "Vec::is_empty", default)]
129 pub depends: Vec<String>,
130 #[serde(skip_serializing_if = "Vec::is_empty", default)]
131 pub watch: Vec<String>,
132 #[serde(skip_serializing_if = "Option::is_none", default)]
133 pub dir: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none", default)]
135 pub env: Option<IndexMap<String, String>>,
136 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub hooks: Option<PitchforkTomlHooks>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
139 pub mise: Option<bool>,
140 #[serde(skip_serializing_if = "Option::is_none", default)]
142 pub memory_limit: Option<MemoryLimit>,
143 #[serde(skip_serializing_if = "Option::is_none", default)]
145 pub cpu_limit: Option<CpuLimit>,
146}
147
148#[derive(Debug, Default, JsonSchema)]
153#[schemars(title = "Pitchfork Configuration")]
154pub struct PitchforkToml {
155 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
157 pub namespace: Option<String>,
162 #[serde(default)]
171 pub(crate) settings: SettingsPartial,
172 #[schemars(skip)]
173 pub path: Option<PathBuf>,
174}
175
176pub(crate) fn is_global_config(path: &Path) -> bool {
177 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
178}
179
180fn is_local_config(path: &Path) -> bool {
181 path.file_name()
182 .map(|n| n == "pitchfork.local.toml")
183 .unwrap_or(false)
184}
185
186pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
187 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
188}
189
190fn sibling_base_config(path: &Path) -> Option<PathBuf> {
191 if !is_local_config(path) {
192 return None;
193 }
194 path.parent().map(|p| p.join("pitchfork.toml"))
195}
196
197fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
198 use toml::Value;
199
200 let doc: Value = toml::from_str(content)
201 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
202 let Some(value) = doc.get("namespace") else {
203 return Ok(None);
204 };
205
206 match value {
207 Value::String(s) => Ok(Some(s.clone())),
208 _ => Err(ConfigParseError::InvalidNamespace {
209 path: path.to_path_buf(),
210 namespace: value.to_string(),
211 reason: "top-level 'namespace' must be a string".to_string(),
212 }
213 .into()),
214 }
215}
216
217fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
218 if !path.exists() {
219 return Ok(None);
220 }
221 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
222 path: path.to_path_buf(),
223 source: e,
224 })?;
225 parse_namespace_override_from_content(path, &content)
226}
227
228fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
229 if let Err(e) = DaemonId::try_new(namespace, "probe") {
230 return Err(ConfigParseError::InvalidNamespace {
231 path: path.to_path_buf(),
232 namespace: namespace.to_string(),
233 reason: e.to_string(),
234 }
235 .into());
236 }
237 Ok(namespace.to_string())
238}
239
240fn derive_namespace_from_dir(path: &Path) -> Result<String> {
241 let dir_for_namespace = if is_dot_config_pitchfork(path) {
242 path.parent().and_then(|p| p.parent())
243 } else {
244 path.parent()
245 };
246
247 let raw_namespace = dir_for_namespace
248 .and_then(|p| p.file_name())
249 .and_then(|n| n.to_str())
250 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
251 .to_string();
252
253 validate_namespace(path, &raw_namespace).map_err(|e| {
254 ConfigParseError::InvalidNamespace {
255 path: path.to_path_buf(),
256 namespace: raw_namespace,
257 reason: format!(
258 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
259 ),
260 }
261 .into()
262 })
263}
264
265fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
266 if is_global_config(path) {
267 if let Some(ns) = explicit
268 && ns != "global"
269 {
270 return Err(ConfigParseError::InvalidNamespace {
271 path: path.to_path_buf(),
272 namespace: ns.to_string(),
273 reason: "global config files must use namespace 'global'".to_string(),
274 }
275 .into());
276 }
277 return Ok("global".to_string());
278 }
279
280 if let Some(ns) = explicit {
281 return validate_namespace(path, ns);
282 }
283
284 derive_namespace_from_dir(path)
285}
286
287fn namespace_from_file(path: &Path) -> Result<String> {
288 let explicit = read_namespace_override_from_file(path)?;
289 let base_explicit = sibling_base_config(path)
290 .and_then(|p| if p.exists() { Some(p) } else { None })
291 .map(|p| read_namespace_override_from_file(&p))
292 .transpose()?
293 .flatten();
294
295 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
296 && local_ns != base_ns
297 {
298 return Err(ConfigParseError::InvalidNamespace {
299 path: path.to_path_buf(),
300 namespace: local_ns.to_string(),
301 reason: format!(
302 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
303 ),
304 }
305 .into());
306 }
307
308 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
309 namespace_from_path_with_override(path, effective_explicit)
310}
311
312pub fn namespace_from_path(path: &Path) -> Result<String> {
325 namespace_from_file(path)
326}
327
328impl PitchforkToml {
329 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
342 if user_id.contains('/') {
344 return match DaemonId::parse(user_id) {
345 Ok(id) => Ok(vec![id]),
346 Err(e) => Err(e), };
348 }
349
350 let matches: Vec<DaemonId> = self
352 .daemons
353 .keys()
354 .filter(|id| id.name() == user_id)
355 .cloned()
356 .collect();
357
358 if matches.is_empty() {
359 let _ = DaemonId::try_new("global", user_id)?;
361 }
362 Ok(matches)
363 }
364
365 #[allow(dead_code)]
386 pub fn resolve_daemon_id_prefer_local(
387 &self,
388 user_id: &str,
389 current_dir: &Path,
390 ) -> Result<DaemonId> {
391 if user_id.contains('/') {
393 return DaemonId::parse(user_id);
394 }
395
396 let current_namespace = Self::namespace_for_dir(current_dir)?;
400
401 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
402 }
403
404 fn resolve_daemon_id_with_namespace(
407 &self,
408 user_id: &str,
409 current_namespace: &str,
410 ) -> Result<DaemonId> {
411 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
414 if self.daemons.contains_key(&preferred_id) {
415 return Ok(preferred_id);
416 }
417
418 let matches = self.resolve_daemon_id(user_id)?;
420
421 if matches.len() > 1 {
423 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
424 candidates.sort();
425 return Err(miette::miette!(
426 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
427 user_id,
428 candidates.join(", ")
429 ));
430 }
431
432 if let Some(id) = matches.into_iter().next() {
433 return Ok(id);
434 }
435
436 let global_id = DaemonId::try_new("global", user_id)?;
439 if self.daemons.contains_key(&global_id) {
440 return Ok(global_id);
441 }
442
443 if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
447 && state.daemons.contains_key(&global_id)
448 {
449 return Ok(global_id);
450 }
451
452 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
453 Err(DependencyError::DaemonNotFound {
454 name: user_id.to_string(),
455 suggestion,
456 }
457 .into())
458 }
459
460 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
463 Ok(Self::list_paths_from(dir)
464 .iter()
465 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
467 .transpose()?
468 .unwrap_or_else(|| "global".to_string()))
469 }
470
471 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
481 if user_id.contains('/') {
482 return DaemonId::parse(user_id);
483 }
484
485 let config = Self::all_merged()?;
488 let ns = Self::namespace_for_dir(&env::CWD)?;
489 config.resolve_daemon_id_with_namespace(user_id, &ns)
490 }
491
492 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
498 if user_id.contains('/') {
499 return DaemonId::parse(user_id);
500 }
501
502 let config = Self::all_merged()?;
503 let ns = Self::namespace_for_dir(&env::CWD)?;
504
505 let preferred_id = DaemonId::try_new(&ns, user_id)?;
506 if config.daemons.contains_key(&preferred_id) {
507 return Ok(preferred_id);
508 }
509
510 let matches = config.resolve_daemon_id(user_id)?;
511 if matches.len() > 1 {
512 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
513 candidates.sort();
514 return Err(miette::miette!(
515 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
516 user_id,
517 candidates.join(", ")
518 ));
519 }
520 if let Some(id) = matches.into_iter().next() {
521 return Ok(id);
522 }
523
524 DaemonId::try_new("global", user_id)
525 }
526
527 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
538 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
540 return user_ids
541 .iter()
542 .map(|s| DaemonId::parse(s.as_ref()))
543 .collect();
544 }
545
546 let config = Self::all_merged()?;
547 let ns = Self::namespace_for_dir(&env::CWD)?;
549 user_ids
550 .iter()
551 .map(|s| {
552 let id = s.as_ref();
553 if id.contains('/') {
554 DaemonId::parse(id)
555 } else {
556 config.resolve_daemon_id_with_namespace(id, &ns)
557 }
558 })
559 .collect()
560 }
561
562 pub fn list_paths() -> Vec<PathBuf> {
565 Self::list_paths_from(&env::CWD)
566 }
567
568 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
579 let mut paths = Vec::new();
580 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
581 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
582
583 let mut project_paths = xx::file::find_up_all(
587 cwd,
588 &[
589 "pitchfork.local.toml",
590 "pitchfork.toml",
591 ".config/pitchfork.local.toml",
592 ".config/pitchfork.toml",
593 ],
594 );
595 project_paths.reverse();
596 paths.extend(project_paths);
597
598 paths
599 }
600
601 pub fn all_merged() -> Result<PitchforkToml> {
604 Self::all_merged_from(&env::CWD)
605 }
606
607 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
621 use std::collections::HashMap;
622
623 let paths = Self::list_paths_from(cwd);
624 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
625
626 let mut pt = Self::default();
627 for p in paths {
628 match Self::read(&p) {
629 Ok(pt2) => {
630 if p.exists() && !is_global_config(&p) {
634 let ns = namespace_from_path(&p)?;
635 let origin_dir = if is_dot_config_pitchfork(&p) {
636 p.parent().and_then(|d| d.parent())
637 } else {
638 p.parent()
639 }
640 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
641 .unwrap_or_else(|| p.clone());
642
643 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
644 && *other_dir != origin_dir
645 {
646 return Err(crate::error::ConfigParseError::NamespaceCollision {
647 path_a: other_path.clone(),
648 path_b: p.clone(),
649 ns,
650 }
651 .into());
652 }
653 ns_to_origin.insert(ns, (p.clone(), origin_dir));
654 }
655 pt.merge(pt2)
656 }
657 Err(e) => eprintln!("error reading {}: {}", p.display(), e),
658 }
659 }
660 Ok(pt)
661 }
662}
663
664impl PitchforkToml {
665 pub fn new(path: PathBuf) -> Self {
666 Self {
667 daemons: Default::default(),
668 namespace: None,
669 settings: SettingsPartial::default(),
670 path: Some(path),
671 }
672 }
673
674 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
682 let raw_config: PitchforkTomlRaw = toml::from_str(content)
683 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
684
685 let namespace = {
686 let base_explicit = sibling_base_config(path)
687 .and_then(|p| if p.exists() { Some(p) } else { None })
688 .map(|p| read_namespace_override_from_file(&p))
689 .transpose()?
690 .flatten();
691
692 if is_local_config(path)
693 && let (Some(local_ns), Some(base_ns)) =
694 (raw_config.namespace.as_deref(), base_explicit.as_deref())
695 && local_ns != base_ns
696 {
697 return Err(ConfigParseError::InvalidNamespace {
698 path: path.to_path_buf(),
699 namespace: local_ns.to_string(),
700 reason: format!(
701 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
702 ),
703 }
704 .into());
705 }
706
707 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
708 namespace_from_path_with_override(path, explicit)?
709 };
710 let mut pt = Self::new(path.to_path_buf());
711 pt.namespace = raw_config.namespace.clone();
712
713 for (short_name, raw_daemon) in raw_config.daemons {
714 let id = match DaemonId::try_new(&namespace, &short_name) {
715 Ok(id) => id,
716 Err(e) => {
717 return Err(ConfigParseError::InvalidDaemonName {
718 name: short_name,
719 path: path.to_path_buf(),
720 reason: e.to_string(),
721 }
722 .into());
723 }
724 };
725
726 let mut depends = Vec::new();
727 for dep in raw_daemon.depends {
728 let dep_id = if dep.contains('/') {
729 match DaemonId::parse(&dep) {
730 Ok(id) => id,
731 Err(e) => {
732 return Err(ConfigParseError::InvalidDependency {
733 daemon: short_name.clone(),
734 dependency: dep,
735 path: path.to_path_buf(),
736 reason: e.to_string(),
737 }
738 .into());
739 }
740 }
741 } else {
742 match DaemonId::try_new(&namespace, &dep) {
743 Ok(id) => id,
744 Err(e) => {
745 return Err(ConfigParseError::InvalidDependency {
746 daemon: short_name.clone(),
747 dependency: dep,
748 path: path.to_path_buf(),
749 reason: e.to_string(),
750 }
751 .into());
752 }
753 }
754 };
755 depends.push(dep_id);
756 }
757
758 let daemon = PitchforkTomlDaemon {
759 run: raw_daemon.run,
760 auto: raw_daemon.auto,
761 cron: raw_daemon.cron,
762 retry: raw_daemon.retry,
763 ready_delay: raw_daemon.ready_delay,
764 ready_output: raw_daemon.ready_output,
765 ready_http: raw_daemon.ready_http,
766 ready_port: raw_daemon.ready_port,
767 ready_cmd: raw_daemon.ready_cmd,
768 expected_port: raw_daemon.expected_port,
769 auto_bump_port: raw_daemon.auto_bump_port.unwrap_or(false),
770 port_bump_attempts: raw_daemon
771 .port_bump_attempts
772 .unwrap_or_else(|| settings().default_port_bump_attempts()),
773 boot_start: raw_daemon.boot_start,
774 depends,
775 watch: raw_daemon.watch,
776 dir: raw_daemon.dir,
777 env: raw_daemon.env,
778 hooks: raw_daemon.hooks,
779 mise: raw_daemon.mise,
780 memory_limit: raw_daemon.memory_limit,
781 cpu_limit: raw_daemon.cpu_limit,
782 path: Some(path.to_path_buf()),
783 };
784 pt.daemons.insert(id, daemon);
785 }
786
787 if let Some(settings) = raw_config.settings {
789 pt.settings = settings;
790 }
791
792 Ok(pt)
793 }
794
795 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
796 let path = path.as_ref();
797 if !path.exists() {
798 return Ok(Self::new(path.to_path_buf()));
799 }
800 let _lock = xx::fslock::get(path, false)
801 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
802 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
803 path: path.to_path_buf(),
804 source: e,
805 })?;
806 Self::parse_str(&raw, path)
807 }
808
809 pub fn write(&self) -> Result<()> {
810 if let Some(path) = &self.path {
811 let _lock = xx::fslock::get(path, false)
812 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
813
814 let config_namespace = if path.exists() {
816 namespace_from_path(path)?
817 } else {
818 namespace_from_path_with_override(path, self.namespace.as_deref())?
819 };
820
821 let mut raw = PitchforkTomlRaw {
823 namespace: self.namespace.clone(),
824 ..PitchforkTomlRaw::default()
825 };
826 for (id, daemon) in &self.daemons {
827 if id.namespace() != config_namespace {
828 return Err(miette::miette!(
829 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
830 id,
831 path.display(),
832 id.namespace(),
833 config_namespace
834 ));
835 }
836 let raw_daemon = PitchforkTomlDaemonRaw {
837 run: daemon.run.clone(),
838 auto: daemon.auto.clone(),
839 cron: daemon.cron.clone(),
840 retry: daemon.retry,
841 ready_delay: daemon.ready_delay,
842 ready_output: daemon.ready_output.clone(),
843 ready_http: daemon.ready_http.clone(),
844 ready_port: daemon.ready_port,
845 ready_cmd: daemon.ready_cmd.clone(),
846 expected_port: daemon.expected_port.clone(),
847 auto_bump_port: Some(daemon.auto_bump_port),
848 port_bump_attempts: Some(daemon.port_bump_attempts),
849 boot_start: daemon.boot_start,
850 depends: daemon
853 .depends
854 .iter()
855 .map(|d| {
856 if d.namespace() == config_namespace {
857 d.name().to_string()
858 } else {
859 d.qualified()
860 }
861 })
862 .collect(),
863 watch: daemon.watch.clone(),
864 dir: daemon.dir.clone(),
865 env: daemon.env.clone(),
866 hooks: daemon.hooks.clone(),
867 mise: daemon.mise,
868 memory_limit: daemon.memory_limit,
869 cpu_limit: daemon.cpu_limit,
870 };
871 raw.daemons.insert(id.name().to_string(), raw_daemon);
872 }
873
874 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
875 path: path.clone(),
876 source: e,
877 })?;
878 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
879 path: path.clone(),
880 details: Some(e.to_string()),
881 })?;
882 Ok(())
883 } else {
884 Err(FileError::NoPath.into())
885 }
886 }
887
888 pub fn merge(&mut self, pt: Self) {
893 for (id, d) in pt.daemons {
894 self.daemons.insert(id, d);
895 }
896 self.settings.merge_from(&pt.settings);
898 }
899}
900
901#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
903pub struct PitchforkTomlHooks {
904 #[serde(skip_serializing_if = "Option::is_none", default)]
906 pub on_ready: Option<String>,
907 #[serde(skip_serializing_if = "Option::is_none", default)]
909 pub on_fail: Option<String>,
910 #[serde(skip_serializing_if = "Option::is_none", default)]
912 pub on_retry: Option<String>,
913 #[serde(skip_serializing_if = "Option::is_none", default)]
915 pub on_stop: Option<String>,
916 #[serde(skip_serializing_if = "Option::is_none", default)]
918 pub on_exit: Option<String>,
919}
920
921#[derive(Debug, Clone, JsonSchema)]
923pub struct PitchforkTomlDaemon {
924 #[schemars(example = example_run_command())]
926 pub run: String,
927 #[schemars(default)]
929 pub auto: Vec<PitchforkTomlAuto>,
930 pub cron: Option<PitchforkTomlCron>,
932 #[schemars(default)]
935 pub retry: Retry,
936 pub ready_delay: Option<u64>,
938 pub ready_output: Option<String>,
940 pub ready_http: Option<String>,
942 #[schemars(range(min = 1, max = 65535))]
944 pub ready_port: Option<u16>,
945 pub ready_cmd: Option<String>,
947 #[serde(skip_serializing_if = "Vec::is_empty", default)]
949 pub expected_port: Vec<u16>,
950 #[serde(default)]
952 pub auto_bump_port: bool,
953 #[serde(default = "default_port_bump_attempts")]
955 pub port_bump_attempts: u32,
956 pub boot_start: Option<bool>,
958 #[schemars(default)]
960 pub depends: Vec<DaemonId>,
961 #[schemars(default)]
963 pub watch: Vec<String>,
964 pub dir: Option<String>,
966 pub env: Option<IndexMap<String, String>>,
968 pub hooks: Option<PitchforkTomlHooks>,
970 pub mise: Option<bool>,
973 pub memory_limit: Option<MemoryLimit>,
976 pub cpu_limit: Option<CpuLimit>,
979 #[schemars(skip)]
980 pub path: Option<PathBuf>,
981}
982
983impl Default for PitchforkTomlDaemon {
984 fn default() -> Self {
985 Self {
986 run: String::new(),
987 auto: Vec::new(),
988 cron: None,
989 retry: Retry::default(),
990 ready_delay: None,
991 ready_output: None,
992 ready_http: None,
993 ready_port: None,
994 ready_cmd: None,
995 expected_port: Vec::new(),
996 auto_bump_port: false,
997 port_bump_attempts: 10,
998 boot_start: None,
999 depends: Vec::new(),
1000 watch: Vec::new(),
1001 dir: None,
1002 env: None,
1003 hooks: None,
1004 mise: None,
1005 memory_limit: None,
1006 cpu_limit: None,
1007 path: None,
1008 }
1009 }
1010}
1011
1012impl PitchforkTomlDaemon {
1013 pub fn to_run_options(
1018 &self,
1019 id: &crate::daemon_id::DaemonId,
1020 cmd: Vec<String>,
1021 ) -> crate::daemon::RunOptions {
1022 use crate::daemon::RunOptions;
1023
1024 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1025
1026 RunOptions {
1027 id: id.clone(),
1028 cmd,
1029 force: false,
1030 shell_pid: None,
1031 dir,
1032 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1033 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1034 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1035 retry: self.retry.count(),
1036 retry_count: 0,
1037 ready_delay: self.ready_delay,
1038 ready_output: self.ready_output.clone(),
1039 ready_http: self.ready_http.clone(),
1040 ready_port: self.ready_port,
1041 ready_cmd: self.ready_cmd.clone(),
1042 expected_port: self.expected_port.clone(),
1043 auto_bump_port: self.auto_bump_port,
1044 port_bump_attempts: self.port_bump_attempts,
1045 wait_ready: false,
1046 depends: self.depends.clone(),
1047 env: self.env.clone(),
1048 watch: self.watch.clone(),
1049 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1050 self.path.as_deref(),
1051 )),
1052 mise: self.mise.unwrap_or(settings().general.mise),
1053 memory_limit: self.memory_limit,
1054 cpu_limit: self.cpu_limit,
1055 }
1056 }
1057}
1058
1059fn example_run_command() -> &'static str {
1060 "exec node server.js"
1061}
1062
1063fn default_port_bump_attempts() -> u32 {
1064 10
1068}
1069
1070#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1072pub struct PitchforkTomlCron {
1073 #[schemars(example = example_cron_schedule())]
1075 pub schedule: String,
1076 #[serde(default = "default_retrigger")]
1078 pub retrigger: CronRetrigger,
1079}
1080
1081fn default_retrigger() -> CronRetrigger {
1082 CronRetrigger::Finish
1083}
1084
1085fn example_cron_schedule() -> &'static str {
1086 "0 * * * *"
1087}
1088
1089#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
1091#[serde(rename_all = "snake_case")]
1092pub enum CronRetrigger {
1093 Finish,
1095 Always,
1097 Success,
1099 Fail,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
1105#[serde(rename_all = "snake_case")]
1106pub enum PitchforkTomlAuto {
1107 Start,
1108 Stop,
1109}
1110
1111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
1116pub struct Retry(pub u32);
1117
1118impl std::fmt::Display for Retry {
1119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1120 if self.is_infinite() {
1121 write!(f, "infinite")
1122 } else {
1123 write!(f, "{}", self.0)
1124 }
1125 }
1126}
1127
1128impl Retry {
1129 pub const INFINITE: Retry = Retry(u32::MAX);
1130
1131 pub fn count(&self) -> u32 {
1132 self.0
1133 }
1134
1135 pub fn is_infinite(&self) -> bool {
1136 self.0 == u32::MAX
1137 }
1138}
1139
1140impl From<u32> for Retry {
1141 fn from(n: u32) -> Self {
1142 Retry(n)
1143 }
1144}
1145
1146impl From<bool> for Retry {
1147 fn from(b: bool) -> Self {
1148 if b { Retry::INFINITE } else { Retry(0) }
1149 }
1150}
1151
1152impl Serialize for Retry {
1153 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1154 where
1155 S: Serializer,
1156 {
1157 if self.is_infinite() {
1159 serializer.serialize_bool(true)
1160 } else {
1161 serializer.serialize_u32(self.0)
1162 }
1163 }
1164}
1165
1166impl<'de> Deserialize<'de> for Retry {
1167 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1168 where
1169 D: Deserializer<'de>,
1170 {
1171 use serde::de::{self, Visitor};
1172
1173 struct RetryVisitor;
1174
1175 impl Visitor<'_> for RetryVisitor {
1176 type Value = Retry;
1177
1178 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1179 formatter.write_str("a boolean or non-negative integer")
1180 }
1181
1182 fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
1183 where
1184 E: de::Error,
1185 {
1186 Ok(Retry::from(v))
1187 }
1188
1189 fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
1190 where
1191 E: de::Error,
1192 {
1193 if v < 0 {
1194 Err(de::Error::custom("retry count cannot be negative"))
1195 } else if v > u32::MAX as i64 {
1196 Ok(Retry::INFINITE)
1197 } else {
1198 Ok(Retry(v as u32))
1199 }
1200 }
1201
1202 fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
1203 where
1204 E: de::Error,
1205 {
1206 if v > u32::MAX as u64 {
1207 Ok(Retry::INFINITE)
1208 } else {
1209 Ok(Retry(v as u32))
1210 }
1211 }
1212 }
1213
1214 deserializer.deserialize_any(RetryVisitor)
1215 }
1216}