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, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
19 PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ReadyCmd,
20 ReadyHttp, ReadyOutput, ReadyPort, Retry, 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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub config: Vec<String>,
105}
106
107#[derive(Debug, Clone)]
109pub struct NamespaceEntry {
110 pub dir: PathBuf,
112 pub config: Vec<PathBuf>,
113}
114
115#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
117struct PitchforkTomlRaw {
118 #[serde(skip_serializing_if = "Option::is_none", default)]
119 pub namespace: Option<String>,
120 #[serde(default)]
121 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
122 #[serde(skip_serializing_if = "Option::is_none", default)]
125 pub env: Option<IndexMap<String, String>>,
126 #[serde(default)]
127 pub settings: Option<SettingsPartial>,
128 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
131 pub slugs: IndexMap<String, SlugEntryRaw>,
132 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
134 pub groups: IndexMap<String, GroupEntryRaw>,
135 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
138 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
139}
140
141#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
146pub struct PitchforkTomlDaemonLogs {
147 #[serde(skip_serializing_if = "Option::is_none", default)]
150 pub log_format: Option<String>,
151 #[serde(skip_serializing_if = "Option::is_none", default)]
153 pub time_retention: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none", default)]
156 pub line_retention: Option<i64>,
157 #[serde(skip_serializing_if = "Option::is_none", default)]
159 pub archive_hook: Option<String>,
160}
161
162#[derive(Debug, serde::Serialize, serde::Deserialize)]
169struct PitchforkTomlDaemonRaw {
170 pub run: String,
171 #[serde(skip_serializing_if = "Vec::is_empty", default)]
172 pub auto: Vec<PitchforkTomlAuto>,
173 #[serde(skip_serializing_if = "Option::is_none", default)]
174 pub cron: Option<PitchforkTomlCron>,
175 #[serde(default)]
176 pub retry: Retry,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 pub ready_delay: Option<u64>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub ready_output: Option<ReadyOutput>,
181 #[serde(skip_serializing_if = "Option::is_none", default)]
182 pub ready_http: Option<ReadyHttp>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
184 pub ready_port: Option<ReadyPort>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub ready_cmd: Option<ReadyCmd>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub health_cmd: Option<HealthCmd>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
190 pub health_http: Option<HealthHttp>,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
192 pub health_port: Option<HealthPort>,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
195 pub port: Option<PortConfig>,
196 #[serde(skip_serializing_if = "Vec::is_empty", default)]
198 pub expected_port: Vec<u16>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub auto_bump_port: Option<bool>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub port_bump_attempts: Option<u32>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub boot_start: Option<bool>,
207 #[serde(skip_serializing_if = "Vec::is_empty", default)]
208 pub depends: Vec<String>,
209 #[serde(skip_serializing_if = "Vec::is_empty", default)]
210 pub watch: Vec<String>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub watch_mode: Option<WatchMode>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
214 pub dir: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub env: Option<IndexMap<String, String>>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
218 pub hooks: Option<PitchforkTomlHooks>,
219 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub mise: Option<bool>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
223 pub user: Option<String>,
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub memory_limit: Option<MemoryLimit>,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
229 pub cpu_limit: Option<CpuLimit>,
230 #[serde(skip_serializing_if = "Option::is_none", default)]
232 pub stop_signal: Option<StopConfig>,
233 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub pty: Option<bool>,
236 #[serde(skip_serializing_if = "Option::is_none", default)]
239 pub time_retention: Option<String>,
240 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub line_retention: Option<i64>,
244 #[serde(skip_serializing_if = "Option::is_none", default)]
247 pub archive_hook: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
250 pub logs: Option<PitchforkTomlDaemonLogs>,
251}
252
253#[derive(Debug, Clone, Default, JsonSchema)]
258#[schemars(title = "Pitchfork Configuration")]
259pub struct PitchforkToml {
260 #[serde(default)]
262 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
263 #[serde(skip_serializing_if = "Option::is_none", default)]
267 pub env: Option<IndexMap<String, String>>,
268 pub namespace: Option<String>,
273 #[serde(default)]
282 pub(crate) settings: SettingsPartial,
283 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
288 pub slugs: IndexMap<String, SlugEntry>,
289 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
291 pub groups: IndexMap<String, GroupEntry>,
292 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
295 pub namespaces: IndexMap<String, NamespaceEntry>,
296 #[schemars(skip)]
297 pub path: Option<PathBuf>,
298}
299
300pub(crate) fn is_global_config(path: &Path) -> bool {
301 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
302}
303
304pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
305 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
306}
307
308fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
309 use toml::Value;
310
311 let doc: Value = toml::from_str(content)
312 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
313 let Some(value) = doc.get("namespace") else {
314 return Ok(None);
315 };
316
317 match value {
318 Value::String(s) => Ok(Some(s.clone())),
319 _ => Err(ConfigParseError::InvalidNamespace {
320 path: path.to_path_buf(),
321 namespace: value.to_string(),
322 reason: "top-level 'namespace' must be a string".to_string(),
323 }
324 .into()),
325 }
326}
327
328fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
329 if !path.exists() {
330 return Ok(None);
331 }
332 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
333 path: path.to_path_buf(),
334 source: e,
335 })?;
336 parse_namespace_override_from_content(path, &content)
337}
338
339pub(crate) fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
340 crate::extra_configs::project_dir(path).or_else(|| {
341 if is_dot_config_pitchfork(path) {
342 path.parent().and_then(Path::parent).map(Path::to_path_buf)
343 } else {
344 path.parent().map(Path::to_path_buf)
345 }
346 })
347}
348
349fn project_config_family(path: &Path) -> Vec<PathBuf> {
350 let Some(dir) = project_dir_for_config(path) else {
351 return vec![path.to_path_buf()];
352 };
353 vec![
354 dir.join(".config/pitchfork.toml"),
355 dir.join(".config/pitchfork.local.toml"),
356 dir.join("pitchfork.toml"),
357 dir.join("pitchfork.local.toml"),
358 ]
359}
360
361fn directory_namespace_override(
364 path: &Path,
365 content_override: Option<&str>,
366) -> Result<Option<String>> {
367 if is_global_config(path) {
368 return match content_override {
369 Some(content) => parse_namespace_override_from_content(path, content),
370 None => read_namespace_override_from_file(path),
371 };
372 }
373
374 let mut selected: Option<(String, PathBuf)> = None;
375 for candidate in project_config_family(path) {
376 let explicit = if candidate == path {
377 match content_override {
378 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
379 None => read_namespace_override_from_file(&candidate)?,
380 }
381 } else {
382 read_namespace_override_from_file(&candidate)?
383 };
384 let Some(namespace) = explicit else { continue };
385 if let Some((selected_namespace, selected_path)) = &selected
386 && selected_namespace != &namespace
387 {
388 return Err(ConfigParseError::InvalidNamespace {
389 path: candidate,
390 namespace,
391 reason: format!(
392 "namespace does not match directory-level namespace '{}' declared in {}",
393 selected_namespace,
394 selected_path.display()
395 ),
396 }
397 .into());
398 }
399 selected = Some((namespace, candidate));
400 }
401 Ok(selected.map(|(namespace, _)| namespace))
402}
403
404fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
405 if let Err(e) = DaemonId::try_new(namespace, "probe") {
406 return Err(ConfigParseError::InvalidNamespace {
407 path: path.to_path_buf(),
408 namespace: namespace.to_string(),
409 reason: e.to_string(),
410 }
411 .into());
412 }
413 Ok(namespace.to_string())
414}
415
416fn derive_namespace_from_dir(path: &Path) -> Result<String> {
417 let dir_for_namespace = project_dir_for_config(path);
418 if let Some(namespace) = dir_for_namespace
419 .as_deref()
420 .and_then(crate::extra_configs::namespace_for_dir)
421 {
422 return validate_namespace(path, &namespace);
423 }
424 let raw_namespace = dir_for_namespace
425 .as_deref()
426 .and_then(|p| p.file_name())
427 .and_then(|n| n.to_str())
428 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
429 .to_string();
430
431 validate_namespace(path, &raw_namespace).map_err(|e| {
432 ConfigParseError::InvalidNamespace {
433 path: path.to_path_buf(),
434 namespace: raw_namespace,
435 reason: format!(
436 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
437 ),
438 }
439 .into()
440 })
441}
442
443fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
444 if is_global_config(path) {
445 if let Some(ns) = explicit
446 && ns != "global"
447 {
448 return Err(ConfigParseError::InvalidNamespace {
449 path: path.to_path_buf(),
450 namespace: ns.to_string(),
451 reason: "global config files must use namespace 'global'".to_string(),
452 }
453 .into());
454 }
455 return Ok("global".to_string());
456 }
457
458 if let Some(ns) = explicit {
459 return validate_namespace(path, ns);
460 }
461
462 derive_namespace_from_dir(path)
463}
464
465fn namespace_from_file(path: &Path) -> Result<String> {
466 let explicit = directory_namespace_override(path, None)?;
467 namespace_from_path_with_override(path, explicit.as_deref())
468}
469
470pub fn namespace_from_path(path: &Path) -> Result<String> {
483 namespace_from_file(path)
484}
485
486fn find_project_root(dir: &Path) -> Option<PathBuf> {
494 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
498 let mut current = canonical_dir.as_path();
499 loop {
500 if current.join(".git").exists() || current.join(".jj").exists() {
501 return Some(current.to_path_buf());
502 }
503 current = current.parent()?;
504 }
505}
506
507struct ConfigCacheEntry {
526 config: PitchforkToml,
527 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
529}
530
531static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
538 Lazy::new(|| StdMutex::new(HashMap::new()));
539
540fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
546 if paths.len() != snapshot.len() {
547 return false;
548 }
549 paths
550 .iter()
551 .zip(snapshot.iter())
552 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
553}
554
555pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
557 let md = std::fs::metadata(path).ok()?;
558 Some((md.modified().ok()?, md.len()))
559}
560
561fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
563 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
564}
565
566pub fn invalidate_config_cache() {
583 crate::extra_configs::invalidate();
584 if let Ok(mut cache) = CONFIG_CACHE.lock() {
585 cache.clear();
586 }
587}
588
589impl PitchforkToml {
590 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
603 if user_id.contains('/') {
605 return match DaemonId::parse(user_id) {
606 Ok(id) => Ok(vec![id]),
607 Err(e) => Err(e), };
609 }
610
611 let global_slugs = Self::read_global_slugs();
613 if let Some(entry) = global_slugs.get(user_id) {
614 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
616 if let Some(dir) = entry.resolve_dir()
617 && let Ok(project_config) = Self::all_merged_from(&dir)
618 {
619 let matches: Vec<DaemonId> = project_config
621 .daemons
622 .keys()
623 .filter(|id| id.name() == daemon_name)
624 .cloned()
625 .collect();
626 match matches.as_slice() {
627 [] => {}
628 [id] => return Ok(vec![id.clone()]),
629 _ => {
630 let mut candidates: Vec<String> =
631 matches.iter().map(|id| id.qualified()).collect();
632 candidates.sort();
633 return Err(miette::miette!(
634 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
635 user_id,
636 daemon_name,
637 candidates.join(", ")
638 ));
639 }
640 }
641 }
642 }
643
644 let matches: Vec<DaemonId> = self
646 .daemons
647 .keys()
648 .filter(|id| id.name() == user_id)
649 .cloned()
650 .collect();
651
652 if matches.is_empty() {
653 let state_matches = Self::find_in_state_file(user_id);
655 match state_matches.as_slice() {
656 [] => {}
657 [id] => return Ok(vec![id.clone()]),
658 _ => {
659 let mut candidates: Vec<String> =
660 state_matches.iter().map(|id| id.qualified()).collect();
661 candidates.sort();
662 return Err(miette::miette!(
663 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
664 user_id,
665 candidates.join(", ")
666 ));
667 }
668 }
669 let _ = DaemonId::try_new("global", user_id)?;
671 }
672 Ok(matches)
673 }
674
675 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
681 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
682 Ok(state) => state
683 .daemons
684 .keys()
685 .filter(|id| id.name() == short_name)
686 .cloned()
687 .collect(),
688 Err(e) => {
689 warn!("cannot read state file: {e}");
690 Vec::new()
691 }
692 }
693 }
694
695 #[allow(dead_code)]
716 pub fn resolve_daemon_id_prefer_local(
717 &self,
718 user_id: &str,
719 current_dir: &Path,
720 ) -> Result<DaemonId> {
721 if user_id.contains('/') {
723 return DaemonId::parse(user_id);
724 }
725
726 let current_namespace = Self::namespace_for_dir(current_dir)?;
730
731 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
732 }
733
734 fn resolve_daemon_id_with_namespace(
737 &self,
738 user_id: &str,
739 current_namespace: &str,
740 ) -> Result<DaemonId> {
741 let global_slugs = Self::read_global_slugs();
743 if let Some(entry) = global_slugs.get(user_id) {
744 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
745 if let Some(dir) = entry.resolve_dir()
746 && let Ok(project_config) = Self::all_merged_from(&dir)
747 {
748 let matches: Vec<DaemonId> = project_config
749 .daemons
750 .keys()
751 .filter(|id| id.name() == daemon_name)
752 .cloned()
753 .collect();
754 match matches.as_slice() {
755 [] => {}
756 [id] => return Ok(id.clone()),
757 _ => {
758 let mut candidates: Vec<String> =
759 matches.iter().map(|id| id.qualified()).collect();
760 candidates.sort();
761 return Err(miette::miette!(
762 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
763 user_id,
764 daemon_name,
765 candidates.join(", ")
766 ));
767 }
768 }
769 }
770 }
771
772 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
775 if self.daemons.contains_key(&preferred_id) {
776 return Ok(preferred_id);
777 }
778
779 let matches = self.resolve_daemon_id(user_id)?;
781
782 if matches.len() > 1 {
784 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
785 candidates.sort();
786 return Err(miette::miette!(
787 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
788 user_id,
789 candidates.join(", ")
790 ));
791 }
792
793 if let Some(id) = matches.into_iter().next() {
794 return Ok(id);
795 }
796
797 let global_id = DaemonId::try_new("global", user_id)?;
800 if self.daemons.contains_key(&global_id) {
801 return Ok(global_id);
802 }
803
804 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
805 Err(DependencyError::DaemonNotFound {
806 name: user_id.to_string(),
807 suggestion,
808 }
809 .into())
810 }
811
812 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
814 namespace_from_path(&dir.join("pitchfork.toml"))
815 }
816
817 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
819 directory_namespace_override(&dir.join("pitchfork.toml"), None)
820 }
821
822 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
824 Ok(Self::list_paths_from(dir)
825 .iter()
826 .filter(|p| p.exists())
827 .max_by_key(|p| {
828 if is_global_config(p) {
829 0
830 } else {
831 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
832 }
833 })
834 .map(|p| namespace_from_path(p))
835 .transpose()?
836 .unwrap_or_else(|| "global".to_string()))
837 }
838
839 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
849 if user_id.contains('/') {
850 return DaemonId::parse(user_id);
851 }
852
853 let config = Self::all_merged()?;
856 let ns = Self::namespace_for_dir(&env::CWD)?;
857 config.resolve_daemon_id_with_namespace(user_id, &ns)
858 }
859
860 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
866 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
867 }
868
869 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
870 if user_id.contains('/') {
871 return DaemonId::parse(user_id);
872 }
873
874 let ns = Self::namespace_for_dir(dir)?;
875 DaemonId::try_new(ns, user_id)
876 }
877
878 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
889 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
891 return user_ids
892 .iter()
893 .map(|s| DaemonId::parse(s.as_ref()))
894 .collect();
895 }
896
897 let config = Self::all_merged()?;
898 let ns = Self::namespace_for_dir(&env::CWD)?;
900 user_ids
901 .iter()
902 .map(|s| {
903 let id = s.as_ref();
904 if id.contains('/') {
905 DaemonId::parse(id)
906 } else {
907 config.resolve_daemon_id_with_namespace(id, &ns)
908 }
909 })
910 .collect()
911 }
912
913 pub fn resolve_ids_and_group<S: AsRef<str>>(
918 user_ids: &[S],
919 group_name: Option<&str>,
920 ) -> Result<Vec<DaemonId>> {
921 let config = Self::all_merged()?;
922 let ns = Self::namespace_for_dir(&env::CWD)?;
923 let mut ids = Vec::new();
924 let mut seen = std::collections::HashSet::new();
925
926 for id in user_ids {
927 let id_str = id.as_ref();
928 let daemon_id = if id_str.contains('/') {
929 DaemonId::parse(id_str)?
930 } else {
931 config.resolve_daemon_id_with_namespace(id_str, &ns)?
932 };
933 if seen.insert(daemon_id.clone()) {
934 ids.push(daemon_id);
935 }
936 }
937
938 if let Some(name) = group_name {
939 match config.groups.get(name) {
940 Some(group) => {
941 let missing: Vec<String> = group
942 .daemons
943 .iter()
944 .filter(|id| !config.daemons.contains_key(*id))
945 .map(|id| id.qualified())
946 .collect();
947 if !missing.is_empty() {
948 return Err(miette::miette!(
949 "group '{}' references undefined daemon{}: {}",
950 name,
951 if missing.len() > 1 { "s" } else { "" },
952 missing.join(", ")
953 ));
954 }
955 for daemon_id in &group.daemons {
956 if seen.insert(daemon_id.clone()) {
957 ids.push(daemon_id.clone());
958 }
959 }
960 }
961 None => {
962 let suggestion =
963 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
964 return Err(miette::miette!(
965 "group '{}' not found in configuration{}",
966 name,
967 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
968 ));
969 }
970 }
971 }
972
973 Ok(ids)
974 }
975
976 pub fn list_paths() -> Vec<PathBuf> {
979 Self::list_paths_from(&env::CWD)
980 }
981
982 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
993 let mut paths = Vec::new();
994 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
995 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
996
997 let mut project_paths = xx::file::find_up_all(
1001 cwd,
1002 &[
1003 "pitchfork.local.toml",
1004 "pitchfork.toml",
1005 ".config/pitchfork.local.toml",
1006 ".config/pitchfork.toml",
1007 ],
1008 );
1009 project_paths.reverse();
1010 paths.extend(project_paths);
1011 paths.extend(crate::extra_configs::paths_for(cwd));
1012
1013 paths
1014 }
1015
1016 pub fn all_merged() -> Result<PitchforkToml> {
1019 Self::all_merged_from(&env::CWD)
1020 }
1021 pub fn all_merged_all_namespaces() -> Result<Self> {
1028 Self::all_merged_all_namespaces_from(&env::CWD)
1029 }
1030
1031 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1034 let mut pt = Self::all_merged_from(start_dir)?;
1035
1036 let namespaces = Self::read_global_namespaces();
1037 for (ns_name, entry) in namespaces {
1038 match Self::all_merged_from(&entry.dir) {
1039 Ok(ns_config) => {
1040 for (daemon_id, daemon_config) in ns_config.daemons {
1041 if !pt.daemons.contains_key(&daemon_id) {
1042 pt.daemons.insert(daemon_id, daemon_config);
1043 }
1044 }
1045 pt.settings.merge_from(&ns_config.settings);
1048 }
1049 Err(e) => {
1050 log::warn!(
1051 "Failed to load namespace '{ns_name}' from {}: {e}",
1052 entry.dir.display()
1053 );
1054 }
1055 }
1056 }
1057
1058 if crate::settings::settings().general.worktree
1068 && let Some(project_root) = find_project_root(start_dir)
1069 {
1070 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1071 for wt in &worktrees {
1072 match Self::all_merged_from(&wt.path) {
1073 Ok(wt_config) => {
1074 for (daemon_id, daemon_config) in wt_config.daemons {
1075 if !pt.daemons.contains_key(&daemon_id) {
1076 pt.daemons.insert(daemon_id, daemon_config);
1077 }
1078 }
1079 pt.settings.merge_from(&wt_config.settings);
1080 }
1081 Err(e) => {
1082 log::warn!(
1083 "Failed to load worktree '{}' config from {}: {e}",
1084 wt.branch,
1085 wt.path.display()
1086 );
1087 }
1088 }
1089 }
1090 }
1091
1092 Ok(pt)
1093 }
1094
1095 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1113 let paths = Self::list_paths_from(cwd);
1114
1115 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1119
1120 {
1121 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1122 if let Some(entry) = cache.get(&cache_key)
1123 && meta_matches(&paths, &entry.source_meta)
1124 {
1125 return Ok(entry.config.clone());
1126 }
1127 }
1128
1129 let snapshot = snapshot_meta(&paths);
1135 let pt = Self::all_merged_from_uncached(&paths)?;
1136
1137 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1139 cache.insert(
1140 cache_key,
1141 ConfigCacheEntry {
1142 config: pt.clone(),
1143 source_meta: snapshot,
1144 },
1145 );
1146
1147 Ok(pt)
1148 }
1149
1150 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1155 use std::collections::HashMap as StdHashMap;
1156
1157 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1158
1159 let mut pt = Self::default();
1160 for p in paths {
1161 match Self::read(p) {
1162 Ok(pt2) => {
1163 if p.exists() && !is_global_config(p) {
1167 let ns = namespace_from_path(p)?;
1168 let origin_dir = project_dir_for_config(p)
1169 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1170 .unwrap_or_else(|| p.clone());
1171
1172 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1173 && *other_dir != origin_dir
1174 {
1175 return Err(crate::error::ConfigParseError::NamespaceCollision {
1176 path_a: other_path.clone(),
1177 path_b: p.clone(),
1178 ns,
1179 }
1180 .into());
1181 }
1182 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1183 }
1184
1185 pt.merge(pt2)
1186 }
1187 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1188 }
1189 }
1190 Ok(pt)
1191 }
1192}
1193
1194impl PitchforkToml {
1195 pub fn new(path: PathBuf) -> Self {
1196 Self {
1197 daemons: Default::default(),
1198 env: None,
1199 namespace: None,
1200 settings: SettingsPartial::default(),
1201 slugs: IndexMap::new(),
1202 groups: IndexMap::new(),
1203 namespaces: IndexMap::new(),
1204 path: Some(path),
1205 }
1206 }
1207
1208 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1216 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1217 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1218 if let Some(settings) = &mut raw_config.settings {
1219 settings.canonicalize_aliases();
1220 }
1221
1222 let explicit = directory_namespace_override(path, Some(content))?;
1223 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1224 let mut pt = Self::new(path.to_path_buf());
1225 pt.namespace = raw_config.namespace.clone();
1226
1227 for (short_name, raw_daemon) in raw_config.daemons {
1228 let id = match DaemonId::try_new(&namespace, &short_name) {
1229 Ok(id) => id,
1230 Err(e) => {
1231 return Err(ConfigParseError::InvalidDaemonName {
1232 name: short_name,
1233 path: path.to_path_buf(),
1234 reason: e.to_string(),
1235 }
1236 .into());
1237 }
1238 };
1239
1240 let mut depends = Vec::new();
1241 for dep in raw_daemon.depends {
1242 let dep_id = if dep.contains('/') {
1243 match DaemonId::parse(&dep) {
1244 Ok(id) => id,
1245 Err(e) => {
1246 return Err(ConfigParseError::InvalidDependency {
1247 daemon: short_name.clone(),
1248 dependency: dep,
1249 path: path.to_path_buf(),
1250 reason: e.to_string(),
1251 }
1252 .into());
1253 }
1254 }
1255 } else {
1256 match DaemonId::try_new(&namespace, &dep) {
1257 Ok(id) => id,
1258 Err(e) => {
1259 return Err(ConfigParseError::InvalidDependency {
1260 daemon: short_name.clone(),
1261 dependency: dep,
1262 path: path.to_path_buf(),
1263 reason: e.to_string(),
1264 }
1265 .into());
1266 }
1267 }
1268 };
1269 depends.push(dep_id);
1270 }
1271
1272 let has_deprecated = !raw_daemon.expected_port.is_empty()
1274 || raw_daemon.auto_bump_port.is_some()
1275 || raw_daemon.port_bump_attempts.is_some();
1276 let port = if let Some(port) = raw_daemon.port {
1277 if has_deprecated {
1278 warn!(
1279 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1280 );
1281 }
1282 Some(port)
1283 } else if has_deprecated {
1284 warn!(
1285 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1286 );
1287 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1288 PortBump(
1289 raw_daemon
1290 .port_bump_attempts
1291 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1292 )
1293 } else {
1294 PortBump(0)
1295 };
1296 Some(PortConfig {
1297 expect: raw_daemon.expected_port,
1298 bump,
1299 })
1300 } else {
1301 None
1302 };
1303
1304 let daemon = PitchforkTomlDaemon {
1305 run: raw_daemon.run,
1306 auto: raw_daemon.auto,
1307 cron: raw_daemon.cron,
1308 retry: raw_daemon.retry,
1309 ready_delay: raw_daemon.ready_delay,
1310 ready_output: raw_daemon.ready_output,
1311 ready_http: raw_daemon.ready_http,
1312 ready_port: raw_daemon.ready_port,
1313 ready_cmd: raw_daemon.ready_cmd,
1314 health_cmd: raw_daemon.health_cmd,
1315 health_http: raw_daemon.health_http,
1316 health_port: raw_daemon.health_port,
1317 port,
1318 boot_start: raw_daemon.boot_start,
1319 depends,
1320 watch: raw_daemon.watch,
1321 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1322 dir: raw_daemon.dir,
1323 env: raw_daemon.env,
1324 hooks: raw_daemon.hooks,
1325 mise: raw_daemon.mise,
1326 user: raw_daemon.user,
1327 memory_limit: raw_daemon.memory_limit,
1328 cpu_limit: raw_daemon.cpu_limit,
1329 stop_signal: raw_daemon.stop_signal,
1330 pty: raw_daemon.pty,
1331 time_retention: raw_daemon.time_retention,
1332 line_retention: raw_daemon.line_retention,
1333 archive_hook: raw_daemon.archive_hook,
1334 logs: raw_daemon.logs,
1335 path: Some(path.to_path_buf()),
1336 };
1337 pt.daemons.insert(id, daemon);
1338 }
1339
1340 if let Some(settings) = raw_config.settings {
1342 pt.settings = settings;
1343 }
1344
1345 pt.env = raw_config.env;
1347
1348 for (slug, entry) in raw_config.slugs {
1350 pt.slugs.insert(
1351 slug,
1352 SlugEntry {
1353 dir: entry.dir.map(env::expand_tilde),
1354 namespace: entry.namespace,
1355 daemon: entry.daemon,
1356 },
1357 );
1358 }
1359
1360 for (name, entry) in raw_config.namespaces {
1362 pt.namespaces.insert(
1363 name,
1364 NamespaceEntry {
1365 config: entry
1366 .config
1367 .iter()
1368 .map(|p| {
1369 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1370 })
1371 .collect(),
1372 dir: env::expand_tilde(entry.dir),
1373 },
1374 );
1375 }
1376
1377 for (group_name, raw_group) in raw_config.groups {
1379 let mut daemons = Vec::new();
1380 for daemon_name in &raw_group.daemons {
1381 let id = if daemon_name.contains('/') {
1382 DaemonId::parse(daemon_name).map_err(|e| {
1383 ConfigParseError::InvalidDependency {
1384 daemon: group_name.clone(),
1385 dependency: daemon_name.clone(),
1386 path: path.to_path_buf(),
1387 reason: e.to_string(),
1388 }
1389 })?
1390 } else {
1391 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1392 ConfigParseError::InvalidDaemonName {
1393 name: daemon_name.clone(),
1394 path: path.to_path_buf(),
1395 reason: e.to_string(),
1396 }
1397 })?
1398 };
1399 daemons.push(id);
1400 }
1401 pt.groups.insert(group_name, GroupEntry { daemons });
1402 }
1403
1404 Ok(pt)
1405 }
1406
1407 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1408 let path = path.as_ref();
1409 if !path.exists() {
1410 return Ok(Self::new(path.to_path_buf()));
1411 }
1412 let _lock = xx::fslock::get(path, false)
1413 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1414 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1415 path: path.to_path_buf(),
1416 source: e,
1417 })?;
1418 Self::parse_str(&raw, path)
1419 }
1420
1421 pub fn write(&self) -> Result<()> {
1422 if let Some(path) = &self.path {
1423 let _lock = xx::fslock::get(path, false)
1424 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1425 self.write_unlocked()
1426 } else {
1427 Err(FileError::NoPath.into())
1428 }
1429 }
1430
1431 pub(crate) fn write_unlocked(&self) -> Result<()> {
1437 if let Some(path) = &self.path {
1438 let config_namespace = if path.exists() {
1440 namespace_from_path(path)?
1441 } else {
1442 namespace_from_path_with_override(path, self.namespace.as_deref())?
1443 };
1444
1445 let mut raw = PitchforkTomlRaw {
1449 namespace: self.namespace.clone(),
1450 env: self.env.clone(),
1451 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1452 ..PitchforkTomlRaw::default()
1453 };
1454 for (id, daemon) in &self.daemons {
1455 if id.namespace() != config_namespace {
1456 return Err(miette::miette!(
1457 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1458 id,
1459 path.display(),
1460 id.namespace(),
1461 config_namespace
1462 ));
1463 }
1464 let port = daemon.port.as_ref();
1465 let raw_daemon = PitchforkTomlDaemonRaw {
1466 run: daemon.run.clone(),
1467 auto: daemon.auto.clone(),
1468 cron: daemon.cron.clone(),
1469 retry: daemon.retry,
1470 ready_delay: daemon.ready_delay,
1471 ready_output: daemon.ready_output.clone(),
1472 ready_http: daemon.ready_http.clone(),
1473 ready_port: daemon.ready_port.clone(),
1474 ready_cmd: daemon.ready_cmd.clone(),
1475 health_cmd: daemon.health_cmd.clone(),
1476 health_http: daemon.health_http.clone(),
1477 health_port: daemon.health_port.clone(),
1478 port: port.cloned(),
1479 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1481 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1482 port_bump_attempts: port
1483 .filter(|p| p.auto_bump())
1484 .map(|p| p.max_bump_attempts()),
1485 boot_start: daemon.boot_start,
1486 depends: daemon
1489 .depends
1490 .iter()
1491 .map(|d| {
1492 if d.namespace() == config_namespace {
1493 d.name().to_string()
1494 } else {
1495 d.qualified()
1496 }
1497 })
1498 .collect(),
1499 watch: daemon.watch.clone(),
1500 watch_mode: match daemon.watch_mode {
1501 WatchMode::Native => None,
1502 mode => Some(mode),
1503 },
1504 dir: daemon.dir.clone(),
1505 env: daemon.env.clone(),
1506 hooks: daemon.hooks.clone(),
1507 mise: daemon.mise,
1508 user: daemon.user.clone(),
1509 memory_limit: daemon.memory_limit,
1510 cpu_limit: daemon.cpu_limit,
1511 stop_signal: daemon.stop_signal,
1512 pty: daemon.pty,
1513 time_retention: daemon.time_retention.clone(),
1514 line_retention: daemon.line_retention,
1515 archive_hook: daemon.archive_hook.clone(),
1516 logs: daemon.logs.clone(),
1517 };
1518 raw.daemons.insert(id.name().to_string(), raw_daemon);
1519 }
1520
1521 for (slug, entry) in &self.slugs {
1523 raw.slugs.insert(
1524 slug.clone(),
1525 SlugEntryRaw {
1526 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1527 namespace: entry.namespace.clone(),
1528 daemon: entry.daemon.clone(),
1529 },
1530 );
1531 }
1532
1533 for (name, group) in &self.groups {
1535 let raw_daemons: Vec<String> = group
1536 .daemons
1537 .iter()
1538 .map(|id| {
1539 if id.namespace() == config_namespace {
1540 id.name().to_string()
1541 } else {
1542 id.qualified()
1543 }
1544 })
1545 .collect();
1546 raw.groups.insert(
1547 name.clone(),
1548 GroupEntryRaw {
1549 daemons: raw_daemons,
1550 },
1551 );
1552 }
1553
1554 for (name, entry) in &self.namespaces {
1556 raw.namespaces.insert(
1557 name.clone(),
1558 NamespaceEntryRaw {
1559 dir: entry.dir.to_string_lossy().to_string(),
1560 config: entry
1561 .config
1562 .iter()
1563 .map(|p| p.to_string_lossy().into_owned())
1564 .collect(),
1565 },
1566 );
1567 }
1568
1569 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1570 path: path.clone(),
1571 source: e,
1572 })?;
1573 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1574 path: path.clone(),
1575 details: Some(e.to_string()),
1576 })?;
1577 invalidate_config_cache();
1578 Ok(())
1579 } else {
1580 Err(FileError::NoPath.into())
1581 }
1582 }
1583
1584 pub fn merge(&mut self, pt: Self) {
1589 for (id, d) in pt.daemons {
1590 self.daemons.insert(id, d);
1591 }
1592 if let Some(env) = pt.env {
1594 let merged = self.env.get_or_insert_with(IndexMap::new);
1595 for (k, v) in env {
1596 merged.insert(k, v);
1597 }
1598 }
1599 for (slug, entry) in pt.slugs {
1601 self.slugs.insert(slug, entry);
1602 }
1603 for (name, group) in pt.groups {
1605 self.groups.insert(name, group);
1606 }
1607 for (name, entry) in pt.namespaces {
1609 self.namespaces.insert(name, entry);
1610 }
1611 self.settings.merge_from(&pt.settings);
1613 }
1614
1615 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1620 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1621 Ok(pt) => pt.slugs,
1622 Err(_) => IndexMap::new(),
1623 }
1624 }
1625
1626 pub fn find_slug_for_daemon_in_registry(
1628 daemon_id: &DaemonId,
1629 global_slugs: &IndexMap<String, SlugEntry>,
1630 ) -> Option<String> {
1631 global_slugs
1632 .iter()
1633 .find(|(slug, entry)| {
1634 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1635 if daemon_id.name() != daemon_name {
1636 return false;
1637 }
1638
1639 match entry.resolve_namespace() {
1640 Some(namespace) => daemon_id.namespace() == namespace,
1641 None => false,
1642 }
1643 })
1644 .map(|(slug, _)| slug.clone())
1645 }
1646
1647 #[allow(dead_code)]
1649 pub fn is_slug_registered(slug: &str) -> bool {
1650 Self::read_global_slugs().contains_key(slug)
1651 }
1652
1653 pub fn add_slug_with_namespace(
1659 slug: &str,
1660 namespace: Option<&str>,
1661 daemon: Option<&str>,
1662 ) -> Result<()> {
1663 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1664
1665 if let Some(parent) = global_path.parent() {
1667 std::fs::create_dir_all(parent).map_err(|e| {
1668 miette::miette!(
1669 "Failed to create config directory {}: {e}",
1670 parent.display()
1671 )
1672 })?;
1673 }
1674
1675 let _lock = xx::fslock::get(global_path, false)
1676 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1677
1678 let mut pt = if global_path.exists() {
1679 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1680 path: global_path.to_path_buf(),
1681 source: e,
1682 })?;
1683 Self::parse_str(&raw, global_path)?
1684 } else {
1685 Self::new(global_path.to_path_buf())
1686 };
1687
1688 if let Some(ns) = namespace
1692 && !pt.namespaces.contains_key(ns)
1693 {
1694 let dir = pt
1699 .slugs
1700 .get(slug)
1701 .and_then(|e| {
1702 e.dir.clone().or_else(|| {
1703 e.namespace
1704 .as_ref()
1705 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1706 })
1707 })
1708 .or_else(|| env::CWD.as_path().canonicalize().ok());
1709 if let Some(ref d) = dir {
1710 pt.namespaces.insert(
1711 ns.to_string(),
1712 NamespaceEntry {
1713 dir: d.clone(),
1714 config: Vec::new(),
1715 },
1716 );
1717 }
1718 }
1719
1720 pt.slugs.insert(
1721 slug.to_string(),
1722 SlugEntry {
1723 dir: None,
1724 namespace: namespace.map(str::to_string),
1725 daemon: daemon.map(str::to_string),
1726 },
1727 );
1728 pt.write_unlocked()?;
1729 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1735 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1736 Ok(())
1737 }
1738
1739 pub fn remove_slug(slug: &str) -> Result<bool> {
1741 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1742 if !global_path.exists() {
1743 return Ok(false);
1744 }
1745
1746 let _lock = xx::fslock::get(global_path, false)
1747 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1748
1749 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1750 path: global_path.to_path_buf(),
1751 source: e,
1752 })?;
1753 let mut pt = Self::parse_str(&raw, global_path)?;
1754
1755 let removed = pt.slugs.shift_remove(slug).is_some();
1756 if removed {
1757 pt.write_unlocked()?;
1758 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1764 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1765 }
1766 Ok(removed)
1767 }
1768 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1771 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1772 Ok(pt) => pt.namespaces,
1773 Err(_) => IndexMap::new(),
1774 }
1775 }
1776
1777 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1781 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1782
1783 if let Some(parent) = global_path.parent() {
1785 std::fs::create_dir_all(parent).map_err(|e| {
1786 miette::miette!(
1787 "Failed to create config directory {}: {e}",
1788 parent.display()
1789 )
1790 })?;
1791 }
1792
1793 let _lock = xx::fslock::get(global_path, false)
1794 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1795
1796 let mut pt = if global_path.exists() {
1797 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1798 crate::error::FileError::ReadError {
1799 path: global_path.to_path_buf(),
1800 source: e,
1801 }
1802 })?;
1803 Self::parse_str(&raw, global_path)?
1804 } else {
1805 Self::new(global_path.to_path_buf())
1806 };
1807
1808 let dir = env::expand_tilde(dir);
1809 if let Some(entry) = pt.namespaces.get_mut(name) {
1810 if !entry.config.is_empty()
1811 && crate::extra_configs::normalize(&entry.dir)
1812 != crate::extra_configs::normalize(&dir)
1813 {
1814 miette::bail!(
1815 "namespace '{name}' has external configuration attached to another directory"
1816 );
1817 }
1818 entry.dir = dir;
1819 } else {
1820 pt.namespaces.insert(
1821 name.to_string(),
1822 NamespaceEntry {
1823 dir,
1824 config: Vec::new(),
1825 },
1826 );
1827 }
1828 pt.write_unlocked()?;
1829 Ok(())
1830 }
1831
1832 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1834 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1835 if !global_path.exists() {
1836 return Ok(false);
1837 }
1838
1839 let _lock = xx::fslock::get(global_path, false)
1840 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1841
1842 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1843 crate::error::FileError::ReadError {
1844 path: global_path.to_path_buf(),
1845 source: e,
1846 }
1847 })?;
1848 let mut pt = Self::parse_str(&raw, global_path)?;
1849
1850 let removed = pt.namespaces.shift_remove(name).is_some();
1851 if removed {
1852 pt.write_unlocked()?;
1853 }
1854 Ok(removed)
1855 }
1856}
1857
1858#[derive(Debug, Clone, JsonSchema, Default)]
1860pub struct PitchforkTomlDaemon {
1861 #[schemars(example = example_run_command())]
1863 pub run: String,
1864 #[schemars(default)]
1866 pub auto: Vec<PitchforkTomlAuto>,
1867 pub cron: Option<PitchforkTomlCron>,
1869 #[schemars(default)]
1872 pub retry: Retry,
1873 pub ready_delay: Option<u64>,
1875 pub ready_output: Option<ReadyOutput>,
1877 pub ready_http: Option<ReadyHttp>,
1879 pub ready_port: Option<ReadyPort>,
1883 pub ready_cmd: Option<ReadyCmd>,
1885 pub health_cmd: Option<HealthCmd>,
1887 pub health_http: Option<HealthHttp>,
1889 pub health_port: Option<HealthPort>,
1893 pub port: Option<PortConfig>,
1895 pub boot_start: Option<bool>,
1897 #[schemars(default)]
1899 pub depends: Vec<DaemonId>,
1900 #[schemars(default)]
1902 pub watch: Vec<String>,
1903 #[schemars(default)]
1909 pub watch_mode: WatchMode,
1910 pub dir: Option<String>,
1912 pub env: Option<IndexMap<String, String>>,
1914 pub hooks: Option<PitchforkTomlHooks>,
1916 pub mise: Option<bool>,
1919 pub user: Option<String>,
1921 pub memory_limit: Option<MemoryLimit>,
1924 pub cpu_limit: Option<CpuLimit>,
1927 pub stop_signal: Option<StopConfig>,
1930 pub pty: Option<bool>,
1932 pub time_retention: Option<String>,
1935 pub line_retention: Option<i64>,
1938 pub archive_hook: Option<String>,
1941 pub logs: Option<PitchforkTomlDaemonLogs>,
1943 #[schemars(skip)]
1944 pub path: Option<PathBuf>,
1945}
1946
1947impl PitchforkTomlDaemon {
1948 pub fn effective_user(&self) -> Option<String> {
1952 let daemon_user = self
1953 .user
1954 .as_deref()
1955 .map(str::trim)
1956 .filter(|u| !u.is_empty());
1957 daemon_user.map(str::to_owned).or_else(|| {
1958 let s = crate::settings::settings();
1959 let su = s.supervisor.user.trim();
1960 (!su.is_empty()).then(|| su.to_owned())
1961 })
1962 }
1963
1964 pub fn to_run_options(
1969 &self,
1970 id: &crate::daemon_id::DaemonId,
1971 cmd: Vec<String>,
1972 ) -> crate::daemon::RunOptions {
1973 use crate::daemon::RunOptions;
1974
1975 let effective_user = self.effective_user();
1976 let dir = crate::ipc::batch::resolve_daemon_dir(
1977 self.dir.as_deref(),
1978 self.path.as_deref(),
1979 effective_user.as_deref(),
1980 );
1981 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1982 .into_iter()
1983 .find(|(slug, entry)| {
1984 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1985 if daemon_name != id.name() {
1986 return false;
1987 }
1988
1989 match entry.resolve_namespace() {
1990 Some(namespace) => namespace == id.namespace(),
1991 None => false,
1992 }
1993 })
1994 .map(|(slug, _)| slug);
1995
1996 RunOptions {
1997 id: id.clone(),
1998 cmd,
1999 run: Some(self.run.clone()),
2000 force: false,
2001 shell_pid: None,
2002 dir: Dir(dir),
2003 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2004 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2005 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2006 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2007 retry: self.retry,
2008 retry_count: 0,
2009 ready_delay: self.ready_delay,
2010 ready_output: self.ready_output.clone(),
2011 ready_http: self.ready_http.clone(),
2012 ready_port: self.ready_port.clone(),
2013 ready_cmd: self.ready_cmd.clone(),
2014 health_cmd: self.health_cmd.clone(),
2015 health_http: self.health_http.clone(),
2016 health_port: self.health_port.clone(),
2017 port: self.port.clone(),
2018 wait_ready: false,
2019 depends: self.depends.clone(),
2020 env: self.env.clone(),
2021 watch: self.watch.clone(),
2022 watch_mode: self.watch_mode,
2023 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2024 self.path.as_deref(),
2025 )),
2026 mise: self.mise,
2027 slug,
2028 proxy: None,
2029 user: self.user.clone(),
2030 memory_limit: self.memory_limit,
2031 cpu_limit: self.cpu_limit,
2032 stop_signal: self.stop_signal,
2033 archive_hook: self
2034 .logs
2035 .as_ref()
2036 .and_then(|l| l.archive_hook.clone())
2037 .or_else(|| self.archive_hook.clone()),
2038 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2039 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2040 pty: self.pty,
2041 }
2042 }
2043}
2044fn example_run_command() -> &'static str {
2045 "exec node server.js"
2046}
2047
2048#[cfg(test)]
2049mod tests {
2050 use super::*;
2051 use std::path::Path;
2052
2053 #[test]
2054 fn test_daemon_user_parses_and_flows_to_run_options() {
2055 let pt = PitchforkToml::parse_str(
2056 r#"
2057[daemons.api]
2058run = "node server.js"
2059user = "postgres"
2060"#,
2061 Path::new("/tmp/my-project/pitchfork.toml"),
2062 )
2063 .unwrap();
2064
2065 let id = DaemonId::new("my-project", "api");
2066 let daemon = pt.daemons.get(&id).unwrap();
2067 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2068
2069 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2070 assert_eq!(opts.user.as_deref(), Some("postgres"));
2071 }
2072
2073 #[test]
2074 fn test_daemon_user_write_roundtrip() {
2075 let temp = tempfile::tempdir().unwrap();
2076 let path = temp.path().join("pitchfork.toml");
2077 let mut pt = PitchforkToml::new(path.clone());
2078 pt.namespace = Some("test-project".to_string());
2079 pt.daemons.insert(
2080 DaemonId::new("test-project", "api"),
2081 PitchforkTomlDaemon {
2082 run: "node server.js".to_string(),
2083 user: Some("postgres".to_string()),
2084 ..PitchforkTomlDaemon::default()
2085 },
2086 );
2087
2088 pt.write().unwrap();
2089
2090 let raw = std::fs::read_to_string(&path).unwrap();
2091 assert!(raw.contains("user = \"postgres\""));
2092
2093 let parsed = PitchforkToml::read(&path).unwrap();
2094 let daemon = parsed
2095 .daemons
2096 .get(&DaemonId::new("test-project", "api"))
2097 .unwrap();
2098 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2099 }
2100
2101 #[test]
2102 fn test_registry_dirs_expand_tilde() {
2103 let pt = PitchforkToml::parse_str(
2104 r#"
2105[slugs.api]
2106dir = "~/projects/api"
2107
2108[namespaces.web]
2109dir = "~/projects/web"
2110"#,
2111 Path::new("/tmp/config.toml"),
2112 )
2113 .unwrap();
2114
2115 assert_eq!(
2116 pt.slugs["api"].dir,
2117 Some(crate::env::HOME_DIR.join("projects/api"))
2118 );
2119 assert_eq!(
2120 pt.namespaces["web"].dir,
2121 crate::env::HOME_DIR.join("projects/web")
2122 );
2123 }
2124
2125 #[test]
2126 fn test_settings_write_roundtrip() {
2127 let temp = tempfile::tempdir().unwrap();
2128 let path = temp.path().join("pitchfork.toml");
2129 let mut pt = PitchforkToml::new(path.clone());
2130 pt.namespace = Some("test-project".to_string());
2131 pt.settings.web.auto_start = Some(true);
2132 pt.settings.general.log_level = Some("debug".to_string());
2133
2134 pt.write().unwrap();
2135
2136 let raw = std::fs::read_to_string(&path).unwrap();
2137 assert!(
2138 raw.contains("[settings.web]"),
2139 "settings.web section should be written, got:\n{raw}"
2140 );
2141 assert!(raw.contains("auto_start = true"));
2142 assert!(raw.contains("log_level = \"debug\""));
2143
2144 let parsed = PitchforkToml::read(&path).unwrap();
2145 assert_eq!(parsed.settings.web.auto_start, Some(true));
2146 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2147 }
2148
2149 #[test]
2150 fn test_settings_preserved_on_unrelated_write() {
2151 let temp = tempfile::tempdir().unwrap();
2154 let path = temp.path().join("pitchfork.toml");
2155 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2156
2157 let mut pt = PitchforkToml::read(&path).unwrap();
2158 pt.slugs.insert(
2159 "api".to_string(),
2160 SlugEntry {
2161 dir: None,
2162 namespace: Some("myproject".to_string()),
2163 daemon: None,
2164 },
2165 );
2166 pt.namespaces.insert(
2167 "myproject".to_string(),
2168 NamespaceEntry {
2169 dir: PathBuf::from("/tmp/myproject"),
2170 config: Vec::new(),
2171 },
2172 );
2173 pt.write().unwrap();
2174
2175 let raw = std::fs::read_to_string(&path).unwrap();
2176 assert!(
2177 raw.contains("[settings.web]"),
2178 "existing settings must be preserved, got:\n{raw}"
2179 );
2180 assert!(raw.contains("auto_start = true"));
2181 assert!(raw.contains("[slugs.api]"));
2182
2183 let parsed = PitchforkToml::read(&path).unwrap();
2184 assert_eq!(parsed.settings.web.auto_start, Some(true));
2185 assert!(parsed.slugs.contains_key("api"));
2186 }
2187
2188 #[tokio::test]
2189 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2190 let temp = tempfile::tempdir().unwrap();
2191 let path = temp.path().join("pitchfork.toml");
2192 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2193 .await
2194 .unwrap();
2195
2196 let read_path = path.clone();
2197 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2198 .await
2199 .unwrap()
2200 .unwrap();
2201 assert_eq!(pt.settings.general.worktree, Some(false));
2202 assert_eq!(pt.settings.proxy.worktree, None);
2203 tokio::task::spawn_blocking(move || pt.write())
2204 .await
2205 .unwrap()
2206 .unwrap();
2207
2208 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2209 assert!(raw.contains("[settings.general]"), "{raw}");
2210 assert!(raw.contains("worktree = false"), "{raw}");
2211 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2212
2213 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2214 .await
2215 .unwrap()
2216 .unwrap();
2217 assert_eq!(parsed.settings.general.worktree, Some(false));
2218 }
2219
2220 #[test]
2221 fn test_config_cache_hit_and_invalidation() {
2222 let temp = tempfile::tempdir().unwrap();
2223 let dir = temp.path();
2224 let config_path = dir.join("pitchfork.toml");
2225 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2226
2227 super::invalidate_config_cache();
2229
2230 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2232 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2233 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2234
2235 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2237 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2238
2239 std::thread::sleep(std::time::Duration::from_millis(50));
2242 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2243
2244 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2245 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2246
2247 super::invalidate_config_cache();
2249 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2250 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2251
2252 super::invalidate_config_cache();
2254 }
2255
2256 #[test]
2257 fn test_config_cache_invalidation_on_write() {
2258 let temp = tempfile::tempdir().unwrap();
2259 let dir = temp.path();
2260 let config_path = dir.join("pitchfork.toml");
2261 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2262
2263 super::invalidate_config_cache();
2264
2265 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2267 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2268 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2269
2270 let mut pt = PitchforkToml::read(&config_path).unwrap();
2272 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2273 let _ = pt.write();
2275
2276 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2278 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2279
2280 super::invalidate_config_cache();
2281 }
2282
2283 #[test]
2284 fn test_config_cache_size_invalidation() {
2285 let temp = tempfile::tempdir().unwrap();
2286 let dir = temp.path();
2287 let config_path = dir.join("pitchfork.toml");
2288 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2289
2290 super::invalidate_config_cache();
2291
2292 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2294 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2295 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2296
2297 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2301 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2302 let file = std::fs::OpenOptions::new()
2305 .write(true)
2306 .open(&config_path)
2307 .unwrap();
2308 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2309 file.set_times(times).unwrap();
2310
2311 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2314 assert_eq!(
2315 pt2.daemons[&daemon_id].run, "echo different",
2316 "cache should invalidate on size change even with identical mtime"
2317 );
2318
2319 super::invalidate_config_cache();
2320 }
2321
2322 #[test]
2323 fn test_find_project_root_in_plain_dir_returns_none() {
2324 let temp = tempfile::tempdir().unwrap();
2325 assert_eq!(find_project_root(temp.path()), None);
2326 }
2327
2328 #[test]
2329 fn test_find_project_root_finds_git_marker() {
2330 let temp = tempfile::tempdir().unwrap();
2331 let repo = temp.path().join("my-repo");
2332 std::fs::create_dir(&repo).unwrap();
2333 std::fs::create_dir(repo.join(".git")).unwrap();
2334
2335 let sub = repo.join("sub/dir");
2336 std::fs::create_dir_all(&sub).unwrap();
2337
2338 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2341 }
2342
2343 #[test]
2344 fn test_find_project_root_accepts_git_file_marker() {
2345 let temp = tempfile::tempdir().unwrap();
2348 let wt = temp.path().join("my-worktree");
2349 std::fs::create_dir(&wt).unwrap();
2350 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2351
2352 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2353 }
2354
2355 #[cfg(unix)]
2358 #[test]
2359 fn test_find_project_root_resolves_symlinked_start_dir() {
2360 use std::os::unix::fs::symlink;
2361
2362 let temp = tempfile::tempdir().unwrap();
2363 let repo = temp.path().join("real-repo");
2364 std::fs::create_dir(&repo).unwrap();
2365 std::fs::create_dir(repo.join(".git")).unwrap();
2366
2367 let sub = repo.join("sub/dir");
2368 std::fs::create_dir_all(&sub).unwrap();
2369 let link = temp.path().join("link-to-sub");
2370 symlink(&sub, &link).unwrap();
2371
2372 assert_eq!(find_project_root(&link), Some(repo));
2373 }
2374
2375 #[test]
2378 fn test_all_merged_all_namespaces_discovers_worktrees() {
2379 let temp = tempfile::tempdir().unwrap();
2380 let repo = temp.path().join("my-repo");
2381 std::fs::create_dir(&repo).unwrap();
2382
2383 let git_init = std::process::Command::new("git")
2385 .args(["init", "-b", "main"])
2386 .current_dir(&repo)
2387 .output()
2388 .expect("git init");
2389 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2390
2391 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2392
2393 let git_commit = std::process::Command::new("git")
2394 .args([
2395 "-c",
2396 "user.name=pitchfork-test",
2397 "-c",
2398 "user.email=pitchfork-test@example.com",
2399 "add",
2400 "-A",
2401 ])
2402 .current_dir(&repo)
2403 .output()
2404 .expect("git add");
2405 assert!(git_commit.status.success());
2406
2407 let git_commit = std::process::Command::new("git")
2408 .args([
2409 "-c",
2410 "user.name=pitchfork-test",
2411 "-c",
2412 "user.email=pitchfork-test@example.com",
2413 "commit",
2414 "-m",
2415 "init",
2416 ])
2417 .current_dir(&repo)
2418 .output()
2419 .expect("git commit");
2420 assert!(
2421 git_commit.status.success(),
2422 "git commit failed: {:?}",
2423 git_commit
2424 );
2425
2426 let wt = temp.path().join("my-repo-feature");
2427 let git_wt = std::process::Command::new("git")
2428 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2429 .current_dir(&repo)
2430 .output()
2431 .expect("git worktree add");
2432 assert!(
2433 git_wt.status.success(),
2434 "git worktree add failed: {:?}",
2435 git_wt
2436 );
2437
2438 std::fs::write(
2440 repo.join("pitchfork.toml"),
2441 "[daemons.api]\nrun = \"echo main\"\n",
2442 )
2443 .unwrap();
2444 std::fs::write(
2446 wt.join("pitchfork.toml"),
2447 "[daemons.worker]\nrun = \"echo wt\"\n",
2448 )
2449 .unwrap();
2450
2451 super::invalidate_config_cache();
2452
2453 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2455
2456 let main_id = DaemonId::new("my-repo", "api");
2457 let wt_id = DaemonId::new("my-repo-feature", "worker");
2458 assert!(
2459 pt.daemons.contains_key(&main_id),
2460 "main checkout daemon missing"
2461 );
2462 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2463
2464 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2466 assert!(pt_from_main.daemons.contains_key(&wt_id));
2467
2468 let _ = std::process::Command::new("git")
2470 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2471 .current_dir(&repo)
2472 .output();
2473 super::invalidate_config_cache();
2474 }
2475
2476 #[test]
2477 fn test_adhoc_id_uses_invocation_directory_namespace() {
2478 let temp = tempfile::tempdir().unwrap();
2479 let project = temp.path().join("feature-tree");
2480 std::fs::create_dir(&project).unwrap();
2481 std::fs::write(
2482 project.join("pitchfork.toml"),
2483 "[daemons.other]\nrun = \"true\"\n",
2484 )
2485 .unwrap();
2486
2487 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
2488 assert_eq!(id, DaemonId::new("feature-tree", "api"));
2489 let qualified =
2490 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
2491 assert_eq!(qualified, DaemonId::new("explicit", "api"));
2492 }
2493
2494 #[test]
2495 fn test_adhoc_id_falls_back_to_global_without_project_config() {
2496 let temp = tempfile::tempdir().unwrap();
2497 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
2498 assert_eq!(id, DaemonId::new("global", "api"));
2499 }
2500}