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 once_cell::sync::Lazy;
10use schemars::JsonSchema;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex as StdMutex;
14use std::time::SystemTime;
15
16pub use crate::config_types::{
18 CpuLimit, CronRetrigger, Dir, MemoryLimit, OnOutputHook, PitchforkTomlAuto, PitchforkTomlCron,
19 PitchforkTomlHooks, PortBump, PortConfig, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry,
20 StopConfig, StopSignal, WatchMode,
21};
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
31pub struct SlugEntryRaw {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub dir: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub namespace: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub daemon: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct SlugEntry {
46 pub dir: Option<PathBuf>,
48 pub namespace: Option<String>,
50 pub daemon: Option<String>,
52}
53
54impl SlugEntry {
55 pub fn resolve_dir(&self) -> Option<PathBuf> {
58 self.dir.clone().or_else(|| {
59 self.namespace.as_ref().and_then(|ns| {
60 let namespaces = PitchforkToml::read_global_namespaces();
61 namespaces.get(ns).map(|entry| entry.dir.clone())
62 })
63 })
64 }
65
66 pub fn resolve_namespace(&self) -> Option<String> {
69 self.namespace.clone().or_else(|| {
70 self.resolve_dir()
71 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
72 })
73 }
74}
75
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
82pub struct GroupEntryRaw {
83 #[schemars(with = "Vec<DaemonId>")]
84 pub daemons: Vec<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct GroupEntry {
90 pub daemons: Vec<DaemonId>,
91}
92
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
99pub struct NamespaceEntryRaw {
100 pub dir: String,
102}
103
104#[derive(Debug, Clone)]
106pub struct NamespaceEntry {
107 pub dir: PathBuf,
109}
110
111#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
113struct PitchforkTomlRaw {
114 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub namespace: Option<String>,
116 #[serde(default)]
117 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
121 pub env: Option<IndexMap<String, String>>,
122 #[serde(default)]
123 pub settings: Option<SettingsPartial>,
124 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
127 pub slugs: IndexMap<String, SlugEntryRaw>,
128 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
130 pub groups: IndexMap<String, GroupEntryRaw>,
131 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
134 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
135}
136
137#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
142pub struct PitchforkTomlDaemonLogs {
143 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub log_format: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
149 pub time_retention: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub line_retention: Option<i64>,
153 #[serde(skip_serializing_if = "Option::is_none", default)]
155 pub archive_hook: Option<String>,
156}
157
158#[derive(Debug, serde::Serialize, serde::Deserialize)]
165struct PitchforkTomlDaemonRaw {
166 pub run: String,
167 #[serde(skip_serializing_if = "Vec::is_empty", default)]
168 pub auto: Vec<PitchforkTomlAuto>,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
170 pub cron: Option<PitchforkTomlCron>,
171 #[serde(default)]
172 pub retry: Retry,
173 #[serde(skip_serializing_if = "Option::is_none", default)]
174 pub ready_delay: Option<u64>,
175 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub ready_output: Option<ReadyOutput>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 pub ready_http: Option<ReadyHttp>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub ready_port: Option<ReadyPort>,
181 #[serde(skip_serializing_if = "Option::is_none", default)]
182 pub ready_cmd: Option<ReadyCmd>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub port: Option<PortConfig>,
186 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub expected_port: Vec<u16>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub auto_bump_port: Option<bool>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub port_bump_attempts: Option<u32>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub boot_start: Option<bool>,
197 #[serde(skip_serializing_if = "Vec::is_empty", default)]
198 pub depends: Vec<String>,
199 #[serde(skip_serializing_if = "Vec::is_empty", default)]
200 pub watch: Vec<String>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub watch_mode: Option<WatchMode>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub dir: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub env: Option<IndexMap<String, String>>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub hooks: Option<PitchforkTomlHooks>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub mise: Option<bool>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub user: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub memory_limit: Option<MemoryLimit>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
219 pub cpu_limit: Option<CpuLimit>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
222 pub stop_signal: Option<StopConfig>,
223 #[serde(skip_serializing_if = "Option::is_none", default)]
225 pub pty: Option<bool>,
226 #[serde(skip_serializing_if = "Option::is_none", default)]
229 pub time_retention: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub line_retention: Option<i64>,
234 #[serde(skip_serializing_if = "Option::is_none", default)]
237 pub archive_hook: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none", default)]
240 pub logs: Option<PitchforkTomlDaemonLogs>,
241}
242
243#[derive(Debug, Clone, Default, JsonSchema)]
248#[schemars(title = "Pitchfork Configuration")]
249pub struct PitchforkToml {
250 #[serde(default)]
252 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
253 #[serde(skip_serializing_if = "Option::is_none", default)]
257 pub env: Option<IndexMap<String, String>>,
258 pub namespace: Option<String>,
263 #[serde(default)]
272 pub(crate) settings: SettingsPartial,
273 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
278 pub slugs: IndexMap<String, SlugEntry>,
279 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
281 pub groups: IndexMap<String, GroupEntry>,
282 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
285 pub namespaces: IndexMap<String, NamespaceEntry>,
286 #[schemars(skip)]
287 pub path: Option<PathBuf>,
288}
289
290pub(crate) fn is_global_config(path: &Path) -> bool {
291 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
292}
293
294fn is_local_config(path: &Path) -> bool {
295 path.file_name()
296 .map(|n| n == "pitchfork.local.toml")
297 .unwrap_or(false)
298}
299
300pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
301 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
302}
303
304fn sibling_base_config(path: &Path) -> Option<PathBuf> {
305 if !is_local_config(path) {
306 return None;
307 }
308 path.parent().map(|p| p.join("pitchfork.toml"))
309}
310
311fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
312 use toml::Value;
313
314 let doc: Value = toml::from_str(content)
315 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
316 let Some(value) = doc.get("namespace") else {
317 return Ok(None);
318 };
319
320 match value {
321 Value::String(s) => Ok(Some(s.clone())),
322 _ => Err(ConfigParseError::InvalidNamespace {
323 path: path.to_path_buf(),
324 namespace: value.to_string(),
325 reason: "top-level 'namespace' must be a string".to_string(),
326 }
327 .into()),
328 }
329}
330
331fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
332 if !path.exists() {
333 return Ok(None);
334 }
335 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
336 path: path.to_path_buf(),
337 source: e,
338 })?;
339 parse_namespace_override_from_content(path, &content)
340}
341
342fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
343 if let Err(e) = DaemonId::try_new(namespace, "probe") {
344 return Err(ConfigParseError::InvalidNamespace {
345 path: path.to_path_buf(),
346 namespace: namespace.to_string(),
347 reason: e.to_string(),
348 }
349 .into());
350 }
351 Ok(namespace.to_string())
352}
353
354fn derive_namespace_from_dir(path: &Path) -> Result<String> {
355 let dir_for_namespace = if is_dot_config_pitchfork(path) {
356 path.parent().and_then(|p| p.parent())
357 } else {
358 path.parent()
359 };
360
361 let raw_namespace = dir_for_namespace
362 .and_then(|p| p.file_name())
363 .and_then(|n| n.to_str())
364 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
365 .to_string();
366
367 validate_namespace(path, &raw_namespace).map_err(|e| {
368 ConfigParseError::InvalidNamespace {
369 path: path.to_path_buf(),
370 namespace: raw_namespace,
371 reason: format!(
372 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
373 ),
374 }
375 .into()
376 })
377}
378
379fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
380 if is_global_config(path) {
381 if let Some(ns) = explicit
382 && ns != "global"
383 {
384 return Err(ConfigParseError::InvalidNamespace {
385 path: path.to_path_buf(),
386 namespace: ns.to_string(),
387 reason: "global config files must use namespace 'global'".to_string(),
388 }
389 .into());
390 }
391 return Ok("global".to_string());
392 }
393
394 if let Some(ns) = explicit {
395 return validate_namespace(path, ns);
396 }
397
398 derive_namespace_from_dir(path)
399}
400
401fn namespace_from_file(path: &Path) -> Result<String> {
402 let explicit = read_namespace_override_from_file(path)?;
403 let base_explicit = sibling_base_config(path)
404 .filter(|p| p.exists())
405 .map(|p| read_namespace_override_from_file(&p))
406 .transpose()?
407 .flatten();
408
409 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
410 && local_ns != base_ns
411 {
412 return Err(ConfigParseError::InvalidNamespace {
413 path: path.to_path_buf(),
414 namespace: local_ns.to_string(),
415 reason: format!(
416 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
417 ),
418 }
419 .into());
420 }
421
422 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
423 namespace_from_path_with_override(path, effective_explicit)
424}
425
426pub fn namespace_from_path(path: &Path) -> Result<String> {
439 namespace_from_file(path)
440}
441
442struct ConfigCacheEntry {
461 config: PitchforkToml,
462 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
464}
465
466static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
473 Lazy::new(|| StdMutex::new(HashMap::new()));
474
475fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
481 if paths.len() != snapshot.len() {
482 return false;
483 }
484 paths
485 .iter()
486 .zip(snapshot.iter())
487 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
488}
489
490fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
492 let md = std::fs::metadata(path).ok()?;
493 Some((md.modified().ok()?, md.len()))
494}
495
496fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
498 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
499}
500
501pub fn invalidate_config_cache() {
518 if let Ok(mut cache) = CONFIG_CACHE.lock() {
519 cache.clear();
520 }
521}
522
523impl PitchforkToml {
524 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
537 if user_id.contains('/') {
539 return match DaemonId::parse(user_id) {
540 Ok(id) => Ok(vec![id]),
541 Err(e) => Err(e), };
543 }
544
545 let global_slugs = Self::read_global_slugs();
547 if let Some(entry) = global_slugs.get(user_id) {
548 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
550 if let Some(dir) = entry.resolve_dir()
551 && let Ok(project_config) = Self::all_merged_from(&dir)
552 {
553 let matches: Vec<DaemonId> = project_config
555 .daemons
556 .keys()
557 .filter(|id| id.name() == daemon_name)
558 .cloned()
559 .collect();
560 match matches.as_slice() {
561 [] => {}
562 [id] => return Ok(vec![id.clone()]),
563 _ => {
564 let mut candidates: Vec<String> =
565 matches.iter().map(|id| id.qualified()).collect();
566 candidates.sort();
567 return Err(miette::miette!(
568 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
569 user_id,
570 daemon_name,
571 candidates.join(", ")
572 ));
573 }
574 }
575 }
576 }
577
578 let matches: Vec<DaemonId> = self
580 .daemons
581 .keys()
582 .filter(|id| id.name() == user_id)
583 .cloned()
584 .collect();
585
586 if matches.is_empty() {
587 let state_matches = Self::find_in_state_file(user_id);
589 match state_matches.as_slice() {
590 [] => {}
591 [id] => return Ok(vec![id.clone()]),
592 _ => {
593 let mut candidates: Vec<String> =
594 state_matches.iter().map(|id| id.qualified()).collect();
595 candidates.sort();
596 return Err(miette::miette!(
597 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
598 user_id,
599 candidates.join(", ")
600 ));
601 }
602 }
603 let _ = DaemonId::try_new("global", user_id)?;
605 }
606 Ok(matches)
607 }
608
609 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
615 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
616 Ok(state) => state
617 .daemons
618 .keys()
619 .filter(|id| id.name() == short_name)
620 .cloned()
621 .collect(),
622 Err(e) => {
623 warn!("cannot read state file: {e}");
624 Vec::new()
625 }
626 }
627 }
628
629 #[allow(dead_code)]
650 pub fn resolve_daemon_id_prefer_local(
651 &self,
652 user_id: &str,
653 current_dir: &Path,
654 ) -> Result<DaemonId> {
655 if user_id.contains('/') {
657 return DaemonId::parse(user_id);
658 }
659
660 let current_namespace = Self::namespace_for_dir(current_dir)?;
664
665 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
666 }
667
668 fn resolve_daemon_id_with_namespace(
671 &self,
672 user_id: &str,
673 current_namespace: &str,
674 ) -> Result<DaemonId> {
675 let global_slugs = Self::read_global_slugs();
677 if let Some(entry) = global_slugs.get(user_id) {
678 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
679 if let Some(dir) = entry.resolve_dir()
680 && let Ok(project_config) = Self::all_merged_from(&dir)
681 {
682 let matches: Vec<DaemonId> = project_config
683 .daemons
684 .keys()
685 .filter(|id| id.name() == daemon_name)
686 .cloned()
687 .collect();
688 match matches.as_slice() {
689 [] => {}
690 [id] => return Ok(id.clone()),
691 _ => {
692 let mut candidates: Vec<String> =
693 matches.iter().map(|id| id.qualified()).collect();
694 candidates.sort();
695 return Err(miette::miette!(
696 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
697 user_id,
698 daemon_name,
699 candidates.join(", ")
700 ));
701 }
702 }
703 }
704 }
705
706 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
709 if self.daemons.contains_key(&preferred_id) {
710 return Ok(preferred_id);
711 }
712
713 let matches = self.resolve_daemon_id(user_id)?;
715
716 if matches.len() > 1 {
718 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
719 candidates.sort();
720 return Err(miette::miette!(
721 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
722 user_id,
723 candidates.join(", ")
724 ));
725 }
726
727 if let Some(id) = matches.into_iter().next() {
728 return Ok(id);
729 }
730
731 let global_id = DaemonId::try_new("global", user_id)?;
734 if self.daemons.contains_key(&global_id) {
735 return Ok(global_id);
736 }
737
738 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
739 Err(DependencyError::DaemonNotFound {
740 name: user_id.to_string(),
741 suggestion,
742 }
743 .into())
744 }
745
746 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
749 Ok(Self::list_paths_from(dir)
750 .iter()
751 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
753 .transpose()?
754 .unwrap_or_else(|| "global".to_string()))
755 }
756
757 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
767 if user_id.contains('/') {
768 return DaemonId::parse(user_id);
769 }
770
771 let config = Self::all_merged()?;
774 let ns = Self::namespace_for_dir(&env::CWD)?;
775 config.resolve_daemon_id_with_namespace(user_id, &ns)
776 }
777
778 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
784 if user_id.contains('/') {
785 return DaemonId::parse(user_id);
786 }
787
788 let config = Self::all_merged()?;
789 let ns = Self::namespace_for_dir(&env::CWD)?;
790
791 let preferred_id = DaemonId::try_new(&ns, user_id)?;
792 if config.daemons.contains_key(&preferred_id) {
793 return Ok(preferred_id);
794 }
795
796 let matches = config.resolve_daemon_id(user_id)?;
797 if matches.len() > 1 {
798 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
799 candidates.sort();
800 return Err(miette::miette!(
801 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
802 user_id,
803 candidates.join(", ")
804 ));
805 }
806 if let Some(id) = matches.into_iter().next() {
807 return Ok(id);
808 }
809
810 DaemonId::try_new("global", user_id)
811 }
812
813 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
824 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
826 return user_ids
827 .iter()
828 .map(|s| DaemonId::parse(s.as_ref()))
829 .collect();
830 }
831
832 let config = Self::all_merged()?;
833 let ns = Self::namespace_for_dir(&env::CWD)?;
835 user_ids
836 .iter()
837 .map(|s| {
838 let id = s.as_ref();
839 if id.contains('/') {
840 DaemonId::parse(id)
841 } else {
842 config.resolve_daemon_id_with_namespace(id, &ns)
843 }
844 })
845 .collect()
846 }
847
848 pub fn resolve_ids_and_group<S: AsRef<str>>(
853 user_ids: &[S],
854 group_name: Option<&str>,
855 ) -> Result<Vec<DaemonId>> {
856 let config = Self::all_merged()?;
857 let ns = Self::namespace_for_dir(&env::CWD)?;
858 let mut ids = Vec::new();
859 let mut seen = std::collections::HashSet::new();
860
861 for id in user_ids {
862 let id_str = id.as_ref();
863 let daemon_id = if id_str.contains('/') {
864 DaemonId::parse(id_str)?
865 } else {
866 config.resolve_daemon_id_with_namespace(id_str, &ns)?
867 };
868 if seen.insert(daemon_id.clone()) {
869 ids.push(daemon_id);
870 }
871 }
872
873 if let Some(name) = group_name {
874 match config.groups.get(name) {
875 Some(group) => {
876 let missing: Vec<String> = group
877 .daemons
878 .iter()
879 .filter(|id| !config.daemons.contains_key(*id))
880 .map(|id| id.qualified())
881 .collect();
882 if !missing.is_empty() {
883 return Err(miette::miette!(
884 "group '{}' references undefined daemon{}: {}",
885 name,
886 if missing.len() > 1 { "s" } else { "" },
887 missing.join(", ")
888 ));
889 }
890 for daemon_id in &group.daemons {
891 if seen.insert(daemon_id.clone()) {
892 ids.push(daemon_id.clone());
893 }
894 }
895 }
896 None => {
897 let suggestion =
898 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
899 return Err(miette::miette!(
900 "group '{}' not found in configuration{}",
901 name,
902 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
903 ));
904 }
905 }
906 }
907
908 Ok(ids)
909 }
910
911 pub fn list_paths() -> Vec<PathBuf> {
914 Self::list_paths_from(&env::CWD)
915 }
916
917 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
928 let mut paths = Vec::new();
929 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
930 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
931
932 let mut project_paths = xx::file::find_up_all(
936 cwd,
937 &[
938 "pitchfork.local.toml",
939 "pitchfork.toml",
940 ".config/pitchfork.local.toml",
941 ".config/pitchfork.toml",
942 ],
943 );
944 project_paths.reverse();
945 paths.extend(project_paths);
946
947 paths
948 }
949
950 pub fn all_merged() -> Result<PitchforkToml> {
953 Self::all_merged_from(&env::CWD)
954 }
955 pub fn all_merged_all_namespaces() -> Result<Self> {
962 let mut pt = Self::all_merged_from(&env::CWD)?;
963
964 let namespaces = Self::read_global_namespaces();
965 for (ns_name, entry) in namespaces {
966 match Self::all_merged_from(&entry.dir) {
967 Ok(ns_config) => {
968 for (daemon_id, daemon_config) in ns_config.daemons {
969 if !pt.daemons.contains_key(&daemon_id) {
970 pt.daemons.insert(daemon_id, daemon_config);
971 }
972 }
973 pt.settings.merge_from(&ns_config.settings);
976 }
977 Err(e) => {
978 log::warn!(
979 "Failed to load namespace '{ns_name}' from {}: {e}",
980 entry.dir.display()
981 );
982 }
983 }
984 }
985
986 Ok(pt)
987 }
988
989 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1007 let paths = Self::list_paths_from(cwd);
1008
1009 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1013
1014 {
1015 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1016 if let Some(entry) = cache.get(&cache_key)
1017 && meta_matches(&paths, &entry.source_meta)
1018 {
1019 return Ok(entry.config.clone());
1020 }
1021 }
1022
1023 let snapshot = snapshot_meta(&paths);
1029 let pt = Self::all_merged_from_uncached(&paths)?;
1030
1031 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1033 cache.insert(
1034 cache_key,
1035 ConfigCacheEntry {
1036 config: pt.clone(),
1037 source_meta: snapshot,
1038 },
1039 );
1040
1041 Ok(pt)
1042 }
1043
1044 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1049 use std::collections::HashMap as StdHashMap;
1050
1051 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1052
1053 let mut pt = Self::default();
1054 for p in paths {
1055 match Self::read(p) {
1056 Ok(pt2) => {
1057 if p.exists() && !is_global_config(p) {
1061 let ns = namespace_from_path(p)?;
1062 let origin_dir = if is_dot_config_pitchfork(p) {
1063 p.parent().and_then(|d| d.parent())
1064 } else {
1065 p.parent()
1066 }
1067 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1068 .unwrap_or_else(|| p.clone());
1069
1070 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1071 && *other_dir != origin_dir
1072 {
1073 return Err(crate::error::ConfigParseError::NamespaceCollision {
1074 path_a: other_path.clone(),
1075 path_b: p.clone(),
1076 ns,
1077 }
1078 .into());
1079 }
1080 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1081 }
1082
1083 pt.merge(pt2)
1084 }
1085 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1086 }
1087 }
1088 Ok(pt)
1089 }
1090}
1091
1092impl PitchforkToml {
1093 pub fn new(path: PathBuf) -> Self {
1094 Self {
1095 daemons: Default::default(),
1096 env: None,
1097 namespace: None,
1098 settings: SettingsPartial::default(),
1099 slugs: IndexMap::new(),
1100 groups: IndexMap::new(),
1101 namespaces: IndexMap::new(),
1102 path: Some(path),
1103 }
1104 }
1105
1106 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1114 let raw_config: PitchforkTomlRaw = toml::from_str(content)
1115 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1116
1117 let namespace = {
1118 let base_explicit = sibling_base_config(path)
1119 .filter(|p| p.exists())
1120 .map(|p| read_namespace_override_from_file(&p))
1121 .transpose()?
1122 .flatten();
1123
1124 if is_local_config(path)
1125 && let (Some(local_ns), Some(base_ns)) =
1126 (raw_config.namespace.as_deref(), base_explicit.as_deref())
1127 && local_ns != base_ns
1128 {
1129 return Err(ConfigParseError::InvalidNamespace {
1130 path: path.to_path_buf(),
1131 namespace: local_ns.to_string(),
1132 reason: format!(
1133 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
1134 ),
1135 }
1136 .into());
1137 }
1138
1139 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
1140 namespace_from_path_with_override(path, explicit)?
1141 };
1142 let mut pt = Self::new(path.to_path_buf());
1143 pt.namespace = raw_config.namespace.clone();
1144
1145 for (short_name, raw_daemon) in raw_config.daemons {
1146 let id = match DaemonId::try_new(&namespace, &short_name) {
1147 Ok(id) => id,
1148 Err(e) => {
1149 return Err(ConfigParseError::InvalidDaemonName {
1150 name: short_name,
1151 path: path.to_path_buf(),
1152 reason: e.to_string(),
1153 }
1154 .into());
1155 }
1156 };
1157
1158 let mut depends = Vec::new();
1159 for dep in raw_daemon.depends {
1160 let dep_id = if dep.contains('/') {
1161 match DaemonId::parse(&dep) {
1162 Ok(id) => id,
1163 Err(e) => {
1164 return Err(ConfigParseError::InvalidDependency {
1165 daemon: short_name.clone(),
1166 dependency: dep,
1167 path: path.to_path_buf(),
1168 reason: e.to_string(),
1169 }
1170 .into());
1171 }
1172 }
1173 } else {
1174 match DaemonId::try_new(&namespace, &dep) {
1175 Ok(id) => id,
1176 Err(e) => {
1177 return Err(ConfigParseError::InvalidDependency {
1178 daemon: short_name.clone(),
1179 dependency: dep,
1180 path: path.to_path_buf(),
1181 reason: e.to_string(),
1182 }
1183 .into());
1184 }
1185 }
1186 };
1187 depends.push(dep_id);
1188 }
1189
1190 let has_deprecated = !raw_daemon.expected_port.is_empty()
1192 || raw_daemon.auto_bump_port.is_some()
1193 || raw_daemon.port_bump_attempts.is_some();
1194 let port = if let Some(port) = raw_daemon.port {
1195 if has_deprecated {
1196 warn!(
1197 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1198 );
1199 }
1200 Some(port)
1201 } else if has_deprecated {
1202 warn!(
1203 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1204 );
1205 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1206 PortBump(
1207 raw_daemon
1208 .port_bump_attempts
1209 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1210 )
1211 } else {
1212 PortBump(0)
1213 };
1214 Some(PortConfig {
1215 expect: raw_daemon.expected_port,
1216 bump,
1217 })
1218 } else {
1219 None
1220 };
1221
1222 let daemon = PitchforkTomlDaemon {
1223 run: raw_daemon.run,
1224 auto: raw_daemon.auto,
1225 cron: raw_daemon.cron,
1226 retry: raw_daemon.retry,
1227 ready_delay: raw_daemon.ready_delay,
1228 ready_output: raw_daemon.ready_output,
1229 ready_http: raw_daemon.ready_http,
1230 ready_port: raw_daemon.ready_port,
1231 ready_cmd: raw_daemon.ready_cmd,
1232 port,
1233 boot_start: raw_daemon.boot_start,
1234 depends,
1235 watch: raw_daemon.watch,
1236 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1237 dir: raw_daemon.dir,
1238 env: raw_daemon.env,
1239 hooks: raw_daemon.hooks,
1240 mise: raw_daemon.mise,
1241 user: raw_daemon.user,
1242 memory_limit: raw_daemon.memory_limit,
1243 cpu_limit: raw_daemon.cpu_limit,
1244 stop_signal: raw_daemon.stop_signal,
1245 pty: raw_daemon.pty,
1246 time_retention: raw_daemon.time_retention,
1247 line_retention: raw_daemon.line_retention,
1248 archive_hook: raw_daemon.archive_hook,
1249 logs: raw_daemon.logs,
1250 path: Some(path.to_path_buf()),
1251 };
1252 pt.daemons.insert(id, daemon);
1253 }
1254
1255 if let Some(settings) = raw_config.settings {
1257 pt.settings = settings;
1258 }
1259
1260 pt.env = raw_config.env;
1262
1263 for (slug, entry) in raw_config.slugs {
1265 pt.slugs.insert(
1266 slug,
1267 SlugEntry {
1268 dir: entry.dir.map(env::expand_tilde),
1269 namespace: entry.namespace,
1270 daemon: entry.daemon,
1271 },
1272 );
1273 }
1274
1275 for (name, entry) in raw_config.namespaces {
1277 pt.namespaces.insert(
1278 name,
1279 NamespaceEntry {
1280 dir: env::expand_tilde(entry.dir),
1281 },
1282 );
1283 }
1284
1285 for (group_name, raw_group) in raw_config.groups {
1287 let mut daemons = Vec::new();
1288 for daemon_name in &raw_group.daemons {
1289 let id = if daemon_name.contains('/') {
1290 DaemonId::parse(daemon_name).map_err(|e| {
1291 ConfigParseError::InvalidDependency {
1292 daemon: group_name.clone(),
1293 dependency: daemon_name.clone(),
1294 path: path.to_path_buf(),
1295 reason: e.to_string(),
1296 }
1297 })?
1298 } else {
1299 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1300 ConfigParseError::InvalidDaemonName {
1301 name: daemon_name.clone(),
1302 path: path.to_path_buf(),
1303 reason: e.to_string(),
1304 }
1305 })?
1306 };
1307 daemons.push(id);
1308 }
1309 pt.groups.insert(group_name, GroupEntry { daemons });
1310 }
1311
1312 Ok(pt)
1313 }
1314
1315 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1316 let path = path.as_ref();
1317 if !path.exists() {
1318 return Ok(Self::new(path.to_path_buf()));
1319 }
1320 let _lock = xx::fslock::get(path, false)
1321 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1322 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1323 path: path.to_path_buf(),
1324 source: e,
1325 })?;
1326 Self::parse_str(&raw, path)
1327 }
1328
1329 pub fn write(&self) -> Result<()> {
1330 if let Some(path) = &self.path {
1331 let _lock = xx::fslock::get(path, false)
1332 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1333 self.write_unlocked()
1334 } else {
1335 Err(FileError::NoPath.into())
1336 }
1337 }
1338
1339 fn write_unlocked(&self) -> Result<()> {
1345 if let Some(path) = &self.path {
1346 let config_namespace = if path.exists() {
1348 namespace_from_path(path)?
1349 } else {
1350 namespace_from_path_with_override(path, self.namespace.as_deref())?
1351 };
1352
1353 let mut raw = PitchforkTomlRaw {
1357 namespace: self.namespace.clone(),
1358 env: self.env.clone(),
1359 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1360 ..PitchforkTomlRaw::default()
1361 };
1362 for (id, daemon) in &self.daemons {
1363 if id.namespace() != config_namespace {
1364 return Err(miette::miette!(
1365 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1366 id,
1367 path.display(),
1368 id.namespace(),
1369 config_namespace
1370 ));
1371 }
1372 let port = daemon.port.as_ref();
1373 let raw_daemon = PitchforkTomlDaemonRaw {
1374 run: daemon.run.clone(),
1375 auto: daemon.auto.clone(),
1376 cron: daemon.cron.clone(),
1377 retry: daemon.retry,
1378 ready_delay: daemon.ready_delay,
1379 ready_output: daemon.ready_output.clone(),
1380 ready_http: daemon.ready_http.clone(),
1381 ready_port: daemon.ready_port.clone(),
1382 ready_cmd: daemon.ready_cmd.clone(),
1383 port: port.cloned(),
1384 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1386 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1387 port_bump_attempts: port
1388 .filter(|p| p.auto_bump())
1389 .map(|p| p.max_bump_attempts()),
1390 boot_start: daemon.boot_start,
1391 depends: daemon
1394 .depends
1395 .iter()
1396 .map(|d| {
1397 if d.namespace() == config_namespace {
1398 d.name().to_string()
1399 } else {
1400 d.qualified()
1401 }
1402 })
1403 .collect(),
1404 watch: daemon.watch.clone(),
1405 watch_mode: match daemon.watch_mode {
1406 WatchMode::Native => None,
1407 mode => Some(mode),
1408 },
1409 dir: daemon.dir.clone(),
1410 env: daemon.env.clone(),
1411 hooks: daemon.hooks.clone(),
1412 mise: daemon.mise,
1413 user: daemon.user.clone(),
1414 memory_limit: daemon.memory_limit,
1415 cpu_limit: daemon.cpu_limit,
1416 stop_signal: daemon.stop_signal,
1417 pty: daemon.pty,
1418 time_retention: daemon.time_retention.clone(),
1419 line_retention: daemon.line_retention,
1420 archive_hook: daemon.archive_hook.clone(),
1421 logs: daemon.logs.clone(),
1422 };
1423 raw.daemons.insert(id.name().to_string(), raw_daemon);
1424 }
1425
1426 for (slug, entry) in &self.slugs {
1428 raw.slugs.insert(
1429 slug.clone(),
1430 SlugEntryRaw {
1431 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1432 namespace: entry.namespace.clone(),
1433 daemon: entry.daemon.clone(),
1434 },
1435 );
1436 }
1437
1438 for (name, group) in &self.groups {
1440 let raw_daemons: Vec<String> = group
1441 .daemons
1442 .iter()
1443 .map(|id| {
1444 if id.namespace() == config_namespace {
1445 id.name().to_string()
1446 } else {
1447 id.qualified()
1448 }
1449 })
1450 .collect();
1451 raw.groups.insert(
1452 name.clone(),
1453 GroupEntryRaw {
1454 daemons: raw_daemons,
1455 },
1456 );
1457 }
1458
1459 for (name, entry) in &self.namespaces {
1461 raw.namespaces.insert(
1462 name.clone(),
1463 NamespaceEntryRaw {
1464 dir: entry.dir.to_string_lossy().to_string(),
1465 },
1466 );
1467 }
1468
1469 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1470 path: path.clone(),
1471 source: e,
1472 })?;
1473 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1474 path: path.clone(),
1475 details: Some(e.to_string()),
1476 })?;
1477 invalidate_config_cache();
1478 Ok(())
1479 } else {
1480 Err(FileError::NoPath.into())
1481 }
1482 }
1483
1484 pub fn merge(&mut self, pt: Self) {
1489 for (id, d) in pt.daemons {
1490 self.daemons.insert(id, d);
1491 }
1492 if let Some(env) = pt.env {
1494 let merged = self.env.get_or_insert_with(IndexMap::new);
1495 for (k, v) in env {
1496 merged.insert(k, v);
1497 }
1498 }
1499 for (slug, entry) in pt.slugs {
1501 self.slugs.insert(slug, entry);
1502 }
1503 for (name, group) in pt.groups {
1505 self.groups.insert(name, group);
1506 }
1507 for (name, entry) in pt.namespaces {
1509 self.namespaces.insert(name, entry);
1510 }
1511 self.settings.merge_from(&pt.settings);
1513 }
1514
1515 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1520 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1521 Ok(pt) => pt.slugs,
1522 Err(_) => IndexMap::new(),
1523 }
1524 }
1525
1526 pub fn find_slug_for_daemon_in_registry(
1528 daemon_id: &DaemonId,
1529 global_slugs: &IndexMap<String, SlugEntry>,
1530 ) -> Option<String> {
1531 global_slugs
1532 .iter()
1533 .find(|(slug, entry)| {
1534 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1535 if daemon_id.name() != daemon_name {
1536 return false;
1537 }
1538
1539 match entry.resolve_namespace() {
1540 Some(namespace) => daemon_id.namespace() == namespace,
1541 None => false,
1542 }
1543 })
1544 .map(|(slug, _)| slug.clone())
1545 }
1546
1547 #[allow(dead_code)]
1549 pub fn is_slug_registered(slug: &str) -> bool {
1550 Self::read_global_slugs().contains_key(slug)
1551 }
1552
1553 pub fn add_slug_with_namespace(
1559 slug: &str,
1560 namespace: Option<&str>,
1561 daemon: Option<&str>,
1562 ) -> Result<()> {
1563 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1564
1565 if let Some(parent) = global_path.parent() {
1567 std::fs::create_dir_all(parent).map_err(|e| {
1568 miette::miette!(
1569 "Failed to create config directory {}: {e}",
1570 parent.display()
1571 )
1572 })?;
1573 }
1574
1575 let _lock = xx::fslock::get(global_path, false)
1576 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1577
1578 let mut pt = if global_path.exists() {
1579 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1580 path: global_path.to_path_buf(),
1581 source: e,
1582 })?;
1583 Self::parse_str(&raw, global_path)?
1584 } else {
1585 Self::new(global_path.to_path_buf())
1586 };
1587
1588 if let Some(ns) = namespace
1592 && !pt.namespaces.contains_key(ns)
1593 {
1594 let dir = pt
1595 .slugs
1596 .get(slug)
1597 .and_then(|e| e.resolve_dir())
1598 .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1599 if let Some(ref d) = dir {
1600 pt.namespaces
1601 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1602 }
1603 }
1604
1605 pt.slugs.insert(
1606 slug.to_string(),
1607 SlugEntry {
1608 dir: None,
1609 namespace: namespace.map(str::to_string),
1610 daemon: daemon.map(str::to_string),
1611 },
1612 );
1613 pt.write_unlocked()?;
1614 crate::proxy::hosts::sync_hosts_from_settings();
1615 Ok(())
1616 }
1617
1618 pub fn remove_slug(slug: &str) -> Result<bool> {
1620 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1621 if !global_path.exists() {
1622 return Ok(false);
1623 }
1624
1625 let _lock = xx::fslock::get(global_path, false)
1626 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1627
1628 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1629 path: global_path.to_path_buf(),
1630 source: e,
1631 })?;
1632 let mut pt = Self::parse_str(&raw, global_path)?;
1633
1634 let removed = pt.slugs.shift_remove(slug).is_some();
1635 if removed {
1636 pt.write_unlocked()?;
1637 crate::proxy::hosts::sync_hosts_from_settings();
1638 }
1639 Ok(removed)
1640 }
1641 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1644 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1645 Ok(pt) => pt.namespaces,
1646 Err(_) => IndexMap::new(),
1647 }
1648 }
1649
1650 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1654 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1655
1656 if let Some(parent) = global_path.parent() {
1658 std::fs::create_dir_all(parent).map_err(|e| {
1659 miette::miette!(
1660 "Failed to create config directory {}: {e}",
1661 parent.display()
1662 )
1663 })?;
1664 }
1665
1666 let _lock = xx::fslock::get(global_path, false)
1667 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1668
1669 let mut pt = if global_path.exists() {
1670 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1671 crate::error::FileError::ReadError {
1672 path: global_path.to_path_buf(),
1673 source: e,
1674 }
1675 })?;
1676 Self::parse_str(&raw, global_path)?
1677 } else {
1678 Self::new(global_path.to_path_buf())
1679 };
1680
1681 pt.namespaces.insert(
1682 name.to_string(),
1683 NamespaceEntry {
1684 dir: env::expand_tilde(dir),
1685 },
1686 );
1687 pt.write_unlocked()?;
1688 Ok(())
1689 }
1690
1691 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1693 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1694 if !global_path.exists() {
1695 return Ok(false);
1696 }
1697
1698 let _lock = xx::fslock::get(global_path, false)
1699 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1700
1701 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1702 crate::error::FileError::ReadError {
1703 path: global_path.to_path_buf(),
1704 source: e,
1705 }
1706 })?;
1707 let mut pt = Self::parse_str(&raw, global_path)?;
1708
1709 let removed = pt.namespaces.shift_remove(name).is_some();
1710 if removed {
1711 pt.write_unlocked()?;
1712 }
1713 Ok(removed)
1714 }
1715}
1716
1717#[derive(Debug, Clone, JsonSchema, Default)]
1719pub struct PitchforkTomlDaemon {
1720 #[schemars(example = example_run_command())]
1722 pub run: String,
1723 #[schemars(default)]
1725 pub auto: Vec<PitchforkTomlAuto>,
1726 pub cron: Option<PitchforkTomlCron>,
1728 #[schemars(default)]
1731 pub retry: Retry,
1732 pub ready_delay: Option<u64>,
1734 pub ready_output: Option<ReadyOutput>,
1736 pub ready_http: Option<ReadyHttp>,
1738 pub ready_port: Option<ReadyPort>,
1742 pub ready_cmd: Option<ReadyCmd>,
1744 pub port: Option<PortConfig>,
1746 pub boot_start: Option<bool>,
1748 #[schemars(default)]
1750 pub depends: Vec<DaemonId>,
1751 #[schemars(default)]
1753 pub watch: Vec<String>,
1754 #[schemars(default)]
1760 pub watch_mode: WatchMode,
1761 pub dir: Option<String>,
1763 pub env: Option<IndexMap<String, String>>,
1765 pub hooks: Option<PitchforkTomlHooks>,
1767 pub mise: Option<bool>,
1770 pub user: Option<String>,
1772 pub memory_limit: Option<MemoryLimit>,
1775 pub cpu_limit: Option<CpuLimit>,
1778 pub stop_signal: Option<StopConfig>,
1781 pub pty: Option<bool>,
1783 pub time_retention: Option<String>,
1786 pub line_retention: Option<i64>,
1789 pub archive_hook: Option<String>,
1792 pub logs: Option<PitchforkTomlDaemonLogs>,
1794 #[schemars(skip)]
1795 pub path: Option<PathBuf>,
1796}
1797
1798impl PitchforkTomlDaemon {
1799 pub fn effective_user(&self) -> Option<String> {
1803 let daemon_user = self
1804 .user
1805 .as_deref()
1806 .map(str::trim)
1807 .filter(|u| !u.is_empty());
1808 daemon_user.map(str::to_owned).or_else(|| {
1809 let s = crate::settings::settings();
1810 let su = s.supervisor.user.trim();
1811 (!su.is_empty()).then(|| su.to_owned())
1812 })
1813 }
1814
1815 pub fn to_run_options(
1820 &self,
1821 id: &crate::daemon_id::DaemonId,
1822 cmd: Vec<String>,
1823 ) -> crate::daemon::RunOptions {
1824 use crate::daemon::RunOptions;
1825
1826 let effective_user = self.effective_user();
1827 let dir = crate::ipc::batch::resolve_daemon_dir(
1828 self.dir.as_deref(),
1829 self.path.as_deref(),
1830 effective_user.as_deref(),
1831 );
1832 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1833 .into_iter()
1834 .find(|(slug, entry)| {
1835 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1836 if daemon_name != id.name() {
1837 return false;
1838 }
1839
1840 match entry.resolve_namespace() {
1841 Some(namespace) => namespace == id.namespace(),
1842 None => false,
1843 }
1844 })
1845 .map(|(slug, _)| slug);
1846
1847 RunOptions {
1848 id: id.clone(),
1849 cmd,
1850 run: Some(self.run.clone()),
1851 force: false,
1852 shell_pid: None,
1853 dir: Dir(dir),
1854 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1855 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1856 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1857 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1858 retry: self.retry,
1859 retry_count: 0,
1860 ready_delay: self.ready_delay,
1861 ready_output: self.ready_output.clone(),
1862 ready_http: self.ready_http.clone(),
1863 ready_port: self.ready_port.clone(),
1864 ready_cmd: self.ready_cmd.clone(),
1865 port: self.port.clone(),
1866 wait_ready: false,
1867 depends: self.depends.clone(),
1868 env: self.env.clone(),
1869 watch: self.watch.clone(),
1870 watch_mode: self.watch_mode,
1871 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1872 self.path.as_deref(),
1873 )),
1874 mise: self.mise,
1875 slug,
1876 proxy: None,
1877 user: self.user.clone(),
1878 memory_limit: self.memory_limit,
1879 cpu_limit: self.cpu_limit,
1880 stop_signal: self.stop_signal,
1881 archive_hook: self
1882 .logs
1883 .as_ref()
1884 .and_then(|l| l.archive_hook.clone())
1885 .or_else(|| self.archive_hook.clone()),
1886 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1887 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1888 pty: self.pty,
1889 }
1890 }
1891}
1892fn example_run_command() -> &'static str {
1893 "exec node server.js"
1894}
1895
1896#[cfg(test)]
1897mod tests {
1898 use super::*;
1899 use std::path::Path;
1900
1901 #[test]
1902 fn test_daemon_user_parses_and_flows_to_run_options() {
1903 let pt = PitchforkToml::parse_str(
1904 r#"
1905[daemons.api]
1906run = "node server.js"
1907user = "postgres"
1908"#,
1909 Path::new("/tmp/my-project/pitchfork.toml"),
1910 )
1911 .unwrap();
1912
1913 let id = DaemonId::new("my-project", "api");
1914 let daemon = pt.daemons.get(&id).unwrap();
1915 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1916
1917 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1918 assert_eq!(opts.user.as_deref(), Some("postgres"));
1919 }
1920
1921 #[test]
1922 fn test_daemon_user_write_roundtrip() {
1923 let temp = tempfile::tempdir().unwrap();
1924 let path = temp.path().join("pitchfork.toml");
1925 let mut pt = PitchforkToml::new(path.clone());
1926 pt.namespace = Some("test-project".to_string());
1927 pt.daemons.insert(
1928 DaemonId::new("test-project", "api"),
1929 PitchforkTomlDaemon {
1930 run: "node server.js".to_string(),
1931 user: Some("postgres".to_string()),
1932 ..PitchforkTomlDaemon::default()
1933 },
1934 );
1935
1936 pt.write().unwrap();
1937
1938 let raw = std::fs::read_to_string(&path).unwrap();
1939 assert!(raw.contains("user = \"postgres\""));
1940
1941 let parsed = PitchforkToml::read(&path).unwrap();
1942 let daemon = parsed
1943 .daemons
1944 .get(&DaemonId::new("test-project", "api"))
1945 .unwrap();
1946 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1947 }
1948
1949 #[test]
1950 fn test_registry_dirs_expand_tilde() {
1951 let pt = PitchforkToml::parse_str(
1952 r#"
1953[slugs.api]
1954dir = "~/projects/api"
1955
1956[namespaces.web]
1957dir = "~/projects/web"
1958"#,
1959 Path::new("/tmp/config.toml"),
1960 )
1961 .unwrap();
1962
1963 assert_eq!(
1964 pt.slugs["api"].dir,
1965 Some(crate::env::HOME_DIR.join("projects/api"))
1966 );
1967 assert_eq!(
1968 pt.namespaces["web"].dir,
1969 crate::env::HOME_DIR.join("projects/web")
1970 );
1971 }
1972
1973 #[test]
1974 fn test_settings_write_roundtrip() {
1975 let temp = tempfile::tempdir().unwrap();
1976 let path = temp.path().join("pitchfork.toml");
1977 let mut pt = PitchforkToml::new(path.clone());
1978 pt.namespace = Some("test-project".to_string());
1979 pt.settings.web.auto_start = Some(true);
1980 pt.settings.general.log_level = Some("debug".to_string());
1981
1982 pt.write().unwrap();
1983
1984 let raw = std::fs::read_to_string(&path).unwrap();
1985 assert!(
1986 raw.contains("[settings.web]"),
1987 "settings.web section should be written, got:\n{raw}"
1988 );
1989 assert!(raw.contains("auto_start = true"));
1990 assert!(raw.contains("log_level = \"debug\""));
1991
1992 let parsed = PitchforkToml::read(&path).unwrap();
1993 assert_eq!(parsed.settings.web.auto_start, Some(true));
1994 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
1995 }
1996
1997 #[test]
1998 fn test_settings_preserved_on_unrelated_write() {
1999 let temp = tempfile::tempdir().unwrap();
2002 let path = temp.path().join("pitchfork.toml");
2003 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2004
2005 let mut pt = PitchforkToml::read(&path).unwrap();
2006 pt.slugs.insert(
2007 "api".to_string(),
2008 SlugEntry {
2009 dir: None,
2010 namespace: Some("myproject".to_string()),
2011 daemon: None,
2012 },
2013 );
2014 pt.namespaces.insert(
2015 "myproject".to_string(),
2016 NamespaceEntry {
2017 dir: PathBuf::from("/tmp/myproject"),
2018 },
2019 );
2020 pt.write().unwrap();
2021
2022 let raw = std::fs::read_to_string(&path).unwrap();
2023 assert!(
2024 raw.contains("[settings.web]"),
2025 "existing settings must be preserved, got:\n{raw}"
2026 );
2027 assert!(raw.contains("auto_start = true"));
2028 assert!(raw.contains("[slugs.api]"));
2029
2030 let parsed = PitchforkToml::read(&path).unwrap();
2031 assert_eq!(parsed.settings.web.auto_start, Some(true));
2032 assert!(parsed.slugs.contains_key("api"));
2033 }
2034
2035 #[test]
2036 fn test_config_cache_hit_and_invalidation() {
2037 let temp = tempfile::tempdir().unwrap();
2038 let dir = temp.path();
2039 let config_path = dir.join("pitchfork.toml");
2040 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2041
2042 super::invalidate_config_cache();
2044
2045 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2047 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2048 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2049
2050 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2052 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2053
2054 std::thread::sleep(std::time::Duration::from_millis(50));
2057 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2058
2059 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2060 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2061
2062 super::invalidate_config_cache();
2064 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2065 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2066
2067 super::invalidate_config_cache();
2069 }
2070
2071 #[test]
2072 fn test_config_cache_invalidation_on_write() {
2073 let temp = tempfile::tempdir().unwrap();
2074 let dir = temp.path();
2075 let config_path = dir.join("pitchfork.toml");
2076 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2077
2078 super::invalidate_config_cache();
2079
2080 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2082 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2083 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2084
2085 let mut pt = PitchforkToml::read(&config_path).unwrap();
2087 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2088 let _ = pt.write();
2090
2091 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2093 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2094
2095 super::invalidate_config_cache();
2096 }
2097
2098 #[test]
2099 fn test_config_cache_size_invalidation() {
2100 let temp = tempfile::tempdir().unwrap();
2101 let dir = temp.path();
2102 let config_path = dir.join("pitchfork.toml");
2103 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2104
2105 super::invalidate_config_cache();
2106
2107 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2109 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2110 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2111
2112 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2116 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2117 let file = std::fs::OpenOptions::new()
2120 .write(true)
2121 .open(&config_path)
2122 .unwrap();
2123 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2124 file.set_times(times).unwrap();
2125
2126 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2129 assert_eq!(
2130 pt2.daemons[&daemon_id].run, "echo different",
2131 "cache should invalidate on size change even with identical mtime"
2132 );
2133
2134 super::invalidate_config_cache();
2135 }
2136}