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(default)]
114 pub settings: Option<SettingsPartial>,
115 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
118 pub slugs: IndexMap<String, SlugEntryRaw>,
119 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
121 pub groups: IndexMap<String, GroupEntryRaw>,
122 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
125 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
126}
127
128#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
133pub struct PitchforkTomlDaemonLogs {
134 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub log_format: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub time_retention: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
143 pub line_retention: Option<i64>,
144 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub archive_hook: Option<String>,
147}
148
149#[derive(Debug, serde::Serialize, serde::Deserialize)]
156struct PitchforkTomlDaemonRaw {
157 pub run: String,
158 #[serde(skip_serializing_if = "Vec::is_empty", default)]
159 pub auto: Vec<PitchforkTomlAuto>,
160 #[serde(skip_serializing_if = "Option::is_none", default)]
161 pub cron: Option<PitchforkTomlCron>,
162 #[serde(default)]
163 pub retry: Retry,
164 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub ready_delay: Option<u64>,
166 #[serde(skip_serializing_if = "Option::is_none", default)]
167 pub ready_output: Option<ReadyOutput>,
168 #[serde(skip_serializing_if = "Option::is_none", default)]
169 pub ready_http: Option<ReadyHttp>,
170 #[serde(skip_serializing_if = "Option::is_none", default)]
171 pub ready_port: Option<ReadyPort>,
172 #[serde(skip_serializing_if = "Option::is_none", default)]
173 pub ready_cmd: Option<ReadyCmd>,
174 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub port: Option<PortConfig>,
177 #[serde(skip_serializing_if = "Vec::is_empty", default)]
179 pub expected_port: Vec<u16>,
180 #[serde(skip_serializing_if = "Option::is_none", default)]
182 pub auto_bump_port: Option<bool>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub port_bump_attempts: Option<u32>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
187 pub boot_start: Option<bool>,
188 #[serde(skip_serializing_if = "Vec::is_empty", default)]
189 pub depends: Vec<String>,
190 #[serde(skip_serializing_if = "Vec::is_empty", default)]
191 pub watch: Vec<String>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
193 pub watch_mode: Option<WatchMode>,
194 #[serde(skip_serializing_if = "Option::is_none", default)]
195 pub dir: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub env: Option<IndexMap<String, String>>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
199 pub hooks: Option<PitchforkTomlHooks>,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub mise: Option<bool>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub user: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
207 pub memory_limit: Option<MemoryLimit>,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub cpu_limit: Option<CpuLimit>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub stop_signal: Option<StopConfig>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub pty: Option<bool>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub time_retention: Option<String>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub line_retention: Option<i64>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub archive_hook: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub logs: Option<PitchforkTomlDaemonLogs>,
232}
233
234#[derive(Debug, Default, JsonSchema)]
239#[schemars(title = "Pitchfork Configuration")]
240pub struct PitchforkToml {
241 #[serde(default)]
243 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
244 pub namespace: Option<String>,
249 #[serde(default)]
258 pub(crate) settings: SettingsPartial,
259 #[schemars(skip)]
264 pub slugs: IndexMap<String, SlugEntry>,
265 #[schemars(skip)]
267 pub groups: IndexMap<String, GroupEntry>,
268 #[schemars(skip)]
271 pub namespaces: IndexMap<String, NamespaceEntry>,
272 #[schemars(skip)]
273 pub path: Option<PathBuf>,
274}
275
276pub(crate) fn is_global_config(path: &Path) -> bool {
277 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
278}
279
280fn is_local_config(path: &Path) -> bool {
281 path.file_name()
282 .map(|n| n == "pitchfork.local.toml")
283 .unwrap_or(false)
284}
285
286pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
287 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
288}
289
290fn sibling_base_config(path: &Path) -> Option<PathBuf> {
291 if !is_local_config(path) {
292 return None;
293 }
294 path.parent().map(|p| p.join("pitchfork.toml"))
295}
296
297fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
298 use toml::Value;
299
300 let doc: Value = toml::from_str(content)
301 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
302 let Some(value) = doc.get("namespace") else {
303 return Ok(None);
304 };
305
306 match value {
307 Value::String(s) => Ok(Some(s.clone())),
308 _ => Err(ConfigParseError::InvalidNamespace {
309 path: path.to_path_buf(),
310 namespace: value.to_string(),
311 reason: "top-level 'namespace' must be a string".to_string(),
312 }
313 .into()),
314 }
315}
316
317fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
318 if !path.exists() {
319 return Ok(None);
320 }
321 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
322 path: path.to_path_buf(),
323 source: e,
324 })?;
325 parse_namespace_override_from_content(path, &content)
326}
327
328fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
329 if let Err(e) = DaemonId::try_new(namespace, "probe") {
330 return Err(ConfigParseError::InvalidNamespace {
331 path: path.to_path_buf(),
332 namespace: namespace.to_string(),
333 reason: e.to_string(),
334 }
335 .into());
336 }
337 Ok(namespace.to_string())
338}
339
340fn derive_namespace_from_dir(path: &Path) -> Result<String> {
341 let dir_for_namespace = if is_dot_config_pitchfork(path) {
342 path.parent().and_then(|p| p.parent())
343 } else {
344 path.parent()
345 };
346
347 let raw_namespace = dir_for_namespace
348 .and_then(|p| p.file_name())
349 .and_then(|n| n.to_str())
350 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
351 .to_string();
352
353 validate_namespace(path, &raw_namespace).map_err(|e| {
354 ConfigParseError::InvalidNamespace {
355 path: path.to_path_buf(),
356 namespace: raw_namespace,
357 reason: format!(
358 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
359 ),
360 }
361 .into()
362 })
363}
364
365fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
366 if is_global_config(path) {
367 if let Some(ns) = explicit
368 && ns != "global"
369 {
370 return Err(ConfigParseError::InvalidNamespace {
371 path: path.to_path_buf(),
372 namespace: ns.to_string(),
373 reason: "global config files must use namespace 'global'".to_string(),
374 }
375 .into());
376 }
377 return Ok("global".to_string());
378 }
379
380 if let Some(ns) = explicit {
381 return validate_namespace(path, ns);
382 }
383
384 derive_namespace_from_dir(path)
385}
386
387fn namespace_from_file(path: &Path) -> Result<String> {
388 let explicit = read_namespace_override_from_file(path)?;
389 let base_explicit = sibling_base_config(path)
390 .filter(|p| p.exists())
391 .map(|p| read_namespace_override_from_file(&p))
392 .transpose()?
393 .flatten();
394
395 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
396 && local_ns != base_ns
397 {
398 return Err(ConfigParseError::InvalidNamespace {
399 path: path.to_path_buf(),
400 namespace: local_ns.to_string(),
401 reason: format!(
402 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
403 ),
404 }
405 .into());
406 }
407
408 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
409 namespace_from_path_with_override(path, effective_explicit)
410}
411
412pub fn namespace_from_path(path: &Path) -> Result<String> {
425 namespace_from_file(path)
426}
427
428impl PitchforkToml {
429 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
442 if user_id.contains('/') {
444 return match DaemonId::parse(user_id) {
445 Ok(id) => Ok(vec![id]),
446 Err(e) => Err(e), };
448 }
449
450 let global_slugs = Self::read_global_slugs();
452 if let Some(entry) = global_slugs.get(user_id) {
453 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
455 if let Some(dir) = entry.resolve_dir() {
456 if let Ok(project_config) = Self::all_merged_from(&dir) {
457 let matches: Vec<DaemonId> = project_config
459 .daemons
460 .keys()
461 .filter(|id| id.name() == daemon_name)
462 .cloned()
463 .collect();
464 match matches.as_slice() {
465 [] => {}
466 [id] => return Ok(vec![id.clone()]),
467 _ => {
468 let mut candidates: Vec<String> =
469 matches.iter().map(|id| id.qualified()).collect();
470 candidates.sort();
471 return Err(miette::miette!(
472 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
473 user_id,
474 daemon_name,
475 candidates.join(", ")
476 ));
477 }
478 }
479 }
480 }
481 }
482
483 let matches: Vec<DaemonId> = self
485 .daemons
486 .keys()
487 .filter(|id| id.name() == user_id)
488 .cloned()
489 .collect();
490
491 if matches.is_empty() {
492 let state_matches = Self::find_in_state_file(user_id);
494 match state_matches.as_slice() {
495 [] => {}
496 [id] => return Ok(vec![id.clone()]),
497 _ => {
498 let mut candidates: Vec<String> =
499 state_matches.iter().map(|id| id.qualified()).collect();
500 candidates.sort();
501 return Err(miette::miette!(
502 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
503 user_id,
504 candidates.join(", ")
505 ));
506 }
507 }
508 let _ = DaemonId::try_new("global", user_id)?;
510 }
511 Ok(matches)
512 }
513
514 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
520 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
521 Ok(state) => state
522 .daemons
523 .keys()
524 .filter(|id| id.name() == short_name)
525 .cloned()
526 .collect(),
527 Err(e) => {
528 warn!("cannot read state file: {e}");
529 Vec::new()
530 }
531 }
532 }
533
534 #[allow(dead_code)]
555 pub fn resolve_daemon_id_prefer_local(
556 &self,
557 user_id: &str,
558 current_dir: &Path,
559 ) -> Result<DaemonId> {
560 if user_id.contains('/') {
562 return DaemonId::parse(user_id);
563 }
564
565 let current_namespace = Self::namespace_for_dir(current_dir)?;
569
570 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
571 }
572
573 fn resolve_daemon_id_with_namespace(
576 &self,
577 user_id: &str,
578 current_namespace: &str,
579 ) -> Result<DaemonId> {
580 let global_slugs = Self::read_global_slugs();
582 if let Some(entry) = global_slugs.get(user_id) {
583 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
584 if let Some(dir) = entry.resolve_dir() {
585 if let Ok(project_config) = Self::all_merged_from(&dir) {
586 let matches: Vec<DaemonId> = project_config
587 .daemons
588 .keys()
589 .filter(|id| id.name() == daemon_name)
590 .cloned()
591 .collect();
592 match matches.as_slice() {
593 [] => {}
594 [id] => return Ok(id.clone()),
595 _ => {
596 let mut candidates: Vec<String> =
597 matches.iter().map(|id| id.qualified()).collect();
598 candidates.sort();
599 return Err(miette::miette!(
600 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
601 user_id,
602 daemon_name,
603 candidates.join(", ")
604 ));
605 }
606 }
607 }
608 }
609 }
610
611 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
614 if self.daemons.contains_key(&preferred_id) {
615 return Ok(preferred_id);
616 }
617
618 let matches = self.resolve_daemon_id(user_id)?;
620
621 if matches.len() > 1 {
623 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
624 candidates.sort();
625 return Err(miette::miette!(
626 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
627 user_id,
628 candidates.join(", ")
629 ));
630 }
631
632 if let Some(id) = matches.into_iter().next() {
633 return Ok(id);
634 }
635
636 let global_id = DaemonId::try_new("global", user_id)?;
639 if self.daemons.contains_key(&global_id) {
640 return Ok(global_id);
641 }
642
643 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
644 Err(DependencyError::DaemonNotFound {
645 name: user_id.to_string(),
646 suggestion,
647 }
648 .into())
649 }
650
651 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
654 Ok(Self::list_paths_from(dir)
655 .iter()
656 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
658 .transpose()?
659 .unwrap_or_else(|| "global".to_string()))
660 }
661
662 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
672 if user_id.contains('/') {
673 return DaemonId::parse(user_id);
674 }
675
676 let config = Self::all_merged()?;
679 let ns = Self::namespace_for_dir(&env::CWD)?;
680 config.resolve_daemon_id_with_namespace(user_id, &ns)
681 }
682
683 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
689 if user_id.contains('/') {
690 return DaemonId::parse(user_id);
691 }
692
693 let config = Self::all_merged()?;
694 let ns = Self::namespace_for_dir(&env::CWD)?;
695
696 let preferred_id = DaemonId::try_new(&ns, user_id)?;
697 if config.daemons.contains_key(&preferred_id) {
698 return Ok(preferred_id);
699 }
700
701 let matches = config.resolve_daemon_id(user_id)?;
702 if matches.len() > 1 {
703 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
704 candidates.sort();
705 return Err(miette::miette!(
706 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
707 user_id,
708 candidates.join(", ")
709 ));
710 }
711 if let Some(id) = matches.into_iter().next() {
712 return Ok(id);
713 }
714
715 DaemonId::try_new("global", user_id)
716 }
717
718 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
729 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
731 return user_ids
732 .iter()
733 .map(|s| DaemonId::parse(s.as_ref()))
734 .collect();
735 }
736
737 let config = Self::all_merged()?;
738 let ns = Self::namespace_for_dir(&env::CWD)?;
740 user_ids
741 .iter()
742 .map(|s| {
743 let id = s.as_ref();
744 if id.contains('/') {
745 DaemonId::parse(id)
746 } else {
747 config.resolve_daemon_id_with_namespace(id, &ns)
748 }
749 })
750 .collect()
751 }
752
753 pub fn resolve_ids_and_group<S: AsRef<str>>(
758 user_ids: &[S],
759 group_name: Option<&str>,
760 ) -> Result<Vec<DaemonId>> {
761 let config = Self::all_merged()?;
762 let ns = Self::namespace_for_dir(&env::CWD)?;
763 let mut ids = Vec::new();
764 let mut seen = std::collections::HashSet::new();
765
766 for id in user_ids {
767 let id_str = id.as_ref();
768 let daemon_id = if id_str.contains('/') {
769 DaemonId::parse(id_str)?
770 } else {
771 config.resolve_daemon_id_with_namespace(id_str, &ns)?
772 };
773 if seen.insert(daemon_id.clone()) {
774 ids.push(daemon_id);
775 }
776 }
777
778 if let Some(name) = group_name {
779 match config.groups.get(name) {
780 Some(group) => {
781 let missing: Vec<String> = group
782 .daemons
783 .iter()
784 .filter(|id| !config.daemons.contains_key(*id))
785 .map(|id| id.qualified())
786 .collect();
787 if !missing.is_empty() {
788 return Err(miette::miette!(
789 "group '{}' references undefined daemon{}: {}",
790 name,
791 if missing.len() > 1 { "s" } else { "" },
792 missing.join(", ")
793 ));
794 }
795 for daemon_id in &group.daemons {
796 if seen.insert(daemon_id.clone()) {
797 ids.push(daemon_id.clone());
798 }
799 }
800 }
801 None => {
802 let suggestion =
803 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
804 return Err(miette::miette!(
805 "group '{}' not found in configuration{}",
806 name,
807 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
808 ));
809 }
810 }
811 }
812
813 Ok(ids)
814 }
815
816 pub fn list_paths() -> Vec<PathBuf> {
819 Self::list_paths_from(&env::CWD)
820 }
821
822 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
833 let mut paths = Vec::new();
834 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
835 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
836
837 let mut project_paths = xx::file::find_up_all(
841 cwd,
842 &[
843 "pitchfork.local.toml",
844 "pitchfork.toml",
845 ".config/pitchfork.local.toml",
846 ".config/pitchfork.toml",
847 ],
848 );
849 project_paths.reverse();
850 paths.extend(project_paths);
851
852 paths
853 }
854
855 pub fn all_merged() -> Result<PitchforkToml> {
858 Self::all_merged_from(&env::CWD)
859 }
860 pub fn all_merged_all_namespaces() -> Result<Self> {
867 let mut pt = Self::all_merged_from(&env::CWD)?;
868
869 let namespaces = Self::read_global_namespaces();
870 for (ns_name, entry) in namespaces {
871 match Self::all_merged_from(&entry.dir) {
872 Ok(ns_config) => {
873 for (daemon_id, daemon_config) in ns_config.daemons {
874 if !pt.daemons.contains_key(&daemon_id) {
875 pt.daemons.insert(daemon_id, daemon_config);
876 }
877 }
878 pt.settings.merge_from(&ns_config.settings);
881 }
882 Err(e) => {
883 log::warn!(
884 "Failed to load namespace '{ns_name}' from {}: {e}",
885 entry.dir.display()
886 );
887 }
888 }
889 }
890
891 Ok(pt)
892 }
893
894 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
908 use std::collections::HashMap;
909
910 let paths = Self::list_paths_from(cwd);
911 let mut ns_to_origin: HashMap<String, (PathBuf, PathBuf)> = HashMap::new();
912
913 let mut pt = Self::default();
914 for p in paths {
915 match Self::read(&p) {
916 Ok(pt2) => {
917 if p.exists() && !is_global_config(&p) {
921 let ns = namespace_from_path(&p)?;
922 let origin_dir = if is_dot_config_pitchfork(&p) {
923 p.parent().and_then(|d| d.parent())
924 } else {
925 p.parent()
926 }
927 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
928 .unwrap_or_else(|| p.clone());
929
930 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
931 && *other_dir != origin_dir
932 {
933 return Err(crate::error::ConfigParseError::NamespaceCollision {
934 path_a: other_path.clone(),
935 path_b: p.clone(),
936 ns,
937 }
938 .into());
939 }
940 ns_to_origin.insert(ns, (p.clone(), origin_dir));
941 }
942
943 pt.merge(pt2)
944 }
945 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
946 }
947 }
948 Ok(pt)
949 }
950}
951
952impl PitchforkToml {
953 pub fn new(path: PathBuf) -> Self {
954 Self {
955 daemons: Default::default(),
956 namespace: None,
957 settings: SettingsPartial::default(),
958 slugs: IndexMap::new(),
959 groups: IndexMap::new(),
960 namespaces: IndexMap::new(),
961 path: Some(path),
962 }
963 }
964
965 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
973 let raw_config: PitchforkTomlRaw = toml::from_str(content)
974 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
975
976 let namespace = {
977 let base_explicit = sibling_base_config(path)
978 .filter(|p| p.exists())
979 .map(|p| read_namespace_override_from_file(&p))
980 .transpose()?
981 .flatten();
982
983 if is_local_config(path)
984 && let (Some(local_ns), Some(base_ns)) =
985 (raw_config.namespace.as_deref(), base_explicit.as_deref())
986 && local_ns != base_ns
987 {
988 return Err(ConfigParseError::InvalidNamespace {
989 path: path.to_path_buf(),
990 namespace: local_ns.to_string(),
991 reason: format!(
992 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
993 ),
994 }
995 .into());
996 }
997
998 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
999 namespace_from_path_with_override(path, explicit)?
1000 };
1001 let mut pt = Self::new(path.to_path_buf());
1002 pt.namespace = raw_config.namespace.clone();
1003
1004 for (short_name, raw_daemon) in raw_config.daemons {
1005 let id = match DaemonId::try_new(&namespace, &short_name) {
1006 Ok(id) => id,
1007 Err(e) => {
1008 return Err(ConfigParseError::InvalidDaemonName {
1009 name: short_name,
1010 path: path.to_path_buf(),
1011 reason: e.to_string(),
1012 }
1013 .into());
1014 }
1015 };
1016
1017 let mut depends = Vec::new();
1018 for dep in raw_daemon.depends {
1019 let dep_id = if dep.contains('/') {
1020 match DaemonId::parse(&dep) {
1021 Ok(id) => id,
1022 Err(e) => {
1023 return Err(ConfigParseError::InvalidDependency {
1024 daemon: short_name.clone(),
1025 dependency: dep,
1026 path: path.to_path_buf(),
1027 reason: e.to_string(),
1028 }
1029 .into());
1030 }
1031 }
1032 } else {
1033 match DaemonId::try_new(&namespace, &dep) {
1034 Ok(id) => id,
1035 Err(e) => {
1036 return Err(ConfigParseError::InvalidDependency {
1037 daemon: short_name.clone(),
1038 dependency: dep,
1039 path: path.to_path_buf(),
1040 reason: e.to_string(),
1041 }
1042 .into());
1043 }
1044 }
1045 };
1046 depends.push(dep_id);
1047 }
1048
1049 let has_deprecated = !raw_daemon.expected_port.is_empty()
1051 || raw_daemon.auto_bump_port.is_some()
1052 || raw_daemon.port_bump_attempts.is_some();
1053 let port = if let Some(port) = raw_daemon.port {
1054 if has_deprecated {
1055 warn!(
1056 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1057 );
1058 }
1059 Some(port)
1060 } else if has_deprecated {
1061 warn!(
1062 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1063 );
1064 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1065 PortBump(
1066 raw_daemon
1067 .port_bump_attempts
1068 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1069 )
1070 } else {
1071 PortBump(0)
1072 };
1073 Some(PortConfig {
1074 expect: raw_daemon.expected_port,
1075 bump,
1076 })
1077 } else {
1078 None
1079 };
1080
1081 let daemon = PitchforkTomlDaemon {
1082 run: raw_daemon.run,
1083 auto: raw_daemon.auto,
1084 cron: raw_daemon.cron,
1085 retry: raw_daemon.retry,
1086 ready_delay: raw_daemon.ready_delay,
1087 ready_output: raw_daemon.ready_output,
1088 ready_http: raw_daemon.ready_http,
1089 ready_port: raw_daemon.ready_port,
1090 ready_cmd: raw_daemon.ready_cmd,
1091 port,
1092 boot_start: raw_daemon.boot_start,
1093 depends,
1094 watch: raw_daemon.watch,
1095 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1096 dir: raw_daemon.dir,
1097 env: raw_daemon.env,
1098 hooks: raw_daemon.hooks,
1099 mise: raw_daemon.mise,
1100 user: raw_daemon.user,
1101 memory_limit: raw_daemon.memory_limit,
1102 cpu_limit: raw_daemon.cpu_limit,
1103 stop_signal: raw_daemon.stop_signal,
1104 pty: raw_daemon.pty,
1105 time_retention: raw_daemon.time_retention,
1106 line_retention: raw_daemon.line_retention,
1107 archive_hook: raw_daemon.archive_hook,
1108 logs: raw_daemon.logs,
1109 path: Some(path.to_path_buf()),
1110 };
1111 pt.daemons.insert(id, daemon);
1112 }
1113
1114 if let Some(settings) = raw_config.settings {
1116 pt.settings = settings;
1117 }
1118
1119 for (slug, entry) in raw_config.slugs {
1121 pt.slugs.insert(
1122 slug,
1123 SlugEntry {
1124 dir: entry.dir.map(PathBuf::from),
1125 namespace: entry.namespace,
1126 daemon: entry.daemon,
1127 },
1128 );
1129 }
1130
1131 for (name, entry) in raw_config.namespaces {
1133 pt.namespaces.insert(
1134 name,
1135 NamespaceEntry {
1136 dir: PathBuf::from(entry.dir),
1137 },
1138 );
1139 }
1140
1141 for (group_name, raw_group) in raw_config.groups {
1143 let mut daemons = Vec::new();
1144 for daemon_name in &raw_group.daemons {
1145 let id = if daemon_name.contains('/') {
1146 DaemonId::parse(daemon_name).map_err(|e| {
1147 ConfigParseError::InvalidDependency {
1148 daemon: group_name.clone(),
1149 dependency: daemon_name.clone(),
1150 path: path.to_path_buf(),
1151 reason: e.to_string(),
1152 }
1153 })?
1154 } else {
1155 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1156 ConfigParseError::InvalidDaemonName {
1157 name: daemon_name.clone(),
1158 path: path.to_path_buf(),
1159 reason: e.to_string(),
1160 }
1161 })?
1162 };
1163 daemons.push(id);
1164 }
1165 pt.groups.insert(group_name, GroupEntry { daemons });
1166 }
1167
1168 Ok(pt)
1169 }
1170
1171 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1172 let path = path.as_ref();
1173 if !path.exists() {
1174 return Ok(Self::new(path.to_path_buf()));
1175 }
1176 let _lock = xx::fslock::get(path, false)
1177 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1178 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1179 path: path.to_path_buf(),
1180 source: e,
1181 })?;
1182 Self::parse_str(&raw, path)
1183 }
1184
1185 pub fn write(&self) -> Result<()> {
1186 if let Some(path) = &self.path {
1187 let _lock = xx::fslock::get(path, false)
1188 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1189 self.write_unlocked()
1190 } else {
1191 Err(FileError::NoPath.into())
1192 }
1193 }
1194
1195 fn write_unlocked(&self) -> Result<()> {
1201 if let Some(path) = &self.path {
1202 let config_namespace = if path.exists() {
1204 namespace_from_path(path)?
1205 } else {
1206 namespace_from_path_with_override(path, self.namespace.as_deref())?
1207 };
1208
1209 let mut raw = PitchforkTomlRaw {
1213 namespace: self.namespace.clone(),
1214 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1215 ..PitchforkTomlRaw::default()
1216 };
1217 for (id, daemon) in &self.daemons {
1218 if id.namespace() != config_namespace {
1219 return Err(miette::miette!(
1220 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1221 id,
1222 path.display(),
1223 id.namespace(),
1224 config_namespace
1225 ));
1226 }
1227 let port = daemon.port.as_ref();
1228 let raw_daemon = PitchforkTomlDaemonRaw {
1229 run: daemon.run.clone(),
1230 auto: daemon.auto.clone(),
1231 cron: daemon.cron.clone(),
1232 retry: daemon.retry,
1233 ready_delay: daemon.ready_delay,
1234 ready_output: daemon.ready_output.clone(),
1235 ready_http: daemon.ready_http.clone(),
1236 ready_port: daemon.ready_port.clone(),
1237 ready_cmd: daemon.ready_cmd.clone(),
1238 port: port.cloned(),
1239 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1241 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1242 port_bump_attempts: port
1243 .filter(|p| p.auto_bump())
1244 .map(|p| p.max_bump_attempts()),
1245 boot_start: daemon.boot_start,
1246 depends: daemon
1249 .depends
1250 .iter()
1251 .map(|d| {
1252 if d.namespace() == config_namespace {
1253 d.name().to_string()
1254 } else {
1255 d.qualified()
1256 }
1257 })
1258 .collect(),
1259 watch: daemon.watch.clone(),
1260 watch_mode: match daemon.watch_mode {
1261 WatchMode::Native => None,
1262 mode => Some(mode),
1263 },
1264 dir: daemon.dir.clone(),
1265 env: daemon.env.clone(),
1266 hooks: daemon.hooks.clone(),
1267 mise: daemon.mise,
1268 user: daemon.user.clone(),
1269 memory_limit: daemon.memory_limit,
1270 cpu_limit: daemon.cpu_limit,
1271 stop_signal: daemon.stop_signal,
1272 pty: daemon.pty,
1273 time_retention: daemon.time_retention.clone(),
1274 line_retention: daemon.line_retention,
1275 archive_hook: daemon.archive_hook.clone(),
1276 logs: daemon.logs.clone(),
1277 };
1278 raw.daemons.insert(id.name().to_string(), raw_daemon);
1279 }
1280
1281 for (slug, entry) in &self.slugs {
1283 raw.slugs.insert(
1284 slug.clone(),
1285 SlugEntryRaw {
1286 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1287 namespace: entry.namespace.clone(),
1288 daemon: entry.daemon.clone(),
1289 },
1290 );
1291 }
1292
1293 for (name, group) in &self.groups {
1295 let raw_daemons: Vec<String> = group
1296 .daemons
1297 .iter()
1298 .map(|id| {
1299 if id.namespace() == config_namespace {
1300 id.name().to_string()
1301 } else {
1302 id.qualified()
1303 }
1304 })
1305 .collect();
1306 raw.groups.insert(
1307 name.clone(),
1308 GroupEntryRaw {
1309 daemons: raw_daemons,
1310 },
1311 );
1312 }
1313
1314 for (name, entry) in &self.namespaces {
1316 raw.namespaces.insert(
1317 name.clone(),
1318 NamespaceEntryRaw {
1319 dir: entry.dir.to_string_lossy().to_string(),
1320 },
1321 );
1322 }
1323
1324 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1325 path: path.clone(),
1326 source: e,
1327 })?;
1328 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1329 path: path.clone(),
1330 details: Some(e.to_string()),
1331 })?;
1332 Ok(())
1333 } else {
1334 Err(FileError::NoPath.into())
1335 }
1336 }
1337
1338 pub fn merge(&mut self, pt: Self) {
1343 for (id, d) in pt.daemons {
1344 self.daemons.insert(id, d);
1345 }
1346 for (slug, entry) in pt.slugs {
1348 self.slugs.insert(slug, entry);
1349 }
1350 for (name, group) in pt.groups {
1352 self.groups.insert(name, group);
1353 }
1354 for (name, entry) in pt.namespaces {
1356 self.namespaces.insert(name, entry);
1357 }
1358 self.settings.merge_from(&pt.settings);
1360 }
1361
1362 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1367 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1368 Ok(pt) => pt.slugs,
1369 Err(_) => IndexMap::new(),
1370 }
1371 }
1372
1373 pub fn find_slug_for_daemon_in_registry(
1375 daemon_id: &DaemonId,
1376 global_slugs: &IndexMap<String, SlugEntry>,
1377 ) -> Option<String> {
1378 global_slugs
1379 .iter()
1380 .find(|(slug, entry)| {
1381 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1382 if daemon_id.name() != daemon_name {
1383 return false;
1384 }
1385
1386 match entry.resolve_namespace() {
1387 Some(namespace) => daemon_id.namespace() == namespace,
1388 None => false,
1389 }
1390 })
1391 .map(|(slug, _)| slug.clone())
1392 }
1393
1394 #[allow(dead_code)]
1396 pub fn is_slug_registered(slug: &str) -> bool {
1397 Self::read_global_slugs().contains_key(slug)
1398 }
1399
1400 pub fn add_slug_with_namespace(
1406 slug: &str,
1407 namespace: Option<&str>,
1408 daemon: Option<&str>,
1409 ) -> Result<()> {
1410 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1411
1412 if let Some(parent) = global_path.parent() {
1414 std::fs::create_dir_all(parent).map_err(|e| {
1415 miette::miette!(
1416 "Failed to create config directory {}: {e}",
1417 parent.display()
1418 )
1419 })?;
1420 }
1421
1422 let _lock = xx::fslock::get(global_path, false)
1423 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1424
1425 let mut pt = if global_path.exists() {
1426 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1427 path: global_path.to_path_buf(),
1428 source: e,
1429 })?;
1430 Self::parse_str(&raw, global_path)?
1431 } else {
1432 Self::new(global_path.to_path_buf())
1433 };
1434
1435 if let Some(ns) = namespace {
1439 if !pt.namespaces.contains_key(ns) {
1440 let dir = pt
1441 .slugs
1442 .get(slug)
1443 .and_then(|e| e.resolve_dir())
1444 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1445 if let Some(ref d) = dir {
1446 pt.namespaces
1447 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1448 }
1449 }
1450 }
1451
1452 pt.slugs.insert(
1453 slug.to_string(),
1454 SlugEntry {
1455 dir: None,
1456 namespace: namespace.map(str::to_string),
1457 daemon: daemon.map(str::to_string),
1458 },
1459 );
1460 pt.write_unlocked()?;
1461 crate::proxy::hosts::sync_hosts_from_settings();
1462 Ok(())
1463 }
1464
1465 pub fn remove_slug(slug: &str) -> Result<bool> {
1467 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1468 if !global_path.exists() {
1469 return Ok(false);
1470 }
1471
1472 let _lock = xx::fslock::get(global_path, false)
1473 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1474
1475 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1476 path: global_path.to_path_buf(),
1477 source: e,
1478 })?;
1479 let mut pt = Self::parse_str(&raw, global_path)?;
1480
1481 let removed = pt.slugs.shift_remove(slug).is_some();
1482 if removed {
1483 pt.write_unlocked()?;
1484 crate::proxy::hosts::sync_hosts_from_settings();
1485 }
1486 Ok(removed)
1487 }
1488 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1491 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1492 Ok(pt) => pt.namespaces,
1493 Err(_) => IndexMap::new(),
1494 }
1495 }
1496
1497 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1501 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1502
1503 if let Some(parent) = global_path.parent() {
1505 std::fs::create_dir_all(parent).map_err(|e| {
1506 miette::miette!(
1507 "Failed to create config directory {}: {e}",
1508 parent.display()
1509 )
1510 })?;
1511 }
1512
1513 let _lock = xx::fslock::get(global_path, false)
1514 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1515
1516 let mut pt = if global_path.exists() {
1517 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1518 crate::error::FileError::ReadError {
1519 path: global_path.to_path_buf(),
1520 source: e,
1521 }
1522 })?;
1523 Self::parse_str(&raw, global_path)?
1524 } else {
1525 Self::new(global_path.to_path_buf())
1526 };
1527
1528 pt.namespaces.insert(
1529 name.to_string(),
1530 NamespaceEntry {
1531 dir: PathBuf::from(dir),
1532 },
1533 );
1534 pt.write_unlocked()?;
1535 Ok(())
1536 }
1537
1538 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1540 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1541 if !global_path.exists() {
1542 return Ok(false);
1543 }
1544
1545 let _lock = xx::fslock::get(global_path, false)
1546 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1547
1548 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1549 crate::error::FileError::ReadError {
1550 path: global_path.to_path_buf(),
1551 source: e,
1552 }
1553 })?;
1554 let mut pt = Self::parse_str(&raw, global_path)?;
1555
1556 let removed = pt.namespaces.shift_remove(name).is_some();
1557 if removed {
1558 pt.write_unlocked()?;
1559 }
1560 Ok(removed)
1561 }
1562}
1563
1564#[derive(Debug, Clone, JsonSchema, Default)]
1566pub struct PitchforkTomlDaemon {
1567 #[schemars(example = example_run_command())]
1569 pub run: String,
1570 #[schemars(default)]
1572 pub auto: Vec<PitchforkTomlAuto>,
1573 pub cron: Option<PitchforkTomlCron>,
1575 #[schemars(default)]
1578 pub retry: Retry,
1579 pub ready_delay: Option<u64>,
1581 pub ready_output: Option<ReadyOutput>,
1583 pub ready_http: Option<ReadyHttp>,
1585 pub ready_port: Option<ReadyPort>,
1589 pub ready_cmd: Option<ReadyCmd>,
1591 pub port: Option<PortConfig>,
1593 pub boot_start: Option<bool>,
1595 #[schemars(default)]
1597 pub depends: Vec<DaemonId>,
1598 #[schemars(default)]
1600 pub watch: Vec<String>,
1601 #[schemars(default)]
1607 pub watch_mode: WatchMode,
1608 pub dir: Option<String>,
1610 pub env: Option<IndexMap<String, String>>,
1612 pub hooks: Option<PitchforkTomlHooks>,
1614 pub mise: Option<bool>,
1617 pub user: Option<String>,
1619 pub memory_limit: Option<MemoryLimit>,
1622 pub cpu_limit: Option<CpuLimit>,
1625 pub stop_signal: Option<StopConfig>,
1628 pub pty: Option<bool>,
1630 pub time_retention: Option<String>,
1633 pub line_retention: Option<i64>,
1636 pub archive_hook: Option<String>,
1639 pub logs: Option<PitchforkTomlDaemonLogs>,
1641 #[schemars(skip)]
1642 pub path: Option<PathBuf>,
1643}
1644
1645impl PitchforkTomlDaemon {
1646 pub fn to_run_options(
1651 &self,
1652 id: &crate::daemon_id::DaemonId,
1653 cmd: Vec<String>,
1654 ) -> crate::daemon::RunOptions {
1655 use crate::daemon::RunOptions;
1656
1657 let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1658 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1659 .into_iter()
1660 .find(|(slug, entry)| {
1661 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1662 if daemon_name != id.name() {
1663 return false;
1664 }
1665
1666 match entry.resolve_namespace() {
1667 Some(namespace) => namespace == id.namespace(),
1668 None => false,
1669 }
1670 })
1671 .map(|(slug, _)| slug);
1672
1673 RunOptions {
1674 id: id.clone(),
1675 cmd,
1676 run: Some(self.run.clone()),
1677 force: false,
1678 shell_pid: None,
1679 dir: Dir(dir),
1680 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1681 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1682 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1683 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1684 retry: self.retry,
1685 retry_count: 0,
1686 ready_delay: self.ready_delay,
1687 ready_output: self.ready_output.clone(),
1688 ready_http: self.ready_http.clone(),
1689 ready_port: self.ready_port.clone(),
1690 ready_cmd: self.ready_cmd.clone(),
1691 port: self.port.clone(),
1692 wait_ready: false,
1693 depends: self.depends.clone(),
1694 env: self.env.clone(),
1695 watch: self.watch.clone(),
1696 watch_mode: self.watch_mode,
1697 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1698 self.path.as_deref(),
1699 )),
1700 mise: self.mise,
1701 slug,
1702 proxy: None,
1703 user: self.user.clone(),
1704 memory_limit: self.memory_limit,
1705 cpu_limit: self.cpu_limit,
1706 stop_signal: self.stop_signal,
1707 archive_hook: self
1708 .logs
1709 .as_ref()
1710 .and_then(|l| l.archive_hook.clone())
1711 .or_else(|| self.archive_hook.clone()),
1712 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1713 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1714 pty: self.pty,
1715 }
1716 }
1717}
1718fn example_run_command() -> &'static str {
1719 "exec node server.js"
1720}
1721
1722#[cfg(test)]
1723mod tests {
1724 use super::*;
1725 use std::path::Path;
1726
1727 #[test]
1728 fn test_daemon_user_parses_and_flows_to_run_options() {
1729 let pt = PitchforkToml::parse_str(
1730 r#"
1731[daemons.api]
1732run = "node server.js"
1733user = "postgres"
1734"#,
1735 Path::new("/tmp/my-project/pitchfork.toml"),
1736 )
1737 .unwrap();
1738
1739 let id = DaemonId::new("my-project", "api");
1740 let daemon = pt.daemons.get(&id).unwrap();
1741 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1742
1743 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1744 assert_eq!(opts.user.as_deref(), Some("postgres"));
1745 }
1746
1747 #[test]
1748 fn test_daemon_user_write_roundtrip() {
1749 let temp = tempfile::tempdir().unwrap();
1750 let path = temp.path().join("pitchfork.toml");
1751 let mut pt = PitchforkToml::new(path.clone());
1752 pt.namespace = Some("test-project".to_string());
1753 pt.daemons.insert(
1754 DaemonId::new("test-project", "api"),
1755 PitchforkTomlDaemon {
1756 run: "node server.js".to_string(),
1757 user: Some("postgres".to_string()),
1758 ..PitchforkTomlDaemon::default()
1759 },
1760 );
1761
1762 pt.write().unwrap();
1763
1764 let raw = std::fs::read_to_string(&path).unwrap();
1765 assert!(raw.contains("user = \"postgres\""));
1766
1767 let parsed = PitchforkToml::read(&path).unwrap();
1768 let daemon = parsed
1769 .daemons
1770 .get(&DaemonId::new("test-project", "api"))
1771 .unwrap();
1772 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1773 }
1774
1775 #[test]
1776 fn test_settings_write_roundtrip() {
1777 let temp = tempfile::tempdir().unwrap();
1778 let path = temp.path().join("pitchfork.toml");
1779 let mut pt = PitchforkToml::new(path.clone());
1780 pt.namespace = Some("test-project".to_string());
1781 pt.settings.web.auto_start = Some(true);
1782 pt.settings.general.log_level = Some("debug".to_string());
1783
1784 pt.write().unwrap();
1785
1786 let raw = std::fs::read_to_string(&path).unwrap();
1787 assert!(
1788 raw.contains("[settings.web]"),
1789 "settings.web section should be written, got:\n{raw}"
1790 );
1791 assert!(raw.contains("auto_start = true"));
1792 assert!(raw.contains("log_level = \"debug\""));
1793
1794 let parsed = PitchforkToml::read(&path).unwrap();
1795 assert_eq!(parsed.settings.web.auto_start, Some(true));
1796 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
1797 }
1798
1799 #[test]
1800 fn test_settings_preserved_on_unrelated_write() {
1801 let temp = tempfile::tempdir().unwrap();
1804 let path = temp.path().join("pitchfork.toml");
1805 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
1806
1807 let mut pt = PitchforkToml::read(&path).unwrap();
1808 pt.slugs.insert(
1809 "api".to_string(),
1810 SlugEntry {
1811 dir: None,
1812 namespace: Some("myproject".to_string()),
1813 daemon: None,
1814 },
1815 );
1816 pt.namespaces.insert(
1817 "myproject".to_string(),
1818 NamespaceEntry {
1819 dir: PathBuf::from("/tmp/myproject"),
1820 },
1821 );
1822 pt.write().unwrap();
1823
1824 let raw = std::fs::read_to_string(&path).unwrap();
1825 assert!(
1826 raw.contains("[settings.web]"),
1827 "existing settings must be preserved, got:\n{raw}"
1828 );
1829 assert!(raw.contains("auto_start = true"));
1830 assert!(raw.contains("[slugs.api]"));
1831
1832 let parsed = PitchforkToml::read(&path).unwrap();
1833 assert_eq!(parsed.settings.web.auto_start, Some(true));
1834 assert!(parsed.slugs.contains_key("api"));
1835 }
1836}