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, Clone, serde::Serialize, serde::Deserialize)]
92pub struct SlugEntryRaw {
93 pub dir: String,
95 #[serde(skip_serializing_if = "Option::is_none", default)]
97 pub daemon: Option<String>,
98}
99
100#[derive(Debug, Clone)]
102pub struct SlugEntry {
103 pub dir: PathBuf,
105 pub daemon: Option<String>,
107}
108
109#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
111struct PitchforkTomlRaw {
112 #[serde(skip_serializing_if = "Option::is_none", default)]
113 pub namespace: Option<String>,
114 #[serde(default)]
115 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
116 #[serde(default)]
117 pub settings: Option<SettingsPartial>,
118 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
121 pub slugs: IndexMap<String, SlugEntryRaw>,
122}
123
124#[derive(Debug, serde::Serialize, serde::Deserialize)]
131struct PitchforkTomlDaemonRaw {
132 pub run: String,
133 #[serde(skip_serializing_if = "Vec::is_empty", default)]
134 pub auto: Vec<PitchforkTomlAuto>,
135 #[serde(skip_serializing_if = "Option::is_none", default)]
136 pub cron: Option<PitchforkTomlCron>,
137 #[serde(default)]
138 pub retry: Retry,
139 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub ready_delay: Option<u64>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
142 pub ready_output: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none", default)]
144 pub ready_http: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub ready_port: Option<u16>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
148 pub ready_cmd: Option<String>,
149 #[serde(skip_serializing_if = "Vec::is_empty", default)]
150 pub expected_port: Vec<u16>,
151 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub auto_bump_port: Option<bool>,
153 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub port_bump_attempts: Option<u32>,
155 #[serde(skip_serializing_if = "Option::is_none", default)]
156 pub boot_start: Option<bool>,
157 #[serde(skip_serializing_if = "Vec::is_empty", default)]
158 pub depends: Vec<String>,
159 #[serde(skip_serializing_if = "Vec::is_empty", default)]
160 pub watch: Vec<String>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
162 pub dir: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none", default)]
164 pub env: Option<IndexMap<String, String>>,
165 #[serde(skip_serializing_if = "Option::is_none", default)]
166 pub hooks: Option<PitchforkTomlHooks>,
167 #[serde(skip_serializing_if = "Option::is_none", default)]
168 pub mise: Option<bool>,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
171 pub memory_limit: Option<MemoryLimit>,
172 #[serde(skip_serializing_if = "Option::is_none", default)]
174 pub cpu_limit: Option<CpuLimit>,
175}
176
177#[derive(Debug, Default, JsonSchema)]
182#[schemars(title = "Pitchfork Configuration")]
183pub struct PitchforkToml {
184 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
186 pub namespace: Option<String>,
191 #[serde(default)]
200 pub(crate) settings: SettingsPartial,
201 #[schemars(skip)]
206 pub slugs: IndexMap<String, SlugEntry>,
207 #[schemars(skip)]
208 pub path: Option<PathBuf>,
209}
210
211pub(crate) fn is_global_config(path: &Path) -> bool {
212 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
213}
214
215fn is_local_config(path: &Path) -> bool {
216 path.file_name()
217 .map(|n| n == "pitchfork.local.toml")
218 .unwrap_or(false)
219}
220
221pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
222 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
223}
224
225fn sibling_base_config(path: &Path) -> Option<PathBuf> {
226 if !is_local_config(path) {
227 return None;
228 }
229 path.parent().map(|p| p.join("pitchfork.toml"))
230}
231
232fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
233 use toml::Value;
234
235 let doc: Value = toml::from_str(content)
236 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
237 let Some(value) = doc.get("namespace") else {
238 return Ok(None);
239 };
240
241 match value {
242 Value::String(s) => Ok(Some(s.clone())),
243 _ => Err(ConfigParseError::InvalidNamespace {
244 path: path.to_path_buf(),
245 namespace: value.to_string(),
246 reason: "top-level 'namespace' must be a string".to_string(),
247 }
248 .into()),
249 }
250}
251
252fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
253 if !path.exists() {
254 return Ok(None);
255 }
256 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
257 path: path.to_path_buf(),
258 source: e,
259 })?;
260 parse_namespace_override_from_content(path, &content)
261}
262
263fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
264 if let Err(e) = DaemonId::try_new(namespace, "probe") {
265 return Err(ConfigParseError::InvalidNamespace {
266 path: path.to_path_buf(),
267 namespace: namespace.to_string(),
268 reason: e.to_string(),
269 }
270 .into());
271 }
272 Ok(namespace.to_string())
273}
274
275fn derive_namespace_from_dir(path: &Path) -> Result<String> {
276 let dir_for_namespace = if is_dot_config_pitchfork(path) {
277 path.parent().and_then(|p| p.parent())
278 } else {
279 path.parent()
280 };
281
282 let raw_namespace = dir_for_namespace
283 .and_then(|p| p.file_name())
284 .and_then(|n| n.to_str())
285 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
286 .to_string();
287
288 validate_namespace(path, &raw_namespace).map_err(|e| {
289 ConfigParseError::InvalidNamespace {
290 path: path.to_path_buf(),
291 namespace: raw_namespace,
292 reason: format!(
293 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
294 ),
295 }
296 .into()
297 })
298}
299
300fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
301 if is_global_config(path) {
302 if let Some(ns) = explicit
303 && ns != "global"
304 {
305 return Err(ConfigParseError::InvalidNamespace {
306 path: path.to_path_buf(),
307 namespace: ns.to_string(),
308 reason: "global config files must use namespace 'global'".to_string(),
309 }
310 .into());
311 }
312 return Ok("global".to_string());
313 }
314
315 if let Some(ns) = explicit {
316 return validate_namespace(path, ns);
317 }
318
319 derive_namespace_from_dir(path)
320}
321
322fn namespace_from_file(path: &Path) -> Result<String> {
323 let explicit = read_namespace_override_from_file(path)?;
324 let base_explicit = sibling_base_config(path)
325 .and_then(|p| if p.exists() { Some(p) } else { None })
326 .map(|p| read_namespace_override_from_file(&p))
327 .transpose()?
328 .flatten();
329
330 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
331 && local_ns != base_ns
332 {
333 return Err(ConfigParseError::InvalidNamespace {
334 path: path.to_path_buf(),
335 namespace: local_ns.to_string(),
336 reason: format!(
337 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
338 ),
339 }
340 .into());
341 }
342
343 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
344 namespace_from_path_with_override(path, effective_explicit)
345}
346
347pub fn namespace_from_path(path: &Path) -> Result<String> {
360 namespace_from_file(path)
361}
362
363impl PitchforkToml {
364 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
377 if user_id.contains('/') {
379 return match DaemonId::parse(user_id) {
380 Ok(id) => Ok(vec![id]),
381 Err(e) => Err(e), };
383 }
384
385 let global_slugs = Self::read_global_slugs();
387 if let Some(entry) = global_slugs.get(user_id) {
388 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
390 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
391 let matches: Vec<DaemonId> = project_config
393 .daemons
394 .keys()
395 .filter(|id| id.name() == daemon_name)
396 .cloned()
397 .collect();
398 match matches.as_slice() {
399 [] => {}
400 [id] => return Ok(vec![id.clone()]),
401 _ => {
402 let mut candidates: Vec<String> =
403 matches.iter().map(|id| id.qualified()).collect();
404 candidates.sort();
405 return Err(miette::miette!(
406 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
407 user_id,
408 daemon_name,
409 candidates.join(", ")
410 ));
411 }
412 }
413 }
414 }
415
416 let matches: Vec<DaemonId> = self
418 .daemons
419 .keys()
420 .filter(|id| id.name() == user_id)
421 .cloned()
422 .collect();
423
424 if matches.is_empty() {
425 let _ = DaemonId::try_new("global", user_id)?;
427 }
428 Ok(matches)
429 }
430
431 #[allow(dead_code)]
452 pub fn resolve_daemon_id_prefer_local(
453 &self,
454 user_id: &str,
455 current_dir: &Path,
456 ) -> Result<DaemonId> {
457 if user_id.contains('/') {
459 return DaemonId::parse(user_id);
460 }
461
462 let current_namespace = Self::namespace_for_dir(current_dir)?;
466
467 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
468 }
469
470 fn resolve_daemon_id_with_namespace(
473 &self,
474 user_id: &str,
475 current_namespace: &str,
476 ) -> Result<DaemonId> {
477 let global_slugs = Self::read_global_slugs();
479 if let Some(entry) = global_slugs.get(user_id) {
480 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
481 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
482 let matches: Vec<DaemonId> = project_config
483 .daemons
484 .keys()
485 .filter(|id| id.name() == daemon_name)
486 .cloned()
487 .collect();
488 match matches.as_slice() {
489 [] => {}
490 [id] => return Ok(id.clone()),
491 _ => {
492 let mut candidates: Vec<String> =
493 matches.iter().map(|id| id.qualified()).collect();
494 candidates.sort();
495 return Err(miette::miette!(
496 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
497 user_id,
498 daemon_name,
499 candidates.join(", ")
500 ));
501 }
502 }
503 }
504 }
505
506 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
509 if self.daemons.contains_key(&preferred_id) {
510 return Ok(preferred_id);
511 }
512
513 let matches = self.resolve_daemon_id(user_id)?;
515
516 if matches.len() > 1 {
518 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
519 candidates.sort();
520 return Err(miette::miette!(
521 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
522 user_id,
523 candidates.join(", ")
524 ));
525 }
526
527 if let Some(id) = matches.into_iter().next() {
528 return Ok(id);
529 }
530
531 let global_id = DaemonId::try_new("global", user_id)?;
534 if self.daemons.contains_key(&global_id) {
535 return Ok(global_id);
536 }
537
538 if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
542 && state.daemons.contains_key(&global_id)
543 {
544 return Ok(global_id);
545 }
546
547 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
548 Err(DependencyError::DaemonNotFound {
549 name: user_id.to_string(),
550 suggestion,
551 }
552 .into())
553 }
554
555 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
558 Ok(Self::list_paths_from(dir)
559 .iter()
560 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
562 .transpose()?
563 .unwrap_or_else(|| "global".to_string()))
564 }
565
566 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
576 if user_id.contains('/') {
577 return DaemonId::parse(user_id);
578 }
579
580 let config = Self::all_merged()?;
583 let ns = Self::namespace_for_dir(&env::CWD)?;
584 config.resolve_daemon_id_with_namespace(user_id, &ns)
585 }
586
587 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
593 if user_id.contains('/') {
594 return DaemonId::parse(user_id);
595 }
596
597 let config = Self::all_merged()?;
598 let ns = Self::namespace_for_dir(&env::CWD)?;
599
600 let preferred_id = DaemonId::try_new(&ns, user_id)?;
601 if config.daemons.contains_key(&preferred_id) {
602 return Ok(preferred_id);
603 }
604
605 let matches = config.resolve_daemon_id(user_id)?;
606 if matches.len() > 1 {
607 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
608 candidates.sort();
609 return Err(miette::miette!(
610 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
611 user_id,
612 candidates.join(", ")
613 ));
614 }
615 if let Some(id) = matches.into_iter().next() {
616 return Ok(id);
617 }
618
619 DaemonId::try_new("global", user_id)
620 }
621
622 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
633 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
635 return user_ids
636 .iter()
637 .map(|s| DaemonId::parse(s.as_ref()))
638 .collect();
639 }
640
641 let config = Self::all_merged()?;
642 let ns = Self::namespace_for_dir(&env::CWD)?;
644 user_ids
645 .iter()
646 .map(|s| {
647 let id = s.as_ref();
648 if id.contains('/') {
649 DaemonId::parse(id)
650 } else {
651 config.resolve_daemon_id_with_namespace(id, &ns)
652 }
653 })
654 .collect()
655 }
656
657 pub fn list_paths() -> Vec<PathBuf> {
660 Self::list_paths_from(&env::CWD)
661 }
662
663 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
674 let mut paths = Vec::new();
675 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
676 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
677
678 let mut project_paths = xx::file::find_up_all(
682 cwd,
683 &[
684 "pitchfork.local.toml",
685 "pitchfork.toml",
686 ".config/pitchfork.local.toml",
687 ".config/pitchfork.toml",
688 ],
689 );
690 project_paths.reverse();
691 paths.extend(project_paths);
692
693 paths
694 }
695
696 pub fn all_merged() -> Result<PitchforkToml> {
699 Self::all_merged_from(&env::CWD)
700 }
701
702 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
716 use std::collections::HashMap;
717
718 let paths = Self::list_paths_from(cwd);
719 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
720
721 let mut pt = Self::default();
722 for p in paths {
723 match Self::read(&p) {
724 Ok(pt2) => {
725 if p.exists() && !is_global_config(&p) {
729 let ns = namespace_from_path(&p)?;
730 let origin_dir = if is_dot_config_pitchfork(&p) {
731 p.parent().and_then(|d| d.parent())
732 } else {
733 p.parent()
734 }
735 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
736 .unwrap_or_else(|| p.clone());
737
738 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
739 && *other_dir != origin_dir
740 {
741 return Err(crate::error::ConfigParseError::NamespaceCollision {
742 path_a: other_path.clone(),
743 path_b: p.clone(),
744 ns,
745 }
746 .into());
747 }
748 ns_to_origin.insert(ns, (p.clone(), origin_dir));
749 }
750
751 pt.merge(pt2)
752 }
753 Err(e) => eprintln!("error reading {}: {}", p.display(), e),
754 }
755 }
756 Ok(pt)
757 }
758}
759
760impl PitchforkToml {
761 pub fn new(path: PathBuf) -> Self {
762 Self {
763 daemons: Default::default(),
764 namespace: None,
765 settings: SettingsPartial::default(),
766 slugs: IndexMap::new(),
767 path: Some(path),
768 }
769 }
770
771 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
779 let raw_config: PitchforkTomlRaw = toml::from_str(content)
780 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
781
782 let namespace = {
783 let base_explicit = sibling_base_config(path)
784 .and_then(|p| if p.exists() { Some(p) } else { None })
785 .map(|p| read_namespace_override_from_file(&p))
786 .transpose()?
787 .flatten();
788
789 if is_local_config(path)
790 && let (Some(local_ns), Some(base_ns)) =
791 (raw_config.namespace.as_deref(), base_explicit.as_deref())
792 && local_ns != base_ns
793 {
794 return Err(ConfigParseError::InvalidNamespace {
795 path: path.to_path_buf(),
796 namespace: local_ns.to_string(),
797 reason: format!(
798 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
799 ),
800 }
801 .into());
802 }
803
804 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
805 namespace_from_path_with_override(path, explicit)?
806 };
807 let mut pt = Self::new(path.to_path_buf());
808 pt.namespace = raw_config.namespace.clone();
809
810 for (short_name, raw_daemon) in raw_config.daemons {
811 let id = match DaemonId::try_new(&namespace, &short_name) {
812 Ok(id) => id,
813 Err(e) => {
814 return Err(ConfigParseError::InvalidDaemonName {
815 name: short_name,
816 path: path.to_path_buf(),
817 reason: e.to_string(),
818 }
819 .into());
820 }
821 };
822
823 let mut depends = Vec::new();
824 for dep in raw_daemon.depends {
825 let dep_id = if dep.contains('/') {
826 match DaemonId::parse(&dep) {
827 Ok(id) => id,
828 Err(e) => {
829 return Err(ConfigParseError::InvalidDependency {
830 daemon: short_name.clone(),
831 dependency: dep,
832 path: path.to_path_buf(),
833 reason: e.to_string(),
834 }
835 .into());
836 }
837 }
838 } else {
839 match DaemonId::try_new(&namespace, &dep) {
840 Ok(id) => id,
841 Err(e) => {
842 return Err(ConfigParseError::InvalidDependency {
843 daemon: short_name.clone(),
844 dependency: dep,
845 path: path.to_path_buf(),
846 reason: e.to_string(),
847 }
848 .into());
849 }
850 }
851 };
852 depends.push(dep_id);
853 }
854
855 let daemon = PitchforkTomlDaemon {
856 run: raw_daemon.run,
857 auto: raw_daemon.auto,
858 cron: raw_daemon.cron,
859 retry: raw_daemon.retry,
860 ready_delay: raw_daemon.ready_delay,
861 ready_output: raw_daemon.ready_output,
862 ready_http: raw_daemon.ready_http,
863 ready_port: raw_daemon.ready_port,
864 ready_cmd: raw_daemon.ready_cmd,
865 expected_port: raw_daemon.expected_port,
866 auto_bump_port: raw_daemon.auto_bump_port.unwrap_or(false),
867 port_bump_attempts: raw_daemon
868 .port_bump_attempts
869 .unwrap_or_else(|| settings().default_port_bump_attempts()),
870 boot_start: raw_daemon.boot_start,
871 depends,
872 watch: raw_daemon.watch,
873 dir: raw_daemon.dir,
874 env: raw_daemon.env,
875 hooks: raw_daemon.hooks,
876 mise: raw_daemon.mise,
877 memory_limit: raw_daemon.memory_limit,
878 cpu_limit: raw_daemon.cpu_limit,
879 path: Some(path.to_path_buf()),
880 };
881 pt.daemons.insert(id, daemon);
882 }
883
884 if let Some(settings) = raw_config.settings {
886 pt.settings = settings;
887 }
888
889 for (slug, entry) in raw_config.slugs {
891 pt.slugs.insert(
892 slug,
893 SlugEntry {
894 dir: PathBuf::from(entry.dir),
895 daemon: entry.daemon,
896 },
897 );
898 }
899
900 Ok(pt)
901 }
902
903 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
904 let path = path.as_ref();
905 if !path.exists() {
906 return Ok(Self::new(path.to_path_buf()));
907 }
908 let _lock = xx::fslock::get(path, false)
909 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
910 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
911 path: path.to_path_buf(),
912 source: e,
913 })?;
914 Self::parse_str(&raw, path)
915 }
916
917 pub fn write(&self) -> Result<()> {
918 if let Some(path) = &self.path {
919 let _lock = xx::fslock::get(path, false)
920 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
921 self.write_unlocked()
922 } else {
923 Err(FileError::NoPath.into())
924 }
925 }
926
927 fn write_unlocked(&self) -> Result<()> {
933 if let Some(path) = &self.path {
934 let config_namespace = if path.exists() {
936 namespace_from_path(path)?
937 } else {
938 namespace_from_path_with_override(path, self.namespace.as_deref())?
939 };
940
941 let mut raw = PitchforkTomlRaw {
943 namespace: self.namespace.clone(),
944 ..PitchforkTomlRaw::default()
945 };
946 for (id, daemon) in &self.daemons {
947 if id.namespace() != config_namespace {
948 return Err(miette::miette!(
949 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
950 id,
951 path.display(),
952 id.namespace(),
953 config_namespace
954 ));
955 }
956 let raw_daemon = PitchforkTomlDaemonRaw {
957 run: daemon.run.clone(),
958 auto: daemon.auto.clone(),
959 cron: daemon.cron.clone(),
960 retry: daemon.retry,
961 ready_delay: daemon.ready_delay,
962 ready_output: daemon.ready_output.clone(),
963 ready_http: daemon.ready_http.clone(),
964 ready_port: daemon.ready_port,
965 ready_cmd: daemon.ready_cmd.clone(),
966 expected_port: daemon.expected_port.clone(),
967 auto_bump_port: Some(daemon.auto_bump_port),
968 port_bump_attempts: Some(daemon.port_bump_attempts),
969 boot_start: daemon.boot_start,
970 depends: daemon
973 .depends
974 .iter()
975 .map(|d| {
976 if d.namespace() == config_namespace {
977 d.name().to_string()
978 } else {
979 d.qualified()
980 }
981 })
982 .collect(),
983 watch: daemon.watch.clone(),
984 dir: daemon.dir.clone(),
985 env: daemon.env.clone(),
986 hooks: daemon.hooks.clone(),
987 mise: daemon.mise,
988 memory_limit: daemon.memory_limit,
989 cpu_limit: daemon.cpu_limit,
990 };
991 raw.daemons.insert(id.name().to_string(), raw_daemon);
992 }
993
994 for (slug, entry) in &self.slugs {
996 raw.slugs.insert(
997 slug.clone(),
998 SlugEntryRaw {
999 dir: entry.dir.to_string_lossy().to_string(),
1000 daemon: entry.daemon.clone(),
1001 },
1002 );
1003 }
1004
1005 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1006 path: path.clone(),
1007 source: e,
1008 })?;
1009 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1010 path: path.clone(),
1011 details: Some(e.to_string()),
1012 })?;
1013 Ok(())
1014 } else {
1015 Err(FileError::NoPath.into())
1016 }
1017 }
1018
1019 pub fn merge(&mut self, pt: Self) {
1024 for (id, d) in pt.daemons {
1025 self.daemons.insert(id, d);
1026 }
1027 for (slug, entry) in pt.slugs {
1029 self.slugs.insert(slug, entry);
1030 }
1031 self.settings.merge_from(&pt.settings);
1033 }
1034
1035 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1040 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1041 Ok(pt) => pt.slugs,
1042 Err(_) => IndexMap::new(),
1043 }
1044 }
1045
1046 #[allow(dead_code)]
1048 pub fn is_slug_registered(slug: &str) -> bool {
1049 Self::read_global_slugs().contains_key(slug)
1050 }
1051
1052 pub fn add_slug(slug: &str, dir: &Path, daemon: Option<&str>) -> Result<()> {
1056 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1057
1058 if let Some(parent) = global_path.parent() {
1060 std::fs::create_dir_all(parent).map_err(|e| {
1061 miette::miette!(
1062 "Failed to create config directory {}: {e}",
1063 parent.display()
1064 )
1065 })?;
1066 }
1067
1068 let _lock = xx::fslock::get(global_path, false)
1072 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1073
1074 let mut pt = if global_path.exists() {
1075 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1076 path: global_path.to_path_buf(),
1077 source: e,
1078 })?;
1079 Self::parse_str(&raw, global_path)?
1080 } else {
1081 Self::new(global_path.to_path_buf())
1082 };
1083
1084 pt.slugs.insert(
1085 slug.to_string(),
1086 SlugEntry {
1087 dir: dir.to_path_buf(),
1088 daemon: daemon.map(str::to_string),
1089 },
1090 );
1091 pt.write_unlocked()
1092 }
1093
1094 pub fn remove_slug(slug: &str) -> Result<bool> {
1096 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1097 if !global_path.exists() {
1098 return Ok(false);
1099 }
1100
1101 let _lock = xx::fslock::get(global_path, false)
1102 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1103
1104 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1105 path: global_path.to_path_buf(),
1106 source: e,
1107 })?;
1108 let mut pt = Self::parse_str(&raw, global_path)?;
1109
1110 let removed = pt.slugs.shift_remove(slug).is_some();
1111 if removed {
1112 pt.write_unlocked()?;
1113 }
1114 Ok(removed)
1115 }
1116}
1117
1118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1120pub struct PitchforkTomlHooks {
1121 #[serde(skip_serializing_if = "Option::is_none", default)]
1123 pub on_ready: Option<String>,
1124 #[serde(skip_serializing_if = "Option::is_none", default)]
1126 pub on_fail: Option<String>,
1127 #[serde(skip_serializing_if = "Option::is_none", default)]
1129 pub on_retry: Option<String>,
1130 #[serde(skip_serializing_if = "Option::is_none", default)]
1132 pub on_stop: Option<String>,
1133 #[serde(skip_serializing_if = "Option::is_none", default)]
1135 pub on_exit: Option<String>,
1136}
1137
1138#[derive(Debug, Clone, JsonSchema)]
1140pub struct PitchforkTomlDaemon {
1141 #[schemars(example = example_run_command())]
1143 pub run: String,
1144 #[schemars(default)]
1146 pub auto: Vec<PitchforkTomlAuto>,
1147 pub cron: Option<PitchforkTomlCron>,
1149 #[schemars(default)]
1152 pub retry: Retry,
1153 pub ready_delay: Option<u64>,
1155 pub ready_output: Option<String>,
1157 pub ready_http: Option<String>,
1159 #[schemars(range(min = 1, max = 65535))]
1161 pub ready_port: Option<u16>,
1162 pub ready_cmd: Option<String>,
1164 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1166 pub expected_port: Vec<u16>,
1167 #[serde(default)]
1169 pub auto_bump_port: bool,
1170 #[serde(default = "default_port_bump_attempts")]
1172 pub port_bump_attempts: u32,
1173 pub boot_start: Option<bool>,
1175 #[schemars(default)]
1177 pub depends: Vec<DaemonId>,
1178 #[schemars(default)]
1180 pub watch: Vec<String>,
1181 pub dir: Option<String>,
1183 pub env: Option<IndexMap<String, String>>,
1185 pub hooks: Option<PitchforkTomlHooks>,
1187 pub mise: Option<bool>,
1190 pub memory_limit: Option<MemoryLimit>,
1193 pub cpu_limit: Option<CpuLimit>,
1196 #[schemars(skip)]
1197 pub path: Option<PathBuf>,
1198}
1199
1200impl Default for PitchforkTomlDaemon {
1201 fn default() -> Self {
1202 Self {
1203 run: String::new(),
1204 auto: Vec::new(),
1205 cron: None,
1206 retry: Retry::default(),
1207 ready_delay: None,
1208 ready_output: None,
1209 ready_http: None,
1210 ready_port: None,
1211 ready_cmd: None,
1212 expected_port: Vec::new(),
1213 auto_bump_port: false,
1214 port_bump_attempts: 10,
1215 boot_start: None,
1216 depends: Vec::new(),
1217 watch: Vec::new(),
1218 dir: None,
1219 env: None,
1220 hooks: None,
1221 mise: None,
1222 memory_limit: None,
1223 cpu_limit: None,
1224 path: None,
1225 }
1226 }
1227}
1228
1229impl PitchforkTomlDaemon {
1230 pub fn to_run_options(
1235 &self,
1236 id: &crate::daemon_id::DaemonId,
1237 cmd: Vec<String>,
1238 ) -> crate::daemon::RunOptions {
1239 use crate::daemon::RunOptions;
1240
1241 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1242
1243 RunOptions {
1244 id: id.clone(),
1245 cmd,
1246 force: false,
1247 shell_pid: None,
1248 dir,
1249 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1250 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1251 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1252 retry: self.retry.count(),
1253 retry_count: 0,
1254 ready_delay: self.ready_delay,
1255 ready_output: self.ready_output.clone(),
1256 ready_http: self.ready_http.clone(),
1257 ready_port: self.ready_port,
1258 ready_cmd: self.ready_cmd.clone(),
1259 expected_port: self.expected_port.clone(),
1260 auto_bump_port: self.auto_bump_port,
1261 port_bump_attempts: self.port_bump_attempts,
1262 wait_ready: false,
1263 depends: self.depends.clone(),
1264 env: self.env.clone(),
1265 watch: self.watch.clone(),
1266 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1267 self.path.as_deref(),
1268 )),
1269 mise: self.mise,
1270 slug: None,
1271 proxy: None,
1272 memory_limit: self.memory_limit,
1273 cpu_limit: self.cpu_limit,
1274 }
1275 }
1276}
1277fn example_run_command() -> &'static str {
1278 "exec node server.js"
1279}
1280
1281fn default_port_bump_attempts() -> u32 {
1282 10
1286}
1287
1288#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1290pub struct PitchforkTomlCron {
1291 #[schemars(example = example_cron_schedule())]
1293 pub schedule: String,
1294 #[serde(default = "default_retrigger")]
1296 pub retrigger: CronRetrigger,
1297}
1298
1299fn default_retrigger() -> CronRetrigger {
1300 CronRetrigger::Finish
1301}
1302
1303fn example_cron_schedule() -> &'static str {
1304 "0 * * * *"
1305}
1306
1307#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
1309#[serde(rename_all = "snake_case")]
1310pub enum CronRetrigger {
1311 Finish,
1313 Always,
1315 Success,
1317 Fail,
1319}
1320
1321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
1323#[serde(rename_all = "snake_case")]
1324pub enum PitchforkTomlAuto {
1325 Start,
1326 Stop,
1327}
1328
1329#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
1334pub struct Retry(pub u32);
1335
1336impl std::fmt::Display for Retry {
1337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1338 if self.is_infinite() {
1339 write!(f, "infinite")
1340 } else {
1341 write!(f, "{}", self.0)
1342 }
1343 }
1344}
1345
1346impl Retry {
1347 pub const INFINITE: Retry = Retry(u32::MAX);
1348
1349 pub fn count(&self) -> u32 {
1350 self.0
1351 }
1352
1353 pub fn is_infinite(&self) -> bool {
1354 self.0 == u32::MAX
1355 }
1356}
1357
1358impl From<u32> for Retry {
1359 fn from(n: u32) -> Self {
1360 Retry(n)
1361 }
1362}
1363
1364impl From<bool> for Retry {
1365 fn from(b: bool) -> Self {
1366 if b { Retry::INFINITE } else { Retry(0) }
1367 }
1368}
1369
1370impl Serialize for Retry {
1371 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1372 where
1373 S: Serializer,
1374 {
1375 if self.is_infinite() {
1377 serializer.serialize_bool(true)
1378 } else {
1379 serializer.serialize_u32(self.0)
1380 }
1381 }
1382}
1383
1384impl<'de> Deserialize<'de> for Retry {
1385 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1386 where
1387 D: Deserializer<'de>,
1388 {
1389 use serde::de::{self, Visitor};
1390
1391 struct RetryVisitor;
1392
1393 impl Visitor<'_> for RetryVisitor {
1394 type Value = Retry;
1395
1396 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1397 formatter.write_str("a boolean or non-negative integer")
1398 }
1399
1400 fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
1401 where
1402 E: de::Error,
1403 {
1404 Ok(Retry::from(v))
1405 }
1406
1407 fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
1408 where
1409 E: de::Error,
1410 {
1411 if v < 0 {
1412 Err(de::Error::custom("retry count cannot be negative"))
1413 } else if v > u32::MAX as i64 {
1414 Ok(Retry::INFINITE)
1415 } else {
1416 Ok(Retry(v as u32))
1417 }
1418 }
1419
1420 fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
1421 where
1422 E: de::Error,
1423 {
1424 if v > u32::MAX as u64 {
1425 Ok(Retry::INFINITE)
1426 } else {
1427 Ok(Retry(v as u32))
1428 }
1429 }
1430 }
1431
1432 deserializer.deserialize_any(RetryVisitor)
1433 }
1434}