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, ProxyConfig,
20 ProxyIdleTimeout, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig,
21 StopSignal, WatchMode,
22};
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
32pub struct SlugEntryRaw {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub dir: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub namespace: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub daemon: Option<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct SlugEntry {
47 pub dir: Option<PathBuf>,
49 pub namespace: Option<String>,
51 pub daemon: Option<String>,
53}
54
55impl SlugEntry {
56 pub fn resolve_dir(&self) -> Option<PathBuf> {
59 self.dir.clone().or_else(|| {
60 self.namespace.as_ref().and_then(|ns| {
61 let namespaces = PitchforkToml::read_global_namespaces();
62 namespaces.get(ns).map(|entry| entry.dir.clone())
63 })
64 })
65 }
66
67 pub fn resolve_namespace(&self) -> Option<String> {
70 self.namespace.clone().or_else(|| {
71 self.resolve_dir()
72 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
73 })
74 }
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
83pub struct GroupEntryRaw {
84 #[schemars(with = "Vec<DaemonId>")]
85 pub daemons: Vec<String>,
86}
87
88#[derive(Debug, Clone)]
90pub struct GroupEntry {
91 pub daemons: Vec<DaemonId>,
92}
93
94#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
100pub struct NamespaceEntryRaw {
101 pub dir: String,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub config: Vec<String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct NamespaceEntry {
111 pub dir: PathBuf,
113 pub config: Vec<PathBuf>,
114}
115
116#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
118struct PitchforkTomlRaw {
119 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub namespace: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub worktree_label: Option<String>,
124 #[serde(default)]
125 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
129 pub env: Option<IndexMap<String, String>>,
130 #[serde(default)]
131 pub settings: Option<SettingsPartial>,
132 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
135 pub slugs: IndexMap<String, SlugEntryRaw>,
136 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
138 pub groups: IndexMap<String, GroupEntryRaw>,
139 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
142 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
143}
144
145#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
150pub struct PitchforkTomlDaemonLogs {
151 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub log_format: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub time_retention: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub line_retention: Option<i64>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub archive_hook: Option<String>,
164}
165
166#[derive(Debug, serde::Serialize, serde::Deserialize)]
173struct PitchforkTomlDaemonRaw {
174 pub run: RunCommand,
175 #[serde(skip_serializing_if = "Vec::is_empty", default)]
176 pub auto: Vec<PitchforkTomlAuto>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 pub oneshot: Option<bool>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub cron: Option<PitchforkTomlCron>,
181 #[serde(default)]
182 pub retry: Retry,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
184 pub ready_delay: Option<u64>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub ready_output: Option<ReadyOutput>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub ready_http: Option<ReadyHttp>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
190 pub ready_port: Option<ReadyPort>,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
192 pub ready_cmd: Option<ReadyCmd>,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub health_cmd: Option<HealthCmd>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub health_http: Option<HealthHttp>,
197 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub health_port: Option<HealthPort>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub port: Option<PortConfig>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub proxy: Option<ProxyConfig>,
205 #[serde(skip_serializing_if = "Vec::is_empty", default)]
207 pub expected_port: Vec<u16>,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub auto_bump_port: Option<bool>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub port_bump_attempts: Option<u32>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub proxy_tls: Option<ProxyTlsMode>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
219 pub proxy_tls_port: Option<u16>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
223 pub proxy_port: Option<u16>,
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub boot_start: Option<bool>,
229 #[serde(skip_serializing_if = "Vec::is_empty", default)]
230 pub depends: Vec<String>,
231 #[serde(skip_serializing_if = "Vec::is_empty", default)]
232 pub watch: Vec<String>,
233 #[serde(skip_serializing_if = "Option::is_none", default)]
234 pub watch_mode: Option<WatchMode>,
235 #[serde(skip_serializing_if = "Option::is_none", default)]
236 pub dir: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none", default)]
238 pub env: Option<IndexMap<String, String>>,
239 #[serde(skip_serializing_if = "Option::is_none", default)]
240 pub hooks: Option<PitchforkTomlHooks>,
241 #[serde(skip_serializing_if = "Option::is_none", default)]
242 pub mise: Option<bool>,
243 #[serde(skip_serializing_if = "Option::is_none", default)]
245 pub user: Option<String>,
246 #[serde(skip_serializing_if = "Option::is_none", default)]
248 pub memory_limit: Option<MemoryLimit>,
249 #[serde(skip_serializing_if = "Option::is_none", default)]
251 pub cpu_limit: Option<CpuLimit>,
252 #[serde(skip_serializing_if = "Option::is_none", default)]
254 pub stop_signal: Option<StopConfig>,
255 #[serde(skip_serializing_if = "Option::is_none", default)]
257 pub pty: Option<bool>,
258 #[serde(skip_serializing_if = "Option::is_none", default)]
261 pub time_retention: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none", default)]
265 pub line_retention: Option<i64>,
266 #[serde(skip_serializing_if = "Option::is_none", default)]
269 pub archive_hook: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none", default)]
272 pub logs: Option<PitchforkTomlDaemonLogs>,
273}
274
275#[derive(Debug, Clone, Default, JsonSchema)]
280#[schemars(title = "Pitchfork Configuration")]
281pub struct PitchforkToml {
282 #[serde(default)]
284 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
285 #[serde(skip_serializing_if = "Option::is_none", default)]
289 pub env: Option<IndexMap<String, String>>,
290 pub namespace: Option<String>,
295 #[schemars(default, with = "Option<String>")]
299 pub worktree_label: Option<String>,
300 #[serde(default)]
309 pub(crate) settings: SettingsPartial,
310 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
315 pub slugs: IndexMap<String, SlugEntry>,
316 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
318 pub groups: IndexMap<String, GroupEntry>,
319 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
322 pub namespaces: IndexMap<String, NamespaceEntry>,
323 #[schemars(skip)]
324 pub path: Option<PathBuf>,
325 #[schemars(skip)]
333 pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
334}
335
336impl PitchforkToml {
337 pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
340 self.foreign_env
341 .get(id)
342 .map_or(self.env.as_ref(), |env| env.as_ref())
343 }
344}
345
346pub fn is_global_config(path: &Path) -> bool {
347 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
348}
349
350pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
351 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
352}
353
354fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
355 use toml::Value;
356
357 let doc: Value = toml::from_str(content)
358 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
359 let Some(value) = doc.get("namespace") else {
360 return Ok(None);
361 };
362
363 match value {
364 Value::String(s) => Ok(Some(s.clone())),
365 _ => Err(ConfigParseError::InvalidNamespace {
366 path: path.to_path_buf(),
367 namespace: value.to_string(),
368 reason: "top-level 'namespace' must be a string".to_string(),
369 }
370 .into()),
371 }
372}
373
374fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
375 if !path.exists() {
376 return Ok(None);
377 }
378 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
379 path: path.to_path_buf(),
380 source: e,
381 })?;
382 parse_namespace_override_from_content(path, &content)
383}
384
385pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
386 crate::extra_configs::project_dir(path).or_else(|| {
387 if is_dot_config_pitchfork(path) {
388 path.parent().and_then(Path::parent).map(Path::to_path_buf)
389 } else {
390 path.parent().map(Path::to_path_buf)
391 }
392 })
393}
394
395fn project_config_family(path: &Path) -> Vec<PathBuf> {
396 let Some(dir) = project_dir_for_config(path) else {
397 return vec![path.to_path_buf()];
398 };
399 vec![
400 dir.join(".config/pitchfork.toml"),
401 dir.join(".config/pitchfork.local.toml"),
402 dir.join("pitchfork.toml"),
403 dir.join("pitchfork.local.toml"),
404 ]
405}
406
407fn directory_namespace_override(
410 path: &Path,
411 content_override: Option<&str>,
412) -> Result<Option<String>> {
413 if is_global_config(path) {
414 return match content_override {
415 Some(content) => parse_namespace_override_from_content(path, content),
416 None => read_namespace_override_from_file(path),
417 };
418 }
419
420 let mut selected: Option<(String, PathBuf)> = None;
421 for candidate in project_config_family(path) {
422 let explicit = if candidate == path {
423 match content_override {
424 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
425 None => read_namespace_override_from_file(&candidate)?,
426 }
427 } else {
428 read_namespace_override_from_file(&candidate)?
429 };
430 let Some(namespace) = explicit else { continue };
431 if let Some((selected_namespace, selected_path)) = &selected
432 && selected_namespace != &namespace
433 {
434 return Err(ConfigParseError::InvalidNamespace {
435 path: candidate,
436 namespace,
437 reason: format!(
438 "namespace does not match directory-level namespace '{}' declared in {}",
439 selected_namespace,
440 selected_path.display()
441 ),
442 }
443 .into());
444 }
445 selected = Some((namespace, candidate));
446 }
447 Ok(selected.map(|(namespace, _)| namespace))
448}
449
450fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
451 if let Err(e) = DaemonId::try_new(namespace, "probe") {
452 return Err(ConfigParseError::InvalidNamespace {
453 path: path.to_path_buf(),
454 namespace: namespace.to_string(),
455 reason: e.to_string(),
456 }
457 .into());
458 }
459 Ok(namespace.to_string())
460}
461
462fn derive_namespace_from_dir(path: &Path) -> Result<String> {
463 let dir_for_namespace = project_dir_for_config(path);
464 if let Some(namespace) = dir_for_namespace
465 .as_deref()
466 .and_then(crate::extra_configs::namespace_for_dir)
467 {
468 return validate_namespace(path, &namespace);
469 }
470 let raw_namespace = dir_for_namespace
471 .as_deref()
472 .and_then(|p| p.file_name())
473 .and_then(|n| n.to_str())
474 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
475 .to_string();
476
477 validate_namespace(path, &raw_namespace).map_err(|e| {
478 ConfigParseError::InvalidNamespace {
479 path: path.to_path_buf(),
480 namespace: raw_namespace,
481 reason: format!(
482 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
483 ),
484 }
485 .into()
486 })
487}
488
489fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
490 if is_global_config(path) {
491 if let Some(ns) = explicit
492 && ns != "global"
493 {
494 return Err(ConfigParseError::InvalidNamespace {
495 path: path.to_path_buf(),
496 namespace: ns.to_string(),
497 reason: "global config files must use namespace 'global'".to_string(),
498 }
499 .into());
500 }
501 return Ok("global".to_string());
502 }
503
504 if let Some(ns) = explicit {
505 return validate_namespace(path, ns);
506 }
507
508 derive_namespace_from_dir(path)
509}
510
511fn namespace_from_file(path: &Path) -> Result<String> {
512 let explicit = directory_namespace_override(path, None)?;
513 namespace_from_path_with_override(path, explicit.as_deref())
514}
515
516pub fn namespace_from_path(path: &Path) -> Result<String> {
529 namespace_from_file(path)
530}
531
532fn find_project_root(dir: &Path) -> Option<PathBuf> {
540 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
544 let mut current = canonical_dir.as_path();
545 loop {
546 if current.join(".git").exists() || current.join(".jj").exists() {
547 return Some(current.to_path_buf());
548 }
549 current = current.parent()?;
550 }
551}
552
553struct ConfigCacheEntry {
572 config: PitchforkToml,
573 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
575}
576
577static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
584 Lazy::new(|| StdMutex::new(HashMap::new()));
585
586fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
592 if paths.len() != snapshot.len() {
593 return false;
594 }
595 paths
596 .iter()
597 .zip(snapshot.iter())
598 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
599}
600
601pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
603 let md = std::fs::metadata(path).ok()?;
604 Some((md.modified().ok()?, md.len()))
605}
606
607fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
609 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
610}
611
612pub fn invalidate_config_cache() {
629 crate::extra_configs::invalidate();
630 crate::web::routes::api::projects::invalidate_group_cache();
633 if let Ok(mut cache) = CONFIG_CACHE.lock() {
634 cache.clear();
635 }
636}
637
638impl PitchforkToml {
639 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
652 if user_id.contains('/') {
654 return match DaemonId::parse(user_id) {
655 Ok(id) => Ok(vec![id]),
656 Err(e) => Err(e), };
658 }
659
660 let global_slugs = Self::read_global_slugs();
662 if let Some(entry) = global_slugs.get(user_id) {
663 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
665 if let Some(dir) = entry.resolve_dir()
666 && let Ok(project_config) = Self::all_merged_from(&dir)
667 {
668 let matches: Vec<DaemonId> = project_config
670 .daemons
671 .keys()
672 .filter(|id| id.name() == daemon_name)
673 .cloned()
674 .collect();
675 match matches.as_slice() {
676 [] => {}
677 [id] => return Ok(vec![id.clone()]),
678 _ => {
679 let mut candidates: Vec<String> =
680 matches.iter().map(|id| id.qualified()).collect();
681 candidates.sort();
682 return Err(miette::miette!(
683 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
684 user_id,
685 daemon_name,
686 candidates.join(", ")
687 ));
688 }
689 }
690 }
691 }
692
693 let matches: Vec<DaemonId> = self
695 .daemons
696 .keys()
697 .filter(|id| id.name() == user_id)
698 .cloned()
699 .collect();
700
701 if matches.is_empty() {
702 let state_matches = Self::find_in_state_file(user_id);
704 match state_matches.as_slice() {
705 [] => {}
706 [id] => return Ok(vec![id.clone()]),
707 _ => {
708 let mut candidates: Vec<String> =
709 state_matches.iter().map(|id| id.qualified()).collect();
710 candidates.sort();
711 return Err(miette::miette!(
712 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
713 user_id,
714 candidates.join(", ")
715 ));
716 }
717 }
718 let _ = DaemonId::try_new("global", user_id)?;
720 }
721 Ok(matches)
722 }
723
724 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
730 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
731 Ok(state) => state
732 .daemons
733 .keys()
734 .filter(|id| id.name() == short_name)
735 .cloned()
736 .collect(),
737 Err(e) => {
738 warn!("cannot read state file: {e}");
739 Vec::new()
740 }
741 }
742 }
743
744 #[allow(dead_code)]
765 pub fn resolve_daemon_id_prefer_local(
766 &self,
767 user_id: &str,
768 current_dir: &Path,
769 ) -> Result<DaemonId> {
770 if user_id.contains('/') {
772 return DaemonId::parse(user_id);
773 }
774
775 let current_namespace = Self::namespace_for_dir(current_dir)?;
779
780 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
781 }
782
783 fn resolve_daemon_id_with_namespace(
786 &self,
787 user_id: &str,
788 current_namespace: &str,
789 ) -> Result<DaemonId> {
790 let global_slugs = Self::read_global_slugs();
792 if let Some(entry) = global_slugs.get(user_id) {
793 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
794 if let Some(dir) = entry.resolve_dir()
795 && let Ok(project_config) = Self::all_merged_from(&dir)
796 {
797 let matches: Vec<DaemonId> = project_config
798 .daemons
799 .keys()
800 .filter(|id| id.name() == daemon_name)
801 .cloned()
802 .collect();
803 match matches.as_slice() {
804 [] => {}
805 [id] => return Ok(id.clone()),
806 _ => {
807 let mut candidates: Vec<String> =
808 matches.iter().map(|id| id.qualified()).collect();
809 candidates.sort();
810 return Err(miette::miette!(
811 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
812 user_id,
813 daemon_name,
814 candidates.join(", ")
815 ));
816 }
817 }
818 }
819 }
820
821 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
824 if self.daemons.contains_key(&preferred_id) {
825 return Ok(preferred_id);
826 }
827
828 let matches = self.resolve_daemon_id(user_id)?;
830
831 if matches.len() > 1 {
833 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
834 candidates.sort();
835 return Err(miette::miette!(
836 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
837 user_id,
838 candidates.join(", ")
839 ));
840 }
841
842 if let Some(id) = matches.into_iter().next() {
843 return Ok(id);
844 }
845
846 let global_id = DaemonId::try_new("global", user_id)?;
849 if self.daemons.contains_key(&global_id) {
850 return Ok(global_id);
851 }
852
853 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
854 Err(DependencyError::DaemonNotFound {
855 name: user_id.to_string(),
856 suggestion,
857 }
858 .into())
859 }
860
861 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
863 namespace_from_path(&dir.join("pitchfork.toml"))
864 }
865
866 pub fn project_worktree_label(dir: &Path) -> Option<String> {
874 let mut label = None;
875 let candidates = project_config_family(&dir.join("pitchfork.toml"))
876 .into_iter()
877 .chain(crate::extra_configs::configs_for_dir(dir));
878 for candidate in candidates {
879 if !candidate.exists() {
880 continue;
881 }
882 if let Ok(pt) = Self::read(&candidate)
883 && let Some(found) = pt.worktree_label
884 {
885 label = Some(found);
886 }
887 }
888 label
889 }
890
891 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
893 directory_namespace_override(&dir.join("pitchfork.toml"), None)
894 }
895
896 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
898 Ok(Self::list_paths_from(dir)
899 .iter()
900 .filter(|p| p.exists())
901 .max_by_key(|p| {
902 if is_global_config(p) {
903 0
904 } else {
905 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
906 }
907 })
908 .map(|p| namespace_from_path(p))
909 .transpose()?
910 .unwrap_or_else(|| "global".to_string()))
911 }
912
913 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
923 if user_id.contains('/') {
924 return DaemonId::parse(user_id);
925 }
926
927 let config = Self::all_merged()?;
930 let ns = Self::namespace_for_dir(&env::CWD)?;
931 config.resolve_daemon_id_with_namespace(user_id, &ns)
932 }
933
934 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
940 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
941 }
942
943 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
944 if user_id.contains('/') {
945 return DaemonId::parse(user_id);
946 }
947
948 let ns = Self::namespace_for_dir(dir)?;
949 DaemonId::try_new(ns, user_id)
950 }
951
952 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
963 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
965 return user_ids
966 .iter()
967 .map(|s| DaemonId::parse(s.as_ref()))
968 .collect();
969 }
970
971 let config = Self::all_merged()?;
972 let ns = Self::namespace_for_dir(&env::CWD)?;
974 user_ids
975 .iter()
976 .map(|s| {
977 let id = s.as_ref();
978 if id.contains('/') {
979 DaemonId::parse(id)
980 } else {
981 config.resolve_daemon_id_with_namespace(id, &ns)
982 }
983 })
984 .collect()
985 }
986
987 pub fn resolve_ids_and_group<S: AsRef<str>>(
992 user_ids: &[S],
993 group_name: Option<&str>,
994 ) -> Result<Vec<DaemonId>> {
995 let config = Self::all_merged()?;
996 let ns = Self::namespace_for_dir(&env::CWD)?;
997 let mut ids = Vec::new();
998 let mut seen = std::collections::HashSet::new();
999
1000 for id in user_ids {
1001 let id_str = id.as_ref();
1002 let daemon_id = if id_str.contains('/') {
1003 DaemonId::parse(id_str)?
1004 } else {
1005 config.resolve_daemon_id_with_namespace(id_str, &ns)?
1006 };
1007 if seen.insert(daemon_id.clone()) {
1008 ids.push(daemon_id);
1009 }
1010 }
1011
1012 if let Some(name) = group_name {
1013 match config.groups.get(name) {
1014 Some(group) => {
1015 let missing: Vec<String> = group
1016 .daemons
1017 .iter()
1018 .filter(|id| !config.daemons.contains_key(*id))
1019 .map(|id| id.qualified())
1020 .collect();
1021 if !missing.is_empty() {
1022 return Err(miette::miette!(
1023 "group '{}' references undefined daemon{}: {}",
1024 name,
1025 if missing.len() > 1 { "s" } else { "" },
1026 missing.join(", ")
1027 ));
1028 }
1029 for daemon_id in &group.daemons {
1030 if seen.insert(daemon_id.clone()) {
1031 ids.push(daemon_id.clone());
1032 }
1033 }
1034 }
1035 None => {
1036 let suggestion =
1037 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
1038 return Err(miette::miette!(
1039 "group '{}' not found in configuration{}",
1040 name,
1041 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
1042 ));
1043 }
1044 }
1045 }
1046
1047 Ok(ids)
1048 }
1049
1050 pub fn list_paths() -> Vec<PathBuf> {
1053 Self::list_paths_from(&env::CWD)
1054 }
1055
1056 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1067 let mut paths = Vec::new();
1068 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1069 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1070
1071 let mut project_paths = xx::file::find_up_all(
1075 cwd,
1076 &[
1077 "pitchfork.local.toml",
1078 "pitchfork.toml",
1079 ".config/pitchfork.local.toml",
1080 ".config/pitchfork.toml",
1081 ],
1082 );
1083 project_paths.reverse();
1084 paths.extend(project_paths);
1085 paths.extend(crate::extra_configs::paths_for(cwd));
1086
1087 paths
1088 }
1089
1090 pub fn all_merged() -> Result<PitchforkToml> {
1093 Self::all_merged_from(&env::CWD)
1094 }
1095 pub fn all_merged_all_namespaces() -> Result<Self> {
1102 Self::all_merged_all_namespaces_from(&env::CWD)
1103 }
1104
1105 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1108 let mut pt = Self::all_merged_from(start_dir)?;
1109
1110 let namespaces = Self::read_global_namespaces();
1111 for (ns_name, entry) in namespaces {
1112 match Self::all_merged_from(&entry.dir) {
1113 Ok(ns_config) => {
1114 for (daemon_id, daemon_config) in ns_config.daemons {
1115 if !pt.daemons.contains_key(&daemon_id) {
1116 pt.foreign_env
1117 .insert(daemon_id.clone(), ns_config.env.clone());
1118 pt.daemons.insert(daemon_id, daemon_config);
1119 }
1120 }
1121 pt.settings.merge_from(&ns_config.settings);
1124 }
1125 Err(e) => {
1126 log::warn!(
1127 "Failed to load namespace '{ns_name}' from {}: {e}",
1128 entry.dir.display()
1129 );
1130 }
1131 }
1132 }
1133
1134 if crate::settings::settings().general.worktree
1144 && let Some(project_root) = find_project_root(start_dir)
1145 {
1146 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1147 for wt in &worktrees {
1148 match Self::all_merged_from(&wt.path) {
1149 Ok(wt_config) => {
1150 for (daemon_id, daemon_config) in wt_config.daemons {
1151 if !pt.daemons.contains_key(&daemon_id) {
1152 pt.daemons.insert(daemon_id, daemon_config);
1153 }
1154 }
1155 pt.settings.merge_from(&wt_config.settings);
1156 }
1157 Err(e) => {
1158 log::warn!(
1159 "Failed to load worktree '{}' config from {}: {e}",
1160 wt.branch,
1161 wt.path.display()
1162 );
1163 }
1164 }
1165 }
1166 }
1167
1168 Ok(pt)
1169 }
1170
1171 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1189 let paths = Self::list_paths_from(cwd);
1190
1191 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1195
1196 {
1197 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1198 if let Some(entry) = cache.get(&cache_key)
1199 && meta_matches(&paths, &entry.source_meta)
1200 {
1201 return Ok(entry.config.clone());
1202 }
1203 }
1204
1205 let snapshot = snapshot_meta(&paths);
1211 let pt = Self::all_merged_from_uncached(&paths)?;
1212
1213 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1215 cache.insert(
1216 cache_key,
1217 ConfigCacheEntry {
1218 config: pt.clone(),
1219 source_meta: snapshot,
1220 },
1221 );
1222
1223 Ok(pt)
1224 }
1225
1226 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1231 use std::collections::HashMap as StdHashMap;
1232
1233 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1234
1235 let mut pt = Self::default();
1236 for p in paths {
1237 match Self::read(p) {
1238 Ok(pt2) => {
1239 if p.exists() && !is_global_config(p) {
1243 let ns = namespace_from_path(p)?;
1244 let origin_dir = project_dir_for_config(p)
1245 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1246 .unwrap_or_else(|| p.clone());
1247
1248 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1249 && *other_dir != origin_dir
1250 {
1251 return Err(crate::error::ConfigParseError::NamespaceCollision {
1252 path_a: other_path.clone(),
1253 path_b: p.clone(),
1254 ns,
1255 }
1256 .into());
1257 }
1258 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1259 }
1260
1261 pt.merge(pt2)
1262 }
1263 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1264 }
1265 }
1266 Ok(pt)
1267 }
1268}
1269
1270impl PitchforkToml {
1271 pub fn new(path: PathBuf) -> Self {
1272 Self {
1273 daemons: Default::default(),
1274 env: None,
1275 namespace: None,
1276 worktree_label: None,
1277 settings: SettingsPartial::default(),
1278 slugs: IndexMap::new(),
1279 groups: IndexMap::new(),
1280 namespaces: IndexMap::new(),
1281 path: Some(path),
1282 foreign_env: IndexMap::new(),
1283 }
1284 }
1285
1286 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1294 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1295 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1296 if let Some(settings) = &mut raw_config.settings {
1297 settings.canonicalize_aliases();
1298 }
1299
1300 let explicit = directory_namespace_override(path, Some(content))?;
1301 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1302 let mut pt = Self::new(path.to_path_buf());
1303 pt.namespace = raw_config.namespace.clone();
1304 pt.worktree_label = raw_config.worktree_label.clone();
1305
1306 for (short_name, raw_daemon) in raw_config.daemons {
1307 let id = match DaemonId::try_new(&namespace, &short_name) {
1308 Ok(id) => id,
1309 Err(e) => {
1310 return Err(ConfigParseError::InvalidDaemonName {
1311 name: short_name,
1312 path: path.to_path_buf(),
1313 reason: e.to_string(),
1314 }
1315 .into());
1316 }
1317 };
1318
1319 let mut depends = Vec::new();
1320 for dep in raw_daemon.depends {
1321 let dep_id = if dep.contains('/') {
1322 match DaemonId::parse(&dep) {
1323 Ok(id) => id,
1324 Err(e) => {
1325 return Err(ConfigParseError::InvalidDependency {
1326 daemon: short_name.clone(),
1327 dependency: dep,
1328 path: path.to_path_buf(),
1329 reason: e.to_string(),
1330 }
1331 .into());
1332 }
1333 }
1334 } else {
1335 match DaemonId::try_new(&namespace, &dep) {
1336 Ok(id) => id,
1337 Err(e) => {
1338 return Err(ConfigParseError::InvalidDependency {
1339 daemon: short_name.clone(),
1340 dependency: dep,
1341 path: path.to_path_buf(),
1342 reason: e.to_string(),
1343 }
1344 .into());
1345 }
1346 }
1347 };
1348 depends.push(dep_id);
1349 }
1350
1351 let has_deprecated = !raw_daemon.expected_port.is_empty()
1353 || raw_daemon.auto_bump_port.is_some()
1354 || raw_daemon.port_bump_attempts.is_some();
1355 let port = if let Some(port) = raw_daemon.port {
1356 if has_deprecated {
1357 warn!(
1358 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1359 );
1360 }
1361 Some(port)
1362 } else if has_deprecated {
1363 warn!(
1364 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1365 );
1366 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1367 PortBump(
1368 raw_daemon
1369 .port_bump_attempts
1370 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1371 )
1372 } else {
1373 PortBump(0)
1374 };
1375 Some(PortConfig {
1376 expect: raw_daemon.expected_port,
1377 bump,
1378 })
1379 } else {
1380 None
1381 };
1382
1383 if let (Some(explicit), Some(short)) =
1385 (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1386 && explicit != short
1387 {
1388 warn!(
1389 "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1390 disagree; using proxy_tls_port"
1391 );
1392 }
1393 let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1394
1395 for (key, value) in [
1398 ("proxy_tls_port", raw_daemon.proxy_tls_port),
1399 ("proxy_port", raw_daemon.proxy_port),
1400 ] {
1401 if value == Some(0) {
1402 return Err(ConfigParseError::ProxyPortZero {
1403 daemon: short_name.clone(),
1404 key,
1405 path: path.to_path_buf(),
1406 }
1407 .into());
1408 }
1409 }
1410
1411 if let Some(port_want) = proxy_tls_port {
1414 let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1415 if !declared.contains(&port_want) {
1416 return Err(ConfigParseError::ProxyPortNotDeclared {
1417 daemon: short_name.clone(),
1418 port: port_want,
1419 declared,
1420 path: path.to_path_buf(),
1421 }
1422 .into());
1423 }
1424 }
1425
1426 if raw_daemon
1429 .proxy_tls
1430 .is_some_and(ProxyTlsMode::is_passthrough)
1431 && port
1432 .as_ref()
1433 .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1434 {
1435 return Err(ConfigParseError::PassthroughWithoutPort {
1436 daemon: short_name.clone(),
1437 path: path.to_path_buf(),
1438 }
1439 .into());
1440 }
1441
1442 if let RunCommand::Argv(argv) = &raw_daemon.run {
1443 match argv.first().map(String::as_str) {
1444 None => {
1445 return Err(ConfigParseError::EmptyRunArgv {
1446 daemon: short_name.clone(),
1447 path: path.to_path_buf(),
1448 }
1449 .into());
1450 }
1451 Some("exec") => {
1454 return Err(ConfigParseError::ExecInRunArgv {
1455 daemon: short_name.clone(),
1456 path: path.to_path_buf(),
1457 }
1458 .into());
1459 }
1460 Some(_) => {}
1461 }
1462 }
1463
1464 let daemon = PitchforkTomlDaemon {
1465 run: raw_daemon.run,
1466 auto: raw_daemon.auto,
1467 oneshot: raw_daemon.oneshot,
1468 cron: raw_daemon.cron,
1469 retry: raw_daemon.retry,
1470 ready_delay: raw_daemon.ready_delay,
1471 ready_output: raw_daemon.ready_output,
1472 ready_http: raw_daemon.ready_http,
1473 ready_port: raw_daemon.ready_port,
1474 ready_cmd: raw_daemon.ready_cmd,
1475 health_cmd: raw_daemon.health_cmd,
1476 health_http: raw_daemon.health_http,
1477 health_port: raw_daemon.health_port,
1478 port,
1479 proxy: raw_daemon.proxy,
1480 proxy_tls: raw_daemon.proxy_tls,
1481 proxy_tls_port: raw_daemon.proxy_tls_port,
1482 proxy_port: raw_daemon.proxy_port,
1483 proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1484 boot_start: raw_daemon.boot_start,
1485 depends,
1486 watch: raw_daemon.watch,
1487 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1488 dir: raw_daemon.dir,
1489 env: raw_daemon.env,
1490 hooks: raw_daemon.hooks,
1491 mise: raw_daemon.mise,
1492 user: raw_daemon.user,
1493 memory_limit: raw_daemon.memory_limit,
1494 cpu_limit: raw_daemon.cpu_limit,
1495 stop_signal: raw_daemon.stop_signal,
1496 pty: raw_daemon.pty,
1497 time_retention: raw_daemon.time_retention,
1498 line_retention: raw_daemon.line_retention,
1499 archive_hook: raw_daemon.archive_hook,
1500 logs: raw_daemon.logs,
1501 path: Some(path.to_path_buf()),
1502 };
1503 if daemon.is_oneshot() {
1504 let conflicts = daemon.oneshot_conflicts();
1505 if !conflicts.is_empty() {
1506 return Err(ConfigParseError::OneshotConflict {
1507 daemon: short_name.clone(),
1508 path: path.to_path_buf(),
1509 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1510 }
1511 .into());
1512 }
1513 }
1514 pt.daemons.insert(id, daemon);
1515 }
1516
1517 if let Some(settings) = raw_config.settings {
1519 pt.settings = settings;
1520 }
1521
1522 pt.env = raw_config.env;
1524
1525 for (slug, entry) in raw_config.slugs {
1527 pt.slugs.insert(
1528 slug,
1529 SlugEntry {
1530 dir: entry.dir.map(env::expand_tilde),
1531 namespace: entry.namespace,
1532 daemon: entry.daemon,
1533 },
1534 );
1535 }
1536
1537 for (name, entry) in raw_config.namespaces {
1539 pt.namespaces.insert(
1540 name,
1541 NamespaceEntry {
1542 config: entry
1543 .config
1544 .iter()
1545 .map(|p| {
1546 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1547 })
1548 .collect(),
1549 dir: env::expand_tilde(entry.dir),
1550 },
1551 );
1552 }
1553
1554 for (group_name, raw_group) in raw_config.groups {
1556 let mut daemons = Vec::new();
1557 for daemon_name in &raw_group.daemons {
1558 let id = if daemon_name.contains('/') {
1559 DaemonId::parse(daemon_name).map_err(|e| {
1560 ConfigParseError::InvalidDependency {
1561 daemon: group_name.clone(),
1562 dependency: daemon_name.clone(),
1563 path: path.to_path_buf(),
1564 reason: e.to_string(),
1565 }
1566 })?
1567 } else {
1568 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1569 ConfigParseError::InvalidDaemonName {
1570 name: daemon_name.clone(),
1571 path: path.to_path_buf(),
1572 reason: e.to_string(),
1573 }
1574 })?
1575 };
1576 daemons.push(id);
1577 }
1578 pt.groups.insert(group_name, GroupEntry { daemons });
1579 }
1580
1581 Ok(pt)
1582 }
1583
1584 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1585 let path = path.as_ref();
1586 if !path.exists() {
1587 return Ok(Self::new(path.to_path_buf()));
1588 }
1589 let _lock = xx::fslock::get(path, false)
1590 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1591 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1592 path: path.to_path_buf(),
1593 source: e,
1594 })?;
1595 Self::parse_str(&raw, path)
1596 }
1597
1598 pub fn write(&self) -> Result<()> {
1599 if let Some(path) = &self.path {
1600 let _lock = xx::fslock::get(path, false)
1601 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1602 self.write_unlocked()
1603 } else {
1604 Err(FileError::NoPath.into())
1605 }
1606 }
1607
1608 pub(crate) fn write_unlocked(&self) -> Result<()> {
1614 if let Some(path) = &self.path {
1615 let config_namespace = if path.exists() {
1617 namespace_from_path(path)?
1618 } else {
1619 namespace_from_path_with_override(path, self.namespace.as_deref())?
1620 };
1621
1622 let mut raw = PitchforkTomlRaw {
1626 namespace: self.namespace.clone(),
1627 worktree_label: self.worktree_label.clone(),
1628 env: self.env.clone(),
1629 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1630 ..PitchforkTomlRaw::default()
1631 };
1632 for (id, daemon) in &self.daemons {
1633 if id.namespace() != config_namespace {
1634 return Err(miette::miette!(
1635 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1636 id,
1637 path.display(),
1638 id.namespace(),
1639 config_namespace
1640 ));
1641 }
1642 let port = daemon.port.as_ref();
1643 let raw_daemon = PitchforkTomlDaemonRaw {
1644 run: daemon.run.clone(),
1645 auto: daemon.auto.clone(),
1646 oneshot: daemon.oneshot,
1647 cron: daemon.cron.clone(),
1648 retry: daemon.retry,
1649 ready_delay: daemon.ready_delay,
1650 ready_output: daemon.ready_output.clone(),
1651 ready_http: daemon.ready_http.clone(),
1652 ready_port: daemon.ready_port.clone(),
1653 ready_cmd: daemon.ready_cmd.clone(),
1654 health_cmd: daemon.health_cmd.clone(),
1655 health_http: daemon.health_http.clone(),
1656 health_port: daemon.health_port.clone(),
1657 port: port.cloned(),
1658 proxy: daemon.proxy.clone(),
1659 proxy_tls: daemon.proxy_tls,
1660 proxy_tls_port: daemon.proxy_tls_port,
1661 proxy_port: daemon.proxy_port,
1662 proxy_idle_timeout: daemon.proxy_idle_timeout,
1663 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1665 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1666 port_bump_attempts: port
1667 .filter(|p| p.auto_bump())
1668 .map(|p| p.max_bump_attempts()),
1669 boot_start: daemon.boot_start,
1670 depends: daemon
1673 .depends
1674 .iter()
1675 .map(|d| {
1676 if d.namespace() == config_namespace {
1677 d.name().to_string()
1678 } else {
1679 d.qualified()
1680 }
1681 })
1682 .collect(),
1683 watch: daemon.watch.clone(),
1684 watch_mode: match daemon.watch_mode {
1685 WatchMode::Native => None,
1686 mode => Some(mode),
1687 },
1688 dir: daemon.dir.clone(),
1689 env: daemon.env.clone(),
1690 hooks: daemon.hooks.clone(),
1691 mise: daemon.mise,
1692 user: daemon.user.clone(),
1693 memory_limit: daemon.memory_limit,
1694 cpu_limit: daemon.cpu_limit,
1695 stop_signal: daemon.stop_signal,
1696 pty: daemon.pty,
1697 time_retention: daemon.time_retention.clone(),
1698 line_retention: daemon.line_retention,
1699 archive_hook: daemon.archive_hook.clone(),
1700 logs: daemon.logs.clone(),
1701 };
1702 raw.daemons.insert(id.name().to_string(), raw_daemon);
1703 }
1704
1705 for (slug, entry) in &self.slugs {
1707 raw.slugs.insert(
1708 slug.clone(),
1709 SlugEntryRaw {
1710 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1711 namespace: entry.namespace.clone(),
1712 daemon: entry.daemon.clone(),
1713 },
1714 );
1715 }
1716
1717 for (name, group) in &self.groups {
1719 let raw_daemons: Vec<String> = group
1720 .daemons
1721 .iter()
1722 .map(|id| {
1723 if id.namespace() == config_namespace {
1724 id.name().to_string()
1725 } else {
1726 id.qualified()
1727 }
1728 })
1729 .collect();
1730 raw.groups.insert(
1731 name.clone(),
1732 GroupEntryRaw {
1733 daemons: raw_daemons,
1734 },
1735 );
1736 }
1737
1738 for (name, entry) in &self.namespaces {
1740 raw.namespaces.insert(
1741 name.clone(),
1742 NamespaceEntryRaw {
1743 dir: entry.dir.to_string_lossy().to_string(),
1744 config: entry
1745 .config
1746 .iter()
1747 .map(|p| p.to_string_lossy().into_owned())
1748 .collect(),
1749 },
1750 );
1751 }
1752
1753 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1754 path: path.clone(),
1755 source: e,
1756 })?;
1757 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1758 path: path.clone(),
1759 details: Some(e.to_string()),
1760 })?;
1761 invalidate_config_cache();
1762 Ok(())
1763 } else {
1764 Err(FileError::NoPath.into())
1765 }
1766 }
1767
1768 pub fn merge(&mut self, pt: Self) {
1773 if pt.worktree_label.is_some() {
1774 self.worktree_label = pt.worktree_label.clone();
1775 }
1776 for (id, d) in pt.daemons {
1777 self.daemons.insert(id, d);
1778 }
1779 if let Some(env) = pt.env {
1781 let merged = self.env.get_or_insert_with(IndexMap::new);
1782 for (k, v) in env {
1783 merged.insert(k, v);
1784 }
1785 }
1786 for (slug, entry) in pt.slugs {
1788 self.slugs.insert(slug, entry);
1789 }
1790 for (name, group) in pt.groups {
1792 self.groups.insert(name, group);
1793 }
1794 for (name, entry) in pt.namespaces {
1796 self.namespaces.insert(name, entry);
1797 }
1798 self.settings.merge_from(&pt.settings);
1800 }
1801
1802 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1807 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1808 Ok(pt) => pt.slugs,
1809 Err(_) => IndexMap::new(),
1810 }
1811 }
1812
1813 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1820 global_slugs
1821 .keys()
1822 .filter(|k| k.eq_ignore_ascii_case(slug))
1823 .count()
1824 > 1
1825 }
1826
1827 pub fn find_slug_for_daemon_in_registry(
1832 daemon_id: &DaemonId,
1833 global_slugs: &IndexMap<String, SlugEntry>,
1834 ) -> Option<String> {
1835 global_slugs
1836 .iter()
1837 .find(|(slug, entry)| {
1838 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1839 if daemon_id.name() != daemon_name {
1840 return false;
1841 }
1842
1843 if Self::slug_is_ambiguous(slug, global_slugs) {
1846 return false;
1847 }
1848
1849 match entry.resolve_namespace() {
1850 Some(namespace) => daemon_id.namespace() == namespace,
1851 None => false,
1852 }
1853 })
1854 .map(|(slug, _)| slug.clone())
1855 }
1856
1857 #[allow(dead_code)]
1859 pub fn is_slug_registered(slug: &str) -> bool {
1860 Self::read_global_slugs().contains_key(slug)
1861 }
1862
1863 pub fn add_slug_with_namespace(
1869 slug: &str,
1870 namespace: Option<&str>,
1871 daemon: Option<&str>,
1872 ) -> Result<()> {
1873 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1874
1875 if let Some(parent) = global_path.parent() {
1877 std::fs::create_dir_all(parent).map_err(|e| {
1878 miette::miette!(
1879 "Failed to create config directory {}: {e}",
1880 parent.display()
1881 )
1882 })?;
1883 }
1884
1885 let _lock = xx::fslock::get(global_path, false)
1886 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1887
1888 let mut pt = if global_path.exists() {
1889 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1890 path: global_path.to_path_buf(),
1891 source: e,
1892 })?;
1893 Self::parse_str(&raw, global_path)?
1894 } else {
1895 Self::new(global_path.to_path_buf())
1896 };
1897
1898 if let Some(ns) = namespace
1902 && !pt.namespaces.contains_key(ns)
1903 {
1904 let dir = pt
1909 .slugs
1910 .get(slug)
1911 .and_then(|e| {
1912 e.dir.clone().or_else(|| {
1913 e.namespace
1914 .as_ref()
1915 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1916 })
1917 })
1918 .or_else(|| env::CWD.as_path().canonicalize().ok());
1919 if let Some(ref d) = dir {
1920 pt.namespaces.insert(
1921 ns.to_string(),
1922 NamespaceEntry {
1923 dir: d.clone(),
1924 config: Vec::new(),
1925 },
1926 );
1927 }
1928 }
1929
1930 pt.slugs.insert(
1931 slug.to_string(),
1932 SlugEntry {
1933 dir: None,
1934 namespace: namespace.map(str::to_string),
1935 daemon: daemon.map(str::to_string),
1936 },
1937 );
1938 pt.write_unlocked()?;
1939 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1945 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1946 Ok(())
1947 }
1948
1949 pub fn remove_slug(slug: &str) -> Result<bool> {
1951 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1952 if !global_path.exists() {
1953 return Ok(false);
1954 }
1955
1956 let _lock = xx::fslock::get(global_path, false)
1957 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1958
1959 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1960 path: global_path.to_path_buf(),
1961 source: e,
1962 })?;
1963 let mut pt = Self::parse_str(&raw, global_path)?;
1964
1965 let removed = pt.slugs.shift_remove(slug).is_some();
1966 if removed {
1967 pt.write_unlocked()?;
1968 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1974 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1975 }
1976 Ok(removed)
1977 }
1978 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1981 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1982 Ok(pt) => pt.namespaces,
1983 Err(_) => IndexMap::new(),
1984 }
1985 }
1986
1987 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1991 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1992
1993 if let Some(parent) = global_path.parent() {
1995 std::fs::create_dir_all(parent).map_err(|e| {
1996 miette::miette!(
1997 "Failed to create config directory {}: {e}",
1998 parent.display()
1999 )
2000 })?;
2001 }
2002
2003 let _lock = xx::fslock::get(global_path, false)
2004 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2005
2006 let mut pt = if global_path.exists() {
2007 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2008 crate::error::FileError::ReadError {
2009 path: global_path.to_path_buf(),
2010 source: e,
2011 }
2012 })?;
2013 Self::parse_str(&raw, global_path)?
2014 } else {
2015 Self::new(global_path.to_path_buf())
2016 };
2017
2018 let dir = env::expand_tilde(dir);
2019 if let Some(entry) = pt.namespaces.get_mut(name) {
2020 if !entry.config.is_empty()
2021 && crate::extra_configs::normalize(&entry.dir)
2022 != crate::extra_configs::normalize(&dir)
2023 {
2024 miette::bail!(
2025 "namespace '{name}' has external configuration attached to another directory"
2026 );
2027 }
2028 entry.dir = dir;
2029 } else {
2030 pt.namespaces.insert(
2031 name.to_string(),
2032 NamespaceEntry {
2033 dir,
2034 config: Vec::new(),
2035 },
2036 );
2037 }
2038 pt.write_unlocked()?;
2039 Ok(())
2040 }
2041
2042 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2044 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2045 if !global_path.exists() {
2046 return Ok(false);
2047 }
2048
2049 let _lock = xx::fslock::get(global_path, false)
2050 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2051
2052 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2053 crate::error::FileError::ReadError {
2054 path: global_path.to_path_buf(),
2055 source: e,
2056 }
2057 })?;
2058 let mut pt = Self::parse_str(&raw, global_path)?;
2059
2060 let removed = pt.namespaces.shift_remove(name).is_some();
2061 if removed {
2062 pt.write_unlocked()?;
2063 }
2064 Ok(removed)
2065 }
2066}
2067
2068#[derive(Debug, Clone, JsonSchema, Default)]
2070pub struct PitchforkTomlDaemon {
2071 #[schemars(example = example_run_command())]
2075 pub run: RunCommand,
2076 #[schemars(default)]
2078 pub auto: Vec<PitchforkTomlAuto>,
2079 pub oneshot: Option<bool>,
2085 pub cron: Option<PitchforkTomlCron>,
2087 #[schemars(default)]
2090 pub retry: Retry,
2091 pub ready_delay: Option<u64>,
2093 pub ready_output: Option<ReadyOutput>,
2095 pub ready_http: Option<ReadyHttp>,
2097 pub ready_port: Option<ReadyPort>,
2101 pub ready_cmd: Option<ReadyCmd>,
2103 pub health_cmd: Option<HealthCmd>,
2105 pub health_http: Option<HealthHttp>,
2107 pub health_port: Option<HealthPort>,
2111 pub port: Option<PortConfig>,
2113 #[serde(skip_serializing_if = "Option::is_none", default)]
2116 pub proxy: Option<ProxyConfig>,
2117 pub proxy_tls: Option<ProxyTlsMode>,
2126 #[schemars(range(min = 1))]
2131 pub proxy_tls_port: Option<u16>,
2132 #[schemars(range(min = 1))]
2138 pub proxy_port: Option<u16>,
2139 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2150 pub boot_start: Option<bool>,
2152 #[schemars(default)]
2154 pub depends: Vec<DaemonId>,
2155 #[schemars(default)]
2157 pub watch: Vec<String>,
2158 #[schemars(default)]
2164 pub watch_mode: WatchMode,
2165 pub dir: Option<String>,
2167 pub env: Option<IndexMap<String, String>>,
2169 pub hooks: Option<PitchforkTomlHooks>,
2171 pub mise: Option<bool>,
2174 pub user: Option<String>,
2176 pub memory_limit: Option<MemoryLimit>,
2179 pub cpu_limit: Option<CpuLimit>,
2182 pub stop_signal: Option<StopConfig>,
2185 pub pty: Option<bool>,
2187 pub time_retention: Option<String>,
2190 pub line_retention: Option<i64>,
2193 pub archive_hook: Option<String>,
2196 pub logs: Option<PitchforkTomlDaemonLogs>,
2198 #[schemars(skip)]
2199 pub path: Option<PathBuf>,
2200}
2201
2202impl PitchforkTomlDaemon {
2203 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2209 self.proxy_tls_port.or(self.proxy_port)
2210 }
2211
2212 pub fn is_oneshot(&self) -> bool {
2214 self.oneshot.unwrap_or(false)
2215 }
2216
2217 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2220 [
2221 ("ready_delay", self.ready_delay.is_some()),
2222 ("ready_output", self.ready_output.is_some()),
2223 ("ready_http", self.ready_http.is_some()),
2224 ("ready_port", self.ready_port.is_some()),
2225 ("ready_cmd", self.ready_cmd.is_some()),
2226 ("health_cmd", self.health_cmd.is_some()),
2227 ("health_http", self.health_http.is_some()),
2228 ("health_port", self.health_port.is_some()),
2229 ]
2230 .into_iter()
2231 .filter(|(_, set)| *set)
2232 .map(|(name, _)| name)
2233 .collect()
2234 }
2235
2236 pub fn effective_user(&self) -> Option<String> {
2240 let daemon_user = self
2241 .user
2242 .as_deref()
2243 .map(str::trim)
2244 .filter(|u| !u.is_empty());
2245 daemon_user.map(str::to_owned).or_else(|| {
2246 let s = crate::settings::settings();
2247 let su = s.supervisor.user.trim();
2248 (!su.is_empty()).then(|| su.to_owned())
2249 })
2250 }
2251
2252 pub fn to_run_options(
2257 &self,
2258 id: &crate::daemon_id::DaemonId,
2259 cmd: Vec<String>,
2260 ) -> crate::daemon::RunOptions {
2261 use crate::daemon::RunOptions;
2262
2263 let effective_user = self.effective_user();
2264 let dir = crate::ipc::batch::resolve_daemon_dir(
2265 self.dir.as_deref(),
2266 self.path.as_deref(),
2267 effective_user.as_deref(),
2268 );
2269 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2272 id,
2273 &PitchforkToml::read_global_slugs(),
2274 );
2275
2276 RunOptions {
2277 id: id.clone(),
2278 cmd,
2279 run: self.run.shell_script().map(str::to_string),
2280 no_shell: self.run.is_argv(),
2281 requested_by_client: false,
2283 force: false,
2284 shell_pid: None,
2285 dir: Dir(dir),
2286 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2287 oneshot: self.is_oneshot(),
2288 cron_started: false,
2291 oneshot_wait: None,
2295 on_directory_enter: false,
2296 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2297 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2298 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2299 retry: self.retry,
2300 retry_count: 0,
2301 ready_delay: self.ready_delay,
2302 ready_output: self.ready_output.clone(),
2303 ready_http: self.ready_http.clone(),
2304 ready_port: self.ready_port.clone(),
2305 ready_cmd: self.ready_cmd.clone(),
2306 health_cmd: self.health_cmd.clone(),
2307 health_http: self.health_http.clone(),
2308 health_port: self.health_port.clone(),
2309 port: self.port.clone(),
2310 wait_ready: false,
2311 depends: self.depends.clone(),
2312 env: self.env.clone(),
2313 watch: self.watch.clone(),
2314 watch_mode: self.watch_mode,
2315 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2316 self.path.as_deref(),
2317 )),
2318 mise: self.mise,
2319 slug,
2320 proxy: None,
2321 user: self.user.clone(),
2322 memory_limit: self.memory_limit,
2323 cpu_limit: self.cpu_limit,
2324 stop_signal: self.stop_signal,
2325 archive_hook: self
2326 .logs
2327 .as_ref()
2328 .and_then(|l| l.archive_hook.clone())
2329 .or_else(|| self.archive_hook.clone()),
2330 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2331 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2332 pty: self.pty,
2333 proxy_idle_timeout_ms: None,
2335 }
2336 }
2337}
2338fn example_run_command() -> &'static str {
2339 "exec node server.js"
2340}
2341
2342#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2345#[serde(untagged)]
2346pub enum RunCommand {
2347 Shell(String),
2350 Argv(#[schemars(length(min = 1))] Vec<String>),
2354}
2355
2356impl Default for RunCommand {
2357 fn default() -> Self {
2358 Self::Shell(String::new())
2359 }
2360}
2361
2362impl From<String> for RunCommand {
2363 fn from(run: String) -> Self {
2364 Self::Shell(run)
2365 }
2366}
2367
2368impl From<&str> for RunCommand {
2369 fn from(run: &str) -> Self {
2370 Self::Shell(run.to_string())
2371 }
2372}
2373
2374impl From<Vec<String>> for RunCommand {
2375 fn from(argv: Vec<String>) -> Self {
2376 Self::Argv(argv)
2377 }
2378}
2379
2380impl PartialEq<str> for RunCommand {
2381 fn eq(&self, other: &str) -> bool {
2382 matches!(self, Self::Shell(run) if run == other)
2383 }
2384}
2385
2386impl PartialEq<&str> for RunCommand {
2387 fn eq(&self, other: &&str) -> bool {
2388 self == *other
2389 }
2390}
2391
2392impl std::fmt::Display for RunCommand {
2395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2396 match self {
2397 Self::Shell(run) => f.write_str(run),
2398 Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2399 }
2400 }
2401}
2402
2403impl RunCommand {
2404 pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2407 match self {
2408 Self::Shell(run) => shell_words::split(run),
2409 Self::Argv(argv) => Ok(argv.clone()),
2410 }
2411 }
2412
2413 pub fn shell_script(&self) -> Option<&str> {
2415 match self {
2416 Self::Shell(run) => Some(run),
2417 Self::Argv(_) => None,
2418 }
2419 }
2420
2421 pub fn is_argv(&self) -> bool {
2422 matches!(self, Self::Argv(_))
2423 }
2424
2425 pub fn try_map<E>(
2427 &self,
2428 mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2429 ) -> std::result::Result<Self, E> {
2430 Ok(match self {
2431 Self::Shell(run) => Self::Shell(f(run)?),
2432 Self::Argv(argv) => Self::Argv(
2433 argv.iter()
2434 .map(|arg| f(arg))
2435 .collect::<std::result::Result<_, _>>()?,
2436 ),
2437 })
2438 }
2439}
2440
2441#[cfg(test)]
2442mod tests {
2443 use super::*;
2444 use std::path::Path;
2445
2446 #[test]
2447 fn test_daemon_user_parses_and_flows_to_run_options() {
2448 let pt = PitchforkToml::parse_str(
2449 r#"
2450[daemons.api]
2451run = "node server.js"
2452user = "postgres"
2453"#,
2454 Path::new("/tmp/my-project/pitchfork.toml"),
2455 )
2456 .unwrap();
2457
2458 let id = DaemonId::new("my-project", "api");
2459 let daemon = pt.daemons.get(&id).unwrap();
2460 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2461
2462 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2463 assert_eq!(opts.user.as_deref(), Some("postgres"));
2464 }
2465
2466 #[test]
2467 fn test_daemon_user_write_roundtrip() {
2468 let temp = tempfile::tempdir().unwrap();
2469 let path = temp.path().join("pitchfork.toml");
2470 let mut pt = PitchforkToml::new(path.clone());
2471 pt.namespace = Some("test-project".to_string());
2472 pt.daemons.insert(
2473 DaemonId::new("test-project", "api"),
2474 PitchforkTomlDaemon {
2475 run: "node server.js".into(),
2476 user: Some("postgres".to_string()),
2477 ..PitchforkTomlDaemon::default()
2478 },
2479 );
2480
2481 pt.write().unwrap();
2482
2483 let raw = std::fs::read_to_string(&path).unwrap();
2484 assert!(raw.contains("user = \"postgres\""));
2485
2486 let parsed = PitchforkToml::read(&path).unwrap();
2487 let daemon = parsed
2488 .daemons
2489 .get(&DaemonId::new("test-project", "api"))
2490 .unwrap();
2491 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2492 }
2493
2494 fn awkward_argv() -> Vec<String> {
2496 [
2497 "node",
2498 "my server.js",
2499 "--name=\"a b\"",
2500 "it's",
2501 "a&b",
2502 "%PATH%",
2503 "$HOME",
2504 ]
2505 .map(str::to_string)
2506 .to_vec()
2507 }
2508
2509 #[test]
2510 fn test_run_array_parses_and_starts_without_a_shell() {
2511 let pt = PitchforkToml::parse_str(
2512 r#"
2513[daemons.api]
2514run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2515"#,
2516 Path::new("/tmp/my-project/pitchfork.toml"),
2517 )
2518 .unwrap();
2519
2520 let id = DaemonId::new("my-project", "api");
2521 let daemon = pt.daemons.get(&id).unwrap();
2522 assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2523
2524 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2525 assert_eq!(opts.cmd, awkward_argv());
2526 assert!(opts.no_shell);
2527 assert_eq!(opts.run, None);
2528 }
2529
2530 #[test]
2531 fn test_run_string_still_goes_through_the_shell() {
2532 let pt = PitchforkToml::parse_str(
2533 "[daemons.api]\nrun = \"exec node server.js\"\n",
2534 Path::new("/tmp/my-project/pitchfork.toml"),
2535 )
2536 .unwrap();
2537
2538 let id = DaemonId::new("my-project", "api");
2539 let daemon = pt.daemons.get(&id).unwrap();
2540 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2541 assert!(!opts.no_shell);
2542 assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2543 }
2544
2545 #[test]
2546 fn test_run_array_write_roundtrip() {
2547 let temp = tempfile::tempdir().unwrap();
2548 let path = temp.path().join("pitchfork.toml");
2549 let mut pt = PitchforkToml::new(path.clone());
2550 pt.namespace = Some("test-project".to_string());
2551 let id = DaemonId::new("test-project", "api");
2552 pt.daemons.insert(
2553 id.clone(),
2554 PitchforkTomlDaemon {
2555 run: RunCommand::Argv(awkward_argv()),
2556 ..PitchforkTomlDaemon::default()
2557 },
2558 );
2559 pt.daemons.insert(
2560 DaemonId::new("test-project", "worker"),
2561 PitchforkTomlDaemon {
2562 run: "exec ./worker --queue \"a b\"".into(),
2563 ..PitchforkTomlDaemon::default()
2564 },
2565 );
2566
2567 pt.write().unwrap();
2568
2569 let parsed = PitchforkToml::read(&path).unwrap();
2570 assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
2571 assert_eq!(
2572 parsed.daemons[&DaemonId::new("test-project", "worker")].run,
2573 "exec ./worker --queue \"a b\""
2574 );
2575 }
2576
2577 #[test]
2578 fn test_run_array_must_name_a_program() {
2579 let err = PitchforkToml::parse_str(
2580 "[daemons.api]\nrun = []\n",
2581 Path::new("/tmp/my-project/pitchfork.toml"),
2582 )
2583 .unwrap_err();
2584 assert!(
2585 err.chain()
2586 .any(|cause| cause.to_string().contains("empty run array")),
2587 "unexpected error: {err:?}"
2588 );
2589 }
2590
2591 #[test]
2592 fn test_run_array_rejects_exec() {
2593 let err = PitchforkToml::parse_str(
2594 "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
2595 Path::new("/tmp/my-project/pitchfork.toml"),
2596 )
2597 .unwrap_err();
2598 assert!(
2599 err.chain().any(|cause| cause
2600 .to_string()
2601 .contains("starts its run array with \"exec\"")),
2602 "unexpected error: {err:?}"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_registry_dirs_expand_tilde() {
2608 let pt = PitchforkToml::parse_str(
2609 r#"
2610[slugs.api]
2611dir = "~/projects/api"
2612
2613[namespaces.web]
2614dir = "~/projects/web"
2615"#,
2616 Path::new("/tmp/config.toml"),
2617 )
2618 .unwrap();
2619
2620 assert_eq!(
2621 pt.slugs["api"].dir,
2622 Some(crate::env::HOME_DIR.join("projects/api"))
2623 );
2624 assert_eq!(
2625 pt.namespaces["web"].dir,
2626 crate::env::HOME_DIR.join("projects/web")
2627 );
2628 }
2629
2630 #[test]
2631 fn test_settings_write_roundtrip() {
2632 let temp = tempfile::tempdir().unwrap();
2633 let path = temp.path().join("pitchfork.toml");
2634 let mut pt = PitchforkToml::new(path.clone());
2635 pt.namespace = Some("test-project".to_string());
2636 pt.settings.web.auto_start = Some(true);
2637 pt.settings.general.log_level = Some("debug".to_string());
2638
2639 pt.write().unwrap();
2640
2641 let raw = std::fs::read_to_string(&path).unwrap();
2642 assert!(
2643 raw.contains("[settings.web]"),
2644 "settings.web section should be written, got:\n{raw}"
2645 );
2646 assert!(raw.contains("auto_start = true"));
2647 assert!(raw.contains("log_level = \"debug\""));
2648
2649 let parsed = PitchforkToml::read(&path).unwrap();
2650 assert_eq!(parsed.settings.web.auto_start, Some(true));
2651 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2652 }
2653
2654 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2655 SlugEntry {
2656 dir: None,
2657 namespace: Some(namespace.to_string()),
2658 daemon: daemon.map(str::to_string),
2659 }
2660 }
2661
2662 #[test]
2663 fn test_slug_is_ambiguous() {
2664 let mut slugs = IndexMap::new();
2665 slugs.insert("api".to_string(), slug_entry("my-project", None));
2666 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
2667
2668 slugs.insert("API".to_string(), slug_entry("other-project", None));
2669 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
2671 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
2672 }
2673
2674 #[test]
2675 fn test_find_slug_for_daemon_skips_case_collisions() {
2676 let id = DaemonId::new("my-project", "api");
2677 let mut slugs = IndexMap::new();
2678 slugs.insert("api".to_string(), slug_entry("my-project", None));
2679 assert_eq!(
2680 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2681 Some("api".to_string())
2682 );
2683
2684 slugs.insert("API".to_string(), slug_entry("other-project", None));
2687 assert_eq!(
2688 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2689 None
2690 );
2691 }
2692
2693 #[test]
2694 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
2695 let id = DaemonId::new("my-project", "api");
2696 let mut slugs = IndexMap::new();
2697 slugs.insert("api".to_string(), slug_entry("my-project", None));
2700 slugs.insert("API".to_string(), slug_entry("my-project", None));
2701 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
2702
2703 assert_eq!(
2704 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2705 Some("my-api".to_string())
2706 );
2707 }
2708
2709 #[test]
2710 fn test_settings_preserved_on_unrelated_write() {
2711 let temp = tempfile::tempdir().unwrap();
2714 let path = temp.path().join("pitchfork.toml");
2715 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2716
2717 let mut pt = PitchforkToml::read(&path).unwrap();
2718 pt.slugs.insert(
2719 "api".to_string(),
2720 SlugEntry {
2721 dir: None,
2722 namespace: Some("myproject".to_string()),
2723 daemon: None,
2724 },
2725 );
2726 pt.namespaces.insert(
2727 "myproject".to_string(),
2728 NamespaceEntry {
2729 dir: PathBuf::from("/tmp/myproject"),
2730 config: Vec::new(),
2731 },
2732 );
2733 pt.write().unwrap();
2734
2735 let raw = std::fs::read_to_string(&path).unwrap();
2736 assert!(
2737 raw.contains("[settings.web]"),
2738 "existing settings must be preserved, got:\n{raw}"
2739 );
2740 assert!(raw.contains("auto_start = true"));
2741 assert!(raw.contains("[slugs.api]"));
2742
2743 let parsed = PitchforkToml::read(&path).unwrap();
2744 assert_eq!(parsed.settings.web.auto_start, Some(true));
2745 assert!(parsed.slugs.contains_key("api"));
2746 }
2747
2748 #[tokio::test]
2749 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2750 let temp = tempfile::tempdir().unwrap();
2751 let path = temp.path().join("pitchfork.toml");
2752 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2753 .await
2754 .unwrap();
2755
2756 let read_path = path.clone();
2757 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2758 .await
2759 .unwrap()
2760 .unwrap();
2761 assert_eq!(pt.settings.general.worktree, Some(false));
2762 assert_eq!(pt.settings.proxy.worktree, None);
2763 tokio::task::spawn_blocking(move || pt.write())
2764 .await
2765 .unwrap()
2766 .unwrap();
2767
2768 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2769 assert!(raw.contains("[settings.general]"), "{raw}");
2770 assert!(raw.contains("worktree = false"), "{raw}");
2771 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2772
2773 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2774 .await
2775 .unwrap()
2776 .unwrap();
2777 assert_eq!(parsed.settings.general.worktree, Some(false));
2778 }
2779
2780 #[test]
2781 fn test_config_cache_hit_and_invalidation() {
2782 let temp = tempfile::tempdir().unwrap();
2783 let dir = temp.path();
2784 let config_path = dir.join("pitchfork.toml");
2785 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2786
2787 super::invalidate_config_cache();
2789
2790 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2792 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2793 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2794
2795 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2797 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2798
2799 std::thread::sleep(std::time::Duration::from_millis(50));
2802 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2803
2804 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2805 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2806
2807 super::invalidate_config_cache();
2809 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2810 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2811
2812 super::invalidate_config_cache();
2814 }
2815
2816 #[test]
2817 fn test_config_cache_invalidation_on_write() {
2818 let temp = tempfile::tempdir().unwrap();
2819 let dir = temp.path();
2820 let config_path = dir.join("pitchfork.toml");
2821 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2822
2823 super::invalidate_config_cache();
2824
2825 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2827 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2828 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2829
2830 let mut pt = PitchforkToml::read(&config_path).unwrap();
2832 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
2833 let _ = pt.write();
2835
2836 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2838 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2839
2840 super::invalidate_config_cache();
2841 }
2842
2843 #[test]
2844 fn test_config_cache_size_invalidation() {
2845 let temp = tempfile::tempdir().unwrap();
2846 let dir = temp.path();
2847 let config_path = dir.join("pitchfork.toml");
2848 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2849
2850 super::invalidate_config_cache();
2851
2852 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2854 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2855 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2856
2857 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2861 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2862 let file = std::fs::OpenOptions::new()
2865 .write(true)
2866 .open(&config_path)
2867 .unwrap();
2868 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2869 file.set_times(times).unwrap();
2870
2871 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2874 assert_eq!(
2875 pt2.daemons[&daemon_id].run, "echo different",
2876 "cache should invalidate on size change even with identical mtime"
2877 );
2878
2879 super::invalidate_config_cache();
2880 }
2881
2882 #[test]
2883 fn test_find_project_root_in_plain_dir_returns_none() {
2884 let temp = tempfile::tempdir().unwrap();
2885 assert_eq!(find_project_root(temp.path()), None);
2886 }
2887
2888 #[test]
2889 fn test_find_project_root_finds_git_marker() {
2890 let temp = tempfile::tempdir().unwrap();
2891 let repo = temp.path().join("my-repo");
2892 std::fs::create_dir(&repo).unwrap();
2893 std::fs::create_dir(repo.join(".git")).unwrap();
2894
2895 let sub = repo.join("sub/dir");
2896 std::fs::create_dir_all(&sub).unwrap();
2897
2898 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2901 }
2902
2903 #[test]
2904 fn test_find_project_root_accepts_git_file_marker() {
2905 let temp = tempfile::tempdir().unwrap();
2908 let wt = temp.path().join("my-worktree");
2909 std::fs::create_dir(&wt).unwrap();
2910 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2911
2912 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2913 }
2914
2915 #[cfg(unix)]
2918 #[test]
2919 fn test_find_project_root_resolves_symlinked_start_dir() {
2920 use std::os::unix::fs::symlink;
2921
2922 let temp = tempfile::tempdir().unwrap();
2923 let repo = temp.path().join("real-repo");
2924 std::fs::create_dir(&repo).unwrap();
2925 std::fs::create_dir(repo.join(".git")).unwrap();
2926
2927 let sub = repo.join("sub/dir");
2928 std::fs::create_dir_all(&sub).unwrap();
2929 let link = temp.path().join("link-to-sub");
2930 symlink(&sub, &link).unwrap();
2931
2932 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
2933 }
2934
2935 #[test]
2938 fn test_all_merged_all_namespaces_discovers_worktrees() {
2939 let temp = tempfile::tempdir().unwrap();
2940 let repo = temp.path().join("my-repo");
2941 std::fs::create_dir(&repo).unwrap();
2942
2943 let git_init = std::process::Command::new("git")
2945 .args(["init", "-b", "main"])
2946 .current_dir(&repo)
2947 .output()
2948 .expect("git init");
2949 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2950
2951 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2952
2953 let git_commit = std::process::Command::new("git")
2954 .args([
2955 "-c",
2956 "user.name=pitchfork-test",
2957 "-c",
2958 "user.email=pitchfork-test@example.com",
2959 "add",
2960 "-A",
2961 ])
2962 .current_dir(&repo)
2963 .output()
2964 .expect("git add");
2965 assert!(git_commit.status.success());
2966
2967 let git_commit = std::process::Command::new("git")
2968 .args([
2969 "-c",
2970 "user.name=pitchfork-test",
2971 "-c",
2972 "user.email=pitchfork-test@example.com",
2973 "commit",
2974 "-m",
2975 "init",
2976 ])
2977 .current_dir(&repo)
2978 .output()
2979 .expect("git commit");
2980 assert!(
2981 git_commit.status.success(),
2982 "git commit failed: {:?}",
2983 git_commit
2984 );
2985
2986 let wt = temp.path().join("my-repo-feature");
2987 let git_wt = std::process::Command::new("git")
2988 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2989 .current_dir(&repo)
2990 .output()
2991 .expect("git worktree add");
2992 assert!(
2993 git_wt.status.success(),
2994 "git worktree add failed: {:?}",
2995 git_wt
2996 );
2997
2998 std::fs::write(
3000 repo.join("pitchfork.toml"),
3001 "[daemons.api]\nrun = \"echo main\"\n",
3002 )
3003 .unwrap();
3004 std::fs::write(
3006 wt.join("pitchfork.toml"),
3007 "[daemons.worker]\nrun = \"echo wt\"\n",
3008 )
3009 .unwrap();
3010
3011 super::invalidate_config_cache();
3012
3013 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
3015
3016 let main_id = DaemonId::new("my-repo", "api");
3017 let wt_id = DaemonId::new("my-repo-feature", "worker");
3018 assert!(
3019 pt.daemons.contains_key(&main_id),
3020 "main checkout daemon missing"
3021 );
3022 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3023
3024 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
3026 assert!(pt_from_main.daemons.contains_key(&wt_id));
3027
3028 let _ = std::process::Command::new("git")
3030 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3031 .current_dir(&repo)
3032 .output();
3033 super::invalidate_config_cache();
3034 }
3035
3036 #[test]
3037 fn test_adhoc_id_uses_invocation_directory_namespace() {
3038 let temp = tempfile::tempdir().unwrap();
3039 let project = temp.path().join("feature-tree");
3040 std::fs::create_dir(&project).unwrap();
3041 std::fs::write(
3042 project.join("pitchfork.toml"),
3043 "[daemons.other]\nrun = \"true\"\n",
3044 )
3045 .unwrap();
3046
3047 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3048 assert_eq!(id, DaemonId::new("feature-tree", "api"));
3049 let qualified =
3050 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3051 assert_eq!(qualified, DaemonId::new("explicit", "api"));
3052 }
3053
3054 #[test]
3055 fn test_adhoc_id_falls_back_to_global_without_project_config() {
3056 let temp = tempfile::tempdir().unwrap();
3057 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3058 assert_eq!(id, DaemonId::new("global", "api"));
3059 }
3060}