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