1use crate::daemon_id::DaemonId;
2use crate::deprecated_at;
3use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
4use crate::settings::SettingsPartial;
5use crate::settings::settings;
6use crate::state_file::StateFile;
7use crate::{Result, env};
8use indexmap::IndexMap;
9use miette::Context;
10use once_cell::sync::Lazy;
11use schemars::JsonSchema;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex as StdMutex;
15use std::time::SystemTime;
16
17pub use crate::config_types::{
19 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
20 PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ProxyConfig,
21 ProxyIdleTimeout, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig,
22 StopSignal, WatchMode,
23};
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
33pub struct SlugEntryRaw {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub dir: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub namespace: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none", default)]
42 pub daemon: Option<String>,
43}
44
45#[derive(Debug, Clone)]
47pub struct SlugEntry {
48 pub dir: Option<PathBuf>,
50 pub namespace: Option<String>,
52 pub daemon: Option<String>,
54}
55
56impl SlugEntry {
57 pub fn resolve_dir(&self) -> Option<PathBuf> {
60 self.dir.clone().or_else(|| {
61 self.namespace.as_ref().and_then(|ns| {
62 let namespaces = PitchforkToml::read_global_namespaces();
63 namespaces.get(ns).map(|entry| entry.dir.clone())
64 })
65 })
66 }
67
68 pub fn resolve_namespace(&self) -> Option<String> {
71 self.namespace.clone().or_else(|| {
72 self.resolve_dir()
73 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
74 })
75 }
76}
77
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
84pub struct GroupEntryRaw {
85 #[schemars(with = "Vec<DaemonId>")]
86 pub daemons: Vec<String>,
87}
88
89#[derive(Debug, Clone)]
91pub struct GroupEntry {
92 pub daemons: Vec<DaemonId>,
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
101pub struct NamespaceEntryRaw {
102 pub dir: String,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub config: Vec<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub label: Option<String>,
112}
113
114#[derive(Debug, Clone)]
116pub struct NamespaceEntry {
117 pub dir: PathBuf,
119 pub config: Vec<PathBuf>,
120 pub label: Option<String>,
122}
123
124#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
126struct PitchforkTomlRaw {
127 #[serde(skip_serializing_if = "Option::is_none", default)]
128 pub namespace: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none", default)]
131 pub worktree_label: Option<String>,
132 #[serde(default)]
133 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
134 #[serde(skip_serializing_if = "Option::is_none", default)]
137 pub env: Option<IndexMap<String, String>>,
138 #[serde(default)]
139 pub settings: Option<SettingsPartial>,
140 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
143 pub slugs: IndexMap<String, SlugEntryRaw>,
144 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
146 pub groups: IndexMap<String, GroupEntryRaw>,
147 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
150 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
151}
152
153#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
158pub struct PitchforkTomlDaemonLogs {
159 #[serde(skip_serializing_if = "Option::is_none", default)]
162 pub log_format: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none", default)]
165 pub time_retention: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none", default)]
168 pub line_retention: Option<i64>,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
171 pub archive_hook: Option<String>,
172}
173
174#[derive(Debug, serde::Serialize, serde::Deserialize)]
181struct PitchforkTomlDaemonRaw {
182 pub run: RunCommand,
183 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub auto: Vec<PitchforkTomlAuto>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub oneshot: Option<bool>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub cron: Option<PitchforkTomlCron>,
189 #[serde(default)]
190 pub retry: Retry,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
192 pub ready_delay: Option<u64>,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub ready_output: Option<ReadyOutput>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub ready_http: Option<ReadyHttp>,
197 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub ready_port: Option<ReadyPort>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
200 pub ready_cmd: Option<ReadyCmd>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub health_cmd: Option<HealthCmd>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub health_http: Option<HealthHttp>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub health_port: Option<HealthPort>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub port: Option<PortConfig>,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub proxy: Option<ProxyConfig>,
213 #[serde(skip_serializing_if = "Vec::is_empty", default)]
215 pub expected_port: Vec<u16>,
216 #[serde(skip_serializing_if = "Option::is_none", default)]
218 pub auto_bump_port: Option<bool>,
219 #[serde(skip_serializing_if = "Option::is_none", default)]
221 pub port_bump_attempts: Option<u32>,
222 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub proxy_tls: Option<ProxyTlsMode>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
227 pub proxy_tls_port: Option<u16>,
228 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub proxy_port: Option<u16>,
232 #[serde(skip_serializing_if = "Option::is_none", default)]
234 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
235 #[serde(skip_serializing_if = "Option::is_none", default)]
236 pub boot_start: Option<bool>,
237 #[serde(skip_serializing_if = "Vec::is_empty", default)]
238 pub depends: Vec<String>,
239 #[serde(skip_serializing_if = "Vec::is_empty", default)]
240 pub watch: Vec<String>,
241 #[serde(skip_serializing_if = "Option::is_none", default)]
242 pub watch_mode: Option<WatchMode>,
243 #[serde(skip_serializing_if = "Option::is_none", default)]
244 pub dir: Option<String>,
245 #[serde(skip_serializing_if = "Option::is_none", default)]
246 pub env: Option<IndexMap<String, String>>,
247 #[serde(skip_serializing_if = "Option::is_none", default)]
248 pub hooks: Option<PitchforkTomlHooks>,
249 #[serde(skip_serializing_if = "Option::is_none", default)]
250 pub mise: Option<bool>,
251 #[serde(skip_serializing_if = "Option::is_none", default)]
253 pub user: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none", default)]
256 pub memory_limit: Option<MemoryLimit>,
257 #[serde(skip_serializing_if = "Option::is_none", default)]
259 pub cpu_limit: Option<CpuLimit>,
260 #[serde(skip_serializing_if = "Option::is_none", default)]
262 pub stop_signal: Option<StopConfig>,
263 #[serde(skip_serializing_if = "Option::is_none", default)]
265 pub pty: Option<bool>,
266 #[serde(skip_serializing_if = "Option::is_none", default)]
269 pub time_retention: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none", default)]
273 pub line_retention: Option<i64>,
274 #[serde(skip_serializing_if = "Option::is_none", default)]
277 pub archive_hook: Option<String>,
278 #[serde(skip_serializing_if = "Option::is_none", default)]
280 pub logs: Option<PitchforkTomlDaemonLogs>,
281}
282
283#[derive(Debug, Clone, Default, JsonSchema)]
288#[schemars(title = "Pitchfork Configuration")]
289pub struct PitchforkToml {
290 #[serde(default)]
292 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
293 #[serde(skip_serializing_if = "Option::is_none", default)]
297 pub env: Option<IndexMap<String, String>>,
298 pub namespace: Option<String>,
303 #[schemars(default, with = "Option<String>")]
307 pub worktree_label: Option<String>,
308 #[serde(default)]
317 pub(crate) settings: SettingsPartial,
318 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
323 pub slugs: IndexMap<String, SlugEntry>,
324 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
326 pub groups: IndexMap<String, GroupEntry>,
327 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
330 pub namespaces: IndexMap<String, NamespaceEntry>,
331 #[schemars(skip)]
332 pub path: Option<PathBuf>,
333 #[schemars(skip)]
341 pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
342}
343
344impl PitchforkToml {
345 pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
348 self.foreign_env
349 .get(id)
350 .map_or(self.env.as_ref(), |env| env.as_ref())
351 }
352}
353
354pub fn is_global_config(path: &Path) -> bool {
355 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
356}
357
358pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
359 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
360}
361
362fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
363 use toml::Value;
364
365 let doc: Value = toml::from_str(content)
366 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
367 let Some(value) = doc.get("namespace") else {
368 return Ok(None);
369 };
370
371 match value {
372 Value::String(s) => Ok(Some(s.clone())),
373 _ => Err(ConfigParseError::InvalidNamespace {
374 path: path.to_path_buf(),
375 namespace: value.to_string(),
376 reason: "top-level 'namespace' must be a string".to_string(),
377 }
378 .into()),
379 }
380}
381
382fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
383 if !path.exists() {
384 return Ok(None);
385 }
386 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
387 path: path.to_path_buf(),
388 source: e,
389 })?;
390 parse_namespace_override_from_content(path, &content)
391}
392
393pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
394 crate::extra_configs::project_dir(path).or_else(|| {
395 if is_dot_config_pitchfork(path) {
396 path.parent().and_then(Path::parent).map(Path::to_path_buf)
397 } else {
398 path.parent().map(Path::to_path_buf)
399 }
400 })
401}
402
403fn project_config_family(path: &Path) -> Vec<PathBuf> {
404 let Some(dir) = project_dir_for_config(path) else {
405 return vec![path.to_path_buf()];
406 };
407 vec![
408 dir.join(".config/pitchfork.toml"),
409 dir.join(".config/pitchfork.local.toml"),
410 dir.join("pitchfork.toml"),
411 dir.join("pitchfork.local.toml"),
412 ]
413}
414
415fn directory_namespace_override(
418 path: &Path,
419 content_override: Option<&str>,
420) -> Result<Option<String>> {
421 if is_global_config(path) {
422 return match content_override {
423 Some(content) => parse_namespace_override_from_content(path, content),
424 None => read_namespace_override_from_file(path),
425 };
426 }
427
428 let mut selected: Option<(String, PathBuf)> = None;
429 for candidate in project_config_family(path) {
430 let explicit = if candidate == path {
431 match content_override {
432 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
433 None => read_namespace_override_from_file(&candidate)?,
434 }
435 } else {
436 read_namespace_override_from_file(&candidate)?
437 };
438 let Some(namespace) = explicit else { continue };
439 if let Some((selected_namespace, selected_path)) = &selected
440 && selected_namespace != &namespace
441 {
442 return Err(ConfigParseError::InvalidNamespace {
443 path: candidate,
444 namespace,
445 reason: format!(
446 "namespace does not match directory-level namespace '{}' declared in {}",
447 selected_namespace,
448 selected_path.display()
449 ),
450 }
451 .into());
452 }
453 selected = Some((namespace, candidate));
454 }
455 Ok(selected.map(|(namespace, _)| namespace))
456}
457
458fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
459 if let Err(e) = DaemonId::try_new(namespace, "probe") {
460 return Err(ConfigParseError::InvalidNamespace {
461 path: path.to_path_buf(),
462 namespace: namespace.to_string(),
463 reason: e.to_string(),
464 }
465 .into());
466 }
467 Ok(namespace.to_string())
468}
469
470fn derive_namespace_from_dir(path: &Path) -> Result<String> {
471 let dir_for_namespace = project_dir_for_config(path);
472 if let Some(namespace) = dir_for_namespace
473 .as_deref()
474 .and_then(crate::extra_configs::namespace_for_dir)
475 {
476 return validate_namespace(path, &namespace);
477 }
478 let raw_namespace = dir_for_namespace
479 .as_deref()
480 .and_then(|p| p.file_name())
481 .and_then(|n| n.to_str())
482 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
483 .to_string();
484
485 validate_namespace(path, &raw_namespace).map_err(|e| {
486 ConfigParseError::InvalidNamespace {
487 path: path.to_path_buf(),
488 namespace: raw_namespace,
489 reason: format!(
490 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
491 ),
492 }
493 .into()
494 })
495}
496
497fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
498 if is_global_config(path) {
499 if let Some(ns) = explicit
500 && ns != "global"
501 {
502 return Err(ConfigParseError::InvalidNamespace {
503 path: path.to_path_buf(),
504 namespace: ns.to_string(),
505 reason: "global config files must use namespace 'global'".to_string(),
506 }
507 .into());
508 }
509 return Ok("global".to_string());
510 }
511
512 if let Some(ns) = explicit {
513 return validate_namespace(path, ns);
514 }
515
516 derive_namespace_from_dir(path)
517}
518
519fn namespace_from_file(path: &Path) -> Result<String> {
520 let explicit = directory_namespace_override(path, None)?;
521 namespace_from_path_with_override(path, explicit.as_deref())
522}
523
524pub fn namespace_from_path(path: &Path) -> Result<String> {
537 namespace_from_file(path)
538}
539
540fn find_project_root(dir: &Path) -> Option<PathBuf> {
548 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
552 let mut current = canonical_dir.as_path();
553 loop {
554 if current.join(".git").exists() || current.join(".jj").exists() {
555 return Some(current.to_path_buf());
556 }
557 current = current.parent()?;
558 }
559}
560
561struct ConfigCacheEntry {
580 config: PitchforkToml,
581 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
583}
584
585static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
592 Lazy::new(|| StdMutex::new(HashMap::new()));
593
594fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
600 if paths.len() != snapshot.len() {
601 return false;
602 }
603 paths
604 .iter()
605 .zip(snapshot.iter())
606 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
607}
608
609pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
611 let md = std::fs::metadata(path).ok()?;
612 Some((md.modified().ok()?, md.len()))
613}
614
615fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
617 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
618}
619
620pub fn invalidate_config_cache() {
637 crate::extra_configs::invalidate();
638 crate::web::routes::api::projects::invalidate_group_cache();
641 if let Ok(mut cache) = CONFIG_CACHE.lock() {
642 cache.clear();
643 }
644}
645
646impl PitchforkToml {
647 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
660 if user_id.contains('/') {
662 return match DaemonId::parse(user_id) {
663 Ok(id) => Ok(vec![id]),
664 Err(e) => Err(e), };
666 }
667
668 let global_slugs = Self::read_global_slugs();
670 if let Some(entry) = global_slugs.get(user_id) {
671 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
673 if let Some(dir) = entry.resolve_dir()
674 && let Ok(project_config) = Self::all_merged_from(&dir)
675 {
676 let matches: Vec<DaemonId> = project_config
678 .daemons
679 .keys()
680 .filter(|id| id.name() == daemon_name)
681 .cloned()
682 .collect();
683 match matches.as_slice() {
684 [] => {}
685 [id] => return Ok(vec![id.clone()]),
686 _ => {
687 let mut candidates: Vec<String> =
688 matches.iter().map(|id| id.qualified()).collect();
689 candidates.sort();
690 return Err(miette::miette!(
691 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
692 user_id,
693 daemon_name,
694 candidates.join(", ")
695 ));
696 }
697 }
698 }
699 }
700
701 let matches: Vec<DaemonId> = self
703 .daemons
704 .keys()
705 .filter(|id| id.name() == user_id)
706 .cloned()
707 .collect();
708
709 if matches.is_empty() {
710 let state_matches = Self::find_in_state_file(user_id);
712 match state_matches.as_slice() {
713 [] => {}
714 [id] => return Ok(vec![id.clone()]),
715 _ => {
716 let mut candidates: Vec<String> =
717 state_matches.iter().map(|id| id.qualified()).collect();
718 candidates.sort();
719 return Err(miette::miette!(
720 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
721 user_id,
722 candidates.join(", ")
723 ));
724 }
725 }
726 let _ = DaemonId::try_new("global", user_id)?;
728 }
729 Ok(matches)
730 }
731
732 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
738 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
739 Ok(state) => state
740 .daemons
741 .keys()
742 .filter(|id| id.name() == short_name)
743 .cloned()
744 .collect(),
745 Err(e) => {
746 warn!("cannot read state file: {e}");
747 Vec::new()
748 }
749 }
750 }
751
752 #[allow(dead_code)]
773 pub fn resolve_daemon_id_prefer_local(
774 &self,
775 user_id: &str,
776 current_dir: &Path,
777 ) -> Result<DaemonId> {
778 if user_id.contains('/') {
780 return DaemonId::parse(user_id);
781 }
782
783 let current_namespace = Self::namespace_for_dir(current_dir)?;
787
788 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
789 }
790
791 fn resolve_daemon_id_with_namespace(
794 &self,
795 user_id: &str,
796 current_namespace: &str,
797 ) -> Result<DaemonId> {
798 if let Ok(preferred_id) = DaemonId::try_new(current_namespace, user_id)
803 && self.daemons.contains_key(&preferred_id)
804 {
805 return Ok(preferred_id);
806 }
807
808 let global_slugs = Self::read_global_slugs();
810 if let Some(entry) = global_slugs.get(user_id) {
811 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
812 if let Some(dir) = entry.resolve_dir()
813 && let Ok(project_config) = Self::all_merged_from(&dir)
814 {
815 let matches: Vec<DaemonId> = project_config
816 .daemons
817 .keys()
818 .filter(|id| id.name() == daemon_name)
819 .cloned()
820 .collect();
821 match matches.as_slice() {
822 [] => {}
823 [id] => return Ok(id.clone()),
824 _ => {
825 let mut candidates: Vec<String> =
826 matches.iter().map(|id| id.qualified()).collect();
827 candidates.sort();
828 return Err(miette::miette!(
829 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
830 user_id,
831 daemon_name,
832 candidates.join(", ")
833 ));
834 }
835 }
836 }
837 }
838
839 DaemonId::try_new(current_namespace, user_id)?;
841
842 let matches = self.resolve_daemon_id(user_id)?;
844
845 if matches.len() > 1 {
847 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
848 candidates.sort();
849 return Err(miette::miette!(
850 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
851 user_id,
852 candidates.join(", ")
853 ));
854 }
855
856 if let Some(id) = matches.into_iter().next() {
857 return Ok(id);
858 }
859
860 let global_id = DaemonId::try_new("global", user_id)?;
863 if self.daemons.contains_key(&global_id) {
864 return Ok(global_id);
865 }
866
867 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
868 Err(DependencyError::DaemonNotFound {
869 name: user_id.to_string(),
870 suggestion,
871 }
872 .into())
873 }
874
875 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
877 namespace_from_path(&dir.join("pitchfork.toml"))
878 }
879
880 pub fn project_worktree_label(dir: &Path) -> Option<String> {
888 let mut label = None;
889 let candidates = project_config_family(&dir.join("pitchfork.toml"))
890 .into_iter()
891 .chain(crate::extra_configs::configs_for_dir(dir));
892 for candidate in candidates {
893 if !candidate.exists() {
894 continue;
895 }
896 if let Ok(pt) = Self::read(&candidate)
897 && let Some(found) = pt.worktree_label
898 {
899 label = Some(found);
900 }
901 }
902 label
903 }
904
905 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
907 directory_namespace_override(&dir.join("pitchfork.toml"), None)
908 }
909
910 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
912 Ok(Self::list_paths_from(dir)
913 .iter()
914 .filter(|p| p.exists())
915 .max_by_key(|p| {
916 if is_global_config(p) {
917 0
918 } else {
919 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
920 }
921 })
922 .map(|p| namespace_from_path(p))
923 .transpose()?
924 .unwrap_or_else(|| "global".to_string()))
925 }
926
927 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
937 if user_id.contains('/') {
938 return DaemonId::parse(user_id);
939 }
940
941 let config = Self::all_merged()?;
944 let ns = Self::namespace_for_dir(&env::CWD)?;
945 config.resolve_daemon_id_with_namespace(user_id, &ns)
946 }
947
948 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
954 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
955 }
956
957 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
958 if user_id.contains('/') {
959 return DaemonId::parse(user_id);
960 }
961
962 let ns = Self::namespace_for_dir(dir)?;
963 DaemonId::try_new(ns, user_id)
964 }
965
966 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
977 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
979 return user_ids
980 .iter()
981 .map(|s| DaemonId::parse(s.as_ref()))
982 .collect();
983 }
984
985 let config = Self::all_merged()?;
986 let ns = Self::namespace_for_dir(&env::CWD)?;
988 user_ids
989 .iter()
990 .map(|s| {
991 let id = s.as_ref();
992 if id.contains('/') {
993 DaemonId::parse(id)
994 } else {
995 config.resolve_daemon_id_with_namespace(id, &ns)
996 }
997 })
998 .collect()
999 }
1000
1001 pub fn resolve_ids_and_group<S: AsRef<str>>(
1006 user_ids: &[S],
1007 group_name: Option<&str>,
1008 ) -> Result<Vec<DaemonId>> {
1009 let config = Self::all_merged()?;
1010 let ns = Self::namespace_for_dir(&env::CWD)?;
1011 let mut ids = Vec::new();
1012 let mut seen = std::collections::HashSet::new();
1013
1014 for id in user_ids {
1015 let id_str = id.as_ref();
1016 let daemon_id = if id_str.contains('/') {
1017 DaemonId::parse(id_str)?
1018 } else {
1019 config.resolve_daemon_id_with_namespace(id_str, &ns)?
1020 };
1021 if seen.insert(daemon_id.clone()) {
1022 ids.push(daemon_id);
1023 }
1024 }
1025
1026 if let Some(name) = group_name {
1027 match config.groups.get(name) {
1028 Some(group) => {
1029 let missing: Vec<String> = group
1030 .daemons
1031 .iter()
1032 .filter(|id| !config.daemons.contains_key(*id))
1033 .map(|id| id.qualified())
1034 .collect();
1035 if !missing.is_empty() {
1036 return Err(miette::miette!(
1037 "group '{}' references undefined daemon{}: {}",
1038 name,
1039 if missing.len() > 1 { "s" } else { "" },
1040 missing.join(", ")
1041 ));
1042 }
1043 for daemon_id in &group.daemons {
1044 if seen.insert(daemon_id.clone()) {
1045 ids.push(daemon_id.clone());
1046 }
1047 }
1048 }
1049 None => {
1050 let suggestion =
1051 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
1052 return Err(miette::miette!(
1053 "group '{}' not found in configuration{}",
1054 name,
1055 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
1056 ));
1057 }
1058 }
1059 }
1060
1061 Ok(ids)
1062 }
1063
1064 pub fn list_paths() -> Vec<PathBuf> {
1067 Self::list_paths_from(&env::CWD)
1068 }
1069
1070 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1081 let mut paths = Vec::new();
1082 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1083 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1084
1085 let mut project_paths = xx::file::find_up_all(
1089 cwd,
1090 &[
1091 "pitchfork.local.toml",
1092 "pitchfork.toml",
1093 ".config/pitchfork.local.toml",
1094 ".config/pitchfork.toml",
1095 ],
1096 );
1097 project_paths.reverse();
1098 paths.extend(project_paths);
1099 paths.extend(crate::extra_configs::paths_for(cwd));
1100
1101 paths
1102 }
1103
1104 pub fn all_merged() -> Result<PitchforkToml> {
1107 Self::all_merged_from(&env::CWD)
1108 }
1109 pub async fn all_merged_all_namespaces() -> Result<Self> {
1121 Self::all_merged_all_namespaces_from(&env::CWD).await
1122 }
1123
1124 pub(crate) async fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1126 let start_dir = start_dir.to_path_buf();
1127 match tokio::task::spawn_blocking(move || {
1128 Self::all_merged_all_namespaces_from_blocking(&start_dir)
1129 })
1130 .await
1131 {
1132 Ok(result) => result,
1133 Err(e) => Err(miette::miette!("reading the merged config panicked: {e}")),
1134 }
1135 }
1136
1137 pub fn all_merged_all_namespaces_blocking() -> Result<Self> {
1140 Self::all_merged_all_namespaces_from_blocking(&env::CWD)
1141 }
1142
1143 pub(crate) fn all_merged_all_namespaces_from_blocking(start_dir: &Path) -> Result<Self> {
1146 let mut pt = Self::all_merged_from(start_dir)?;
1147
1148 let namespaces = Self::read_global_namespaces();
1149 for (ns_name, entry) in namespaces {
1150 match Self::all_merged_from(&entry.dir) {
1151 Ok(ns_config) => {
1152 for (daemon_id, daemon_config) in ns_config.daemons {
1153 if !pt.daemons.contains_key(&daemon_id) {
1154 pt.foreign_env
1155 .insert(daemon_id.clone(), ns_config.env.clone());
1156 pt.daemons.insert(daemon_id, daemon_config);
1157 }
1158 }
1159 pt.settings.merge_from(&ns_config.settings);
1162 }
1163 Err(e) => {
1164 log::warn!(
1165 "Failed to load namespace '{ns_name}' from {}: {e}",
1166 entry.dir.display()
1167 );
1168 }
1169 }
1170 }
1171
1172 if crate::settings::settings().general.worktree
1182 && let Some(project_root) = find_project_root(start_dir)
1183 {
1184 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1185 for wt in &worktrees {
1186 match Self::all_merged_from(&wt.path) {
1187 Ok(wt_config) => {
1188 for (daemon_id, daemon_config) in wt_config.daemons {
1189 if !pt.daemons.contains_key(&daemon_id) {
1190 pt.daemons.insert(daemon_id, daemon_config);
1191 }
1192 }
1193 pt.settings.merge_from(&wt_config.settings);
1194 }
1195 Err(e) => {
1196 log::warn!(
1197 "Failed to load worktree '{}' config from {}: {e}",
1198 wt.branch,
1199 wt.path.display()
1200 );
1201 }
1202 }
1203 }
1204 }
1205
1206 Ok(pt)
1207 }
1208
1209 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1227 let paths = Self::list_paths_from(cwd);
1228
1229 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1233
1234 {
1235 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1236 if let Some(entry) = cache.get(&cache_key)
1237 && meta_matches(&paths, &entry.source_meta)
1238 {
1239 return Ok(entry.config.clone());
1240 }
1241 }
1242
1243 let snapshot = snapshot_meta(&paths);
1249 let pt = Self::all_merged_from_uncached(&paths)?;
1250
1251 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1253 cache.insert(
1254 cache_key,
1255 ConfigCacheEntry {
1256 config: pt.clone(),
1257 source_meta: snapshot,
1258 },
1259 );
1260
1261 Ok(pt)
1262 }
1263
1264 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1269 use std::collections::HashMap as StdHashMap;
1270
1271 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1272
1273 let mut pt = Self::default();
1274 for p in paths {
1275 match Self::read(p) {
1276 Ok(pt2) => {
1277 if p.exists() && !is_global_config(p) {
1281 let ns = namespace_from_path(p)?;
1282 let origin_dir = project_dir_for_config(p)
1283 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1284 .unwrap_or_else(|| p.clone());
1285
1286 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1287 && *other_dir != origin_dir
1288 {
1289 return Err(crate::error::ConfigParseError::NamespaceCollision {
1290 path_a: other_path.clone(),
1291 path_b: p.clone(),
1292 ns,
1293 }
1294 .into());
1295 }
1296 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1297 }
1298
1299 pt.merge(pt2)
1300 }
1301 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1302 }
1303 }
1304 Ok(pt)
1305 }
1306}
1307
1308impl PitchforkToml {
1309 pub fn new(path: PathBuf) -> Self {
1310 Self {
1311 daemons: Default::default(),
1312 env: None,
1313 namespace: None,
1314 worktree_label: None,
1315 settings: SettingsPartial::default(),
1316 slugs: IndexMap::new(),
1317 groups: IndexMap::new(),
1318 namespaces: IndexMap::new(),
1319 path: Some(path),
1320 foreign_env: IndexMap::new(),
1321 }
1322 }
1323
1324 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1332 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1333 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1334 if let Some(settings) = &mut raw_config.settings {
1335 settings.canonicalize_aliases();
1336 }
1337
1338 let explicit = directory_namespace_override(path, Some(content))?;
1339 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1340 let mut pt = Self::new(path.to_path_buf());
1341 pt.namespace = raw_config.namespace.clone();
1342 pt.worktree_label = raw_config.worktree_label.clone();
1343
1344 for (short_name, raw_daemon) in raw_config.daemons {
1345 let id = match DaemonId::try_new(&namespace, &short_name) {
1346 Ok(id) => id,
1347 Err(e) => {
1348 return Err(ConfigParseError::InvalidDaemonName {
1349 name: short_name,
1350 path: path.to_path_buf(),
1351 reason: e.to_string(),
1352 }
1353 .into());
1354 }
1355 };
1356
1357 let mut depends = Vec::new();
1358 for dep in raw_daemon.depends {
1359 let dep_id = if dep.contains('/') {
1360 match DaemonId::parse(&dep) {
1361 Ok(id) => id,
1362 Err(e) => {
1363 return Err(ConfigParseError::InvalidDependency {
1364 daemon: short_name.clone(),
1365 dependency: dep,
1366 path: path.to_path_buf(),
1367 reason: e.to_string(),
1368 }
1369 .into());
1370 }
1371 }
1372 } else {
1373 match DaemonId::try_new(&namespace, &dep) {
1374 Ok(id) => id,
1375 Err(e) => {
1376 return Err(ConfigParseError::InvalidDependency {
1377 daemon: short_name.clone(),
1378 dependency: dep,
1379 path: path.to_path_buf(),
1380 reason: e.to_string(),
1381 }
1382 .into());
1383 }
1384 }
1385 };
1386 depends.push(dep_id);
1387 }
1388
1389 let has_deprecated = !raw_daemon.expected_port.is_empty()
1391 || raw_daemon.auto_bump_port.is_some()
1392 || raw_daemon.port_bump_attempts.is_some();
1393 let deprecated_port = has_deprecated.then(|| {
1394 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1395 PortBump(
1396 raw_daemon
1397 .port_bump_attempts
1398 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1399 )
1400 } else {
1401 PortBump(0)
1402 };
1403 PortConfig {
1404 expect: raw_daemon.expected_port,
1405 bump,
1406 }
1407 });
1408 let port = match (raw_daemon.port, deprecated_port) {
1409 (Some(port), Some(deprecated)) => {
1410 if deprecated != port {
1413 warn!(
1414 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1415 );
1416 }
1417 Some(port)
1418 }
1419 (Some(port), None) => Some(port),
1420 (None, Some(deprecated)) => {
1421 deprecated_at!(
1422 "2026-10-03",
1423 "2027-10-03",
1424 format!("port-fields:{id}"),
1425 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead."
1426 );
1427 Some(deprecated)
1428 }
1429 (None, None) => None,
1430 };
1431
1432 if let (Some(explicit), Some(short)) =
1434 (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1435 && explicit != short
1436 {
1437 warn!(
1438 "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1439 disagree; using proxy_tls_port"
1440 );
1441 }
1442 let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1443
1444 for (key, value) in [
1447 ("proxy_tls_port", raw_daemon.proxy_tls_port),
1448 ("proxy_port", raw_daemon.proxy_port),
1449 ] {
1450 if value == Some(0) {
1451 return Err(ConfigParseError::ProxyPortZero {
1452 daemon: short_name.clone(),
1453 key,
1454 path: path.to_path_buf(),
1455 }
1456 .into());
1457 }
1458 }
1459
1460 if let Some(port_want) = proxy_tls_port {
1463 let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1464 if !declared.contains(&port_want) {
1465 return Err(ConfigParseError::ProxyPortNotDeclared {
1466 daemon: short_name.clone(),
1467 port: port_want,
1468 declared,
1469 path: path.to_path_buf(),
1470 }
1471 .into());
1472 }
1473 }
1474
1475 if raw_daemon
1478 .proxy_tls
1479 .is_some_and(ProxyTlsMode::is_passthrough)
1480 && port
1481 .as_ref()
1482 .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1483 {
1484 return Err(ConfigParseError::PassthroughWithoutPort {
1485 daemon: short_name.clone(),
1486 path: path.to_path_buf(),
1487 }
1488 .into());
1489 }
1490
1491 if let RunCommand::Argv(argv) = &raw_daemon.run {
1492 match argv.first().map(String::as_str) {
1493 None => {
1494 return Err(ConfigParseError::EmptyRunArgv {
1495 daemon: short_name.clone(),
1496 path: path.to_path_buf(),
1497 }
1498 .into());
1499 }
1500 Some("exec") => {
1503 return Err(ConfigParseError::ExecInRunArgv {
1504 daemon: short_name.clone(),
1505 path: path.to_path_buf(),
1506 }
1507 .into());
1508 }
1509 Some(_) => {}
1510 }
1511 }
1512
1513 if let Some(RunCommand::Argv(argv)) = raw_daemon.ready_cmd.as_ref().map(|c| &c.run) {
1514 match argv.first().map(String::as_str) {
1515 None | Some("") => {
1518 return Err(ConfigParseError::EmptyReadyCmdArgv {
1519 daemon: short_name.clone(),
1520 path: path.to_path_buf(),
1521 }
1522 .into());
1523 }
1524 Some("exec") => {
1525 return Err(ConfigParseError::ExecInReadyCmdArgv {
1526 daemon: short_name.clone(),
1527 path: path.to_path_buf(),
1528 }
1529 .into());
1530 }
1531 Some(_) => {}
1532 }
1533 }
1534
1535 if let Some(RunCommand::Argv(argv)) = raw_daemon.health_cmd.as_ref().map(|c| &c.run) {
1536 match argv.first().map(String::as_str) {
1537 None | Some("") => {
1540 return Err(ConfigParseError::EmptyHealthCmdArgv {
1541 daemon: short_name.clone(),
1542 path: path.to_path_buf(),
1543 }
1544 .into());
1545 }
1546 Some("exec") => {
1547 return Err(ConfigParseError::ExecInHealthCmdArgv {
1548 daemon: short_name.clone(),
1549 path: path.to_path_buf(),
1550 }
1551 .into());
1552 }
1553 Some(_) => {}
1554 }
1555 }
1556
1557 let daemon = PitchforkTomlDaemon {
1558 run: raw_daemon.run,
1559 auto: raw_daemon.auto,
1560 oneshot: raw_daemon.oneshot,
1561 cron: raw_daemon.cron,
1562 retry: raw_daemon.retry,
1563 ready_delay: raw_daemon.ready_delay,
1564 ready_output: raw_daemon.ready_output,
1565 ready_http: raw_daemon.ready_http,
1566 ready_port: raw_daemon.ready_port,
1567 ready_cmd: raw_daemon.ready_cmd,
1568 health_cmd: raw_daemon.health_cmd,
1569 health_http: raw_daemon.health_http,
1570 health_port: raw_daemon.health_port,
1571 port,
1572 proxy: raw_daemon.proxy,
1573 proxy_tls: raw_daemon.proxy_tls,
1574 proxy_tls_port: raw_daemon.proxy_tls_port,
1575 proxy_port: raw_daemon.proxy_port,
1576 proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1577 boot_start: raw_daemon.boot_start,
1578 depends,
1579 watch: raw_daemon.watch,
1580 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1581 dir: raw_daemon.dir,
1582 env: raw_daemon.env,
1583 hooks: raw_daemon.hooks,
1584 mise: raw_daemon.mise,
1585 deferred_template_context: None,
1586 user: raw_daemon.user,
1587 memory_limit: raw_daemon.memory_limit,
1588 cpu_limit: raw_daemon.cpu_limit,
1589 stop_signal: raw_daemon.stop_signal,
1590 pty: raw_daemon.pty,
1591 time_retention: raw_daemon.time_retention,
1592 line_retention: raw_daemon.line_retention,
1593 archive_hook: raw_daemon.archive_hook,
1594 logs: raw_daemon.logs,
1595 path: Some(path.to_path_buf()),
1596 };
1597 if daemon.is_oneshot() {
1598 let conflicts = daemon.oneshot_conflicts();
1599 if !conflicts.is_empty() {
1600 return Err(ConfigParseError::OneshotConflict {
1601 daemon: short_name.clone(),
1602 path: path.to_path_buf(),
1603 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1604 }
1605 .into());
1606 }
1607 }
1608 pt.daemons.insert(id, daemon);
1609 }
1610
1611 if let Some(settings) = raw_config.settings {
1613 pt.settings = settings;
1614 }
1615
1616 pt.env = raw_config.env;
1618
1619 for (slug, entry) in raw_config.slugs {
1621 pt.slugs.insert(
1622 slug,
1623 SlugEntry {
1624 dir: entry.dir.map(env::expand_tilde),
1625 namespace: entry.namespace,
1626 daemon: entry.daemon,
1627 },
1628 );
1629 }
1630
1631 for (name, entry) in raw_config.namespaces {
1633 pt.namespaces.insert(
1634 name,
1635 NamespaceEntry {
1636 config: entry
1637 .config
1638 .iter()
1639 .map(|p| {
1640 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1641 })
1642 .collect(),
1643 dir: env::expand_tilde(entry.dir),
1644 label: entry.label,
1645 },
1646 );
1647 }
1648
1649 for (group_name, raw_group) in raw_config.groups {
1651 let mut daemons = Vec::new();
1652 for daemon_name in &raw_group.daemons {
1653 let id = if daemon_name.contains('/') {
1654 DaemonId::parse(daemon_name).map_err(|e| {
1655 ConfigParseError::InvalidDependency {
1656 daemon: group_name.clone(),
1657 dependency: daemon_name.clone(),
1658 path: path.to_path_buf(),
1659 reason: e.to_string(),
1660 }
1661 })?
1662 } else {
1663 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1664 ConfigParseError::InvalidDaemonName {
1665 name: daemon_name.clone(),
1666 path: path.to_path_buf(),
1667 reason: e.to_string(),
1668 }
1669 })?
1670 };
1671 daemons.push(id);
1672 }
1673 pt.groups.insert(group_name, GroupEntry { daemons });
1674 }
1675
1676 Ok(pt)
1677 }
1678
1679 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1680 let path = path.as_ref();
1681 if !path.exists() {
1682 return Ok(Self::new(path.to_path_buf()));
1683 }
1684 let _lock = xx::fslock::get(path, false)
1685 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1686 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1687 path: path.to_path_buf(),
1688 source: e,
1689 })?;
1690 Self::parse_str(&raw, path)
1691 }
1692
1693 pub fn write(&self) -> Result<()> {
1694 if let Some(path) = &self.path {
1695 let _lock = xx::fslock::get(path, false)
1696 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1697 self.write_unlocked()
1698 } else {
1699 Err(FileError::NoPath.into())
1700 }
1701 }
1702
1703 pub(crate) fn write_unlocked(&self) -> Result<()> {
1709 if let Some(path) = &self.path {
1710 let config_namespace = if path.exists() || self.namespace.is_none() {
1714 namespace_from_path(path)?
1715 } else {
1716 namespace_from_path_with_override(path, self.namespace.as_deref())?
1717 };
1718
1719 let mut raw = PitchforkTomlRaw {
1723 namespace: self.namespace.clone(),
1724 worktree_label: self.worktree_label.clone(),
1725 env: self.env.clone(),
1726 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1727 ..PitchforkTomlRaw::default()
1728 };
1729 for (id, daemon) in &self.daemons {
1730 if id.namespace() != config_namespace {
1731 return Err(miette::miette!(
1732 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1733 id,
1734 path.display(),
1735 id.namespace(),
1736 config_namespace
1737 ));
1738 }
1739 let port = daemon.port.as_ref();
1740 let raw_daemon = PitchforkTomlDaemonRaw {
1741 run: daemon.run.clone(),
1742 auto: daemon.auto.clone(),
1743 oneshot: daemon.oneshot,
1744 cron: daemon.cron.clone(),
1745 retry: daemon.retry,
1746 ready_delay: daemon.ready_delay,
1747 ready_output: daemon.ready_output.clone(),
1748 ready_http: daemon.ready_http.clone(),
1749 ready_port: daemon.ready_port.clone(),
1750 ready_cmd: daemon.ready_cmd.clone(),
1751 health_cmd: daemon.health_cmd.clone(),
1752 health_http: daemon.health_http.clone(),
1753 health_port: daemon.health_port.clone(),
1754 port: port.cloned(),
1755 proxy: daemon.proxy.clone(),
1756 proxy_tls: daemon.proxy_tls,
1757 proxy_tls_port: daemon.proxy_tls_port,
1758 proxy_port: daemon.proxy_port,
1759 proxy_idle_timeout: daemon.proxy_idle_timeout,
1760 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1762 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1763 port_bump_attempts: port
1764 .filter(|p| p.auto_bump())
1765 .map(|p| p.max_bump_attempts()),
1766 boot_start: daemon.boot_start,
1767 depends: daemon
1770 .depends
1771 .iter()
1772 .map(|d| {
1773 if d.namespace() == config_namespace {
1774 d.name().to_string()
1775 } else {
1776 d.qualified()
1777 }
1778 })
1779 .collect(),
1780 watch: daemon.watch.clone(),
1781 watch_mode: match daemon.watch_mode {
1782 WatchMode::Native => None,
1783 mode => Some(mode),
1784 },
1785 dir: daemon.dir.clone(),
1786 env: daemon.env.clone(),
1787 hooks: daemon.hooks.clone(),
1788 mise: daemon.mise,
1789 user: daemon.user.clone(),
1790 memory_limit: daemon.memory_limit,
1791 cpu_limit: daemon.cpu_limit,
1792 stop_signal: daemon.stop_signal,
1793 pty: daemon.pty,
1794 time_retention: daemon.time_retention.clone(),
1795 line_retention: daemon.line_retention,
1796 archive_hook: daemon.archive_hook.clone(),
1797 logs: daemon.logs.clone(),
1798 };
1799 raw.daemons.insert(id.name().to_string(), raw_daemon);
1800 }
1801
1802 for (slug, entry) in &self.slugs {
1804 raw.slugs.insert(
1805 slug.clone(),
1806 SlugEntryRaw {
1807 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1808 namespace: entry.namespace.clone(),
1809 daemon: entry.daemon.clone(),
1810 },
1811 );
1812 }
1813
1814 for (name, group) in &self.groups {
1816 let raw_daemons: Vec<String> = group
1817 .daemons
1818 .iter()
1819 .map(|id| {
1820 if id.namespace() == config_namespace {
1821 id.name().to_string()
1822 } else {
1823 id.qualified()
1824 }
1825 })
1826 .collect();
1827 raw.groups.insert(
1828 name.clone(),
1829 GroupEntryRaw {
1830 daemons: raw_daemons,
1831 },
1832 );
1833 }
1834
1835 for (name, entry) in &self.namespaces {
1837 raw.namespaces.insert(
1838 name.clone(),
1839 NamespaceEntryRaw {
1840 dir: entry.dir.to_string_lossy().to_string(),
1841 config: entry
1842 .config
1843 .iter()
1844 .map(|p| p.to_string_lossy().into_owned())
1845 .collect(),
1846 label: entry.label.clone(),
1847 },
1848 );
1849 }
1850
1851 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1852 path: path.clone(),
1853 source: e,
1854 })?;
1855 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1856 path: path.clone(),
1857 details: Some(e.to_string()),
1858 })?;
1859 invalidate_config_cache();
1860 Ok(())
1861 } else {
1862 Err(FileError::NoPath.into())
1863 }
1864 }
1865
1866 pub fn merge(&mut self, pt: Self) {
1871 if pt.worktree_label.is_some() {
1872 self.worktree_label = pt.worktree_label.clone();
1873 }
1874 for (id, d) in pt.daemons {
1875 self.daemons.insert(id, d);
1876 }
1877 if let Some(env) = pt.env {
1879 let merged = self.env.get_or_insert_with(IndexMap::new);
1880 for (k, v) in env {
1881 merged.insert(k, v);
1882 }
1883 }
1884 for (slug, entry) in pt.slugs {
1886 self.slugs.insert(slug, entry);
1887 }
1888 for (name, group) in pt.groups {
1890 self.groups.insert(name, group);
1891 }
1892 for (name, entry) in pt.namespaces {
1894 self.namespaces.insert(name, entry);
1895 }
1896 self.settings.merge_from(&pt.settings);
1898 }
1899
1900 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1905 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1906 Ok(pt) => pt.slugs,
1907 Err(_) => IndexMap::new(),
1908 }
1909 }
1910
1911 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1918 global_slugs
1919 .keys()
1920 .filter(|k| k.eq_ignore_ascii_case(slug))
1921 .count()
1922 > 1
1923 }
1924
1925 pub fn find_slug_for_daemon_in_registry(
1930 daemon_id: &DaemonId,
1931 global_slugs: &IndexMap<String, SlugEntry>,
1932 ) -> Option<String> {
1933 global_slugs
1934 .iter()
1935 .find(|(slug, entry)| {
1936 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1937 if daemon_id.name() != daemon_name {
1938 return false;
1939 }
1940
1941 if Self::slug_is_ambiguous(slug, global_slugs) {
1944 return false;
1945 }
1946
1947 match entry.resolve_namespace() {
1948 Some(namespace) => daemon_id.namespace() == namespace,
1949 None => false,
1950 }
1951 })
1952 .map(|(slug, _)| slug.clone())
1953 }
1954
1955 #[allow(dead_code)]
1957 pub fn is_slug_registered(slug: &str) -> bool {
1958 Self::read_global_slugs().contains_key(slug)
1959 }
1960
1961 pub fn add_slug_with_namespace(
1967 slug: &str,
1968 namespace: Option<&str>,
1969 daemon: Option<&str>,
1970 ) -> Result<()> {
1971 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1972
1973 if let Some(parent) = global_path.parent() {
1975 std::fs::create_dir_all(parent).map_err(|e| {
1976 miette::miette!(
1977 "Failed to create config directory {}: {e}",
1978 parent.display()
1979 )
1980 })?;
1981 }
1982
1983 let _lock = xx::fslock::get(global_path, false)
1984 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1985
1986 let mut pt = if global_path.exists() {
1987 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1988 path: global_path.to_path_buf(),
1989 source: e,
1990 })?;
1991 Self::parse_str(&raw, global_path)?
1992 } else {
1993 Self::new(global_path.to_path_buf())
1994 };
1995
1996 if let Some(ns) = namespace
2000 && !pt.namespaces.contains_key(ns)
2001 {
2002 let dir = pt
2007 .slugs
2008 .get(slug)
2009 .and_then(|e| {
2010 e.dir.clone().or_else(|| {
2011 e.namespace
2012 .as_ref()
2013 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
2014 })
2015 })
2016 .or_else(|| env::CWD.as_path().canonicalize().ok());
2017 if let Some(ref d) = dir {
2018 pt.namespaces.insert(
2019 ns.to_string(),
2020 NamespaceEntry {
2021 dir: d.clone(),
2022 config: Vec::new(),
2023 label: None,
2024 },
2025 );
2026 }
2027 }
2028
2029 pt.slugs.insert(
2030 slug.to_string(),
2031 SlugEntry {
2032 dir: None,
2033 namespace: namespace.map(str::to_string),
2034 daemon: daemon.map(str::to_string),
2035 },
2036 );
2037 pt.write_unlocked()?;
2038 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2044 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2045 Ok(())
2046 }
2047
2048 pub fn remove_slug(slug: &str) -> Result<bool> {
2050 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2051 if !global_path.exists() {
2052 return Ok(false);
2053 }
2054
2055 let _lock = xx::fslock::get(global_path, false)
2056 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2057
2058 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2059 path: global_path.to_path_buf(),
2060 source: e,
2061 })?;
2062 let mut pt = Self::parse_str(&raw, global_path)?;
2063
2064 let removed = pt.slugs.shift_remove(slug).is_some();
2065 if removed {
2066 pt.write_unlocked()?;
2067 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2073 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2074 }
2075 Ok(removed)
2076 }
2077 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
2080 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
2081 Ok(pt) => pt.namespaces,
2082 Err(_) => IndexMap::new(),
2083 }
2084 }
2085
2086 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
2090 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2091
2092 if let Some(parent) = global_path.parent() {
2094 std::fs::create_dir_all(parent).map_err(|e| {
2095 miette::miette!(
2096 "Failed to create config directory {}: {e}",
2097 parent.display()
2098 )
2099 })?;
2100 }
2101
2102 let _lock = xx::fslock::get(global_path, false)
2103 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2104
2105 let mut pt = if global_path.exists() {
2106 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2107 crate::error::FileError::ReadError {
2108 path: global_path.to_path_buf(),
2109 source: e,
2110 }
2111 })?;
2112 Self::parse_str(&raw, global_path)?
2113 } else {
2114 Self::new(global_path.to_path_buf())
2115 };
2116
2117 let dir = env::expand_tilde(dir);
2118 if let Some(entry) = pt.namespaces.get_mut(name) {
2119 if !entry.config.is_empty()
2120 && crate::extra_configs::normalize(&entry.dir)
2121 != crate::extra_configs::normalize(&dir)
2122 {
2123 miette::bail!(
2124 "namespace '{name}' has external configuration attached to another directory"
2125 );
2126 }
2127 entry.dir = dir;
2128 } else {
2129 pt.namespaces.insert(
2130 name.to_string(),
2131 NamespaceEntry {
2132 dir,
2133 config: Vec::new(),
2134 label: None,
2135 },
2136 );
2137 }
2138 pt.write_unlocked()?;
2139 Ok(())
2140 }
2141
2142 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2144 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2145 if !global_path.exists() {
2146 return Ok(false);
2147 }
2148
2149 let _lock = xx::fslock::get(global_path, false)
2150 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2151
2152 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2153 crate::error::FileError::ReadError {
2154 path: global_path.to_path_buf(),
2155 source: e,
2156 }
2157 })?;
2158 let mut pt = Self::parse_str(&raw, global_path)?;
2159
2160 let removed = pt.namespaces.shift_remove(name).is_some();
2161 if removed {
2162 pt.write_unlocked()?;
2163 }
2164 Ok(removed)
2165 }
2166}
2167
2168#[derive(Debug, Clone, JsonSchema, Default)]
2170pub struct PitchforkTomlDaemon {
2171 #[schemars(example = example_run_command())]
2175 pub run: RunCommand,
2176 #[schemars(default)]
2178 pub auto: Vec<PitchforkTomlAuto>,
2179 pub oneshot: Option<bool>,
2185 pub cron: Option<PitchforkTomlCron>,
2187 #[schemars(default)]
2190 pub retry: Retry,
2191 pub ready_delay: Option<u64>,
2193 pub ready_output: Option<ReadyOutput>,
2195 pub ready_http: Option<ReadyHttp>,
2197 pub ready_port: Option<ReadyPort>,
2201 pub ready_cmd: Option<ReadyCmd>,
2203 pub health_cmd: Option<HealthCmd>,
2205 pub health_http: Option<HealthHttp>,
2207 pub health_port: Option<HealthPort>,
2211 pub port: Option<PortConfig>,
2213 #[serde(skip_serializing_if = "Option::is_none", default)]
2216 pub proxy: Option<ProxyConfig>,
2217 pub proxy_tls: Option<ProxyTlsMode>,
2226 #[schemars(range(min = 1))]
2231 pub proxy_tls_port: Option<u16>,
2232 #[schemars(range(min = 1))]
2238 pub proxy_port: Option<u16>,
2239 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2250 pub boot_start: Option<bool>,
2252 #[schemars(default)]
2254 pub depends: Vec<DaemonId>,
2255 #[schemars(default)]
2257 pub watch: Vec<String>,
2258 #[schemars(default)]
2264 pub watch_mode: WatchMode,
2265 pub dir: Option<String>,
2267 pub env: Option<IndexMap<String, String>>,
2269 pub hooks: Option<PitchforkTomlHooks>,
2271 pub mise: Option<bool>,
2274 #[schemars(skip)]
2277 pub deferred_template_context: Option<String>,
2278 pub user: Option<String>,
2280 pub memory_limit: Option<MemoryLimit>,
2283 pub cpu_limit: Option<CpuLimit>,
2286 pub stop_signal: Option<StopConfig>,
2289 pub pty: Option<bool>,
2291 pub time_retention: Option<String>,
2294 pub line_retention: Option<i64>,
2297 pub archive_hook: Option<String>,
2300 pub logs: Option<PitchforkTomlDaemonLogs>,
2302 #[schemars(skip)]
2303 pub path: Option<PathBuf>,
2304}
2305
2306impl PitchforkTomlDaemon {
2307 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2313 self.proxy_tls_port.or(self.proxy_port)
2314 }
2315
2316 pub fn is_oneshot(&self) -> bool {
2318 self.oneshot.unwrap_or(false)
2319 }
2320
2321 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2324 [
2325 ("ready_delay", self.ready_delay.is_some()),
2326 ("ready_output", self.ready_output.is_some()),
2327 ("ready_http", self.ready_http.is_some()),
2328 ("ready_port", self.ready_port.is_some()),
2329 ("ready_cmd", self.ready_cmd.is_some()),
2330 ("health_cmd", self.health_cmd.is_some()),
2331 ("health_http", self.health_http.is_some()),
2332 ("health_port", self.health_port.is_some()),
2333 ]
2334 .into_iter()
2335 .filter(|(_, set)| *set)
2336 .map(|(name, _)| name)
2337 .collect()
2338 }
2339
2340 pub fn effective_user(&self) -> Option<String> {
2344 let daemon_user = self
2345 .user
2346 .as_deref()
2347 .map(str::trim)
2348 .filter(|u| !u.is_empty());
2349 daemon_user.map(str::to_owned).or_else(|| {
2350 let s = crate::settings::settings();
2351 let su = s.supervisor.user.trim();
2352 (!su.is_empty()).then(|| su.to_owned())
2353 })
2354 }
2355
2356 pub fn to_run_options(
2361 &self,
2362 id: &crate::daemon_id::DaemonId,
2363 cmd: Vec<String>,
2364 ) -> crate::daemon::RunOptions {
2365 use crate::daemon::RunOptions;
2366
2367 let effective_user = self.effective_user();
2368 let dir = crate::ipc::batch::resolve_daemon_dir(
2369 self.dir.as_deref(),
2370 self.path.as_deref(),
2371 effective_user.as_deref(),
2372 );
2373 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2376 id,
2377 &PitchforkToml::read_global_slugs(),
2378 );
2379
2380 RunOptions {
2381 id: id.clone(),
2382 cmd,
2383 run: self.run.shell_script().map(str::to_string),
2384 no_shell: self.run.is_argv(),
2385 replaces_ready_checks: false,
2386 requested_by_client: false,
2388 force: false,
2389 shell_pid: None,
2390 dir: Dir(dir),
2391 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2392 oneshot: self.is_oneshot(),
2393 cron_started: false,
2396 oneshot_wait: None,
2400 on_directory_enter: false,
2401 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2402 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2403 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2404 retry: self.retry,
2405 retry_count: 0,
2406 ready_delay: self.ready_delay,
2407 ready_output: self.ready_output.clone(),
2408 ready_http: self.ready_http.clone(),
2409 ready_port: self.ready_port.clone(),
2410 ready_cmd: self.ready_cmd.clone(),
2411 health_cmd: self.health_cmd.clone(),
2412 health_http: self.health_http.clone(),
2413 health_port: self.health_port.clone(),
2414 port: self.port.clone(),
2415 wait_ready: false,
2416 depends: self.depends.clone(),
2417 env: self.env.clone(),
2418 watch: self.watch.clone(),
2419 watch_mode: self.watch_mode,
2420 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2421 self.path.as_deref(),
2422 )),
2423 mise: self.mise,
2424 deferred_template_context: self.deferred_template_context.clone(),
2425 mise_bin: None,
2427 slug,
2428 proxy: None,
2429 user: self.user.clone(),
2430 memory_limit: self.memory_limit,
2431 cpu_limit: self.cpu_limit,
2432 stop_signal: self.stop_signal,
2433 archive_hook: self
2434 .logs
2435 .as_ref()
2436 .and_then(|l| l.archive_hook.clone())
2437 .or_else(|| self.archive_hook.clone()),
2438 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2439 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2440 pty: self.pty,
2441 proxy_idle_timeout_ms: None,
2443 replaces_saved_record: true,
2444 }
2445 }
2446}
2447fn example_run_command() -> &'static str {
2448 "exec node server.js"
2449}
2450
2451#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2454#[serde(untagged)]
2455pub enum RunCommand {
2456 Shell(String),
2459 Argv(#[schemars(length(min = 1))] Vec<String>),
2463}
2464
2465impl Default for RunCommand {
2466 fn default() -> Self {
2467 Self::Shell(String::new())
2468 }
2469}
2470
2471impl From<String> for RunCommand {
2472 fn from(run: String) -> Self {
2473 Self::Shell(run)
2474 }
2475}
2476
2477impl From<&str> for RunCommand {
2478 fn from(run: &str) -> Self {
2479 Self::Shell(run.to_string())
2480 }
2481}
2482
2483impl From<Vec<String>> for RunCommand {
2484 fn from(argv: Vec<String>) -> Self {
2485 Self::Argv(argv)
2486 }
2487}
2488
2489impl PartialEq<str> for RunCommand {
2490 fn eq(&self, other: &str) -> bool {
2491 matches!(self, Self::Shell(run) if run == other)
2492 }
2493}
2494
2495impl PartialEq<&str> for RunCommand {
2496 fn eq(&self, other: &&str) -> bool {
2497 self == *other
2498 }
2499}
2500
2501impl std::fmt::Display for RunCommand {
2504 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2505 match self {
2506 Self::Shell(run) => f.write_str(run),
2507 Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2508 }
2509 }
2510}
2511
2512impl RunCommand {
2513 pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2516 match self {
2517 Self::Shell(run) => shell_words::split(run),
2518 Self::Argv(argv) => Ok(argv.clone()),
2519 }
2520 }
2521
2522 pub fn shell_script(&self) -> Option<&str> {
2524 match self {
2525 Self::Shell(run) => Some(run),
2526 Self::Argv(_) => None,
2527 }
2528 }
2529
2530 pub fn is_argv(&self) -> bool {
2531 matches!(self, Self::Argv(_))
2532 }
2533
2534 pub fn try_map<E>(
2536 &self,
2537 mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2538 ) -> std::result::Result<Self, E> {
2539 Ok(match self {
2540 Self::Shell(run) => Self::Shell(f(run)?),
2541 Self::Argv(argv) => Self::Argv(
2542 argv.iter()
2543 .map(|arg| f(arg))
2544 .collect::<std::result::Result<_, _>>()?,
2545 ),
2546 })
2547 }
2548}
2549
2550#[cfg(test)]
2551mod tests {
2552 use super::*;
2553 use std::path::Path;
2554
2555 #[test]
2556 fn test_daemon_user_parses_and_flows_to_run_options() {
2557 let pt = PitchforkToml::parse_str(
2558 r#"
2559[daemons.api]
2560run = "node server.js"
2561user = "postgres"
2562"#,
2563 Path::new("/tmp/my-project/pitchfork.toml"),
2564 )
2565 .unwrap();
2566
2567 let id = DaemonId::new("my-project", "api");
2568 let daemon = pt.daemons.get(&id).unwrap();
2569 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2570
2571 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2572 assert_eq!(opts.user.as_deref(), Some("postgres"));
2573 assert!(opts.replaces_saved_record);
2575 }
2576
2577 #[test]
2578 fn test_new_local_file_writes_daemons_of_the_directory_namespace() {
2579 let temp = tempfile::tempdir().unwrap();
2580 std::fs::write(
2581 temp.path().join("pitchfork.toml"),
2582 "namespace = \"myns\"\n\n[daemons.web]\nrun = \"sleep 60\"\n",
2583 )
2584 .unwrap();
2585 let path = temp.path().join("pitchfork.local.toml");
2586 let mut pt = PitchforkToml::new(path.clone());
2587 let id = DaemonId::new(namespace_from_path(&path).unwrap(), "api");
2588 assert_eq!(id.namespace(), "myns");
2589 pt.daemons.insert(
2590 id.clone(),
2591 PitchforkTomlDaemon {
2592 run: "sleep 60".into(),
2593 ..PitchforkTomlDaemon::default()
2594 },
2595 );
2596
2597 pt.write().unwrap();
2598
2599 let parsed = PitchforkToml::read(&path).unwrap();
2600 assert!(parsed.daemons.contains_key(&id));
2601 }
2602
2603 #[test]
2604 fn test_daemon_user_write_roundtrip() {
2605 let temp = tempfile::tempdir().unwrap();
2606 let path = temp.path().join("pitchfork.toml");
2607 let mut pt = PitchforkToml::new(path.clone());
2608 pt.namespace = Some("test-project".to_string());
2609 pt.daemons.insert(
2610 DaemonId::new("test-project", "api"),
2611 PitchforkTomlDaemon {
2612 run: "node server.js".into(),
2613 user: Some("postgres".to_string()),
2614 ..PitchforkTomlDaemon::default()
2615 },
2616 );
2617
2618 pt.write().unwrap();
2619
2620 let raw = std::fs::read_to_string(&path).unwrap();
2621 assert!(raw.contains("user = \"postgres\""));
2622
2623 let parsed = PitchforkToml::read(&path).unwrap();
2624 let daemon = parsed
2625 .daemons
2626 .get(&DaemonId::new("test-project", "api"))
2627 .unwrap();
2628 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2629 }
2630
2631 fn awkward_argv() -> Vec<String> {
2633 [
2634 "node",
2635 "my server.js",
2636 "--name=\"a b\"",
2637 "it's",
2638 "a&b",
2639 "%PATH%",
2640 "$HOME",
2641 ]
2642 .map(str::to_string)
2643 .to_vec()
2644 }
2645
2646 #[test]
2647 fn test_run_array_parses_and_starts_without_a_shell() {
2648 let pt = PitchforkToml::parse_str(
2649 r#"
2650[daemons.api]
2651run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2652"#,
2653 Path::new("/tmp/my-project/pitchfork.toml"),
2654 )
2655 .unwrap();
2656
2657 let id = DaemonId::new("my-project", "api");
2658 let daemon = pt.daemons.get(&id).unwrap();
2659 assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2660
2661 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2662 assert_eq!(opts.cmd, awkward_argv());
2663 assert!(opts.no_shell);
2664 assert_eq!(opts.run, None);
2665 }
2666
2667 #[test]
2668 fn test_run_string_still_goes_through_the_shell() {
2669 let pt = PitchforkToml::parse_str(
2670 "[daemons.api]\nrun = \"exec node server.js\"\n",
2671 Path::new("/tmp/my-project/pitchfork.toml"),
2672 )
2673 .unwrap();
2674
2675 let id = DaemonId::new("my-project", "api");
2676 let daemon = pt.daemons.get(&id).unwrap();
2677 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2678 assert!(!opts.no_shell);
2679 assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2680 }
2681
2682 #[test]
2683 fn test_run_array_write_roundtrip() {
2684 let temp = tempfile::tempdir().unwrap();
2685 let path = temp.path().join("pitchfork.toml");
2686 let mut pt = PitchforkToml::new(path.clone());
2687 pt.namespace = Some("test-project".to_string());
2688 let id = DaemonId::new("test-project", "api");
2689 pt.daemons.insert(
2690 id.clone(),
2691 PitchforkTomlDaemon {
2692 run: RunCommand::Argv(awkward_argv()),
2693 ..PitchforkTomlDaemon::default()
2694 },
2695 );
2696 pt.daemons.insert(
2697 DaemonId::new("test-project", "worker"),
2698 PitchforkTomlDaemon {
2699 run: "exec ./worker --queue \"a b\"".into(),
2700 ..PitchforkTomlDaemon::default()
2701 },
2702 );
2703
2704 pt.write().unwrap();
2705
2706 let parsed = PitchforkToml::read(&path).unwrap();
2707 assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
2708 assert_eq!(
2709 parsed.daemons[&DaemonId::new("test-project", "worker")].run,
2710 "exec ./worker --queue \"a b\""
2711 );
2712 }
2713
2714 #[test]
2715 fn test_run_array_must_name_a_program() {
2716 let err = PitchforkToml::parse_str(
2717 "[daemons.api]\nrun = []\n",
2718 Path::new("/tmp/my-project/pitchfork.toml"),
2719 )
2720 .unwrap_err();
2721 assert!(
2722 err.chain()
2723 .any(|cause| cause.to_string().contains("empty run array")),
2724 "unexpected error: {err:?}"
2725 );
2726 }
2727
2728 #[test]
2729 fn test_run_array_rejects_exec() {
2730 let err = PitchforkToml::parse_str(
2731 "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
2732 Path::new("/tmp/my-project/pitchfork.toml"),
2733 )
2734 .unwrap_err();
2735 assert!(
2736 err.chain().any(|cause| cause
2737 .to_string()
2738 .contains("starts its run array with \"exec\"")),
2739 "unexpected error: {err:?}"
2740 );
2741 }
2742
2743 #[test]
2744 fn test_ready_cmd_array_parses_in_both_forms() {
2745 let pt = PitchforkToml::parse_str(
2746 r#"
2747[daemons.api]
2748run = "node server.js"
2749ready_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2750
2751[daemons.db]
2752run = "postgres"
2753ready_cmd = { run = ["pg_isready", "-h", "localhost"], timeout = "30s" }
2754
2755[daemons.cache]
2756run = "redis-server"
2757ready_cmd = "redis-cli ping"
2758"#,
2759 Path::new("/tmp/my-project/pitchfork.toml"),
2760 )
2761 .unwrap();
2762
2763 let ready = |name: &str| {
2764 pt.daemons[&DaemonId::new("my-project", name)]
2765 .ready_cmd
2766 .clone()
2767 .unwrap()
2768 };
2769 assert_eq!(ready("api").run, RunCommand::Argv(awkward_argv()));
2770 assert_eq!(ready("api").timeout, None);
2771 assert_eq!(
2772 ready("db").run,
2773 RunCommand::Argv(
2774 ["pg_isready", "-h", "localhost"]
2775 .map(str::to_string)
2776 .to_vec()
2777 )
2778 );
2779 assert_eq!(
2780 ready("db").timeout,
2781 Some(std::time::Duration::from_secs(30))
2782 );
2783 assert_eq!(ready("cache").run, "redis-cli ping");
2784 }
2785
2786 #[test]
2787 fn test_ready_cmd_array_write_roundtrip() {
2788 let temp = tempfile::tempdir().unwrap();
2789 let path = temp.path().join("pitchfork.toml");
2790 let mut pt = PitchforkToml::new(path.clone());
2791 pt.namespace = Some("test-project".to_string());
2792 let short = ReadyCmd::new(awkward_argv());
2793 let full = ReadyCmd {
2794 run: RunCommand::Argv(
2795 ["pg_isready", "-h", "localhost"]
2796 .map(str::to_string)
2797 .to_vec(),
2798 ),
2799 timeout: Some(std::time::Duration::from_secs(30)),
2800 };
2801 for (name, ready_cmd) in [("api", &short), ("db", &full)] {
2802 pt.daemons.insert(
2803 DaemonId::new("test-project", name),
2804 PitchforkTomlDaemon {
2805 run: "true".into(),
2806 ready_cmd: Some(ready_cmd.clone()),
2807 ..PitchforkTomlDaemon::default()
2808 },
2809 );
2810 }
2811
2812 pt.write().unwrap();
2813
2814 let parsed = PitchforkToml::read(&path).unwrap();
2815 let ready = |name: &str| {
2816 parsed.daemons[&DaemonId::new("test-project", name)]
2817 .ready_cmd
2818 .clone()
2819 };
2820 assert_eq!(ready("api"), Some(short));
2821 assert_eq!(ready("db"), Some(full));
2822 }
2823
2824 #[test]
2825 fn test_ready_cmd_array_must_name_a_program() {
2826 for ready_cmd in ["[]", "[\"\"]", "{ run = [\"\", \"-h\"] }"] {
2827 let err = PitchforkToml::parse_str(
2828 &format!("[daemons.api]\nrun = \"true\"\nready_cmd = {ready_cmd}\n"),
2829 Path::new("/tmp/my-project/pitchfork.toml"),
2830 )
2831 .unwrap_err();
2832 assert!(
2833 err.chain().any(|cause| cause
2834 .to_string()
2835 .contains("has no program in its ready_cmd array")),
2836 "unexpected error for {ready_cmd}: {err:?}"
2837 );
2838 }
2839 }
2840
2841 #[test]
2842 fn test_ready_cmd_array_rejects_exec() {
2843 let err = PitchforkToml::parse_str(
2844 "[daemons.api]\nrun = \"true\"\nready_cmd = { run = [\"exec\", \"pg_isready\"] }\n",
2845 Path::new("/tmp/my-project/pitchfork.toml"),
2846 )
2847 .unwrap_err();
2848 assert!(
2849 err.chain().any(|cause| cause
2850 .to_string()
2851 .contains("starts its ready_cmd array with \"exec\"")),
2852 "unexpected error: {err:?}"
2853 );
2854 }
2855
2856 #[test]
2857 fn test_health_cmd_array_parses_and_round_trips() {
2858 let pt = PitchforkToml::parse_str(
2859 r#"
2860[daemons.api]
2861run = "node server.js"
2862health_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2863
2864[daemons.db]
2865run = "postgres"
2866health_cmd = { run = ["pg_isready", "-h", "localhost"], interval = "5s", retries = 2 }
2867
2868[daemons.cache]
2869run = "redis-server"
2870health_cmd = "redis-cli ping"
2871"#,
2872 Path::new("/tmp/my-project/pitchfork.toml"),
2873 )
2874 .unwrap();
2875 let health = |pt: &PitchforkToml, ns: &str, name: &str| {
2876 pt.daemons[&DaemonId::new(ns, name)]
2877 .health_cmd
2878 .clone()
2879 .unwrap()
2880 };
2881 assert_eq!(
2882 health(&pt, "my-project", "api").run,
2883 RunCommand::Argv(awkward_argv())
2884 );
2885 let db = health(&pt, "my-project", "db");
2886 assert_eq!(
2887 db.run,
2888 RunCommand::Argv(
2889 ["pg_isready", "-h", "localhost"]
2890 .map(str::to_string)
2891 .to_vec()
2892 )
2893 );
2894 assert_eq!(db.interval, Some(std::time::Duration::from_secs(5)));
2895 assert_eq!(db.retries, Some(2));
2896 assert_eq!(health(&pt, "my-project", "cache").run, "redis-cli ping");
2897
2898 let temp = tempfile::tempdir().unwrap();
2899 let path = temp.path().join("pitchfork.toml");
2900 let mut written = PitchforkToml::new(path.clone());
2901 written.namespace = Some("test-project".to_string());
2902 for name in ["api", "db"] {
2903 written.daemons.insert(
2904 DaemonId::new("test-project", name),
2905 PitchforkTomlDaemon {
2906 run: "true".into(),
2907 health_cmd: Some(health(&pt, "my-project", name)),
2908 ..PitchforkTomlDaemon::default()
2909 },
2910 );
2911 }
2912 written.write().unwrap();
2913 let parsed = PitchforkToml::read(&path).unwrap();
2914 for name in ["api", "db"] {
2915 assert_eq!(
2916 health(&parsed, "test-project", name),
2917 health(&pt, "my-project", name)
2918 );
2919 }
2920 }
2921
2922 #[test]
2923 fn test_health_cmd_array_must_name_a_program_and_not_exec() {
2924 for (health_cmd, expected) in [
2925 ("[]", "has no program in its health_cmd array"),
2926 ("[\"\"]", "has no program in its health_cmd array"),
2927 (
2928 "{ run = [\"exec\", \"pg_isready\"] }",
2929 "starts its health_cmd array with \"exec\"",
2930 ),
2931 ] {
2932 let err = PitchforkToml::parse_str(
2933 &format!("[daemons.api]\nrun = \"true\"\nhealth_cmd = {health_cmd}\n"),
2934 Path::new("/tmp/my-project/pitchfork.toml"),
2935 )
2936 .unwrap_err();
2937 assert!(
2938 err.chain()
2939 .any(|cause| cause.to_string().contains(expected)),
2940 "unexpected error for {health_cmd}: {err:?}"
2941 );
2942 }
2943 }
2944
2945 #[test]
2946 fn test_registry_dirs_expand_tilde() {
2947 let pt = PitchforkToml::parse_str(
2948 r#"
2949[slugs.api]
2950dir = "~/projects/api"
2951
2952[namespaces.web]
2953dir = "~/projects/web"
2954"#,
2955 Path::new("/tmp/config.toml"),
2956 )
2957 .unwrap();
2958
2959 assert_eq!(
2960 pt.slugs["api"].dir,
2961 Some(crate::env::HOME_DIR.join("projects/api"))
2962 );
2963 assert_eq!(
2964 pt.namespaces["web"].dir,
2965 crate::env::HOME_DIR.join("projects/web")
2966 );
2967 }
2968
2969 #[test]
2970 fn test_settings_write_roundtrip() {
2971 let temp = tempfile::tempdir().unwrap();
2972 let path = temp.path().join("pitchfork.toml");
2973 let mut pt = PitchforkToml::new(path.clone());
2974 pt.namespace = Some("test-project".to_string());
2975 pt.settings.web.auto_start = Some(true);
2976 pt.settings.general.log_level = Some("debug".to_string());
2977
2978 pt.write().unwrap();
2979
2980 let raw = std::fs::read_to_string(&path).unwrap();
2981 assert!(
2982 raw.contains("[settings.web]"),
2983 "settings.web section should be written, got:\n{raw}"
2984 );
2985 assert!(raw.contains("auto_start = true"));
2986 assert!(raw.contains("log_level = \"debug\""));
2987
2988 let parsed = PitchforkToml::read(&path).unwrap();
2989 assert_eq!(parsed.settings.web.auto_start, Some(true));
2990 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2991 }
2992
2993 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2994 SlugEntry {
2995 dir: None,
2996 namespace: Some(namespace.to_string()),
2997 daemon: daemon.map(str::to_string),
2998 }
2999 }
3000
3001 #[test]
3002 fn test_slug_is_ambiguous() {
3003 let mut slugs = IndexMap::new();
3004 slugs.insert("api".to_string(), slug_entry("my-project", None));
3005 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
3006
3007 slugs.insert("API".to_string(), slug_entry("other-project", None));
3008 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
3010 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
3011 }
3012
3013 #[test]
3014 fn test_find_slug_for_daemon_skips_case_collisions() {
3015 let id = DaemonId::new("my-project", "api");
3016 let mut slugs = IndexMap::new();
3017 slugs.insert("api".to_string(), slug_entry("my-project", None));
3018 assert_eq!(
3019 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3020 Some("api".to_string())
3021 );
3022
3023 slugs.insert("API".to_string(), slug_entry("other-project", None));
3026 assert_eq!(
3027 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3028 None
3029 );
3030 }
3031
3032 #[test]
3033 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
3034 let id = DaemonId::new("my-project", "api");
3035 let mut slugs = IndexMap::new();
3036 slugs.insert("api".to_string(), slug_entry("my-project", None));
3039 slugs.insert("API".to_string(), slug_entry("my-project", None));
3040 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
3041
3042 assert_eq!(
3043 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3044 Some("my-api".to_string())
3045 );
3046 }
3047
3048 #[test]
3049 fn test_settings_preserved_on_unrelated_write() {
3050 let temp = tempfile::tempdir().unwrap();
3053 let path = temp.path().join("pitchfork.toml");
3054 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
3055
3056 let mut pt = PitchforkToml::read(&path).unwrap();
3057 pt.slugs.insert(
3058 "api".to_string(),
3059 SlugEntry {
3060 dir: None,
3061 namespace: Some("myproject".to_string()),
3062 daemon: None,
3063 },
3064 );
3065 pt.namespaces.insert(
3066 "myproject".to_string(),
3067 NamespaceEntry {
3068 dir: PathBuf::from("/tmp/myproject"),
3069 config: Vec::new(),
3070 label: None,
3071 },
3072 );
3073 pt.write().unwrap();
3074
3075 let raw = std::fs::read_to_string(&path).unwrap();
3076 assert!(
3077 raw.contains("[settings.web]"),
3078 "existing settings must be preserved, got:\n{raw}"
3079 );
3080 assert!(raw.contains("auto_start = true"));
3081 assert!(raw.contains("[slugs.api]"));
3082
3083 let parsed = PitchforkToml::read(&path).unwrap();
3084 assert_eq!(parsed.settings.web.auto_start, Some(true));
3085 assert!(parsed.slugs.contains_key("api"));
3086 }
3087
3088 #[tokio::test]
3089 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
3090 let temp = tempfile::tempdir().unwrap();
3091 let path = temp.path().join("pitchfork.toml");
3092 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
3093 .await
3094 .unwrap();
3095
3096 let read_path = path.clone();
3097 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
3098 .await
3099 .unwrap()
3100 .unwrap();
3101 assert_eq!(pt.settings.general.worktree, Some(false));
3102 assert_eq!(pt.settings.proxy.worktree, None);
3103 tokio::task::spawn_blocking(move || pt.write())
3104 .await
3105 .unwrap()
3106 .unwrap();
3107
3108 let raw = tokio::fs::read_to_string(&path).await.unwrap();
3109 assert!(raw.contains("[settings.general]"), "{raw}");
3110 assert!(raw.contains("worktree = false"), "{raw}");
3111 assert!(!raw.contains("[settings.proxy]"), "{raw}");
3112
3113 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
3114 .await
3115 .unwrap()
3116 .unwrap();
3117 assert_eq!(parsed.settings.general.worktree, Some(false));
3118 }
3119
3120 #[test]
3121 fn test_config_cache_hit_and_invalidation() {
3122 let temp = tempfile::tempdir().unwrap();
3123 let dir = temp.path();
3124 let config_path = dir.join("pitchfork.toml");
3125 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3126
3127 super::invalidate_config_cache();
3129
3130 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3132 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3133 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3134
3135 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3137 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
3138
3139 std::thread::sleep(std::time::Duration::from_millis(50));
3142 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
3143
3144 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
3145 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
3146
3147 super::invalidate_config_cache();
3149 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
3150 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
3151
3152 super::invalidate_config_cache();
3154 }
3155
3156 #[test]
3157 fn test_config_cache_invalidation_on_write() {
3158 let temp = tempfile::tempdir().unwrap();
3159 let dir = temp.path();
3160 let config_path = dir.join("pitchfork.toml");
3161 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3162
3163 super::invalidate_config_cache();
3164
3165 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3167 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3168 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3169
3170 let mut pt = PitchforkToml::read(&config_path).unwrap();
3172 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
3173 let _ = pt.write();
3175
3176 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3178 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
3179
3180 super::invalidate_config_cache();
3181 }
3182
3183 #[test]
3184 fn test_config_cache_size_invalidation() {
3185 let temp = tempfile::tempdir().unwrap();
3186 let dir = temp.path();
3187 let config_path = dir.join("pitchfork.toml");
3188 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3189
3190 super::invalidate_config_cache();
3191
3192 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3194 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3195 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3196
3197 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
3201 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
3202 let file = std::fs::OpenOptions::new()
3205 .write(true)
3206 .open(&config_path)
3207 .unwrap();
3208 let times = std::fs::FileTimes::new().set_modified(original_mtime);
3209 file.set_times(times).unwrap();
3210
3211 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3214 assert_eq!(
3215 pt2.daemons[&daemon_id].run, "echo different",
3216 "cache should invalidate on size change even with identical mtime"
3217 );
3218
3219 super::invalidate_config_cache();
3220 }
3221
3222 #[test]
3223 fn test_find_project_root_in_plain_dir_returns_none() {
3224 let temp = tempfile::tempdir().unwrap();
3225 assert_eq!(find_project_root(temp.path()), None);
3226 }
3227
3228 #[test]
3229 fn test_find_project_root_finds_git_marker() {
3230 let temp = tempfile::tempdir().unwrap();
3231 let repo = temp.path().join("my-repo");
3232 std::fs::create_dir(&repo).unwrap();
3233 std::fs::create_dir(repo.join(".git")).unwrap();
3234
3235 let sub = repo.join("sub/dir");
3236 std::fs::create_dir_all(&sub).unwrap();
3237
3238 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
3241 }
3242
3243 #[test]
3244 fn test_find_project_root_accepts_git_file_marker() {
3245 let temp = tempfile::tempdir().unwrap();
3248 let wt = temp.path().join("my-worktree");
3249 std::fs::create_dir(&wt).unwrap();
3250 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
3251
3252 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
3253 }
3254
3255 #[cfg(unix)]
3258 #[test]
3259 fn test_find_project_root_resolves_symlinked_start_dir() {
3260 use std::os::unix::fs::symlink;
3261
3262 let temp = tempfile::tempdir().unwrap();
3263 let repo = temp.path().join("real-repo");
3264 std::fs::create_dir(&repo).unwrap();
3265 std::fs::create_dir(repo.join(".git")).unwrap();
3266
3267 let sub = repo.join("sub/dir");
3268 std::fs::create_dir_all(&sub).unwrap();
3269 let link = temp.path().join("link-to-sub");
3270 symlink(&sub, &link).unwrap();
3271
3272 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
3273 }
3274
3275 #[test]
3278 fn test_all_merged_all_namespaces_discovers_worktrees() {
3279 let temp = tempfile::tempdir().unwrap();
3280 let repo = temp.path().join("my-repo");
3281 std::fs::create_dir(&repo).unwrap();
3282
3283 let git_init = std::process::Command::new("git")
3285 .args(["init", "-b", "main"])
3286 .current_dir(&repo)
3287 .output()
3288 .expect("git init");
3289 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
3290
3291 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
3292
3293 let git_commit = std::process::Command::new("git")
3294 .args([
3295 "-c",
3296 "user.name=pitchfork-test",
3297 "-c",
3298 "user.email=pitchfork-test@example.com",
3299 "add",
3300 "-A",
3301 ])
3302 .current_dir(&repo)
3303 .output()
3304 .expect("git add");
3305 assert!(git_commit.status.success());
3306
3307 let git_commit = std::process::Command::new("git")
3308 .args([
3309 "-c",
3310 "user.name=pitchfork-test",
3311 "-c",
3312 "user.email=pitchfork-test@example.com",
3313 "commit",
3314 "-m",
3315 "init",
3316 ])
3317 .current_dir(&repo)
3318 .output()
3319 .expect("git commit");
3320 assert!(
3321 git_commit.status.success(),
3322 "git commit failed: {:?}",
3323 git_commit
3324 );
3325
3326 let wt = temp.path().join("my-repo-feature");
3327 let git_wt = std::process::Command::new("git")
3328 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3329 .current_dir(&repo)
3330 .output()
3331 .expect("git worktree add");
3332 assert!(
3333 git_wt.status.success(),
3334 "git worktree add failed: {:?}",
3335 git_wt
3336 );
3337
3338 std::fs::write(
3340 repo.join("pitchfork.toml"),
3341 "[daemons.api]\nrun = \"echo main\"\n",
3342 )
3343 .unwrap();
3344 std::fs::write(
3346 wt.join("pitchfork.toml"),
3347 "[daemons.worker]\nrun = \"echo wt\"\n",
3348 )
3349 .unwrap();
3350
3351 super::invalidate_config_cache();
3352
3353 let pt = PitchforkToml::all_merged_all_namespaces_from_blocking(&wt).unwrap();
3355
3356 let main_id = DaemonId::new("my-repo", "api");
3357 let wt_id = DaemonId::new("my-repo-feature", "worker");
3358 assert!(
3359 pt.daemons.contains_key(&main_id),
3360 "main checkout daemon missing"
3361 );
3362 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3363
3364 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from_blocking(&repo).unwrap();
3366 assert!(pt_from_main.daemons.contains_key(&wt_id));
3367
3368 let _ = std::process::Command::new("git")
3370 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3371 .current_dir(&repo)
3372 .output();
3373 super::invalidate_config_cache();
3374 }
3375
3376 #[test]
3377 fn test_adhoc_id_uses_invocation_directory_namespace() {
3378 let temp = tempfile::tempdir().unwrap();
3379 let project = temp.path().join("feature-tree");
3380 std::fs::create_dir(&project).unwrap();
3381 std::fs::write(
3382 project.join("pitchfork.toml"),
3383 "[daemons.other]\nrun = \"true\"\n",
3384 )
3385 .unwrap();
3386
3387 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3388 assert_eq!(id, DaemonId::new("feature-tree", "api"));
3389 let qualified =
3390 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3391 assert_eq!(qualified, DaemonId::new("explicit", "api"));
3392 }
3393
3394 #[test]
3395 fn test_adhoc_id_falls_back_to_global_without_project_config() {
3396 let temp = tempfile::tempdir().unwrap();
3397 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3398 assert_eq!(id, DaemonId::new("global", "api"));
3399 }
3400}