1use crate::daemon_id::DaemonId;
2use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
3use crate::settings::SettingsPartial;
4use crate::settings::settings;
5use crate::state_file::StateFile;
6use crate::{Result, env};
7use indexmap::IndexMap;
8use miette::Context;
9use schemars::JsonSchema;
10use std::path::{Path, PathBuf};
11
12pub use crate::config_types::{
14 CpuLimit, CronRetrigger, Dir, MemoryLimit, OnOutputHook, PitchforkTomlAuto, PitchforkTomlCron,
15 PitchforkTomlHooks, PortBump, PortConfig, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry,
16 StopConfig, StopSignal, WatchMode,
17};
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct SlugEntryRaw {
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub dir: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub namespace: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none", default)]
36 pub daemon: Option<String>,
37}
38
39#[derive(Debug, Clone)]
41pub struct SlugEntry {
42 pub dir: Option<PathBuf>,
44 pub namespace: Option<String>,
46 pub daemon: Option<String>,
48}
49
50impl SlugEntry {
51 pub fn resolve_dir(&self) -> Option<PathBuf> {
54 self.dir.clone().or_else(|| {
55 self.namespace.as_ref().and_then(|ns| {
56 let namespaces = PitchforkToml::read_global_namespaces();
57 namespaces.get(ns).map(|entry| entry.dir.clone())
58 })
59 })
60 }
61
62 pub fn resolve_namespace(&self) -> Option<String> {
65 self.namespace.clone().or_else(|| {
66 self.resolve_dir()
67 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
68 })
69 }
70}
71
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
78pub struct GroupEntryRaw {
79 pub daemons: Vec<String>,
80}
81
82#[derive(Debug, Clone)]
84pub struct GroupEntry {
85 pub daemons: Vec<DaemonId>,
86}
87
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94pub struct NamespaceEntryRaw {
95 pub dir: String,
97}
98
99#[derive(Debug, Clone)]
101pub struct NamespaceEntry {
102 pub dir: PathBuf,
104}
105
106#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
108struct PitchforkTomlRaw {
109 #[serde(skip_serializing_if = "Option::is_none", default)]
110 pub namespace: Option<String>,
111 #[serde(default)]
112 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
113 #[serde(skip_serializing_if = "Option::is_none", default)]
116 pub env: Option<IndexMap<String, String>>,
117 #[serde(default)]
118 pub settings: Option<SettingsPartial>,
119 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
122 pub slugs: IndexMap<String, SlugEntryRaw>,
123 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
125 pub groups: IndexMap<String, GroupEntryRaw>,
126 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
129 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
130}
131
132#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
137pub struct PitchforkTomlDaemonLogs {
138 #[serde(skip_serializing_if = "Option::is_none", default)]
141 pub log_format: Option<String>,
142 #[serde(skip_serializing_if = "Option::is_none", default)]
144 pub time_retention: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none", default)]
147 pub line_retention: Option<i64>,
148 #[serde(skip_serializing_if = "Option::is_none", default)]
150 pub archive_hook: Option<String>,
151}
152
153#[derive(Debug, serde::Serialize, serde::Deserialize)]
160struct PitchforkTomlDaemonRaw {
161 pub run: String,
162 #[serde(skip_serializing_if = "Vec::is_empty", default)]
163 pub auto: Vec<PitchforkTomlAuto>,
164 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub cron: Option<PitchforkTomlCron>,
166 #[serde(default)]
167 pub retry: Retry,
168 #[serde(skip_serializing_if = "Option::is_none", default)]
169 pub ready_delay: Option<u64>,
170 #[serde(skip_serializing_if = "Option::is_none", default)]
171 pub ready_output: Option<ReadyOutput>,
172 #[serde(skip_serializing_if = "Option::is_none", default)]
173 pub ready_http: Option<ReadyHttp>,
174 #[serde(skip_serializing_if = "Option::is_none", default)]
175 pub ready_port: Option<ReadyPort>,
176 #[serde(skip_serializing_if = "Option::is_none", default)]
177 pub ready_cmd: Option<ReadyCmd>,
178 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub port: Option<PortConfig>,
181 #[serde(skip_serializing_if = "Vec::is_empty", default)]
183 pub expected_port: Vec<u16>,
184 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub auto_bump_port: Option<bool>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
189 pub port_bump_attempts: Option<u32>,
190 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub boot_start: Option<bool>,
192 #[serde(skip_serializing_if = "Vec::is_empty", default)]
193 pub depends: Vec<String>,
194 #[serde(skip_serializing_if = "Vec::is_empty", default)]
195 pub watch: Vec<String>,
196 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub watch_mode: Option<WatchMode>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
199 pub dir: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub env: Option<IndexMap<String, String>>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
203 pub hooks: Option<PitchforkTomlHooks>,
204 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub mise: Option<bool>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub user: Option<String>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
211 pub memory_limit: Option<MemoryLimit>,
212 #[serde(skip_serializing_if = "Option::is_none", default)]
214 pub cpu_limit: Option<CpuLimit>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
217 pub stop_signal: Option<StopConfig>,
218 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub pty: Option<bool>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub time_retention: Option<String>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub line_retention: Option<i64>,
229 #[serde(skip_serializing_if = "Option::is_none", default)]
232 pub archive_hook: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub logs: Option<PitchforkTomlDaemonLogs>,
236}
237
238#[derive(Debug, Default, JsonSchema)]
243#[schemars(title = "Pitchfork Configuration")]
244pub struct PitchforkToml {
245 #[serde(default)]
247 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
252 pub env: Option<IndexMap<String, String>>,
253 pub namespace: Option<String>,
258 #[serde(default)]
267 pub(crate) settings: SettingsPartial,
268 #[schemars(skip)]
273 pub slugs: IndexMap<String, SlugEntry>,
274 #[schemars(skip)]
276 pub groups: IndexMap<String, GroupEntry>,
277 #[schemars(skip)]
280 pub namespaces: IndexMap<String, NamespaceEntry>,
281 #[schemars(skip)]
282 pub path: Option<PathBuf>,
283}
284
285pub(crate) fn is_global_config(path: &Path) -> bool {
286 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
287}
288
289fn is_local_config(path: &Path) -> bool {
290 path.file_name()
291 .map(|n| n == "pitchfork.local.toml")
292 .unwrap_or(false)
293}
294
295pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
296 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
297}
298
299fn sibling_base_config(path: &Path) -> Option<PathBuf> {
300 if !is_local_config(path) {
301 return None;
302 }
303 path.parent().map(|p| p.join("pitchfork.toml"))
304}
305
306fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
307 use toml::Value;
308
309 let doc: Value = toml::from_str(content)
310 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
311 let Some(value) = doc.get("namespace") else {
312 return Ok(None);
313 };
314
315 match value {
316 Value::String(s) => Ok(Some(s.clone())),
317 _ => Err(ConfigParseError::InvalidNamespace {
318 path: path.to_path_buf(),
319 namespace: value.to_string(),
320 reason: "top-level 'namespace' must be a string".to_string(),
321 }
322 .into()),
323 }
324}
325
326fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
327 if !path.exists() {
328 return Ok(None);
329 }
330 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
331 path: path.to_path_buf(),
332 source: e,
333 })?;
334 parse_namespace_override_from_content(path, &content)
335}
336
337fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
338 if let Err(e) = DaemonId::try_new(namespace, "probe") {
339 return Err(ConfigParseError::InvalidNamespace {
340 path: path.to_path_buf(),
341 namespace: namespace.to_string(),
342 reason: e.to_string(),
343 }
344 .into());
345 }
346 Ok(namespace.to_string())
347}
348
349fn derive_namespace_from_dir(path: &Path) -> Result<String> {
350 let dir_for_namespace = if is_dot_config_pitchfork(path) {
351 path.parent().and_then(|p| p.parent())
352 } else {
353 path.parent()
354 };
355
356 let raw_namespace = dir_for_namespace
357 .and_then(|p| p.file_name())
358 .and_then(|n| n.to_str())
359 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
360 .to_string();
361
362 validate_namespace(path, &raw_namespace).map_err(|e| {
363 ConfigParseError::InvalidNamespace {
364 path: path.to_path_buf(),
365 namespace: raw_namespace,
366 reason: format!(
367 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
368 ),
369 }
370 .into()
371 })
372}
373
374fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
375 if is_global_config(path) {
376 if let Some(ns) = explicit
377 && ns != "global"
378 {
379 return Err(ConfigParseError::InvalidNamespace {
380 path: path.to_path_buf(),
381 namespace: ns.to_string(),
382 reason: "global config files must use namespace 'global'".to_string(),
383 }
384 .into());
385 }
386 return Ok("global".to_string());
387 }
388
389 if let Some(ns) = explicit {
390 return validate_namespace(path, ns);
391 }
392
393 derive_namespace_from_dir(path)
394}
395
396fn namespace_from_file(path: &Path) -> Result<String> {
397 let explicit = read_namespace_override_from_file(path)?;
398 let base_explicit = sibling_base_config(path)
399 .filter(|p| p.exists())
400 .map(|p| read_namespace_override_from_file(&p))
401 .transpose()?
402 .flatten();
403
404 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
405 && local_ns != base_ns
406 {
407 return Err(ConfigParseError::InvalidNamespace {
408 path: path.to_path_buf(),
409 namespace: local_ns.to_string(),
410 reason: format!(
411 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
412 ),
413 }
414 .into());
415 }
416
417 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
418 namespace_from_path_with_override(path, effective_explicit)
419}
420
421pub fn namespace_from_path(path: &Path) -> Result<String> {
434 namespace_from_file(path)
435}
436
437impl PitchforkToml {
438 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
451 if user_id.contains('/') {
453 return match DaemonId::parse(user_id) {
454 Ok(id) => Ok(vec![id]),
455 Err(e) => Err(e), };
457 }
458
459 let global_slugs = Self::read_global_slugs();
461 if let Some(entry) = global_slugs.get(user_id) {
462 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
464 if let Some(dir) = entry.resolve_dir()
465 && let Ok(project_config) = Self::all_merged_from(&dir)
466 {
467 let matches: Vec<DaemonId> = project_config
469 .daemons
470 .keys()
471 .filter(|id| id.name() == daemon_name)
472 .cloned()
473 .collect();
474 match matches.as_slice() {
475 [] => {}
476 [id] => return Ok(vec![id.clone()]),
477 _ => {
478 let mut candidates: Vec<String> =
479 matches.iter().map(|id| id.qualified()).collect();
480 candidates.sort();
481 return Err(miette::miette!(
482 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
483 user_id,
484 daemon_name,
485 candidates.join(", ")
486 ));
487 }
488 }
489 }
490 }
491
492 let matches: Vec<DaemonId> = self
494 .daemons
495 .keys()
496 .filter(|id| id.name() == user_id)
497 .cloned()
498 .collect();
499
500 if matches.is_empty() {
501 let state_matches = Self::find_in_state_file(user_id);
503 match state_matches.as_slice() {
504 [] => {}
505 [id] => return Ok(vec![id.clone()]),
506 _ => {
507 let mut candidates: Vec<String> =
508 state_matches.iter().map(|id| id.qualified()).collect();
509 candidates.sort();
510 return Err(miette::miette!(
511 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
512 user_id,
513 candidates.join(", ")
514 ));
515 }
516 }
517 let _ = DaemonId::try_new("global", user_id)?;
519 }
520 Ok(matches)
521 }
522
523 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
529 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
530 Ok(state) => state
531 .daemons
532 .keys()
533 .filter(|id| id.name() == short_name)
534 .cloned()
535 .collect(),
536 Err(e) => {
537 warn!("cannot read state file: {e}");
538 Vec::new()
539 }
540 }
541 }
542
543 #[allow(dead_code)]
564 pub fn resolve_daemon_id_prefer_local(
565 &self,
566 user_id: &str,
567 current_dir: &Path,
568 ) -> Result<DaemonId> {
569 if user_id.contains('/') {
571 return DaemonId::parse(user_id);
572 }
573
574 let current_namespace = Self::namespace_for_dir(current_dir)?;
578
579 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
580 }
581
582 fn resolve_daemon_id_with_namespace(
585 &self,
586 user_id: &str,
587 current_namespace: &str,
588 ) -> Result<DaemonId> {
589 let global_slugs = Self::read_global_slugs();
591 if let Some(entry) = global_slugs.get(user_id) {
592 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
593 if let Some(dir) = entry.resolve_dir()
594 && let Ok(project_config) = Self::all_merged_from(&dir)
595 {
596 let matches: Vec<DaemonId> = project_config
597 .daemons
598 .keys()
599 .filter(|id| id.name() == daemon_name)
600 .cloned()
601 .collect();
602 match matches.as_slice() {
603 [] => {}
604 [id] => return Ok(id.clone()),
605 _ => {
606 let mut candidates: Vec<String> =
607 matches.iter().map(|id| id.qualified()).collect();
608 candidates.sort();
609 return Err(miette::miette!(
610 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
611 user_id,
612 daemon_name,
613 candidates.join(", ")
614 ));
615 }
616 }
617 }
618 }
619
620 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
623 if self.daemons.contains_key(&preferred_id) {
624 return Ok(preferred_id);
625 }
626
627 let matches = self.resolve_daemon_id(user_id)?;
629
630 if matches.len() > 1 {
632 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
633 candidates.sort();
634 return Err(miette::miette!(
635 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
636 user_id,
637 candidates.join(", ")
638 ));
639 }
640
641 if let Some(id) = matches.into_iter().next() {
642 return Ok(id);
643 }
644
645 let global_id = DaemonId::try_new("global", user_id)?;
648 if self.daemons.contains_key(&global_id) {
649 return Ok(global_id);
650 }
651
652 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
653 Err(DependencyError::DaemonNotFound {
654 name: user_id.to_string(),
655 suggestion,
656 }
657 .into())
658 }
659
660 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
663 Ok(Self::list_paths_from(dir)
664 .iter()
665 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
667 .transpose()?
668 .unwrap_or_else(|| "global".to_string()))
669 }
670
671 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
681 if user_id.contains('/') {
682 return DaemonId::parse(user_id);
683 }
684
685 let config = Self::all_merged()?;
688 let ns = Self::namespace_for_dir(&env::CWD)?;
689 config.resolve_daemon_id_with_namespace(user_id, &ns)
690 }
691
692 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
698 if user_id.contains('/') {
699 return DaemonId::parse(user_id);
700 }
701
702 let config = Self::all_merged()?;
703 let ns = Self::namespace_for_dir(&env::CWD)?;
704
705 let preferred_id = DaemonId::try_new(&ns, user_id)?;
706 if config.daemons.contains_key(&preferred_id) {
707 return Ok(preferred_id);
708 }
709
710 let matches = config.resolve_daemon_id(user_id)?;
711 if matches.len() > 1 {
712 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
713 candidates.sort();
714 return Err(miette::miette!(
715 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
716 user_id,
717 candidates.join(", ")
718 ));
719 }
720 if let Some(id) = matches.into_iter().next() {
721 return Ok(id);
722 }
723
724 DaemonId::try_new("global", user_id)
725 }
726
727 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
738 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
740 return user_ids
741 .iter()
742 .map(|s| DaemonId::parse(s.as_ref()))
743 .collect();
744 }
745
746 let config = Self::all_merged()?;
747 let ns = Self::namespace_for_dir(&env::CWD)?;
749 user_ids
750 .iter()
751 .map(|s| {
752 let id = s.as_ref();
753 if id.contains('/') {
754 DaemonId::parse(id)
755 } else {
756 config.resolve_daemon_id_with_namespace(id, &ns)
757 }
758 })
759 .collect()
760 }
761
762 pub fn resolve_ids_and_group<S: AsRef<str>>(
767 user_ids: &[S],
768 group_name: Option<&str>,
769 ) -> Result<Vec<DaemonId>> {
770 let config = Self::all_merged()?;
771 let ns = Self::namespace_for_dir(&env::CWD)?;
772 let mut ids = Vec::new();
773 let mut seen = std::collections::HashSet::new();
774
775 for id in user_ids {
776 let id_str = id.as_ref();
777 let daemon_id = if id_str.contains('/') {
778 DaemonId::parse(id_str)?
779 } else {
780 config.resolve_daemon_id_with_namespace(id_str, &ns)?
781 };
782 if seen.insert(daemon_id.clone()) {
783 ids.push(daemon_id);
784 }
785 }
786
787 if let Some(name) = group_name {
788 match config.groups.get(name) {
789 Some(group) => {
790 let missing: Vec<String> = group
791 .daemons
792 .iter()
793 .filter(|id| !config.daemons.contains_key(*id))
794 .map(|id| id.qualified())
795 .collect();
796 if !missing.is_empty() {
797 return Err(miette::miette!(
798 "group '{}' references undefined daemon{}: {}",
799 name,
800 if missing.len() > 1 { "s" } else { "" },
801 missing.join(", ")
802 ));
803 }
804 for daemon_id in &group.daemons {
805 if seen.insert(daemon_id.clone()) {
806 ids.push(daemon_id.clone());
807 }
808 }
809 }
810 None => {
811 let suggestion =
812 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
813 return Err(miette::miette!(
814 "group '{}' not found in configuration{}",
815 name,
816 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
817 ));
818 }
819 }
820 }
821
822 Ok(ids)
823 }
824
825 pub fn list_paths() -> Vec<PathBuf> {
828 Self::list_paths_from(&env::CWD)
829 }
830
831 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
842 let mut paths = Vec::new();
843 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
844 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
845
846 let mut project_paths = xx::file::find_up_all(
850 cwd,
851 &[
852 "pitchfork.local.toml",
853 "pitchfork.toml",
854 ".config/pitchfork.local.toml",
855 ".config/pitchfork.toml",
856 ],
857 );
858 project_paths.reverse();
859 paths.extend(project_paths);
860
861 paths
862 }
863
864 pub fn all_merged() -> Result<PitchforkToml> {
867 Self::all_merged_from(&env::CWD)
868 }
869 pub fn all_merged_all_namespaces() -> Result<Self> {
876 let mut pt = Self::all_merged_from(&env::CWD)?;
877
878 let namespaces = Self::read_global_namespaces();
879 for (ns_name, entry) in namespaces {
880 match Self::all_merged_from(&entry.dir) {
881 Ok(ns_config) => {
882 for (daemon_id, daemon_config) in ns_config.daemons {
883 if !pt.daemons.contains_key(&daemon_id) {
884 pt.daemons.insert(daemon_id, daemon_config);
885 }
886 }
887 pt.settings.merge_from(&ns_config.settings);
890 }
891 Err(e) => {
892 log::warn!(
893 "Failed to load namespace '{ns_name}' from {}: {e}",
894 entry.dir.display()
895 );
896 }
897 }
898 }
899
900 Ok(pt)
901 }
902
903 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
917 use std::collections::HashMap;
918
919 let paths = Self::list_paths_from(cwd);
920 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
921
922 let mut pt = Self::default();
923 for p in paths {
924 match Self::read(&p) {
925 Ok(pt2) => {
926 if p.exists() && !is_global_config(&p) {
930 let ns = namespace_from_path(&p)?;
931 let origin_dir = if is_dot_config_pitchfork(&p) {
932 p.parent().and_then(|d| d.parent())
933 } else {
934 p.parent()
935 }
936 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
937 .unwrap_or_else(|| p.clone());
938
939 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
940 && *other_dir != origin_dir
941 {
942 return Err(crate::error::ConfigParseError::NamespaceCollision {
943 path_a: other_path.clone(),
944 path_b: p.clone(),
945 ns,
946 }
947 .into());
948 }
949 ns_to_origin.insert(ns, (p.clone(), origin_dir));
950 }
951
952 pt.merge(pt2)
953 }
954 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
955 }
956 }
957 Ok(pt)
958 }
959}
960
961impl PitchforkToml {
962 pub fn new(path: PathBuf) -> Self {
963 Self {
964 daemons: Default::default(),
965 env: None,
966 namespace: None,
967 settings: SettingsPartial::default(),
968 slugs: IndexMap::new(),
969 groups: IndexMap::new(),
970 namespaces: IndexMap::new(),
971 path: Some(path),
972 }
973 }
974
975 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
983 let raw_config: PitchforkTomlRaw = toml::from_str(content)
984 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
985
986 let namespace = {
987 let base_explicit = sibling_base_config(path)
988 .filter(|p| p.exists())
989 .map(|p| read_namespace_override_from_file(&p))
990 .transpose()?
991 .flatten();
992
993 if is_local_config(path)
994 && let (Some(local_ns), Some(base_ns)) =
995 (raw_config.namespace.as_deref(), base_explicit.as_deref())
996 && local_ns != base_ns
997 {
998 return Err(ConfigParseError::InvalidNamespace {
999 path: path.to_path_buf(),
1000 namespace: local_ns.to_string(),
1001 reason: format!(
1002 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
1003 ),
1004 }
1005 .into());
1006 }
1007
1008 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
1009 namespace_from_path_with_override(path, explicit)?
1010 };
1011 let mut pt = Self::new(path.to_path_buf());
1012 pt.namespace = raw_config.namespace.clone();
1013
1014 for (short_name, raw_daemon) in raw_config.daemons {
1015 let id = match DaemonId::try_new(&namespace, &short_name) {
1016 Ok(id) => id,
1017 Err(e) => {
1018 return Err(ConfigParseError::InvalidDaemonName {
1019 name: short_name,
1020 path: path.to_path_buf(),
1021 reason: e.to_string(),
1022 }
1023 .into());
1024 }
1025 };
1026
1027 let mut depends = Vec::new();
1028 for dep in raw_daemon.depends {
1029 let dep_id = if dep.contains('/') {
1030 match DaemonId::parse(&dep) {
1031 Ok(id) => id,
1032 Err(e) => {
1033 return Err(ConfigParseError::InvalidDependency {
1034 daemon: short_name.clone(),
1035 dependency: dep,
1036 path: path.to_path_buf(),
1037 reason: e.to_string(),
1038 }
1039 .into());
1040 }
1041 }
1042 } else {
1043 match DaemonId::try_new(&namespace, &dep) {
1044 Ok(id) => id,
1045 Err(e) => {
1046 return Err(ConfigParseError::InvalidDependency {
1047 daemon: short_name.clone(),
1048 dependency: dep,
1049 path: path.to_path_buf(),
1050 reason: e.to_string(),
1051 }
1052 .into());
1053 }
1054 }
1055 };
1056 depends.push(dep_id);
1057 }
1058
1059 let has_deprecated = !raw_daemon.expected_port.is_empty()
1061 || raw_daemon.auto_bump_port.is_some()
1062 || raw_daemon.port_bump_attempts.is_some();
1063 let port = if let Some(port) = raw_daemon.port {
1064 if has_deprecated {
1065 warn!(
1066 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1067 );
1068 }
1069 Some(port)
1070 } else if has_deprecated {
1071 warn!(
1072 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1073 );
1074 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1075 PortBump(
1076 raw_daemon
1077 .port_bump_attempts
1078 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1079 )
1080 } else {
1081 PortBump(0)
1082 };
1083 Some(PortConfig {
1084 expect: raw_daemon.expected_port,
1085 bump,
1086 })
1087 } else {
1088 None
1089 };
1090
1091 let daemon = PitchforkTomlDaemon {
1092 run: raw_daemon.run,
1093 auto: raw_daemon.auto,
1094 cron: raw_daemon.cron,
1095 retry: raw_daemon.retry,
1096 ready_delay: raw_daemon.ready_delay,
1097 ready_output: raw_daemon.ready_output,
1098 ready_http: raw_daemon.ready_http,
1099 ready_port: raw_daemon.ready_port,
1100 ready_cmd: raw_daemon.ready_cmd,
1101 port,
1102 boot_start: raw_daemon.boot_start,
1103 depends,
1104 watch: raw_daemon.watch,
1105 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1106 dir: raw_daemon.dir,
1107 env: raw_daemon.env,
1108 hooks: raw_daemon.hooks,
1109 mise: raw_daemon.mise,
1110 user: raw_daemon.user,
1111 memory_limit: raw_daemon.memory_limit,
1112 cpu_limit: raw_daemon.cpu_limit,
1113 stop_signal: raw_daemon.stop_signal,
1114 pty: raw_daemon.pty,
1115 time_retention: raw_daemon.time_retention,
1116 line_retention: raw_daemon.line_retention,
1117 archive_hook: raw_daemon.archive_hook,
1118 logs: raw_daemon.logs,
1119 path: Some(path.to_path_buf()),
1120 };
1121 pt.daemons.insert(id, daemon);
1122 }
1123
1124 if let Some(settings) = raw_config.settings {
1126 pt.settings = settings;
1127 }
1128
1129 pt.env = raw_config.env;
1131
1132 for (slug, entry) in raw_config.slugs {
1134 pt.slugs.insert(
1135 slug,
1136 SlugEntry {
1137 dir: entry.dir.map(PathBuf::from),
1138 namespace: entry.namespace,
1139 daemon: entry.daemon,
1140 },
1141 );
1142 }
1143
1144 for (name, entry) in raw_config.namespaces {
1146 pt.namespaces.insert(
1147 name,
1148 NamespaceEntry {
1149 dir: PathBuf::from(entry.dir),
1150 },
1151 );
1152 }
1153
1154 for (group_name, raw_group) in raw_config.groups {
1156 let mut daemons = Vec::new();
1157 for daemon_name in &raw_group.daemons {
1158 let id = if daemon_name.contains('/') {
1159 DaemonId::parse(daemon_name).map_err(|e| {
1160 ConfigParseError::InvalidDependency {
1161 daemon: group_name.clone(),
1162 dependency: daemon_name.clone(),
1163 path: path.to_path_buf(),
1164 reason: e.to_string(),
1165 }
1166 })?
1167 } else {
1168 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1169 ConfigParseError::InvalidDaemonName {
1170 name: daemon_name.clone(),
1171 path: path.to_path_buf(),
1172 reason: e.to_string(),
1173 }
1174 })?
1175 };
1176 daemons.push(id);
1177 }
1178 pt.groups.insert(group_name, GroupEntry { daemons });
1179 }
1180
1181 Ok(pt)
1182 }
1183
1184 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1185 let path = path.as_ref();
1186 if !path.exists() {
1187 return Ok(Self::new(path.to_path_buf()));
1188 }
1189 let _lock = xx::fslock::get(path, false)
1190 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1191 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1192 path: path.to_path_buf(),
1193 source: e,
1194 })?;
1195 Self::parse_str(&raw, path)
1196 }
1197
1198 pub fn write(&self) -> Result<()> {
1199 if let Some(path) = &self.path {
1200 let _lock = xx::fslock::get(path, false)
1201 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1202 self.write_unlocked()
1203 } else {
1204 Err(FileError::NoPath.into())
1205 }
1206 }
1207
1208 fn write_unlocked(&self) -> Result<()> {
1214 if let Some(path) = &self.path {
1215 let config_namespace = if path.exists() {
1217 namespace_from_path(path)?
1218 } else {
1219 namespace_from_path_with_override(path, self.namespace.as_deref())?
1220 };
1221
1222 let mut raw = PitchforkTomlRaw {
1226 namespace: self.namespace.clone(),
1227 env: self.env.clone(),
1228 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1229 ..PitchforkTomlRaw::default()
1230 };
1231 for (id, daemon) in &self.daemons {
1232 if id.namespace() != config_namespace {
1233 return Err(miette::miette!(
1234 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1235 id,
1236 path.display(),
1237 id.namespace(),
1238 config_namespace
1239 ));
1240 }
1241 let port = daemon.port.as_ref();
1242 let raw_daemon = PitchforkTomlDaemonRaw {
1243 run: daemon.run.clone(),
1244 auto: daemon.auto.clone(),
1245 cron: daemon.cron.clone(),
1246 retry: daemon.retry,
1247 ready_delay: daemon.ready_delay,
1248 ready_output: daemon.ready_output.clone(),
1249 ready_http: daemon.ready_http.clone(),
1250 ready_port: daemon.ready_port.clone(),
1251 ready_cmd: daemon.ready_cmd.clone(),
1252 port: port.cloned(),
1253 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1255 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1256 port_bump_attempts: port
1257 .filter(|p| p.auto_bump())
1258 .map(|p| p.max_bump_attempts()),
1259 boot_start: daemon.boot_start,
1260 depends: daemon
1263 .depends
1264 .iter()
1265 .map(|d| {
1266 if d.namespace() == config_namespace {
1267 d.name().to_string()
1268 } else {
1269 d.qualified()
1270 }
1271 })
1272 .collect(),
1273 watch: daemon.watch.clone(),
1274 watch_mode: match daemon.watch_mode {
1275 WatchMode::Native => None,
1276 mode => Some(mode),
1277 },
1278 dir: daemon.dir.clone(),
1279 env: daemon.env.clone(),
1280 hooks: daemon.hooks.clone(),
1281 mise: daemon.mise,
1282 user: daemon.user.clone(),
1283 memory_limit: daemon.memory_limit,
1284 cpu_limit: daemon.cpu_limit,
1285 stop_signal: daemon.stop_signal,
1286 pty: daemon.pty,
1287 time_retention: daemon.time_retention.clone(),
1288 line_retention: daemon.line_retention,
1289 archive_hook: daemon.archive_hook.clone(),
1290 logs: daemon.logs.clone(),
1291 };
1292 raw.daemons.insert(id.name().to_string(), raw_daemon);
1293 }
1294
1295 for (slug, entry) in &self.slugs {
1297 raw.slugs.insert(
1298 slug.clone(),
1299 SlugEntryRaw {
1300 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1301 namespace: entry.namespace.clone(),
1302 daemon: entry.daemon.clone(),
1303 },
1304 );
1305 }
1306
1307 for (name, group) in &self.groups {
1309 let raw_daemons: Vec<String> = group
1310 .daemons
1311 .iter()
1312 .map(|id| {
1313 if id.namespace() == config_namespace {
1314 id.name().to_string()
1315 } else {
1316 id.qualified()
1317 }
1318 })
1319 .collect();
1320 raw.groups.insert(
1321 name.clone(),
1322 GroupEntryRaw {
1323 daemons: raw_daemons,
1324 },
1325 );
1326 }
1327
1328 for (name, entry) in &self.namespaces {
1330 raw.namespaces.insert(
1331 name.clone(),
1332 NamespaceEntryRaw {
1333 dir: entry.dir.to_string_lossy().to_string(),
1334 },
1335 );
1336 }
1337
1338 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1339 path: path.clone(),
1340 source: e,
1341 })?;
1342 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1343 path: path.clone(),
1344 details: Some(e.to_string()),
1345 })?;
1346 Ok(())
1347 } else {
1348 Err(FileError::NoPath.into())
1349 }
1350 }
1351
1352 pub fn merge(&mut self, pt: Self) {
1357 for (id, d) in pt.daemons {
1358 self.daemons.insert(id, d);
1359 }
1360 if let Some(env) = pt.env {
1362 let merged = self.env.get_or_insert_with(IndexMap::new);
1363 for (k, v) in env {
1364 merged.insert(k, v);
1365 }
1366 }
1367 for (slug, entry) in pt.slugs {
1369 self.slugs.insert(slug, entry);
1370 }
1371 for (name, group) in pt.groups {
1373 self.groups.insert(name, group);
1374 }
1375 for (name, entry) in pt.namespaces {
1377 self.namespaces.insert(name, entry);
1378 }
1379 self.settings.merge_from(&pt.settings);
1381 }
1382
1383 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1388 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1389 Ok(pt) => pt.slugs,
1390 Err(_) => IndexMap::new(),
1391 }
1392 }
1393
1394 pub fn find_slug_for_daemon_in_registry(
1396 daemon_id: &DaemonId,
1397 global_slugs: &IndexMap<String, SlugEntry>,
1398 ) -> Option<String> {
1399 global_slugs
1400 .iter()
1401 .find(|(slug, entry)| {
1402 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1403 if daemon_id.name() != daemon_name {
1404 return false;
1405 }
1406
1407 match entry.resolve_namespace() {
1408 Some(namespace) => daemon_id.namespace() == namespace,
1409 None => false,
1410 }
1411 })
1412 .map(|(slug, _)| slug.clone())
1413 }
1414
1415 #[allow(dead_code)]
1417 pub fn is_slug_registered(slug: &str) -> bool {
1418 Self::read_global_slugs().contains_key(slug)
1419 }
1420
1421 pub fn add_slug_with_namespace(
1427 slug: &str,
1428 namespace: Option<&str>,
1429 daemon: Option<&str>,
1430 ) -> Result<()> {
1431 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1432
1433 if let Some(parent) = global_path.parent() {
1435 std::fs::create_dir_all(parent).map_err(|e| {
1436 miette::miette!(
1437 "Failed to create config directory {}: {e}",
1438 parent.display()
1439 )
1440 })?;
1441 }
1442
1443 let _lock = xx::fslock::get(global_path, false)
1444 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1445
1446 let mut pt = if global_path.exists() {
1447 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1448 path: global_path.to_path_buf(),
1449 source: e,
1450 })?;
1451 Self::parse_str(&raw, global_path)?
1452 } else {
1453 Self::new(global_path.to_path_buf())
1454 };
1455
1456 if let Some(ns) = namespace
1460 && !pt.namespaces.contains_key(ns)
1461 {
1462 let dir = pt
1463 .slugs
1464 .get(slug)
1465 .and_then(|e| e.resolve_dir())
1466 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1467 if let Some(ref d) = dir {
1468 pt.namespaces
1469 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1470 }
1471 }
1472
1473 pt.slugs.insert(
1474 slug.to_string(),
1475 SlugEntry {
1476 dir: None,
1477 namespace: namespace.map(str::to_string),
1478 daemon: daemon.map(str::to_string),
1479 },
1480 );
1481 pt.write_unlocked()?;
1482 crate::proxy::hosts::sync_hosts_from_settings();
1483 Ok(())
1484 }
1485
1486 pub fn remove_slug(slug: &str) -> Result<bool> {
1488 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1489 if !global_path.exists() {
1490 return Ok(false);
1491 }
1492
1493 let _lock = xx::fslock::get(global_path, false)
1494 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1495
1496 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1497 path: global_path.to_path_buf(),
1498 source: e,
1499 })?;
1500 let mut pt = Self::parse_str(&raw, global_path)?;
1501
1502 let removed = pt.slugs.shift_remove(slug).is_some();
1503 if removed {
1504 pt.write_unlocked()?;
1505 crate::proxy::hosts::sync_hosts_from_settings();
1506 }
1507 Ok(removed)
1508 }
1509 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1512 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1513 Ok(pt) => pt.namespaces,
1514 Err(_) => IndexMap::new(),
1515 }
1516 }
1517
1518 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1522 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1523
1524 if let Some(parent) = global_path.parent() {
1526 std::fs::create_dir_all(parent).map_err(|e| {
1527 miette::miette!(
1528 "Failed to create config directory {}: {e}",
1529 parent.display()
1530 )
1531 })?;
1532 }
1533
1534 let _lock = xx::fslock::get(global_path, false)
1535 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1536
1537 let mut pt = if global_path.exists() {
1538 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1539 crate::error::FileError::ReadError {
1540 path: global_path.to_path_buf(),
1541 source: e,
1542 }
1543 })?;
1544 Self::parse_str(&raw, global_path)?
1545 } else {
1546 Self::new(global_path.to_path_buf())
1547 };
1548
1549 pt.namespaces.insert(
1550 name.to_string(),
1551 NamespaceEntry {
1552 dir: PathBuf::from(dir),
1553 },
1554 );
1555 pt.write_unlocked()?;
1556 Ok(())
1557 }
1558
1559 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1561 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1562 if !global_path.exists() {
1563 return Ok(false);
1564 }
1565
1566 let _lock = xx::fslock::get(global_path, false)
1567 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1568
1569 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1570 crate::error::FileError::ReadError {
1571 path: global_path.to_path_buf(),
1572 source: e,
1573 }
1574 })?;
1575 let mut pt = Self::parse_str(&raw, global_path)?;
1576
1577 let removed = pt.namespaces.shift_remove(name).is_some();
1578 if removed {
1579 pt.write_unlocked()?;
1580 }
1581 Ok(removed)
1582 }
1583}
1584
1585#[derive(Debug, Clone, JsonSchema, Default)]
1587pub struct PitchforkTomlDaemon {
1588 #[schemars(example = example_run_command())]
1590 pub run: String,
1591 #[schemars(default)]
1593 pub auto: Vec<PitchforkTomlAuto>,
1594 pub cron: Option<PitchforkTomlCron>,
1596 #[schemars(default)]
1599 pub retry: Retry,
1600 pub ready_delay: Option<u64>,
1602 pub ready_output: Option<ReadyOutput>,
1604 pub ready_http: Option<ReadyHttp>,
1606 pub ready_port: Option<ReadyPort>,
1610 pub ready_cmd: Option<ReadyCmd>,
1612 pub port: Option<PortConfig>,
1614 pub boot_start: Option<bool>,
1616 #[schemars(default)]
1618 pub depends: Vec<DaemonId>,
1619 #[schemars(default)]
1621 pub watch: Vec<String>,
1622 #[schemars(default)]
1628 pub watch_mode: WatchMode,
1629 pub dir: Option<String>,
1631 pub env: Option<IndexMap<String, String>>,
1633 pub hooks: Option<PitchforkTomlHooks>,
1635 pub mise: Option<bool>,
1638 pub user: Option<String>,
1640 pub memory_limit: Option<MemoryLimit>,
1643 pub cpu_limit: Option<CpuLimit>,
1646 pub stop_signal: Option<StopConfig>,
1649 pub pty: Option<bool>,
1651 pub time_retention: Option<String>,
1654 pub line_retention: Option<i64>,
1657 pub archive_hook: Option<String>,
1660 pub logs: Option<PitchforkTomlDaemonLogs>,
1662 #[schemars(skip)]
1663 pub path: Option<PathBuf>,
1664}
1665
1666impl PitchforkTomlDaemon {
1667 pub fn to_run_options(
1672 &self,
1673 id: &crate::daemon_id::DaemonId,
1674 cmd: Vec<String>,
1675 ) -> crate::daemon::RunOptions {
1676 use crate::daemon::RunOptions;
1677
1678 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1679 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1680 .into_iter()
1681 .find(|(slug, entry)| {
1682 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1683 if daemon_name != id.name() {
1684 return false;
1685 }
1686
1687 match entry.resolve_namespace() {
1688 Some(namespace) => namespace == id.namespace(),
1689 None => false,
1690 }
1691 })
1692 .map(|(slug, _)| slug);
1693
1694 RunOptions {
1695 id: id.clone(),
1696 cmd,
1697 run: Some(self.run.clone()),
1698 force: false,
1699 shell_pid: None,
1700 dir: Dir(dir),
1701 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1702 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1703 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1704 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1705 retry: self.retry,
1706 retry_count: 0,
1707 ready_delay: self.ready_delay,
1708 ready_output: self.ready_output.clone(),
1709 ready_http: self.ready_http.clone(),
1710 ready_port: self.ready_port.clone(),
1711 ready_cmd: self.ready_cmd.clone(),
1712 port: self.port.clone(),
1713 wait_ready: false,
1714 depends: self.depends.clone(),
1715 env: self.env.clone(),
1716 watch: self.watch.clone(),
1717 watch_mode: self.watch_mode,
1718 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1719 self.path.as_deref(),
1720 )),
1721 mise: self.mise,
1722 slug,
1723 proxy: None,
1724 user: self.user.clone(),
1725 memory_limit: self.memory_limit,
1726 cpu_limit: self.cpu_limit,
1727 stop_signal: self.stop_signal,
1728 archive_hook: self
1729 .logs
1730 .as_ref()
1731 .and_then(|l| l.archive_hook.clone())
1732 .or_else(|| self.archive_hook.clone()),
1733 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1734 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1735 pty: self.pty,
1736 }
1737 }
1738}
1739fn example_run_command() -> &'static str {
1740 "exec node server.js"
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745 use super::*;
1746 use std::path::Path;
1747
1748 #[test]
1749 fn test_daemon_user_parses_and_flows_to_run_options() {
1750 let pt = PitchforkToml::parse_str(
1751 r#"
1752[daemons.api]
1753run = "node server.js"
1754user = "postgres"
1755"#,
1756 Path::new("/tmp/my-project/pitchfork.toml"),
1757 )
1758 .unwrap();
1759
1760 let id = DaemonId::new("my-project", "api");
1761 let daemon = pt.daemons.get(&id).unwrap();
1762 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1763
1764 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1765 assert_eq!(opts.user.as_deref(), Some("postgres"));
1766 }
1767
1768 #[test]
1769 fn test_daemon_user_write_roundtrip() {
1770 let temp = tempfile::tempdir().unwrap();
1771 let path = temp.path().join("pitchfork.toml");
1772 let mut pt = PitchforkToml::new(path.clone());
1773 pt.namespace = Some("test-project".to_string());
1774 pt.daemons.insert(
1775 DaemonId::new("test-project", "api"),
1776 PitchforkTomlDaemon {
1777 run: "node server.js".to_string(),
1778 user: Some("postgres".to_string()),
1779 ..PitchforkTomlDaemon::default()
1780 },
1781 );
1782
1783 pt.write().unwrap();
1784
1785 let raw = std::fs::read_to_string(&path).unwrap();
1786 assert!(raw.contains("user = \"postgres\""));
1787
1788 let parsed = PitchforkToml::read(&path).unwrap();
1789 let daemon = parsed
1790 .daemons
1791 .get(&DaemonId::new("test-project", "api"))
1792 .unwrap();
1793 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1794 }
1795
1796 #[test]
1797 fn test_settings_write_roundtrip() {
1798 let temp = tempfile::tempdir().unwrap();
1799 let path = temp.path().join("pitchfork.toml");
1800 let mut pt = PitchforkToml::new(path.clone());
1801 pt.namespace = Some("test-project".to_string());
1802 pt.settings.web.auto_start = Some(true);
1803 pt.settings.general.log_level = Some("debug".to_string());
1804
1805 pt.write().unwrap();
1806
1807 let raw = std::fs::read_to_string(&path).unwrap();
1808 assert!(
1809 raw.contains("[settings.web]"),
1810 "settings.web section should be written, got:\n{raw}"
1811 );
1812 assert!(raw.contains("auto_start = true"));
1813 assert!(raw.contains("log_level = \"debug\""));
1814
1815 let parsed = PitchforkToml::read(&path).unwrap();
1816 assert_eq!(parsed.settings.web.auto_start, Some(true));
1817 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
1818 }
1819
1820 #[test]
1821 fn test_settings_preserved_on_unrelated_write() {
1822 let temp = tempfile::tempdir().unwrap();
1825 let path = temp.path().join("pitchfork.toml");
1826 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
1827
1828 let mut pt = PitchforkToml::read(&path).unwrap();
1829 pt.slugs.insert(
1830 "api".to_string(),
1831 SlugEntry {
1832 dir: None,
1833 namespace: Some("myproject".to_string()),
1834 daemon: None,
1835 },
1836 );
1837 pt.namespaces.insert(
1838 "myproject".to_string(),
1839 NamespaceEntry {
1840 dir: PathBuf::from("/tmp/myproject"),
1841 },
1842 );
1843 pt.write().unwrap();
1844
1845 let raw = std::fs::read_to_string(&path).unwrap();
1846 assert!(
1847 raw.contains("[settings.web]"),
1848 "existing settings must be preserved, got:\n{raw}"
1849 );
1850 assert!(raw.contains("auto_start = true"));
1851 assert!(raw.contains("[slugs.api]"));
1852
1853 let parsed = PitchforkToml::read(&path).unwrap();
1854 assert_eq!(parsed.settings.web.auto_start, Some(true));
1855 assert!(parsed.slugs.contains_key("api"));
1856 }
1857}