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 watch_mode: Option<WatchMode>,
163 #[serde(skip_serializing_if = "Option::is_none", default)]
164 pub dir: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none", default)]
166 pub env: Option<IndexMap<String, String>>,
167 #[serde(skip_serializing_if = "Option::is_none", default)]
168 pub hooks: Option<PitchforkTomlHooks>,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
170 pub mise: Option<bool>,
171 #[serde(skip_serializing_if = "Option::is_none", default)]
173 pub user: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub memory_limit: Option<MemoryLimit>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
179 pub cpu_limit: Option<CpuLimit>,
180}
181
182#[derive(Debug, Default, JsonSchema)]
187#[schemars(title = "Pitchfork Configuration")]
188pub struct PitchforkToml {
189 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
191 pub namespace: Option<String>,
196 #[serde(default)]
205 pub(crate) settings: SettingsPartial,
206 #[schemars(skip)]
211 pub slugs: IndexMap<String, SlugEntry>,
212 #[schemars(skip)]
213 pub path: Option<PathBuf>,
214}
215
216pub(crate) fn is_global_config(path: &Path) -> bool {
217 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
218}
219
220fn is_local_config(path: &Path) -> bool {
221 path.file_name()
222 .map(|n| n == "pitchfork.local.toml")
223 .unwrap_or(false)
224}
225
226pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
227 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
228}
229
230fn sibling_base_config(path: &Path) -> Option<PathBuf> {
231 if !is_local_config(path) {
232 return None;
233 }
234 path.parent().map(|p| p.join("pitchfork.toml"))
235}
236
237fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
238 use toml::Value;
239
240 let doc: Value = toml::from_str(content)
241 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
242 let Some(value) = doc.get("namespace") else {
243 return Ok(None);
244 };
245
246 match value {
247 Value::String(s) => Ok(Some(s.clone())),
248 _ => Err(ConfigParseError::InvalidNamespace {
249 path: path.to_path_buf(),
250 namespace: value.to_string(),
251 reason: "top-level 'namespace' must be a string".to_string(),
252 }
253 .into()),
254 }
255}
256
257fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
258 if !path.exists() {
259 return Ok(None);
260 }
261 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
262 path: path.to_path_buf(),
263 source: e,
264 })?;
265 parse_namespace_override_from_content(path, &content)
266}
267
268fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
269 if let Err(e) = DaemonId::try_new(namespace, "probe") {
270 return Err(ConfigParseError::InvalidNamespace {
271 path: path.to_path_buf(),
272 namespace: namespace.to_string(),
273 reason: e.to_string(),
274 }
275 .into());
276 }
277 Ok(namespace.to_string())
278}
279
280fn derive_namespace_from_dir(path: &Path) -> Result<String> {
281 let dir_for_namespace = if is_dot_config_pitchfork(path) {
282 path.parent().and_then(|p| p.parent())
283 } else {
284 path.parent()
285 };
286
287 let raw_namespace = dir_for_namespace
288 .and_then(|p| p.file_name())
289 .and_then(|n| n.to_str())
290 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
291 .to_string();
292
293 validate_namespace(path, &raw_namespace).map_err(|e| {
294 ConfigParseError::InvalidNamespace {
295 path: path.to_path_buf(),
296 namespace: raw_namespace,
297 reason: format!(
298 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
299 ),
300 }
301 .into()
302 })
303}
304
305fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
306 if is_global_config(path) {
307 if let Some(ns) = explicit
308 && ns != "global"
309 {
310 return Err(ConfigParseError::InvalidNamespace {
311 path: path.to_path_buf(),
312 namespace: ns.to_string(),
313 reason: "global config files must use namespace 'global'".to_string(),
314 }
315 .into());
316 }
317 return Ok("global".to_string());
318 }
319
320 if let Some(ns) = explicit {
321 return validate_namespace(path, ns);
322 }
323
324 derive_namespace_from_dir(path)
325}
326
327fn namespace_from_file(path: &Path) -> Result<String> {
328 let explicit = read_namespace_override_from_file(path)?;
329 let base_explicit = sibling_base_config(path)
330 .and_then(|p| if p.exists() { Some(p) } else { None })
331 .map(|p| read_namespace_override_from_file(&p))
332 .transpose()?
333 .flatten();
334
335 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
336 && local_ns != base_ns
337 {
338 return Err(ConfigParseError::InvalidNamespace {
339 path: path.to_path_buf(),
340 namespace: local_ns.to_string(),
341 reason: format!(
342 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
343 ),
344 }
345 .into());
346 }
347
348 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
349 namespace_from_path_with_override(path, effective_explicit)
350}
351
352pub fn namespace_from_path(path: &Path) -> Result<String> {
365 namespace_from_file(path)
366}
367
368impl PitchforkToml {
369 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
382 if user_id.contains('/') {
384 return match DaemonId::parse(user_id) {
385 Ok(id) => Ok(vec![id]),
386 Err(e) => Err(e), };
388 }
389
390 let global_slugs = Self::read_global_slugs();
392 if let Some(entry) = global_slugs.get(user_id) {
393 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
395 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
396 let matches: Vec<DaemonId> = project_config
398 .daemons
399 .keys()
400 .filter(|id| id.name() == daemon_name)
401 .cloned()
402 .collect();
403 match matches.as_slice() {
404 [] => {}
405 [id] => return Ok(vec![id.clone()]),
406 _ => {
407 let mut candidates: Vec<String> =
408 matches.iter().map(|id| id.qualified()).collect();
409 candidates.sort();
410 return Err(miette::miette!(
411 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
412 user_id,
413 daemon_name,
414 candidates.join(", ")
415 ));
416 }
417 }
418 }
419 }
420
421 let matches: Vec<DaemonId> = self
423 .daemons
424 .keys()
425 .filter(|id| id.name() == user_id)
426 .cloned()
427 .collect();
428
429 if matches.is_empty() {
430 let _ = DaemonId::try_new("global", user_id)?;
432 }
433 Ok(matches)
434 }
435
436 #[allow(dead_code)]
457 pub fn resolve_daemon_id_prefer_local(
458 &self,
459 user_id: &str,
460 current_dir: &Path,
461 ) -> Result<DaemonId> {
462 if user_id.contains('/') {
464 return DaemonId::parse(user_id);
465 }
466
467 let current_namespace = Self::namespace_for_dir(current_dir)?;
471
472 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
473 }
474
475 fn resolve_daemon_id_with_namespace(
478 &self,
479 user_id: &str,
480 current_namespace: &str,
481 ) -> Result<DaemonId> {
482 let global_slugs = Self::read_global_slugs();
484 if let Some(entry) = global_slugs.get(user_id) {
485 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
486 if let Ok(project_config) = Self::all_merged_from(&entry.dir) {
487 let matches: Vec<DaemonId> = project_config
488 .daemons
489 .keys()
490 .filter(|id| id.name() == daemon_name)
491 .cloned()
492 .collect();
493 match matches.as_slice() {
494 [] => {}
495 [id] => return Ok(id.clone()),
496 _ => {
497 let mut candidates: Vec<String> =
498 matches.iter().map(|id| id.qualified()).collect();
499 candidates.sort();
500 return Err(miette::miette!(
501 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
502 user_id,
503 daemon_name,
504 candidates.join(", ")
505 ));
506 }
507 }
508 }
509 }
510
511 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
514 if self.daemons.contains_key(&preferred_id) {
515 return Ok(preferred_id);
516 }
517
518 let matches = self.resolve_daemon_id(user_id)?;
520
521 if matches.len() > 1 {
523 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
524 candidates.sort();
525 return Err(miette::miette!(
526 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
527 user_id,
528 candidates.join(", ")
529 ));
530 }
531
532 if let Some(id) = matches.into_iter().next() {
533 return Ok(id);
534 }
535
536 let global_id = DaemonId::try_new("global", user_id)?;
539 if self.daemons.contains_key(&global_id) {
540 return Ok(global_id);
541 }
542
543 if let Ok(state) = StateFile::read(&*env::PITCHFORK_STATE_FILE)
547 && state.daemons.contains_key(&global_id)
548 {
549 return Ok(global_id);
550 }
551
552 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
553 Err(DependencyError::DaemonNotFound {
554 name: user_id.to_string(),
555 suggestion,
556 }
557 .into())
558 }
559
560 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
563 Ok(Self::list_paths_from(dir)
564 .iter()
565 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
567 .transpose()?
568 .unwrap_or_else(|| "global".to_string()))
569 }
570
571 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
581 if user_id.contains('/') {
582 return DaemonId::parse(user_id);
583 }
584
585 let config = Self::all_merged()?;
588 let ns = Self::namespace_for_dir(&env::CWD)?;
589 config.resolve_daemon_id_with_namespace(user_id, &ns)
590 }
591
592 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
598 if user_id.contains('/') {
599 return DaemonId::parse(user_id);
600 }
601
602 let config = Self::all_merged()?;
603 let ns = Self::namespace_for_dir(&env::CWD)?;
604
605 let preferred_id = DaemonId::try_new(&ns, user_id)?;
606 if config.daemons.contains_key(&preferred_id) {
607 return Ok(preferred_id);
608 }
609
610 let matches = config.resolve_daemon_id(user_id)?;
611 if matches.len() > 1 {
612 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
613 candidates.sort();
614 return Err(miette::miette!(
615 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
616 user_id,
617 candidates.join(", ")
618 ));
619 }
620 if let Some(id) = matches.into_iter().next() {
621 return Ok(id);
622 }
623
624 DaemonId::try_new("global", user_id)
625 }
626
627 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
638 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
640 return user_ids
641 .iter()
642 .map(|s| DaemonId::parse(s.as_ref()))
643 .collect();
644 }
645
646 let config = Self::all_merged()?;
647 let ns = Self::namespace_for_dir(&env::CWD)?;
649 user_ids
650 .iter()
651 .map(|s| {
652 let id = s.as_ref();
653 if id.contains('/') {
654 DaemonId::parse(id)
655 } else {
656 config.resolve_daemon_id_with_namespace(id, &ns)
657 }
658 })
659 .collect()
660 }
661
662 pub fn list_paths() -> Vec<PathBuf> {
665 Self::list_paths_from(&env::CWD)
666 }
667
668 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
679 let mut paths = Vec::new();
680 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
681 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
682
683 let mut project_paths = xx::file::find_up_all(
687 cwd,
688 &[
689 "pitchfork.local.toml",
690 "pitchfork.toml",
691 ".config/pitchfork.local.toml",
692 ".config/pitchfork.toml",
693 ],
694 );
695 project_paths.reverse();
696 paths.extend(project_paths);
697
698 paths
699 }
700
701 pub fn all_merged() -> Result<PitchforkToml> {
704 Self::all_merged_from(&env::CWD)
705 }
706
707 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
721 use std::collections::HashMap;
722
723 let paths = Self::list_paths_from(cwd);
724 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
725
726 let mut pt = Self::default();
727 for p in paths {
728 match Self::read(&p) {
729 Ok(pt2) => {
730 if p.exists() && !is_global_config(&p) {
734 let ns = namespace_from_path(&p)?;
735 let origin_dir = if is_dot_config_pitchfork(&p) {
736 p.parent().and_then(|d| d.parent())
737 } else {
738 p.parent()
739 }
740 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
741 .unwrap_or_else(|| p.clone());
742
743 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
744 && *other_dir != origin_dir
745 {
746 return Err(crate::error::ConfigParseError::NamespaceCollision {
747 path_a: other_path.clone(),
748 path_b: p.clone(),
749 ns,
750 }
751 .into());
752 }
753 ns_to_origin.insert(ns, (p.clone(), origin_dir));
754 }
755
756 pt.merge(pt2)
757 }
758 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
759 }
760 }
761 Ok(pt)
762 }
763}
764
765impl PitchforkToml {
766 pub fn new(path: PathBuf) -> Self {
767 Self {
768 daemons: Default::default(),
769 namespace: None,
770 settings: SettingsPartial::default(),
771 slugs: IndexMap::new(),
772 path: Some(path),
773 }
774 }
775
776 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
784 let raw_config: PitchforkTomlRaw = toml::from_str(content)
785 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
786
787 let namespace = {
788 let base_explicit = sibling_base_config(path)
789 .and_then(|p| if p.exists() { Some(p) } else { None })
790 .map(|p| read_namespace_override_from_file(&p))
791 .transpose()?
792 .flatten();
793
794 if is_local_config(path)
795 && let (Some(local_ns), Some(base_ns)) =
796 (raw_config.namespace.as_deref(), base_explicit.as_deref())
797 && local_ns != base_ns
798 {
799 return Err(ConfigParseError::InvalidNamespace {
800 path: path.to_path_buf(),
801 namespace: local_ns.to_string(),
802 reason: format!(
803 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
804 ),
805 }
806 .into());
807 }
808
809 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
810 namespace_from_path_with_override(path, explicit)?
811 };
812 let mut pt = Self::new(path.to_path_buf());
813 pt.namespace = raw_config.namespace.clone();
814
815 for (short_name, raw_daemon) in raw_config.daemons {
816 let id = match DaemonId::try_new(&namespace, &short_name) {
817 Ok(id) => id,
818 Err(e) => {
819 return Err(ConfigParseError::InvalidDaemonName {
820 name: short_name,
821 path: path.to_path_buf(),
822 reason: e.to_string(),
823 }
824 .into());
825 }
826 };
827
828 let mut depends = Vec::new();
829 for dep in raw_daemon.depends {
830 let dep_id = if dep.contains('/') {
831 match DaemonId::parse(&dep) {
832 Ok(id) => id,
833 Err(e) => {
834 return Err(ConfigParseError::InvalidDependency {
835 daemon: short_name.clone(),
836 dependency: dep,
837 path: path.to_path_buf(),
838 reason: e.to_string(),
839 }
840 .into());
841 }
842 }
843 } else {
844 match DaemonId::try_new(&namespace, &dep) {
845 Ok(id) => id,
846 Err(e) => {
847 return Err(ConfigParseError::InvalidDependency {
848 daemon: short_name.clone(),
849 dependency: dep,
850 path: path.to_path_buf(),
851 reason: e.to_string(),
852 }
853 .into());
854 }
855 }
856 };
857 depends.push(dep_id);
858 }
859
860 let daemon = PitchforkTomlDaemon {
861 run: raw_daemon.run,
862 auto: raw_daemon.auto,
863 cron: raw_daemon.cron,
864 retry: raw_daemon.retry,
865 ready_delay: raw_daemon.ready_delay,
866 ready_output: raw_daemon.ready_output,
867 ready_http: raw_daemon.ready_http,
868 ready_port: raw_daemon.ready_port,
869 ready_cmd: raw_daemon.ready_cmd,
870 expected_port: raw_daemon.expected_port,
871 auto_bump_port: raw_daemon.auto_bump_port.unwrap_or(false),
872 port_bump_attempts: raw_daemon
873 .port_bump_attempts
874 .unwrap_or_else(|| settings().default_port_bump_attempts()),
875 boot_start: raw_daemon.boot_start,
876 depends,
877 watch: raw_daemon.watch,
878 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
879 dir: raw_daemon.dir,
880 env: raw_daemon.env,
881 hooks: raw_daemon.hooks,
882 mise: raw_daemon.mise,
883 user: raw_daemon.user,
884 memory_limit: raw_daemon.memory_limit,
885 cpu_limit: raw_daemon.cpu_limit,
886 path: Some(path.to_path_buf()),
887 };
888 pt.daemons.insert(id, daemon);
889 }
890
891 if let Some(settings) = raw_config.settings {
893 pt.settings = settings;
894 }
895
896 for (slug, entry) in raw_config.slugs {
898 pt.slugs.insert(
899 slug,
900 SlugEntry {
901 dir: PathBuf::from(entry.dir),
902 daemon: entry.daemon,
903 },
904 );
905 }
906
907 Ok(pt)
908 }
909
910 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
911 let path = path.as_ref();
912 if !path.exists() {
913 return Ok(Self::new(path.to_path_buf()));
914 }
915 let _lock = xx::fslock::get(path, false)
916 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
917 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
918 path: path.to_path_buf(),
919 source: e,
920 })?;
921 Self::parse_str(&raw, path)
922 }
923
924 pub fn write(&self) -> Result<()> {
925 if let Some(path) = &self.path {
926 let _lock = xx::fslock::get(path, false)
927 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
928 self.write_unlocked()
929 } else {
930 Err(FileError::NoPath.into())
931 }
932 }
933
934 fn write_unlocked(&self) -> Result<()> {
940 if let Some(path) = &self.path {
941 let config_namespace = if path.exists() {
943 namespace_from_path(path)?
944 } else {
945 namespace_from_path_with_override(path, self.namespace.as_deref())?
946 };
947
948 let mut raw = PitchforkTomlRaw {
950 namespace: self.namespace.clone(),
951 ..PitchforkTomlRaw::default()
952 };
953 for (id, daemon) in &self.daemons {
954 if id.namespace() != config_namespace {
955 return Err(miette::miette!(
956 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
957 id,
958 path.display(),
959 id.namespace(),
960 config_namespace
961 ));
962 }
963 let raw_daemon = PitchforkTomlDaemonRaw {
964 run: daemon.run.clone(),
965 auto: daemon.auto.clone(),
966 cron: daemon.cron.clone(),
967 retry: daemon.retry,
968 ready_delay: daemon.ready_delay,
969 ready_output: daemon.ready_output.clone(),
970 ready_http: daemon.ready_http.clone(),
971 ready_port: daemon.ready_port,
972 ready_cmd: daemon.ready_cmd.clone(),
973 expected_port: daemon.expected_port.clone(),
974 auto_bump_port: Some(daemon.auto_bump_port),
975 port_bump_attempts: Some(daemon.port_bump_attempts),
976 boot_start: daemon.boot_start,
977 depends: daemon
980 .depends
981 .iter()
982 .map(|d| {
983 if d.namespace() == config_namespace {
984 d.name().to_string()
985 } else {
986 d.qualified()
987 }
988 })
989 .collect(),
990 watch: daemon.watch.clone(),
991 watch_mode: match daemon.watch_mode {
992 WatchMode::Native => None,
993 mode => Some(mode),
994 },
995 dir: daemon.dir.clone(),
996 env: daemon.env.clone(),
997 hooks: daemon.hooks.clone(),
998 mise: daemon.mise,
999 user: daemon.user.clone(),
1000 memory_limit: daemon.memory_limit,
1001 cpu_limit: daemon.cpu_limit,
1002 };
1003 raw.daemons.insert(id.name().to_string(), raw_daemon);
1004 }
1005
1006 for (slug, entry) in &self.slugs {
1008 raw.slugs.insert(
1009 slug.clone(),
1010 SlugEntryRaw {
1011 dir: entry.dir.to_string_lossy().to_string(),
1012 daemon: entry.daemon.clone(),
1013 },
1014 );
1015 }
1016
1017 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1018 path: path.clone(),
1019 source: e,
1020 })?;
1021 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1022 path: path.clone(),
1023 details: Some(e.to_string()),
1024 })?;
1025 Ok(())
1026 } else {
1027 Err(FileError::NoPath.into())
1028 }
1029 }
1030
1031 pub fn merge(&mut self, pt: Self) {
1036 for (id, d) in pt.daemons {
1037 self.daemons.insert(id, d);
1038 }
1039 for (slug, entry) in pt.slugs {
1041 self.slugs.insert(slug, entry);
1042 }
1043 self.settings.merge_from(&pt.settings);
1045 }
1046
1047 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1052 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1053 Ok(pt) => pt.slugs,
1054 Err(_) => IndexMap::new(),
1055 }
1056 }
1057
1058 #[allow(dead_code)]
1060 pub fn is_slug_registered(slug: &str) -> bool {
1061 Self::read_global_slugs().contains_key(slug)
1062 }
1063
1064 pub fn add_slug(slug: &str, dir: &Path, daemon: Option<&str>) -> Result<()> {
1068 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1069
1070 if let Some(parent) = global_path.parent() {
1072 std::fs::create_dir_all(parent).map_err(|e| {
1073 miette::miette!(
1074 "Failed to create config directory {}: {e}",
1075 parent.display()
1076 )
1077 })?;
1078 }
1079
1080 let _lock = xx::fslock::get(global_path, false)
1084 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1085
1086 let mut pt = if global_path.exists() {
1087 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1088 path: global_path.to_path_buf(),
1089 source: e,
1090 })?;
1091 Self::parse_str(&raw, global_path)?
1092 } else {
1093 Self::new(global_path.to_path_buf())
1094 };
1095
1096 pt.slugs.insert(
1097 slug.to_string(),
1098 SlugEntry {
1099 dir: dir.to_path_buf(),
1100 daemon: daemon.map(str::to_string),
1101 },
1102 );
1103 pt.write_unlocked()
1104 }
1105
1106 pub fn remove_slug(slug: &str) -> Result<bool> {
1108 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1109 if !global_path.exists() {
1110 return Ok(false);
1111 }
1112
1113 let _lock = xx::fslock::get(global_path, false)
1114 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1115
1116 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1117 path: global_path.to_path_buf(),
1118 source: e,
1119 })?;
1120 let mut pt = Self::parse_str(&raw, global_path)?;
1121
1122 let removed = pt.slugs.shift_remove(slug).is_some();
1123 if removed {
1124 pt.write_unlocked()?;
1125 }
1126 Ok(removed)
1127 }
1128}
1129
1130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1132pub struct PitchforkTomlHooks {
1133 #[serde(skip_serializing_if = "Option::is_none", default)]
1135 pub on_ready: Option<String>,
1136 #[serde(skip_serializing_if = "Option::is_none", default)]
1138 pub on_fail: Option<String>,
1139 #[serde(skip_serializing_if = "Option::is_none", default)]
1141 pub on_retry: Option<String>,
1142 #[serde(skip_serializing_if = "Option::is_none", default)]
1144 pub on_stop: Option<String>,
1145 #[serde(skip_serializing_if = "Option::is_none", default)]
1147 pub on_exit: Option<String>,
1148}
1149
1150#[derive(Debug, Clone, JsonSchema)]
1152pub struct PitchforkTomlDaemon {
1153 #[schemars(example = example_run_command())]
1155 pub run: String,
1156 #[schemars(default)]
1158 pub auto: Vec<PitchforkTomlAuto>,
1159 pub cron: Option<PitchforkTomlCron>,
1161 #[schemars(default)]
1164 pub retry: Retry,
1165 pub ready_delay: Option<u64>,
1167 pub ready_output: Option<String>,
1169 pub ready_http: Option<String>,
1171 #[schemars(range(min = 1, max = 65535))]
1173 pub ready_port: Option<u16>,
1174 pub ready_cmd: Option<String>,
1176 #[serde(skip_serializing_if = "Vec::is_empty", default)]
1178 pub expected_port: Vec<u16>,
1179 #[serde(default)]
1181 pub auto_bump_port: bool,
1182 #[serde(default = "default_port_bump_attempts")]
1184 pub port_bump_attempts: u32,
1185 pub boot_start: Option<bool>,
1187 #[schemars(default)]
1189 pub depends: Vec<DaemonId>,
1190 #[schemars(default)]
1192 pub watch: Vec<String>,
1193 #[schemars(default)]
1199 pub watch_mode: WatchMode,
1200 pub dir: Option<String>,
1202 pub env: Option<IndexMap<String, String>>,
1204 pub hooks: Option<PitchforkTomlHooks>,
1206 pub mise: Option<bool>,
1209 pub user: Option<String>,
1211 pub memory_limit: Option<MemoryLimit>,
1214 pub cpu_limit: Option<CpuLimit>,
1217 #[schemars(skip)]
1218 pub path: Option<PathBuf>,
1219}
1220
1221impl Default for PitchforkTomlDaemon {
1222 fn default() -> Self {
1223 Self {
1224 run: String::new(),
1225 auto: Vec::new(),
1226 cron: None,
1227 retry: Retry::default(),
1228 ready_delay: None,
1229 ready_output: None,
1230 ready_http: None,
1231 ready_port: None,
1232 ready_cmd: None,
1233 expected_port: Vec::new(),
1234 auto_bump_port: false,
1235 port_bump_attempts: 10,
1236 boot_start: None,
1237 depends: Vec::new(),
1238 watch: Vec::new(),
1239 watch_mode: WatchMode::default(),
1240 dir: None,
1241 env: None,
1242 hooks: None,
1243 mise: None,
1244 user: None,
1245 memory_limit: None,
1246 cpu_limit: None,
1247 path: None,
1248 }
1249 }
1250}
1251
1252impl PitchforkTomlDaemon {
1253 pub fn to_run_options(
1258 &self,
1259 id: &crate::daemon_id::DaemonId,
1260 cmd: Vec<String>,
1261 ) -> crate::daemon::RunOptions {
1262 use crate::daemon::RunOptions;
1263
1264 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1265
1266 RunOptions {
1267 id: id.clone(),
1268 cmd,
1269 force: false,
1270 shell_pid: None,
1271 dir,
1272 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1273 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1274 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1275 retry: self.retry.count(),
1276 retry_count: 0,
1277 ready_delay: self.ready_delay,
1278 ready_output: self.ready_output.clone(),
1279 ready_http: self.ready_http.clone(),
1280 ready_port: self.ready_port,
1281 ready_cmd: self.ready_cmd.clone(),
1282 expected_port: self.expected_port.clone(),
1283 auto_bump_port: self.auto_bump_port,
1284 port_bump_attempts: self.port_bump_attempts,
1285 wait_ready: false,
1286 depends: self.depends.clone(),
1287 env: self.env.clone(),
1288 watch: self.watch.clone(),
1289 watch_mode: self.watch_mode,
1290 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1291 self.path.as_deref(),
1292 )),
1293 mise: self.mise,
1294 slug: None,
1295 proxy: None,
1296 user: self.user.clone(),
1297 memory_limit: self.memory_limit,
1298 cpu_limit: self.cpu_limit,
1299 }
1300 }
1301}
1302fn example_run_command() -> &'static str {
1303 "exec node server.js"
1304}
1305
1306fn default_port_bump_attempts() -> u32 {
1307 10
1311}
1312
1313#[derive(
1315 Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, JsonSchema,
1316)]
1317#[serde(rename_all = "snake_case")]
1318pub enum WatchMode {
1319 #[default]
1321 Native,
1322 Poll,
1324 Auto,
1326}
1327
1328#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
1330pub struct PitchforkTomlCron {
1331 #[schemars(example = example_cron_schedule())]
1333 pub schedule: String,
1334 #[serde(default = "default_retrigger")]
1336 pub retrigger: CronRetrigger,
1337}
1338
1339fn default_retrigger() -> CronRetrigger {
1340 CronRetrigger::Finish
1341}
1342
1343fn example_cron_schedule() -> &'static str {
1344 "0 * * * *"
1345}
1346
1347#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, JsonSchema)]
1349#[serde(rename_all = "snake_case")]
1350pub enum CronRetrigger {
1351 Finish,
1353 Always,
1355 Success,
1357 Fail,
1359}
1360
1361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
1363#[serde(rename_all = "snake_case")]
1364pub enum PitchforkTomlAuto {
1365 Start,
1366 Stop,
1367}
1368
1369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
1374pub struct Retry(pub u32);
1375
1376impl std::fmt::Display for Retry {
1377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1378 if self.is_infinite() {
1379 write!(f, "infinite")
1380 } else {
1381 write!(f, "{}", self.0)
1382 }
1383 }
1384}
1385
1386impl Retry {
1387 pub const INFINITE: Retry = Retry(u32::MAX);
1388
1389 pub fn count(&self) -> u32 {
1390 self.0
1391 }
1392
1393 pub fn is_infinite(&self) -> bool {
1394 self.0 == u32::MAX
1395 }
1396}
1397
1398impl From<u32> for Retry {
1399 fn from(n: u32) -> Self {
1400 Retry(n)
1401 }
1402}
1403
1404impl From<bool> for Retry {
1405 fn from(b: bool) -> Self {
1406 if b { Retry::INFINITE } else { Retry(0) }
1407 }
1408}
1409
1410impl Serialize for Retry {
1411 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1412 where
1413 S: Serializer,
1414 {
1415 if self.is_infinite() {
1417 serializer.serialize_bool(true)
1418 } else {
1419 serializer.serialize_u32(self.0)
1420 }
1421 }
1422}
1423
1424impl<'de> Deserialize<'de> for Retry {
1425 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1426 where
1427 D: Deserializer<'de>,
1428 {
1429 use serde::de::{self, Visitor};
1430
1431 struct RetryVisitor;
1432
1433 impl Visitor<'_> for RetryVisitor {
1434 type Value = Retry;
1435
1436 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1437 formatter.write_str("a boolean or non-negative integer")
1438 }
1439
1440 fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
1441 where
1442 E: de::Error,
1443 {
1444 Ok(Retry::from(v))
1445 }
1446
1447 fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
1448 where
1449 E: de::Error,
1450 {
1451 if v < 0 {
1452 Err(de::Error::custom("retry count cannot be negative"))
1453 } else if v > u32::MAX as i64 {
1454 Ok(Retry::INFINITE)
1455 } else {
1456 Ok(Retry(v as u32))
1457 }
1458 }
1459
1460 fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
1461 where
1462 E: de::Error,
1463 {
1464 if v > u32::MAX as u64 {
1465 Ok(Retry::INFINITE)
1466 } else {
1467 Ok(Retry(v as u32))
1468 }
1469 }
1470 }
1471
1472 deserializer.deserialize_any(RetryVisitor)
1473 }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478 use super::*;
1479 use std::path::Path;
1480
1481 #[test]
1482 fn test_daemon_user_parses_and_flows_to_run_options() {
1483 let pt = PitchforkToml::parse_str(
1484 r#"
1485[daemons.api]
1486run = "node server.js"
1487user = "postgres"
1488"#,
1489 Path::new("/tmp/my-project/pitchfork.toml"),
1490 )
1491 .unwrap();
1492
1493 let id = DaemonId::new("my-project", "api");
1494 let daemon = pt.daemons.get(&id).unwrap();
1495 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1496
1497 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1498 assert_eq!(opts.user.as_deref(), Some("postgres"));
1499 }
1500
1501 #[test]
1502 fn test_daemon_user_write_roundtrip() {
1503 let temp = tempfile::tempdir().unwrap();
1504 let path = temp.path().join("pitchfork.toml");
1505 let mut pt = PitchforkToml::new(path.clone());
1506 pt.namespace = Some("test-project".to_string());
1507 pt.daemons.insert(
1508 DaemonId::new("test-project", "api"),
1509 PitchforkTomlDaemon {
1510 run: "node server.js".to_string(),
1511 user: Some("postgres".to_string()),
1512 ..PitchforkTomlDaemon::default()
1513 },
1514 );
1515
1516 pt.write().unwrap();
1517
1518 let raw = std::fs::read_to_string(&path).unwrap();
1519 assert!(raw.contains("user = \"postgres\""));
1520
1521 let parsed = PitchforkToml::read(&path).unwrap();
1522 let daemon = parsed
1523 .daemons
1524 .get(&DaemonId::new("test-project", "api"))
1525 .unwrap();
1526 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1527 }
1528}