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, HashSet};
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, ProxyProtocol, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort,
22 Retry, StopConfig, 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, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
88pub struct GroupEntryRaw {
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 #[schemars(with = "Vec<DaemonId>")]
92 pub daemons: Vec<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub groups: Vec<String>,
96}
97
98#[derive(Debug, Clone, Default)]
104pub struct GroupEntry {
105 pub daemons: Vec<DaemonId>,
106 pub groups: Vec<String>,
107}
108
109pub fn expand_group(groups: &IndexMap<String, GroupEntry>, name: &str) -> Result<Vec<DaemonId>> {
116 fn visit(
117 groups: &IndexMap<String, GroupEntry>,
118 name: &str,
119 path: &mut Vec<String>,
120 done: &mut HashSet<String>,
121 seen: &mut HashSet<DaemonId>,
122 out: &mut Vec<DaemonId>,
123 ) -> Result<()> {
124 if done.contains(name) {
127 return Ok(());
128 }
129 let Some(group) = groups.get(name) else {
130 let suggestion = find_similar_daemon(name, groups.keys().map(|s| s.as_str()));
131 return Err(match path.last() {
132 Some(parent) => miette::miette!(
133 "group '{parent}' includes undefined group '{name}'{}",
134 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
135 ),
136 None => miette::miette!(
137 "group '{name}' not found in configuration{}",
138 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
139 ),
140 });
141 };
142 if path.iter().any(|p| p == name) {
143 return Err(miette::miette!(
144 "group include cycle: {} -> {name}",
145 path.join(" -> ")
146 ));
147 }
148 path.push(name.to_string());
149 for included in &group.groups {
150 visit(groups, included, path, done, seen, out)?;
151 }
152 path.pop();
153 for id in &group.daemons {
154 if seen.insert(id.clone()) {
155 out.push(id.clone());
156 }
157 }
158 done.insert(name.to_string());
159 Ok(())
160 }
161
162 let mut out = Vec::new();
163 visit(
164 groups,
165 name,
166 &mut Vec::new(),
167 &mut HashSet::new(),
168 &mut HashSet::new(),
169 &mut out,
170 )?;
171 Ok(out)
172}
173
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
180pub struct NamespaceEntryRaw {
181 pub dir: String,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub config: Vec<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub label: Option<String>,
191}
192
193#[derive(Debug, Clone)]
195pub struct NamespaceEntry {
196 pub dir: PathBuf,
198 pub config: Vec<PathBuf>,
199 pub label: Option<String>,
201}
202
203#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
205struct PitchforkTomlRaw {
206 #[serde(skip_serializing_if = "Option::is_none", default)]
207 pub namespace: Option<String>,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub worktree_label: Option<String>,
211 #[serde(default)]
212 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub env: Option<IndexMap<String, String>>,
217 #[serde(default)]
218 pub settings: Option<SettingsPartial>,
219 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
222 pub slugs: IndexMap<String, SlugEntryRaw>,
223 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
225 pub groups: IndexMap<String, GroupEntryRaw>,
226 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
229 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
230}
231
232#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
237pub struct PitchforkTomlDaemonLogs {
238 #[serde(skip_serializing_if = "Option::is_none", default)]
241 pub log_format: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none", default)]
244 pub time_retention: Option<String>,
245 #[serde(skip_serializing_if = "Option::is_none", default)]
247 pub line_retention: Option<i64>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
250 pub archive_hook: Option<String>,
251}
252
253#[derive(Debug, serde::Serialize, serde::Deserialize)]
260struct PitchforkTomlDaemonRaw {
261 pub run: RunCommand,
262 #[serde(skip_serializing_if = "Vec::is_empty", default)]
263 pub auto: Vec<PitchforkTomlAuto>,
264 #[serde(skip_serializing_if = "Option::is_none", default)]
265 pub oneshot: Option<bool>,
266 #[serde(skip_serializing_if = "Option::is_none", default)]
267 pub cron: Option<PitchforkTomlCron>,
268 #[serde(default)]
269 pub retry: Retry,
270 #[serde(skip_serializing_if = "Option::is_none", default)]
271 pub ready_delay: Option<u64>,
272 #[serde(skip_serializing_if = "Option::is_none", default)]
273 pub ready_output: Option<ReadyOutput>,
274 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub ready_http: Option<ReadyHttp>,
276 #[serde(skip_serializing_if = "Option::is_none", default)]
277 pub ready_port: Option<ReadyPort>,
278 #[serde(skip_serializing_if = "Option::is_none", default)]
279 pub ready_cmd: Option<ReadyCmd>,
280 #[serde(skip_serializing_if = "Option::is_none", default)]
281 pub health_cmd: Option<HealthCmd>,
282 #[serde(skip_serializing_if = "Option::is_none", default)]
283 pub health_http: Option<HealthHttp>,
284 #[serde(skip_serializing_if = "Option::is_none", default)]
285 pub health_port: Option<HealthPort>,
286 #[serde(skip_serializing_if = "Option::is_none", default)]
288 pub port: Option<PortConfig>,
289 #[serde(skip_serializing_if = "Option::is_none", default)]
291 pub proxy: Option<ProxyConfig>,
292 #[serde(skip_serializing_if = "Vec::is_empty", default)]
294 pub expected_port: Vec<u16>,
295 #[serde(skip_serializing_if = "Option::is_none", default)]
297 pub auto_bump_port: Option<bool>,
298 #[serde(skip_serializing_if = "Option::is_none", default)]
300 pub port_bump_attempts: Option<u32>,
301 #[serde(skip_serializing_if = "Option::is_none", default)]
303 pub proxy_tls: Option<ProxyTlsMode>,
304 #[serde(skip_serializing_if = "Option::is_none", default)]
306 pub proxy_protocol: Option<ProxyProtocol>,
307 #[serde(skip_serializing_if = "Option::is_none", default)]
309 pub proxy_tls_port: Option<u16>,
310 #[serde(skip_serializing_if = "Option::is_none", default)]
313 pub proxy_port: Option<u16>,
314 #[serde(skip_serializing_if = "Option::is_none", default)]
316 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
317 #[serde(skip_serializing_if = "Option::is_none", default)]
318 pub boot_start: Option<bool>,
319 #[serde(skip_serializing_if = "Vec::is_empty", default)]
320 pub depends: Vec<String>,
321 #[serde(skip_serializing_if = "Vec::is_empty", default)]
322 pub watch: Vec<String>,
323 #[serde(skip_serializing_if = "Option::is_none", default)]
324 pub watch_mode: Option<WatchMode>,
325 #[serde(skip_serializing_if = "Option::is_none", default)]
326 pub dir: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none", default)]
328 pub env: Option<IndexMap<String, String>>,
329 #[serde(skip_serializing_if = "Vec::is_empty", default)]
330 pub env_file: Vec<String>,
331 #[serde(skip_serializing_if = "Option::is_none", default)]
332 pub hooks: Option<PitchforkTomlHooks>,
333 #[serde(skip_serializing_if = "Option::is_none", default)]
334 pub mise: Option<bool>,
335 #[serde(skip_serializing_if = "Option::is_none", default)]
337 pub user: Option<String>,
338 #[serde(skip_serializing_if = "Option::is_none", default)]
340 pub memory_limit: Option<MemoryLimit>,
341 #[serde(skip_serializing_if = "Option::is_none", default)]
343 pub cpu_limit: Option<CpuLimit>,
344 #[serde(skip_serializing_if = "Option::is_none", default)]
346 pub stop_signal: Option<StopConfig>,
347 #[serde(skip_serializing_if = "Option::is_none", default)]
349 pub pty: Option<bool>,
350 #[serde(skip_serializing_if = "Option::is_none", default)]
353 pub time_retention: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none", default)]
357 pub line_retention: Option<i64>,
358 #[serde(skip_serializing_if = "Option::is_none", default)]
361 pub archive_hook: Option<String>,
362 #[serde(skip_serializing_if = "Option::is_none", default)]
364 pub logs: Option<PitchforkTomlDaemonLogs>,
365}
366
367#[derive(Debug, Clone, Default, JsonSchema)]
372#[schemars(title = "Pitchfork Configuration")]
373pub struct PitchforkToml {
374 #[serde(default)]
376 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
377 #[serde(skip_serializing_if = "Option::is_none", default)]
381 pub env: Option<IndexMap<String, String>>,
382 pub namespace: Option<String>,
387 #[schemars(default, with = "Option<String>")]
391 pub worktree_label: Option<String>,
392 #[serde(default)]
401 pub(crate) settings: SettingsPartial,
402 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
407 pub slugs: IndexMap<String, SlugEntry>,
408 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
410 pub groups: IndexMap<String, GroupEntry>,
411 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
414 pub namespaces: IndexMap<String, NamespaceEntry>,
415 #[schemars(skip)]
416 pub path: Option<PathBuf>,
417 #[schemars(skip)]
425 pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
426}
427
428impl PitchforkToml {
429 pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
432 self.foreign_env
433 .get(id)
434 .map_or(self.env.as_ref(), |env| env.as_ref())
435 }
436}
437
438pub fn is_global_config(path: &Path) -> bool {
439 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
440}
441
442pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
443 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
444}
445
446fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
447 use toml::Value;
448
449 let doc: Value = toml::from_str(content)
450 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
451 let Some(value) = doc.get("namespace") else {
452 return Ok(None);
453 };
454
455 match value {
456 Value::String(s) => Ok(Some(s.clone())),
457 _ => Err(ConfigParseError::InvalidNamespace {
458 path: path.to_path_buf(),
459 namespace: value.to_string(),
460 reason: "top-level 'namespace' must be a string".to_string(),
461 }
462 .into()),
463 }
464}
465
466fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
467 if !path.exists() {
468 return Ok(None);
469 }
470 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
471 path: path.to_path_buf(),
472 source: e,
473 })?;
474 parse_namespace_override_from_content(path, &content)
475}
476
477pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
478 crate::extra_configs::project_dir(path).or_else(|| {
479 if is_dot_config_pitchfork(path) {
480 path.parent().and_then(Path::parent).map(Path::to_path_buf)
481 } else {
482 path.parent().map(Path::to_path_buf)
483 }
484 })
485}
486
487fn project_config_family(path: &Path) -> Vec<PathBuf> {
488 let Some(dir) = project_dir_for_config(path) else {
489 return vec![path.to_path_buf()];
490 };
491 vec![
492 dir.join(".config/pitchfork.toml"),
493 dir.join(".config/pitchfork.local.toml"),
494 dir.join("pitchfork.toml"),
495 dir.join("pitchfork.local.toml"),
496 ]
497}
498
499fn directory_namespace_override(
502 path: &Path,
503 content_override: Option<&str>,
504) -> Result<Option<String>> {
505 if is_global_config(path) {
506 return match content_override {
507 Some(content) => parse_namespace_override_from_content(path, content),
508 None => read_namespace_override_from_file(path),
509 };
510 }
511
512 let mut selected: Option<(String, PathBuf)> = None;
513 for candidate in project_config_family(path) {
514 let explicit = if candidate == path {
515 match content_override {
516 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
517 None => read_namespace_override_from_file(&candidate)?,
518 }
519 } else {
520 read_namespace_override_from_file(&candidate)?
521 };
522 let Some(namespace) = explicit else { continue };
523 if let Some((selected_namespace, selected_path)) = &selected
524 && selected_namespace != &namespace
525 {
526 return Err(ConfigParseError::InvalidNamespace {
527 path: candidate,
528 namespace,
529 reason: format!(
530 "namespace does not match directory-level namespace '{}' declared in {}",
531 selected_namespace,
532 selected_path.display()
533 ),
534 }
535 .into());
536 }
537 selected = Some((namespace, candidate));
538 }
539 Ok(selected.map(|(namespace, _)| namespace))
540}
541
542fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
543 if let Err(e) = DaemonId::try_new(namespace, "probe") {
544 return Err(ConfigParseError::InvalidNamespace {
545 path: path.to_path_buf(),
546 namespace: namespace.to_string(),
547 reason: e.to_string(),
548 }
549 .into());
550 }
551 Ok(namespace.to_string())
552}
553
554fn derive_namespace_from_dir(path: &Path) -> Result<String> {
555 let dir_for_namespace = project_dir_for_config(path);
556 if let Some(namespace) = dir_for_namespace
557 .as_deref()
558 .and_then(crate::extra_configs::namespace_for_dir)
559 {
560 return validate_namespace(path, &namespace);
561 }
562 let raw_namespace = dir_for_namespace
563 .as_deref()
564 .and_then(|p| p.file_name())
565 .and_then(|n| n.to_str())
566 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
567 .to_string();
568
569 validate_namespace(path, &raw_namespace).map_err(|e| {
570 ConfigParseError::InvalidNamespace {
571 path: path.to_path_buf(),
572 namespace: raw_namespace,
573 reason: format!(
574 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
575 ),
576 }
577 .into()
578 })
579}
580
581fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
582 if is_global_config(path) {
583 if let Some(ns) = explicit
584 && ns != "global"
585 {
586 return Err(ConfigParseError::InvalidNamespace {
587 path: path.to_path_buf(),
588 namespace: ns.to_string(),
589 reason: "global config files must use namespace 'global'".to_string(),
590 }
591 .into());
592 }
593 return Ok("global".to_string());
594 }
595
596 if let Some(ns) = explicit {
597 return validate_namespace(path, ns);
598 }
599
600 derive_namespace_from_dir(path)
601}
602
603fn namespace_from_file(path: &Path) -> Result<String> {
604 let explicit = directory_namespace_override(path, None)?;
605 namespace_from_path_with_override(path, explicit.as_deref())
606}
607
608pub fn namespace_from_path(path: &Path) -> Result<String> {
621 namespace_from_file(path)
622}
623
624fn find_project_root(dir: &Path) -> Option<PathBuf> {
632 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
636 let mut current = canonical_dir.as_path();
637 loop {
638 if current.join(".git").exists() || current.join(".jj").exists() {
639 return Some(current.to_path_buf());
640 }
641 current = current.parent()?;
642 }
643}
644
645struct ConfigCacheEntry {
664 config: PitchforkToml,
665 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
667}
668
669static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
676 Lazy::new(|| StdMutex::new(HashMap::new()));
677
678fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
684 if paths.len() != snapshot.len() {
685 return false;
686 }
687 paths
688 .iter()
689 .zip(snapshot.iter())
690 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
691}
692
693pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
695 let md = std::fs::metadata(path).ok()?;
696 Some((md.modified().ok()?, md.len()))
697}
698
699fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
701 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
702}
703
704pub fn invalidate_config_cache() {
721 crate::extra_configs::invalidate();
722 crate::web::routes::api::projects::invalidate_group_cache();
725 if let Ok(mut cache) = CONFIG_CACHE.lock() {
726 cache.clear();
727 }
728}
729
730impl PitchforkToml {
731 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
744 if user_id.contains('/') {
746 return match DaemonId::parse(user_id) {
747 Ok(id) => Ok(vec![id]),
748 Err(e) => Err(e), };
750 }
751
752 let global_slugs = Self::read_global_slugs();
754 if let Some(entry) = global_slugs.get(user_id) {
755 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
757 if let Some(dir) = entry.resolve_dir()
758 && let Ok(project_config) = Self::all_merged_from(&dir)
759 {
760 let matches: Vec<DaemonId> = project_config
762 .daemons
763 .keys()
764 .filter(|id| id.name() == daemon_name)
765 .cloned()
766 .collect();
767 match matches.as_slice() {
768 [] => {}
769 [id] => return Ok(vec![id.clone()]),
770 _ => {
771 let mut candidates: Vec<String> =
772 matches.iter().map(|id| id.qualified()).collect();
773 candidates.sort();
774 return Err(miette::miette!(
775 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
776 user_id,
777 daemon_name,
778 candidates.join(", ")
779 ));
780 }
781 }
782 }
783 }
784
785 let matches: Vec<DaemonId> = self
787 .daemons
788 .keys()
789 .filter(|id| id.name() == user_id)
790 .cloned()
791 .collect();
792
793 if matches.is_empty() {
794 let state_matches = Self::find_in_state_file(user_id);
796 match state_matches.as_slice() {
797 [] => {}
798 [id] => return Ok(vec![id.clone()]),
799 _ => {
800 let mut candidates: Vec<String> =
801 state_matches.iter().map(|id| id.qualified()).collect();
802 candidates.sort();
803 return Err(miette::miette!(
804 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
805 user_id,
806 candidates.join(", ")
807 ));
808 }
809 }
810 let _ = DaemonId::try_new("global", user_id)?;
812 }
813 Ok(matches)
814 }
815
816 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
822 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
823 Ok(state) => state
824 .daemons
825 .keys()
826 .filter(|id| id.name() == short_name)
827 .cloned()
828 .collect(),
829 Err(e) => {
830 warn!("cannot read state file: {e}");
831 Vec::new()
832 }
833 }
834 }
835
836 #[allow(dead_code)]
857 pub fn resolve_daemon_id_prefer_local(
858 &self,
859 user_id: &str,
860 current_dir: &Path,
861 ) -> Result<DaemonId> {
862 if user_id.contains('/') {
864 return DaemonId::parse(user_id);
865 }
866
867 let current_namespace = Self::namespace_for_dir(current_dir)?;
871
872 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
873 }
874
875 fn resolve_daemon_id_with_namespace(
878 &self,
879 user_id: &str,
880 current_namespace: &str,
881 ) -> Result<DaemonId> {
882 if let Ok(preferred_id) = DaemonId::try_new(current_namespace, user_id)
887 && self.daemons.contains_key(&preferred_id)
888 {
889 return Ok(preferred_id);
890 }
891
892 let global_slugs = Self::read_global_slugs();
894 if let Some(entry) = global_slugs.get(user_id) {
895 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
896 if let Some(dir) = entry.resolve_dir()
897 && let Ok(project_config) = Self::all_merged_from(&dir)
898 {
899 let matches: Vec<DaemonId> = project_config
900 .daemons
901 .keys()
902 .filter(|id| id.name() == daemon_name)
903 .cloned()
904 .collect();
905 match matches.as_slice() {
906 [] => {}
907 [id] => return Ok(id.clone()),
908 _ => {
909 let mut candidates: Vec<String> =
910 matches.iter().map(|id| id.qualified()).collect();
911 candidates.sort();
912 return Err(miette::miette!(
913 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
914 user_id,
915 daemon_name,
916 candidates.join(", ")
917 ));
918 }
919 }
920 }
921 }
922
923 DaemonId::try_new(current_namespace, user_id)?;
925
926 let matches = self.resolve_daemon_id(user_id)?;
928
929 if matches.len() > 1 {
931 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
932 candidates.sort();
933 return Err(miette::miette!(
934 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
935 user_id,
936 candidates.join(", ")
937 ));
938 }
939
940 if let Some(id) = matches.into_iter().next() {
941 return Ok(id);
942 }
943
944 let global_id = DaemonId::try_new("global", user_id)?;
947 if self.daemons.contains_key(&global_id) {
948 return Ok(global_id);
949 }
950
951 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
952 Err(DependencyError::DaemonNotFound {
953 name: user_id.to_string(),
954 suggestion,
955 }
956 .into())
957 }
958
959 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
961 namespace_from_path(&dir.join("pitchfork.toml"))
962 }
963
964 pub fn project_worktree_label(dir: &Path) -> Option<String> {
972 let mut label = None;
973 let candidates = project_config_family(&dir.join("pitchfork.toml"))
974 .into_iter()
975 .chain(crate::extra_configs::configs_for_dir(dir));
976 for candidate in candidates {
977 if !candidate.exists() {
978 continue;
979 }
980 if let Ok(pt) = Self::read(&candidate)
981 && let Some(found) = pt.worktree_label
982 {
983 label = Some(found);
984 }
985 }
986 label
987 }
988
989 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
991 directory_namespace_override(&dir.join("pitchfork.toml"), None)
992 }
993
994 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
996 Ok(Self::list_paths_from(dir)
997 .iter()
998 .filter(|p| p.exists())
999 .max_by_key(|p| {
1000 if is_global_config(p) {
1001 0
1002 } else {
1003 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
1004 }
1005 })
1006 .map(|p| namespace_from_path(p))
1007 .transpose()?
1008 .unwrap_or_else(|| "global".to_string()))
1009 }
1010
1011 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
1021 if user_id.contains('/') {
1022 return DaemonId::parse(user_id);
1023 }
1024
1025 let config = Self::all_merged()?;
1028 let ns = Self::namespace_for_dir(&env::CWD)?;
1029 config.resolve_daemon_id_with_namespace(user_id, &ns)
1030 }
1031
1032 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
1038 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
1039 }
1040
1041 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
1042 if user_id.contains('/') {
1043 return DaemonId::parse(user_id);
1044 }
1045
1046 let ns = Self::namespace_for_dir(dir)?;
1047 DaemonId::try_new(ns, user_id)
1048 }
1049
1050 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
1061 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
1063 return user_ids
1064 .iter()
1065 .map(|s| DaemonId::parse(s.as_ref()))
1066 .collect();
1067 }
1068
1069 let config = Self::all_merged()?;
1070 let ns = Self::namespace_for_dir(&env::CWD)?;
1072 user_ids
1073 .iter()
1074 .map(|s| {
1075 let id = s.as_ref();
1076 if id.contains('/') {
1077 DaemonId::parse(id)
1078 } else {
1079 config.resolve_daemon_id_with_namespace(id, &ns)
1080 }
1081 })
1082 .collect()
1083 }
1084
1085 pub fn resolve_ids_and_group<S: AsRef<str>>(
1090 user_ids: &[S],
1091 group_name: Option<&str>,
1092 ) -> Result<Vec<DaemonId>> {
1093 let config = Self::all_merged()?;
1094 let ns = Self::namespace_for_dir(&env::CWD)?;
1095 let mut ids = Vec::new();
1096 let mut seen = std::collections::HashSet::new();
1097
1098 for id in user_ids {
1099 let id_str = id.as_ref();
1100 let daemon_id = if id_str.contains('/') {
1101 DaemonId::parse(id_str)?
1102 } else {
1103 config.resolve_daemon_id_with_namespace(id_str, &ns)?
1104 };
1105 if seen.insert(daemon_id.clone()) {
1106 ids.push(daemon_id);
1107 }
1108 }
1109
1110 if let Some(name) = group_name {
1111 let members = expand_group(&config.groups, name)?;
1112 let missing: Vec<String> = members
1113 .iter()
1114 .filter(|id| !config.daemons.contains_key(*id))
1115 .map(|id| id.qualified())
1116 .collect();
1117 if !missing.is_empty() {
1118 return Err(miette::miette!(
1119 "group '{}' references undefined daemon{}: {}",
1120 name,
1121 if missing.len() > 1 { "s" } else { "" },
1122 missing.join(", ")
1123 ));
1124 }
1125 for daemon_id in members {
1126 if seen.insert(daemon_id.clone()) {
1127 ids.push(daemon_id);
1128 }
1129 }
1130 }
1131
1132 Ok(ids)
1133 }
1134
1135 pub fn list_paths() -> Vec<PathBuf> {
1138 Self::list_paths_from(&env::CWD)
1139 }
1140
1141 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1152 let mut paths = Vec::new();
1153 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1154 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1155
1156 let mut project_paths = xx::file::find_up_all(
1160 cwd,
1161 &[
1162 "pitchfork.local.toml",
1163 "pitchfork.toml",
1164 ".config/pitchfork.local.toml",
1165 ".config/pitchfork.toml",
1166 ],
1167 );
1168 project_paths.reverse();
1169 paths.extend(project_paths);
1170 paths.extend(crate::extra_configs::paths_for(cwd));
1171
1172 paths
1173 }
1174
1175 pub fn all_merged() -> Result<PitchforkToml> {
1178 Self::all_merged_from(&env::CWD)
1179 }
1180 pub async fn all_merged_all_namespaces() -> Result<Self> {
1192 Self::all_merged_all_namespaces_from(&env::CWD).await
1193 }
1194
1195 pub(crate) async fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1197 let start_dir = start_dir.to_path_buf();
1198 match tokio::task::spawn_blocking(move || {
1199 Self::all_merged_all_namespaces_from_blocking(&start_dir)
1200 })
1201 .await
1202 {
1203 Ok(result) => result,
1204 Err(e) => Err(miette::miette!("reading the merged config panicked: {e}")),
1205 }
1206 }
1207
1208 pub fn all_merged_all_namespaces_blocking() -> Result<Self> {
1211 Self::all_merged_all_namespaces_from_blocking(&env::CWD)
1212 }
1213
1214 pub(crate) fn all_merged_all_namespaces_from_blocking(start_dir: &Path) -> Result<Self> {
1217 let mut pt = Self::all_merged_from(start_dir)?;
1218
1219 let namespaces = Self::read_global_namespaces();
1220 for (ns_name, entry) in namespaces {
1221 match Self::all_merged_from(&entry.dir) {
1222 Ok(ns_config) => {
1223 for (daemon_id, daemon_config) in ns_config.daemons {
1224 if !pt.daemons.contains_key(&daemon_id) {
1225 pt.foreign_env
1226 .insert(daemon_id.clone(), ns_config.env.clone());
1227 pt.daemons.insert(daemon_id, daemon_config);
1228 }
1229 }
1230 pt.settings.merge_from(&ns_config.settings);
1233 }
1234 Err(e) => {
1235 log::warn!(
1236 "Failed to load namespace '{ns_name}' from {}: {e}",
1237 entry.dir.display()
1238 );
1239 }
1240 }
1241 }
1242
1243 if crate::settings::settings().general.worktree
1253 && let Some(project_root) = find_project_root(start_dir)
1254 {
1255 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1256 for wt in &worktrees {
1257 match Self::all_merged_from(&wt.path) {
1258 Ok(wt_config) => {
1259 for (daemon_id, daemon_config) in wt_config.daemons {
1260 if !pt.daemons.contains_key(&daemon_id) {
1261 pt.daemons.insert(daemon_id, daemon_config);
1262 }
1263 }
1264 pt.settings.merge_from(&wt_config.settings);
1265 }
1266 Err(e) => {
1267 log::warn!(
1268 "Failed to load worktree '{}' config from {}: {e}",
1269 wt.branch,
1270 wt.path.display()
1271 );
1272 }
1273 }
1274 }
1275 }
1276
1277 Ok(pt)
1278 }
1279
1280 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1298 let paths = Self::list_paths_from(cwd);
1299
1300 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1304
1305 {
1306 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1307 if let Some(entry) = cache.get(&cache_key)
1308 && meta_matches(&paths, &entry.source_meta)
1309 {
1310 return Ok(entry.config.clone());
1311 }
1312 }
1313
1314 let snapshot = snapshot_meta(&paths);
1320 let pt = Self::all_merged_from_uncached(&paths)?;
1321
1322 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1324 cache.insert(
1325 cache_key,
1326 ConfigCacheEntry {
1327 config: pt.clone(),
1328 source_meta: snapshot,
1329 },
1330 );
1331
1332 Ok(pt)
1333 }
1334
1335 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1340 use std::collections::HashMap as StdHashMap;
1341
1342 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1343
1344 let mut pt = Self::default();
1345 for p in paths {
1346 match Self::read(p) {
1347 Ok(pt2) => {
1348 if p.exists() && !is_global_config(p) {
1352 let ns = namespace_from_path(p)?;
1353 let origin_dir = project_dir_for_config(p)
1354 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1355 .unwrap_or_else(|| p.clone());
1356
1357 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1358 && *other_dir != origin_dir
1359 {
1360 return Err(crate::error::ConfigParseError::NamespaceCollision {
1361 path_a: other_path.clone(),
1362 path_b: p.clone(),
1363 ns,
1364 }
1365 .into());
1366 }
1367 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1368 }
1369
1370 pt.merge(pt2)
1371 }
1372 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1373 }
1374 }
1375 Ok(pt)
1376 }
1377}
1378
1379impl PitchforkToml {
1380 pub fn new(path: PathBuf) -> Self {
1381 Self {
1382 daemons: Default::default(),
1383 env: None,
1384 namespace: None,
1385 worktree_label: None,
1386 settings: SettingsPartial::default(),
1387 slugs: IndexMap::new(),
1388 groups: IndexMap::new(),
1389 namespaces: IndexMap::new(),
1390 path: Some(path),
1391 foreign_env: IndexMap::new(),
1392 }
1393 }
1394
1395 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1403 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1404 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1405 if let Some(settings) = &mut raw_config.settings {
1406 settings.canonicalize_aliases();
1407 }
1408
1409 let explicit = directory_namespace_override(path, Some(content))?;
1410 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1411 let mut pt = Self::new(path.to_path_buf());
1412 pt.namespace = raw_config.namespace.clone();
1413 pt.worktree_label = raw_config.worktree_label.clone();
1414
1415 for (short_name, raw_daemon) in raw_config.daemons {
1416 let id = match DaemonId::try_new(&namespace, &short_name) {
1417 Ok(id) => id,
1418 Err(e) => {
1419 return Err(ConfigParseError::InvalidDaemonName {
1420 name: short_name,
1421 path: path.to_path_buf(),
1422 reason: e.to_string(),
1423 }
1424 .into());
1425 }
1426 };
1427
1428 let mut depends = Vec::new();
1429 for dep in raw_daemon.depends {
1430 let dep_id = if dep.contains('/') {
1431 match DaemonId::parse(&dep) {
1432 Ok(id) => id,
1433 Err(e) => {
1434 return Err(ConfigParseError::InvalidDependency {
1435 daemon: short_name.clone(),
1436 dependency: dep,
1437 path: path.to_path_buf(),
1438 reason: e.to_string(),
1439 }
1440 .into());
1441 }
1442 }
1443 } else {
1444 match DaemonId::try_new(&namespace, &dep) {
1445 Ok(id) => id,
1446 Err(e) => {
1447 return Err(ConfigParseError::InvalidDependency {
1448 daemon: short_name.clone(),
1449 dependency: dep,
1450 path: path.to_path_buf(),
1451 reason: e.to_string(),
1452 }
1453 .into());
1454 }
1455 }
1456 };
1457 depends.push(dep_id);
1458 }
1459
1460 let has_deprecated = !raw_daemon.expected_port.is_empty()
1462 || raw_daemon.auto_bump_port.is_some()
1463 || raw_daemon.port_bump_attempts.is_some();
1464 let deprecated_port = has_deprecated.then(|| {
1465 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1466 PortBump(
1467 raw_daemon
1468 .port_bump_attempts
1469 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1470 )
1471 } else {
1472 PortBump(0)
1473 };
1474 PortConfig {
1475 expect: raw_daemon.expected_port,
1476 bump,
1477 }
1478 });
1479 let port = match (raw_daemon.port, deprecated_port) {
1480 (Some(port), Some(deprecated)) => {
1481 if deprecated != port {
1484 warn!(
1485 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1486 );
1487 }
1488 Some(port)
1489 }
1490 (Some(port), None) => Some(port),
1491 (None, Some(deprecated)) => {
1492 deprecated_at!(
1493 "2026-10-03",
1494 "2027-10-03",
1495 format!("port-fields:{id}"),
1496 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead."
1497 );
1498 Some(deprecated)
1499 }
1500 (None, None) => None,
1501 };
1502
1503 if let (Some(explicit), Some(short)) =
1505 (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1506 && explicit != short
1507 {
1508 warn!(
1509 "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1510 disagree; using proxy_tls_port"
1511 );
1512 }
1513 let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1514
1515 for (key, value) in [
1518 ("proxy_tls_port", raw_daemon.proxy_tls_port),
1519 ("proxy_port", raw_daemon.proxy_port),
1520 ] {
1521 if value == Some(0) {
1522 return Err(ConfigParseError::ProxyPortZero {
1523 daemon: short_name.clone(),
1524 key,
1525 path: path.to_path_buf(),
1526 }
1527 .into());
1528 }
1529 }
1530
1531 if let Some(port_want) = proxy_tls_port {
1534 let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1535 if !declared.contains(&port_want) {
1536 return Err(ConfigParseError::ProxyPortNotDeclared {
1537 daemon: short_name.clone(),
1538 port: port_want,
1539 declared,
1540 path: path.to_path_buf(),
1541 }
1542 .into());
1543 }
1544 }
1545
1546 if raw_daemon
1549 .proxy_tls
1550 .is_some_and(ProxyTlsMode::is_passthrough)
1551 && port
1552 .as_ref()
1553 .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1554 {
1555 return Err(ConfigParseError::PassthroughWithoutPort {
1556 daemon: short_name.clone(),
1557 path: path.to_path_buf(),
1558 }
1559 .into());
1560 }
1561
1562 if let RunCommand::Argv(argv) = &raw_daemon.run {
1563 match argv.first().map(String::as_str) {
1564 None => {
1565 return Err(ConfigParseError::EmptyRunArgv {
1566 daemon: short_name.clone(),
1567 path: path.to_path_buf(),
1568 }
1569 .into());
1570 }
1571 Some("exec") => {
1574 return Err(ConfigParseError::ExecInRunArgv {
1575 daemon: short_name.clone(),
1576 path: path.to_path_buf(),
1577 }
1578 .into());
1579 }
1580 Some(_) => {}
1581 }
1582 }
1583
1584 if let Some(RunCommand::Argv(argv)) = raw_daemon.ready_cmd.as_ref().map(|c| &c.run) {
1585 match argv.first().map(String::as_str) {
1586 None | Some("") => {
1589 return Err(ConfigParseError::EmptyReadyCmdArgv {
1590 daemon: short_name.clone(),
1591 path: path.to_path_buf(),
1592 }
1593 .into());
1594 }
1595 Some("exec") => {
1596 return Err(ConfigParseError::ExecInReadyCmdArgv {
1597 daemon: short_name.clone(),
1598 path: path.to_path_buf(),
1599 }
1600 .into());
1601 }
1602 Some(_) => {}
1603 }
1604 }
1605
1606 if let Some(RunCommand::Argv(argv)) = raw_daemon.health_cmd.as_ref().map(|c| &c.run) {
1607 match argv.first().map(String::as_str) {
1608 None | Some("") => {
1611 return Err(ConfigParseError::EmptyHealthCmdArgv {
1612 daemon: short_name.clone(),
1613 path: path.to_path_buf(),
1614 }
1615 .into());
1616 }
1617 Some("exec") => {
1618 return Err(ConfigParseError::ExecInHealthCmdArgv {
1619 daemon: short_name.clone(),
1620 path: path.to_path_buf(),
1621 }
1622 .into());
1623 }
1624 Some(_) => {}
1625 }
1626 }
1627
1628 let daemon = PitchforkTomlDaemon {
1629 run: raw_daemon.run,
1630 auto: raw_daemon.auto,
1631 oneshot: raw_daemon.oneshot,
1632 cron: raw_daemon.cron,
1633 retry: raw_daemon.retry,
1634 ready_delay: raw_daemon.ready_delay,
1635 ready_output: raw_daemon.ready_output,
1636 ready_http: raw_daemon.ready_http,
1637 ready_port: raw_daemon.ready_port,
1638 ready_cmd: raw_daemon.ready_cmd,
1639 health_cmd: raw_daemon.health_cmd,
1640 health_http: raw_daemon.health_http,
1641 health_port: raw_daemon.health_port,
1642 port,
1643 proxy: raw_daemon.proxy,
1644 proxy_tls: raw_daemon.proxy_tls,
1645 proxy_protocol: raw_daemon.proxy_protocol,
1646 proxy_tls_port: raw_daemon.proxy_tls_port,
1647 proxy_port: raw_daemon.proxy_port,
1648 proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1649 boot_start: raw_daemon.boot_start,
1650 depends,
1651 watch: raw_daemon.watch,
1652 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1653 dir: raw_daemon.dir,
1654 env: raw_daemon.env,
1655 env_file: raw_daemon.env_file,
1656 hooks: raw_daemon.hooks,
1657 mise: raw_daemon.mise,
1658 deferred_template_context: None,
1659 user: raw_daemon.user,
1660 memory_limit: raw_daemon.memory_limit,
1661 cpu_limit: raw_daemon.cpu_limit,
1662 stop_signal: raw_daemon.stop_signal,
1663 pty: raw_daemon.pty,
1664 time_retention: raw_daemon.time_retention,
1665 line_retention: raw_daemon.line_retention,
1666 archive_hook: raw_daemon.archive_hook,
1667 logs: raw_daemon.logs,
1668 path: Some(path.to_path_buf()),
1669 };
1670 if daemon.is_oneshot() {
1671 let conflicts = daemon.oneshot_conflicts();
1672 if !conflicts.is_empty() {
1673 return Err(ConfigParseError::OneshotConflict {
1674 daemon: short_name.clone(),
1675 path: path.to_path_buf(),
1676 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1677 }
1678 .into());
1679 }
1680 }
1681 pt.daemons.insert(id, daemon);
1682 }
1683
1684 if let Some(settings) = raw_config.settings {
1686 pt.settings = settings;
1687 }
1688
1689 pt.env = raw_config.env;
1691
1692 for (slug, entry) in raw_config.slugs {
1694 pt.slugs.insert(
1695 slug,
1696 SlugEntry {
1697 dir: entry.dir.map(env::expand_tilde),
1698 namespace: entry.namespace,
1699 daemon: entry.daemon,
1700 },
1701 );
1702 }
1703
1704 for (name, entry) in raw_config.namespaces {
1706 pt.namespaces.insert(
1707 name,
1708 NamespaceEntry {
1709 config: entry
1710 .config
1711 .iter()
1712 .map(|p| {
1713 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1714 })
1715 .collect(),
1716 dir: env::expand_tilde(entry.dir),
1717 label: entry.label,
1718 },
1719 );
1720 }
1721
1722 for (group_name, raw_group) in raw_config.groups {
1724 let mut daemons = Vec::new();
1725 for daemon_name in &raw_group.daemons {
1726 let id = if daemon_name.contains('/') {
1727 DaemonId::parse(daemon_name).map_err(|e| {
1728 ConfigParseError::InvalidDependency {
1729 daemon: group_name.clone(),
1730 dependency: daemon_name.clone(),
1731 path: path.to_path_buf(),
1732 reason: e.to_string(),
1733 }
1734 })?
1735 } else {
1736 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1737 ConfigParseError::InvalidDaemonName {
1738 name: daemon_name.clone(),
1739 path: path.to_path_buf(),
1740 reason: e.to_string(),
1741 }
1742 })?
1743 };
1744 daemons.push(id);
1745 }
1746 pt.groups.insert(
1747 group_name,
1748 GroupEntry {
1749 daemons,
1750 groups: raw_group.groups,
1751 },
1752 );
1753 }
1754
1755 Ok(pt)
1756 }
1757
1758 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1759 let path = path.as_ref();
1760 if !path.exists() {
1761 return Ok(Self::new(path.to_path_buf()));
1762 }
1763 let _lock = xx::fslock::get(path, false)
1764 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1765 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1766 path: path.to_path_buf(),
1767 source: e,
1768 })?;
1769 Self::parse_str(&raw, path)
1770 }
1771
1772 pub fn write(&self) -> Result<()> {
1773 if let Some(path) = &self.path {
1774 let _lock = xx::fslock::get(path, false)
1775 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1776 self.write_unlocked()
1777 } else {
1778 Err(FileError::NoPath.into())
1779 }
1780 }
1781
1782 pub(crate) fn write_unlocked(&self) -> Result<()> {
1788 if let Some(path) = &self.path {
1789 let config_namespace = if path.exists() || self.namespace.is_none() {
1793 namespace_from_path(path)?
1794 } else {
1795 namespace_from_path_with_override(path, self.namespace.as_deref())?
1796 };
1797
1798 let mut raw = PitchforkTomlRaw {
1802 namespace: self.namespace.clone(),
1803 worktree_label: self.worktree_label.clone(),
1804 env: self.env.clone(),
1805 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1806 ..PitchforkTomlRaw::default()
1807 };
1808 for (id, daemon) in &self.daemons {
1809 if id.namespace() != config_namespace {
1810 return Err(miette::miette!(
1811 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1812 id,
1813 path.display(),
1814 id.namespace(),
1815 config_namespace
1816 ));
1817 }
1818 let port = daemon.port.as_ref();
1819 let raw_daemon = PitchforkTomlDaemonRaw {
1820 run: daemon.run.clone(),
1821 auto: daemon.auto.clone(),
1822 oneshot: daemon.oneshot,
1823 cron: daemon.cron.clone(),
1824 retry: daemon.retry,
1825 ready_delay: daemon.ready_delay,
1826 ready_output: daemon.ready_output.clone(),
1827 ready_http: daemon.ready_http.clone(),
1828 ready_port: daemon.ready_port.clone(),
1829 ready_cmd: daemon.ready_cmd.clone(),
1830 health_cmd: daemon.health_cmd.clone(),
1831 health_http: daemon.health_http.clone(),
1832 health_port: daemon.health_port.clone(),
1833 port: port.cloned(),
1834 proxy: daemon.proxy.clone(),
1835 proxy_tls: daemon.proxy_tls,
1836 proxy_protocol: daemon.proxy_protocol,
1837 proxy_tls_port: daemon.proxy_tls_port,
1838 proxy_port: daemon.proxy_port,
1839 proxy_idle_timeout: daemon.proxy_idle_timeout,
1840 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1842 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1843 port_bump_attempts: port
1844 .filter(|p| p.auto_bump())
1845 .map(|p| p.max_bump_attempts()),
1846 boot_start: daemon.boot_start,
1847 depends: daemon
1850 .depends
1851 .iter()
1852 .map(|d| {
1853 if d.namespace() == config_namespace {
1854 d.name().to_string()
1855 } else {
1856 d.qualified()
1857 }
1858 })
1859 .collect(),
1860 watch: daemon.watch.clone(),
1861 watch_mode: match daemon.watch_mode {
1862 WatchMode::Native => None,
1863 mode => Some(mode),
1864 },
1865 dir: daemon.dir.clone(),
1866 env: daemon.env.clone(),
1867 env_file: daemon.env_file.clone(),
1868 hooks: daemon.hooks.clone(),
1869 mise: daemon.mise,
1870 user: daemon.user.clone(),
1871 memory_limit: daemon.memory_limit,
1872 cpu_limit: daemon.cpu_limit,
1873 stop_signal: daemon.stop_signal,
1874 pty: daemon.pty,
1875 time_retention: daemon.time_retention.clone(),
1876 line_retention: daemon.line_retention,
1877 archive_hook: daemon.archive_hook.clone(),
1878 logs: daemon.logs.clone(),
1879 };
1880 raw.daemons.insert(id.name().to_string(), raw_daemon);
1881 }
1882
1883 for (slug, entry) in &self.slugs {
1885 raw.slugs.insert(
1886 slug.clone(),
1887 SlugEntryRaw {
1888 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1889 namespace: entry.namespace.clone(),
1890 daemon: entry.daemon.clone(),
1891 },
1892 );
1893 }
1894
1895 for (name, group) in &self.groups {
1897 let raw_daemons: Vec<String> = group
1898 .daemons
1899 .iter()
1900 .map(|id| {
1901 if id.namespace() == config_namespace {
1902 id.name().to_string()
1903 } else {
1904 id.qualified()
1905 }
1906 })
1907 .collect();
1908 raw.groups.insert(
1909 name.clone(),
1910 GroupEntryRaw {
1911 daemons: raw_daemons,
1912 groups: group.groups.clone(),
1913 },
1914 );
1915 }
1916
1917 for (name, entry) in &self.namespaces {
1919 raw.namespaces.insert(
1920 name.clone(),
1921 NamespaceEntryRaw {
1922 dir: entry.dir.to_string_lossy().to_string(),
1923 config: entry
1924 .config
1925 .iter()
1926 .map(|p| p.to_string_lossy().into_owned())
1927 .collect(),
1928 label: entry.label.clone(),
1929 },
1930 );
1931 }
1932
1933 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1934 path: path.clone(),
1935 source: e,
1936 })?;
1937 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1938 path: path.clone(),
1939 details: Some(e.to_string()),
1940 })?;
1941 invalidate_config_cache();
1942 Ok(())
1943 } else {
1944 Err(FileError::NoPath.into())
1945 }
1946 }
1947
1948 pub fn merge(&mut self, pt: Self) {
1953 if pt.worktree_label.is_some() {
1954 self.worktree_label = pt.worktree_label.clone();
1955 }
1956 for (id, d) in pt.daemons {
1957 self.daemons.insert(id, d);
1958 }
1959 if let Some(env) = pt.env {
1961 let merged = self.env.get_or_insert_with(IndexMap::new);
1962 for (k, v) in env {
1963 merged.insert(k, v);
1964 }
1965 }
1966 for (slug, entry) in pt.slugs {
1968 self.slugs.insert(slug, entry);
1969 }
1970 for (name, group) in pt.groups {
1972 self.groups.insert(name, group);
1973 }
1974 for (name, entry) in pt.namespaces {
1976 self.namespaces.insert(name, entry);
1977 }
1978 self.settings.merge_from(&pt.settings);
1980 }
1981
1982 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1987 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1988 Ok(pt) => pt.slugs,
1989 Err(_) => IndexMap::new(),
1990 }
1991 }
1992
1993 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
2000 global_slugs
2001 .keys()
2002 .filter(|k| k.eq_ignore_ascii_case(slug))
2003 .count()
2004 > 1
2005 }
2006
2007 pub fn find_slug_for_daemon_in_registry(
2012 daemon_id: &DaemonId,
2013 global_slugs: &IndexMap<String, SlugEntry>,
2014 ) -> Option<String> {
2015 global_slugs
2016 .iter()
2017 .find(|(slug, entry)| {
2018 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
2019 if daemon_id.name() != daemon_name {
2020 return false;
2021 }
2022
2023 if Self::slug_is_ambiguous(slug, global_slugs) {
2026 return false;
2027 }
2028
2029 match entry.resolve_namespace() {
2030 Some(namespace) => daemon_id.namespace() == namespace,
2031 None => false,
2032 }
2033 })
2034 .map(|(slug, _)| slug.clone())
2035 }
2036
2037 #[allow(dead_code)]
2039 pub fn is_slug_registered(slug: &str) -> bool {
2040 Self::read_global_slugs().contains_key(slug)
2041 }
2042
2043 pub fn add_slug_with_namespace(
2049 slug: &str,
2050 namespace: Option<&str>,
2051 daemon: Option<&str>,
2052 ) -> Result<()> {
2053 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2054
2055 if let Some(parent) = global_path.parent() {
2057 std::fs::create_dir_all(parent).map_err(|e| {
2058 miette::miette!(
2059 "Failed to create config directory {}: {e}",
2060 parent.display()
2061 )
2062 })?;
2063 }
2064
2065 let _lock = xx::fslock::get(global_path, false)
2066 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2067
2068 let mut pt = if global_path.exists() {
2069 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2070 path: global_path.to_path_buf(),
2071 source: e,
2072 })?;
2073 Self::parse_str(&raw, global_path)?
2074 } else {
2075 Self::new(global_path.to_path_buf())
2076 };
2077
2078 if let Some(ns) = namespace
2082 && !pt.namespaces.contains_key(ns)
2083 {
2084 let dir = pt
2089 .slugs
2090 .get(slug)
2091 .and_then(|e| {
2092 e.dir.clone().or_else(|| {
2093 e.namespace
2094 .as_ref()
2095 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
2096 })
2097 })
2098 .or_else(|| env::CWD.as_path().canonicalize().ok());
2099 if let Some(ref d) = dir {
2100 pt.namespaces.insert(
2101 ns.to_string(),
2102 NamespaceEntry {
2103 dir: d.clone(),
2104 config: Vec::new(),
2105 label: None,
2106 },
2107 );
2108 }
2109 }
2110
2111 pt.slugs.insert(
2112 slug.to_string(),
2113 SlugEntry {
2114 dir: None,
2115 namespace: namespace.map(str::to_string),
2116 daemon: daemon.map(str::to_string),
2117 },
2118 );
2119 pt.write_unlocked()?;
2120 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2126 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2127 Ok(())
2128 }
2129
2130 pub fn remove_slug(slug: &str) -> Result<bool> {
2132 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2133 if !global_path.exists() {
2134 return Ok(false);
2135 }
2136
2137 let _lock = xx::fslock::get(global_path, false)
2138 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2139
2140 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2141 path: global_path.to_path_buf(),
2142 source: e,
2143 })?;
2144 let mut pt = Self::parse_str(&raw, global_path)?;
2145
2146 let removed = pt.slugs.shift_remove(slug).is_some();
2147 if removed {
2148 pt.write_unlocked()?;
2149 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2155 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2156 }
2157 Ok(removed)
2158 }
2159 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
2162 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
2163 Ok(pt) => pt.namespaces,
2164 Err(_) => IndexMap::new(),
2165 }
2166 }
2167
2168 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
2172 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2173
2174 if let Some(parent) = global_path.parent() {
2176 std::fs::create_dir_all(parent).map_err(|e| {
2177 miette::miette!(
2178 "Failed to create config directory {}: {e}",
2179 parent.display()
2180 )
2181 })?;
2182 }
2183
2184 let _lock = xx::fslock::get(global_path, false)
2185 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2186
2187 let mut pt = if global_path.exists() {
2188 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2189 crate::error::FileError::ReadError {
2190 path: global_path.to_path_buf(),
2191 source: e,
2192 }
2193 })?;
2194 Self::parse_str(&raw, global_path)?
2195 } else {
2196 Self::new(global_path.to_path_buf())
2197 };
2198
2199 let dir = env::expand_tilde(dir);
2200 if let Some(entry) = pt.namespaces.get_mut(name) {
2201 if crate::extra_configs::path_is_gone(&entry.dir)
2202 && crate::extra_configs::normalize(&entry.dir)
2203 != crate::extra_configs::normalize(&dir)
2204 {
2205 if crate::extra_configs::namespace_has_running_daemon(name) {
2206 miette::bail!(
2209 "namespace '{name}' has running daemons from {}, which no longer exists; stop them first",
2210 entry.dir.display()
2211 );
2212 }
2213 entry.config.clear();
2215 entry.label = None;
2216 }
2217 if !entry.config.is_empty()
2218 && crate::extra_configs::normalize(&entry.dir)
2219 != crate::extra_configs::normalize(&dir)
2220 {
2221 miette::bail!(
2222 "namespace '{name}' has external configuration attached to another directory"
2223 );
2224 }
2225 entry.dir = dir;
2226 } else {
2227 pt.namespaces.insert(
2228 name.to_string(),
2229 NamespaceEntry {
2230 dir,
2231 config: Vec::new(),
2232 label: None,
2233 },
2234 );
2235 }
2236 pt.write_unlocked()?;
2237 Ok(())
2238 }
2239
2240 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2242 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2243 if !global_path.exists() {
2244 return Ok(false);
2245 }
2246
2247 let _lock = xx::fslock::get(global_path, false)
2248 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2249
2250 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2251 crate::error::FileError::ReadError {
2252 path: global_path.to_path_buf(),
2253 source: e,
2254 }
2255 })?;
2256 let mut pt = Self::parse_str(&raw, global_path)?;
2257
2258 let removed = pt.namespaces.shift_remove(name).is_some();
2259 if removed {
2260 pt.write_unlocked()?;
2261 }
2262 Ok(removed)
2263 }
2264}
2265
2266#[derive(Debug, Default, Clone, PartialEq, Eq)]
2268pub struct PruneReport {
2269 pub namespaces: Vec<String>,
2271 pub slugs: Vec<String>,
2273 pub config_files: usize,
2275}
2276
2277impl PruneReport {
2278 pub fn is_empty(&self) -> bool {
2279 self.namespaces.is_empty() && self.slugs.is_empty() && self.config_files == 0
2280 }
2281}
2282
2283impl PitchforkToml {
2284 pub fn prune_stale_registrations(
2298 keep: &std::collections::HashSet<String>,
2299 only: &[String],
2300 ) -> Result<PruneReport> {
2301 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2302 let mut report = PruneReport::default();
2303 if !global_path.exists() {
2304 return Ok(report);
2305 }
2306 let _lock = xx::fslock::get(global_path, false)
2307 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2308 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2309 path: global_path.to_path_buf(),
2310 source: e,
2311 })?;
2312 let mut pt = Self::parse_str(&raw, global_path)?;
2313 let in_scope = |ns: &str| only.is_empty() || only.iter().any(|o| o == ns);
2314
2315 for (name, entry) in pt.namespaces.iter_mut() {
2316 if keep.contains(name) || !in_scope(name) {
2317 continue;
2318 }
2319 if crate::extra_configs::path_is_gone(&entry.dir) {
2320 report.namespaces.push(name.clone());
2321 continue;
2322 }
2323 let before = entry.config.len();
2324 entry
2325 .config
2326 .retain(|file| !crate::extra_configs::path_is_gone(file));
2327 let dropped = before - entry.config.len();
2328 if dropped > 0 {
2329 report.config_files += dropped;
2330 if entry.config.is_empty() {
2331 entry.label = None;
2333 }
2334 }
2335 }
2336 for name in &report.namespaces {
2337 pt.namespaces.shift_remove(name);
2338 }
2339
2340 if only.is_empty() || !report.namespaces.is_empty() {
2341 for (slug, entry) in &pt.slugs {
2342 if entry.namespace.as_ref().is_some_and(|ns| keep.contains(ns)) {
2345 continue;
2346 }
2347 let ns_pruned = entry
2348 .namespace
2349 .as_ref()
2350 .is_some_and(|ns| report.namespaces.contains(ns));
2351 let dir_gone = entry
2352 .dir
2353 .as_ref()
2354 .is_some_and(|d| crate::extra_configs::path_is_gone(d));
2355 let scoped_ok = only.is_empty() || ns_pruned;
2358 if scoped_ok && (ns_pruned || dir_gone) {
2359 report.slugs.push(slug.clone());
2360 }
2361 }
2362 for slug in &report.slugs {
2363 pt.slugs.shift_remove(slug);
2364 }
2365 }
2366
2367 if !report.is_empty() {
2368 pt.write_unlocked()?;
2369 if !report.slugs.is_empty() {
2370 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2371 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2372 }
2373 }
2374 Ok(report)
2375 }
2376}
2377
2378#[derive(Debug, Clone, JsonSchema, Default)]
2380pub struct PitchforkTomlDaemon {
2381 #[schemars(example = example_run_command())]
2385 pub run: RunCommand,
2386 #[schemars(default)]
2388 pub auto: Vec<PitchforkTomlAuto>,
2389 pub oneshot: Option<bool>,
2395 pub cron: Option<PitchforkTomlCron>,
2397 #[schemars(default)]
2400 pub retry: Retry,
2401 pub ready_delay: Option<u64>,
2403 pub ready_output: Option<ReadyOutput>,
2405 pub ready_http: Option<ReadyHttp>,
2407 pub ready_port: Option<ReadyPort>,
2411 pub ready_cmd: Option<ReadyCmd>,
2413 pub health_cmd: Option<HealthCmd>,
2415 pub health_http: Option<HealthHttp>,
2417 pub health_port: Option<HealthPort>,
2421 pub port: Option<PortConfig>,
2423 #[serde(skip_serializing_if = "Option::is_none", default)]
2426 pub proxy: Option<ProxyConfig>,
2427 pub proxy_tls: Option<ProxyTlsMode>,
2436 pub proxy_protocol: Option<ProxyProtocol>,
2444 #[schemars(range(min = 1))]
2449 pub proxy_tls_port: Option<u16>,
2450 #[schemars(range(min = 1))]
2456 pub proxy_port: Option<u16>,
2457 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2468 pub boot_start: Option<bool>,
2470 #[schemars(default)]
2472 pub depends: Vec<DaemonId>,
2473 #[schemars(default)]
2475 pub watch: Vec<String>,
2476 #[schemars(default)]
2482 pub watch_mode: WatchMode,
2483 pub dir: Option<String>,
2485 pub env: Option<IndexMap<String, String>>,
2487 #[serde(default)]
2492 pub env_file: Vec<String>,
2493 pub hooks: Option<PitchforkTomlHooks>,
2495 pub mise: Option<bool>,
2498 #[schemars(skip)]
2501 pub deferred_template_context: Option<String>,
2502 pub user: Option<String>,
2504 pub memory_limit: Option<MemoryLimit>,
2507 pub cpu_limit: Option<CpuLimit>,
2510 pub stop_signal: Option<StopConfig>,
2513 pub pty: Option<bool>,
2515 pub time_retention: Option<String>,
2518 pub line_retention: Option<i64>,
2521 pub archive_hook: Option<String>,
2524 pub logs: Option<PitchforkTomlDaemonLogs>,
2526 #[schemars(skip)]
2527 pub path: Option<PathBuf>,
2528}
2529
2530impl PitchforkTomlDaemon {
2531 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2537 self.proxy_tls_port.or(self.proxy_port)
2538 }
2539
2540 pub fn is_oneshot(&self) -> bool {
2542 self.oneshot.unwrap_or(false)
2543 }
2544
2545 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2548 [
2549 ("ready_delay", self.ready_delay.is_some()),
2550 ("ready_output", self.ready_output.is_some()),
2551 ("ready_http", self.ready_http.is_some()),
2552 ("ready_port", self.ready_port.is_some()),
2553 ("ready_cmd", self.ready_cmd.is_some()),
2554 ("health_cmd", self.health_cmd.is_some()),
2555 ("health_http", self.health_http.is_some()),
2556 ("health_port", self.health_port.is_some()),
2557 ]
2558 .into_iter()
2559 .filter(|(_, set)| *set)
2560 .map(|(name, _)| name)
2561 .collect()
2562 }
2563
2564 pub fn effective_user(&self) -> Option<String> {
2568 let daemon_user = self
2569 .user
2570 .as_deref()
2571 .map(str::trim)
2572 .filter(|u| !u.is_empty());
2573 daemon_user.map(str::to_owned).or_else(|| {
2574 let s = crate::settings::settings();
2575 let su = s.supervisor.user.trim();
2576 (!su.is_empty()).then(|| su.to_owned())
2577 })
2578 }
2579
2580 pub fn to_run_options(
2585 &self,
2586 id: &crate::daemon_id::DaemonId,
2587 cmd: Vec<String>,
2588 ) -> crate::daemon::RunOptions {
2589 use crate::daemon::RunOptions;
2590
2591 let effective_user = self.effective_user();
2592 let dir = crate::ipc::batch::resolve_daemon_dir(
2593 self.dir.as_deref(),
2594 self.path.as_deref(),
2595 effective_user.as_deref(),
2596 );
2597 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2600 id,
2601 &PitchforkToml::read_global_slugs(),
2602 );
2603
2604 RunOptions {
2605 id: id.clone(),
2606 cmd,
2607 run: self.run.shell_script().map(str::to_string),
2608 no_shell: self.run.is_argv(),
2609 replaces_ready_checks: false,
2610 requested_by_client: false,
2612 force: false,
2613 shell_pid: None,
2614 dir: Dir(dir),
2615 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2616 oneshot: self.is_oneshot(),
2617 cron_started: false,
2620 oneshot_wait: None,
2624 on_directory_enter: false,
2625 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2626 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2627 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2628 retry: self.retry,
2629 retry_count: 0,
2630 ready_delay: self.ready_delay,
2631 ready_output: self.ready_output.clone(),
2632 ready_http: self.ready_http.clone(),
2633 ready_port: self.ready_port.clone(),
2634 ready_cmd: self.ready_cmd.clone(),
2635 health_cmd: self.health_cmd.clone(),
2636 health_http: self.health_http.clone(),
2637 health_port: self.health_port.clone(),
2638 port: self.port.clone(),
2639 wait_ready: false,
2640 depends: self.depends.clone(),
2641 env: self.env.clone(),
2642 watch: self.watch.clone(),
2643 watch_mode: self.watch_mode,
2644 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2645 self.path.as_deref(),
2646 )),
2647 mise: self.mise,
2648 deferred_template_context: self.deferred_template_context.clone(),
2649 mise_bin: None,
2651 slug,
2652 proxy: None,
2653 user: self.user.clone(),
2654 memory_limit: self.memory_limit,
2655 cpu_limit: self.cpu_limit,
2656 stop_signal: self.stop_signal,
2657 archive_hook: self
2658 .logs
2659 .as_ref()
2660 .and_then(|l| l.archive_hook.clone())
2661 .or_else(|| self.archive_hook.clone()),
2662 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2663 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2664 pty: self.pty,
2665 proxy_idle_timeout_ms: None,
2667 replaces_saved_record: true,
2668 }
2669 }
2670}
2671fn example_run_command() -> &'static str {
2672 "exec node server.js"
2673}
2674
2675#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2678#[serde(untagged)]
2679pub enum RunCommand {
2680 Shell(String),
2683 Argv(#[schemars(length(min = 1))] Vec<String>),
2687}
2688
2689impl Default for RunCommand {
2690 fn default() -> Self {
2691 Self::Shell(String::new())
2692 }
2693}
2694
2695impl From<String> for RunCommand {
2696 fn from(run: String) -> Self {
2697 Self::Shell(run)
2698 }
2699}
2700
2701impl From<&str> for RunCommand {
2702 fn from(run: &str) -> Self {
2703 Self::Shell(run.to_string())
2704 }
2705}
2706
2707impl From<Vec<String>> for RunCommand {
2708 fn from(argv: Vec<String>) -> Self {
2709 Self::Argv(argv)
2710 }
2711}
2712
2713impl PartialEq<str> for RunCommand {
2714 fn eq(&self, other: &str) -> bool {
2715 matches!(self, Self::Shell(run) if run == other)
2716 }
2717}
2718
2719impl PartialEq<&str> for RunCommand {
2720 fn eq(&self, other: &&str) -> bool {
2721 self == *other
2722 }
2723}
2724
2725impl std::fmt::Display for RunCommand {
2728 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2729 match self {
2730 Self::Shell(run) => f.write_str(run),
2731 Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2732 }
2733 }
2734}
2735
2736impl RunCommand {
2737 pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2740 match self {
2741 Self::Shell(run) => shell_words::split(run),
2742 Self::Argv(argv) => Ok(argv.clone()),
2743 }
2744 }
2745
2746 pub fn shell_script(&self) -> Option<&str> {
2748 match self {
2749 Self::Shell(run) => Some(run),
2750 Self::Argv(_) => None,
2751 }
2752 }
2753
2754 pub fn is_argv(&self) -> bool {
2755 matches!(self, Self::Argv(_))
2756 }
2757
2758 pub fn try_map<E>(
2760 &self,
2761 mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2762 ) -> std::result::Result<Self, E> {
2763 Ok(match self {
2764 Self::Shell(run) => Self::Shell(f(run)?),
2765 Self::Argv(argv) => Self::Argv(
2766 argv.iter()
2767 .map(|arg| f(arg))
2768 .collect::<std::result::Result<_, _>>()?,
2769 ),
2770 })
2771 }
2772}
2773
2774#[cfg(test)]
2775mod tests {
2776 use super::*;
2777 use std::path::Path;
2778
2779 fn parse_groups(toml: &str) -> IndexMap<String, GroupEntry> {
2780 PitchforkToml::parse_str(toml, Path::new("/tmp/my-project/pitchfork.toml"))
2781 .unwrap()
2782 .groups
2783 }
2784
2785 fn names(ids: &[DaemonId]) -> Vec<&str> {
2786 ids.iter().map(|id| id.name()).collect()
2787 }
2788
2789 #[test]
2790 fn test_group_includes_expand_before_own_daemons_and_dedupe() {
2791 let groups = parse_groups(
2792 r#"
2793[groups.core]
2794daemons = ["db", "cache"]
2795
2796[groups.extras]
2797daemons = ["mailer", "cache"]
2798
2799[groups.all]
2800groups = ["core", "extras"]
2801daemons = ["api", "db"]
2802"#,
2803 );
2804 let all = expand_group(&groups, "all").unwrap();
2805 assert_eq!(names(&all), ["db", "cache", "mailer", "api"]);
2806 }
2807
2808 #[test]
2809 fn test_group_includes_nest_and_allow_diamonds() {
2810 let groups = parse_groups(
2811 r#"
2812[groups.base]
2813daemons = ["db"]
2814
2815[groups.left]
2816groups = ["base"]
2817daemons = ["l"]
2818
2819[groups.right]
2820groups = ["base"]
2821daemons = ["r"]
2822
2823[groups.top]
2824groups = ["left", "right"]
2825"#,
2826 );
2827 let top = expand_group(&groups, "top").unwrap();
2828 assert_eq!(names(&top), ["db", "l", "r"]);
2829 }
2830
2831 #[test]
2832 fn test_group_includes_shared_by_many_paths_are_expanded_once() {
2833 let mut toml = String::from("[groups.g0]\ngroups = [\"g1\", \"g1\"]\n");
2836 for i in 1..60 {
2837 toml.push_str(&format!(
2838 "\n[groups.g{i}]\ngroups = [\"g{n}\", \"g{n}\"]\n",
2839 n = i + 1
2840 ));
2841 }
2842 toml.push_str("\n[groups.g60]\ndaemons = [\"db\"]\n");
2843 let groups = parse_groups(&toml);
2844 assert_eq!(names(&expand_group(&groups, "g0").unwrap()), ["db"]);
2845 }
2846
2847 #[test]
2848 fn test_group_include_cycle_is_an_error() {
2849 let groups = parse_groups(
2850 r#"
2851[groups.a]
2852groups = ["b"]
2853
2854[groups.b]
2855groups = ["c"]
2856
2857[groups.c]
2858groups = ["a"]
2859"#,
2860 );
2861 let err = expand_group(&groups, "a").unwrap_err().to_string();
2862 assert!(err.contains("a -> b -> c -> a"), "{err}");
2863 let err = expand_group(&parse_groups("[groups.x]\ngroups = [\"x\"]\n"), "x")
2864 .unwrap_err()
2865 .to_string();
2866 assert!(err.contains("x -> x"), "{err}");
2867 }
2868
2869 #[test]
2870 fn test_group_include_of_undefined_group_is_an_error() {
2871 let groups = parse_groups("[groups.all]\ngroups = [\"cor\"]\n\n[groups.core]\n");
2872 let err = expand_group(&groups, "all").unwrap_err().to_string();
2873 assert!(
2874 err.contains("group 'all' includes undefined group 'cor'"),
2875 "{err}"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_group_without_daemons_key_parses() {
2881 let groups = parse_groups("[groups.empty]\n");
2882 assert!(expand_group(&groups, "empty").unwrap().is_empty());
2883 }
2884
2885 #[test]
2886 fn test_daemon_user_parses_and_flows_to_run_options() {
2887 let pt = PitchforkToml::parse_str(
2888 r#"
2889[daemons.api]
2890run = "node server.js"
2891user = "postgres"
2892"#,
2893 Path::new("/tmp/my-project/pitchfork.toml"),
2894 )
2895 .unwrap();
2896
2897 let id = DaemonId::new("my-project", "api");
2898 let daemon = pt.daemons.get(&id).unwrap();
2899 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2900
2901 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2902 assert_eq!(opts.user.as_deref(), Some("postgres"));
2903 assert!(opts.replaces_saved_record);
2905 }
2906
2907 #[test]
2908 fn test_new_local_file_writes_daemons_of_the_directory_namespace() {
2909 let temp = tempfile::tempdir().unwrap();
2910 std::fs::write(
2911 temp.path().join("pitchfork.toml"),
2912 "namespace = \"myns\"\n\n[daemons.web]\nrun = \"sleep 60\"\n",
2913 )
2914 .unwrap();
2915 let path = temp.path().join("pitchfork.local.toml");
2916 let mut pt = PitchforkToml::new(path.clone());
2917 let id = DaemonId::new(namespace_from_path(&path).unwrap(), "api");
2918 assert_eq!(id.namespace(), "myns");
2919 pt.daemons.insert(
2920 id.clone(),
2921 PitchforkTomlDaemon {
2922 run: "sleep 60".into(),
2923 ..PitchforkTomlDaemon::default()
2924 },
2925 );
2926
2927 pt.write().unwrap();
2928
2929 let parsed = PitchforkToml::read(&path).unwrap();
2930 assert!(parsed.daemons.contains_key(&id));
2931 }
2932
2933 #[test]
2934 fn test_daemon_user_write_roundtrip() {
2935 let temp = tempfile::tempdir().unwrap();
2936 let path = temp.path().join("pitchfork.toml");
2937 let mut pt = PitchforkToml::new(path.clone());
2938 pt.namespace = Some("test-project".to_string());
2939 pt.daemons.insert(
2940 DaemonId::new("test-project", "api"),
2941 PitchforkTomlDaemon {
2942 run: "node server.js".into(),
2943 user: Some("postgres".to_string()),
2944 ..PitchforkTomlDaemon::default()
2945 },
2946 );
2947
2948 pt.write().unwrap();
2949
2950 let raw = std::fs::read_to_string(&path).unwrap();
2951 assert!(raw.contains("user = \"postgres\""));
2952
2953 let parsed = PitchforkToml::read(&path).unwrap();
2954 let daemon = parsed
2955 .daemons
2956 .get(&DaemonId::new("test-project", "api"))
2957 .unwrap();
2958 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2959 }
2960
2961 fn awkward_argv() -> Vec<String> {
2963 [
2964 "node",
2965 "my server.js",
2966 "--name=\"a b\"",
2967 "it's",
2968 "a&b",
2969 "%PATH%",
2970 "$HOME",
2971 ]
2972 .map(str::to_string)
2973 .to_vec()
2974 }
2975
2976 #[test]
2977 fn test_run_array_parses_and_starts_without_a_shell() {
2978 let pt = PitchforkToml::parse_str(
2979 r#"
2980[daemons.api]
2981run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2982"#,
2983 Path::new("/tmp/my-project/pitchfork.toml"),
2984 )
2985 .unwrap();
2986
2987 let id = DaemonId::new("my-project", "api");
2988 let daemon = pt.daemons.get(&id).unwrap();
2989 assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2990
2991 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2992 assert_eq!(opts.cmd, awkward_argv());
2993 assert!(opts.no_shell);
2994 assert_eq!(opts.run, None);
2995 }
2996
2997 #[test]
2998 fn test_run_string_still_goes_through_the_shell() {
2999 let pt = PitchforkToml::parse_str(
3000 "[daemons.api]\nrun = \"exec node server.js\"\n",
3001 Path::new("/tmp/my-project/pitchfork.toml"),
3002 )
3003 .unwrap();
3004
3005 let id = DaemonId::new("my-project", "api");
3006 let daemon = pt.daemons.get(&id).unwrap();
3007 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
3008 assert!(!opts.no_shell);
3009 assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
3010 }
3011
3012 #[test]
3013 fn test_run_array_write_roundtrip() {
3014 let temp = tempfile::tempdir().unwrap();
3015 let path = temp.path().join("pitchfork.toml");
3016 let mut pt = PitchforkToml::new(path.clone());
3017 pt.namespace = Some("test-project".to_string());
3018 let id = DaemonId::new("test-project", "api");
3019 pt.daemons.insert(
3020 id.clone(),
3021 PitchforkTomlDaemon {
3022 run: RunCommand::Argv(awkward_argv()),
3023 ..PitchforkTomlDaemon::default()
3024 },
3025 );
3026 pt.daemons.insert(
3027 DaemonId::new("test-project", "worker"),
3028 PitchforkTomlDaemon {
3029 run: "exec ./worker --queue \"a b\"".into(),
3030 ..PitchforkTomlDaemon::default()
3031 },
3032 );
3033
3034 pt.write().unwrap();
3035
3036 let parsed = PitchforkToml::read(&path).unwrap();
3037 assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
3038 assert_eq!(
3039 parsed.daemons[&DaemonId::new("test-project", "worker")].run,
3040 "exec ./worker --queue \"a b\""
3041 );
3042 }
3043
3044 #[test]
3045 fn test_run_array_must_name_a_program() {
3046 let err = PitchforkToml::parse_str(
3047 "[daemons.api]\nrun = []\n",
3048 Path::new("/tmp/my-project/pitchfork.toml"),
3049 )
3050 .unwrap_err();
3051 assert!(
3052 err.chain()
3053 .any(|cause| cause.to_string().contains("empty run array")),
3054 "unexpected error: {err:?}"
3055 );
3056 }
3057
3058 #[test]
3059 fn test_run_array_rejects_exec() {
3060 let err = PitchforkToml::parse_str(
3061 "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
3062 Path::new("/tmp/my-project/pitchfork.toml"),
3063 )
3064 .unwrap_err();
3065 assert!(
3066 err.chain().any(|cause| cause
3067 .to_string()
3068 .contains("starts its run array with \"exec\"")),
3069 "unexpected error: {err:?}"
3070 );
3071 }
3072
3073 #[test]
3074 fn test_ready_cmd_array_parses_in_both_forms() {
3075 let pt = PitchforkToml::parse_str(
3076 r#"
3077[daemons.api]
3078run = "node server.js"
3079ready_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
3080
3081[daemons.db]
3082run = "postgres"
3083ready_cmd = { run = ["pg_isready", "-h", "localhost"], timeout = "30s" }
3084
3085[daemons.cache]
3086run = "redis-server"
3087ready_cmd = "redis-cli ping"
3088"#,
3089 Path::new("/tmp/my-project/pitchfork.toml"),
3090 )
3091 .unwrap();
3092
3093 let ready = |name: &str| {
3094 pt.daemons[&DaemonId::new("my-project", name)]
3095 .ready_cmd
3096 .clone()
3097 .unwrap()
3098 };
3099 assert_eq!(ready("api").run, RunCommand::Argv(awkward_argv()));
3100 assert_eq!(ready("api").timeout, None);
3101 assert_eq!(
3102 ready("db").run,
3103 RunCommand::Argv(
3104 ["pg_isready", "-h", "localhost"]
3105 .map(str::to_string)
3106 .to_vec()
3107 )
3108 );
3109 assert_eq!(
3110 ready("db").timeout,
3111 Some(std::time::Duration::from_secs(30))
3112 );
3113 assert_eq!(ready("cache").run, "redis-cli ping");
3114 }
3115
3116 #[test]
3117 fn test_ready_cmd_array_write_roundtrip() {
3118 let temp = tempfile::tempdir().unwrap();
3119 let path = temp.path().join("pitchfork.toml");
3120 let mut pt = PitchforkToml::new(path.clone());
3121 pt.namespace = Some("test-project".to_string());
3122 let short = ReadyCmd::new(awkward_argv());
3123 let full = ReadyCmd {
3124 run: RunCommand::Argv(
3125 ["pg_isready", "-h", "localhost"]
3126 .map(str::to_string)
3127 .to_vec(),
3128 ),
3129 timeout: Some(std::time::Duration::from_secs(30)),
3130 };
3131 for (name, ready_cmd) in [("api", &short), ("db", &full)] {
3132 pt.daemons.insert(
3133 DaemonId::new("test-project", name),
3134 PitchforkTomlDaemon {
3135 run: "true".into(),
3136 ready_cmd: Some(ready_cmd.clone()),
3137 ..PitchforkTomlDaemon::default()
3138 },
3139 );
3140 }
3141
3142 pt.write().unwrap();
3143
3144 let parsed = PitchforkToml::read(&path).unwrap();
3145 let ready = |name: &str| {
3146 parsed.daemons[&DaemonId::new("test-project", name)]
3147 .ready_cmd
3148 .clone()
3149 };
3150 assert_eq!(ready("api"), Some(short));
3151 assert_eq!(ready("db"), Some(full));
3152 }
3153
3154 #[test]
3155 fn test_ready_cmd_array_must_name_a_program() {
3156 for ready_cmd in ["[]", "[\"\"]", "{ run = [\"\", \"-h\"] }"] {
3157 let err = PitchforkToml::parse_str(
3158 &format!("[daemons.api]\nrun = \"true\"\nready_cmd = {ready_cmd}\n"),
3159 Path::new("/tmp/my-project/pitchfork.toml"),
3160 )
3161 .unwrap_err();
3162 assert!(
3163 err.chain().any(|cause| cause
3164 .to_string()
3165 .contains("has no program in its ready_cmd array")),
3166 "unexpected error for {ready_cmd}: {err:?}"
3167 );
3168 }
3169 }
3170
3171 #[test]
3172 fn test_ready_cmd_array_rejects_exec() {
3173 let err = PitchforkToml::parse_str(
3174 "[daemons.api]\nrun = \"true\"\nready_cmd = { run = [\"exec\", \"pg_isready\"] }\n",
3175 Path::new("/tmp/my-project/pitchfork.toml"),
3176 )
3177 .unwrap_err();
3178 assert!(
3179 err.chain().any(|cause| cause
3180 .to_string()
3181 .contains("starts its ready_cmd array with \"exec\"")),
3182 "unexpected error: {err:?}"
3183 );
3184 }
3185
3186 #[test]
3187 fn test_health_cmd_array_parses_and_round_trips() {
3188 let pt = PitchforkToml::parse_str(
3189 r#"
3190[daemons.api]
3191run = "node server.js"
3192health_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
3193
3194[daemons.db]
3195run = "postgres"
3196health_cmd = { run = ["pg_isready", "-h", "localhost"], interval = "5s", retries = 2 }
3197
3198[daemons.cache]
3199run = "redis-server"
3200health_cmd = "redis-cli ping"
3201"#,
3202 Path::new("/tmp/my-project/pitchfork.toml"),
3203 )
3204 .unwrap();
3205 let health = |pt: &PitchforkToml, ns: &str, name: &str| {
3206 pt.daemons[&DaemonId::new(ns, name)]
3207 .health_cmd
3208 .clone()
3209 .unwrap()
3210 };
3211 assert_eq!(
3212 health(&pt, "my-project", "api").run,
3213 RunCommand::Argv(awkward_argv())
3214 );
3215 let db = health(&pt, "my-project", "db");
3216 assert_eq!(
3217 db.run,
3218 RunCommand::Argv(
3219 ["pg_isready", "-h", "localhost"]
3220 .map(str::to_string)
3221 .to_vec()
3222 )
3223 );
3224 assert_eq!(db.interval, Some(std::time::Duration::from_secs(5)));
3225 assert_eq!(db.retries, Some(2));
3226 assert_eq!(health(&pt, "my-project", "cache").run, "redis-cli ping");
3227
3228 let temp = tempfile::tempdir().unwrap();
3229 let path = temp.path().join("pitchfork.toml");
3230 let mut written = PitchforkToml::new(path.clone());
3231 written.namespace = Some("test-project".to_string());
3232 for name in ["api", "db"] {
3233 written.daemons.insert(
3234 DaemonId::new("test-project", name),
3235 PitchforkTomlDaemon {
3236 run: "true".into(),
3237 health_cmd: Some(health(&pt, "my-project", name)),
3238 ..PitchforkTomlDaemon::default()
3239 },
3240 );
3241 }
3242 written.write().unwrap();
3243 let parsed = PitchforkToml::read(&path).unwrap();
3244 for name in ["api", "db"] {
3245 assert_eq!(
3246 health(&parsed, "test-project", name),
3247 health(&pt, "my-project", name)
3248 );
3249 }
3250 }
3251
3252 #[test]
3253 fn test_health_cmd_array_must_name_a_program_and_not_exec() {
3254 for (health_cmd, expected) in [
3255 ("[]", "has no program in its health_cmd array"),
3256 ("[\"\"]", "has no program in its health_cmd array"),
3257 (
3258 "{ run = [\"exec\", \"pg_isready\"] }",
3259 "starts its health_cmd array with \"exec\"",
3260 ),
3261 ] {
3262 let err = PitchforkToml::parse_str(
3263 &format!("[daemons.api]\nrun = \"true\"\nhealth_cmd = {health_cmd}\n"),
3264 Path::new("/tmp/my-project/pitchfork.toml"),
3265 )
3266 .unwrap_err();
3267 assert!(
3268 err.chain()
3269 .any(|cause| cause.to_string().contains(expected)),
3270 "unexpected error for {health_cmd}: {err:?}"
3271 );
3272 }
3273 }
3274
3275 #[test]
3276 fn test_registry_dirs_expand_tilde() {
3277 let pt = PitchforkToml::parse_str(
3278 r#"
3279[slugs.api]
3280dir = "~/projects/api"
3281
3282[namespaces.web]
3283dir = "~/projects/web"
3284"#,
3285 Path::new("/tmp/config.toml"),
3286 )
3287 .unwrap();
3288
3289 assert_eq!(
3290 pt.slugs["api"].dir,
3291 Some(crate::env::HOME_DIR.join("projects/api"))
3292 );
3293 assert_eq!(
3294 pt.namespaces["web"].dir,
3295 crate::env::HOME_DIR.join("projects/web")
3296 );
3297 }
3298
3299 #[test]
3300 fn test_settings_write_roundtrip() {
3301 let temp = tempfile::tempdir().unwrap();
3302 let path = temp.path().join("pitchfork.toml");
3303 let mut pt = PitchforkToml::new(path.clone());
3304 pt.namespace = Some("test-project".to_string());
3305 pt.settings.web.auto_start = Some(true);
3306 pt.settings.general.log_level = Some("debug".to_string());
3307
3308 pt.write().unwrap();
3309
3310 let raw = std::fs::read_to_string(&path).unwrap();
3311 assert!(
3312 raw.contains("[settings.web]"),
3313 "settings.web section should be written, got:\n{raw}"
3314 );
3315 assert!(raw.contains("auto_start = true"));
3316 assert!(raw.contains("log_level = \"debug\""));
3317
3318 let parsed = PitchforkToml::read(&path).unwrap();
3319 assert_eq!(parsed.settings.web.auto_start, Some(true));
3320 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
3321 }
3322
3323 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
3324 SlugEntry {
3325 dir: None,
3326 namespace: Some(namespace.to_string()),
3327 daemon: daemon.map(str::to_string),
3328 }
3329 }
3330
3331 #[test]
3332 fn test_slug_is_ambiguous() {
3333 let mut slugs = IndexMap::new();
3334 slugs.insert("api".to_string(), slug_entry("my-project", None));
3335 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
3336
3337 slugs.insert("API".to_string(), slug_entry("other-project", None));
3338 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
3340 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
3341 }
3342
3343 #[test]
3344 fn test_find_slug_for_daemon_skips_case_collisions() {
3345 let id = DaemonId::new("my-project", "api");
3346 let mut slugs = IndexMap::new();
3347 slugs.insert("api".to_string(), slug_entry("my-project", None));
3348 assert_eq!(
3349 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3350 Some("api".to_string())
3351 );
3352
3353 slugs.insert("API".to_string(), slug_entry("other-project", None));
3356 assert_eq!(
3357 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3358 None
3359 );
3360 }
3361
3362 #[test]
3363 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
3364 let id = DaemonId::new("my-project", "api");
3365 let mut slugs = IndexMap::new();
3366 slugs.insert("api".to_string(), slug_entry("my-project", None));
3369 slugs.insert("API".to_string(), slug_entry("my-project", None));
3370 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
3371
3372 assert_eq!(
3373 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3374 Some("my-api".to_string())
3375 );
3376 }
3377
3378 #[test]
3379 fn test_settings_preserved_on_unrelated_write() {
3380 let temp = tempfile::tempdir().unwrap();
3383 let path = temp.path().join("pitchfork.toml");
3384 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
3385
3386 let mut pt = PitchforkToml::read(&path).unwrap();
3387 pt.slugs.insert(
3388 "api".to_string(),
3389 SlugEntry {
3390 dir: None,
3391 namespace: Some("myproject".to_string()),
3392 daemon: None,
3393 },
3394 );
3395 pt.namespaces.insert(
3396 "myproject".to_string(),
3397 NamespaceEntry {
3398 dir: PathBuf::from("/tmp/myproject"),
3399 config: Vec::new(),
3400 label: None,
3401 },
3402 );
3403 pt.write().unwrap();
3404
3405 let raw = std::fs::read_to_string(&path).unwrap();
3406 assert!(
3407 raw.contains("[settings.web]"),
3408 "existing settings must be preserved, got:\n{raw}"
3409 );
3410 assert!(raw.contains("auto_start = true"));
3411 assert!(raw.contains("[slugs.api]"));
3412
3413 let parsed = PitchforkToml::read(&path).unwrap();
3414 assert_eq!(parsed.settings.web.auto_start, Some(true));
3415 assert!(parsed.slugs.contains_key("api"));
3416 }
3417
3418 #[tokio::test]
3419 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
3420 let temp = tempfile::tempdir().unwrap();
3421 let path = temp.path().join("pitchfork.toml");
3422 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
3423 .await
3424 .unwrap();
3425
3426 let read_path = path.clone();
3427 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
3428 .await
3429 .unwrap()
3430 .unwrap();
3431 assert_eq!(pt.settings.general.worktree, Some(false));
3432 assert_eq!(pt.settings.proxy.worktree, None);
3433 tokio::task::spawn_blocking(move || pt.write())
3434 .await
3435 .unwrap()
3436 .unwrap();
3437
3438 let raw = tokio::fs::read_to_string(&path).await.unwrap();
3439 assert!(raw.contains("[settings.general]"), "{raw}");
3440 assert!(raw.contains("worktree = false"), "{raw}");
3441 assert!(!raw.contains("[settings.proxy]"), "{raw}");
3442
3443 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
3444 .await
3445 .unwrap()
3446 .unwrap();
3447 assert_eq!(parsed.settings.general.worktree, Some(false));
3448 }
3449
3450 #[test]
3451 fn test_config_cache_hit_and_invalidation() {
3452 let temp = tempfile::tempdir().unwrap();
3453 let dir = temp.path();
3454 let config_path = dir.join("pitchfork.toml");
3455 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3456
3457 super::invalidate_config_cache();
3459
3460 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3462 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3463 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3464
3465 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3467 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
3468
3469 std::thread::sleep(std::time::Duration::from_millis(50));
3472 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
3473
3474 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
3475 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
3476
3477 super::invalidate_config_cache();
3479 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
3480 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
3481
3482 super::invalidate_config_cache();
3484 }
3485
3486 #[test]
3487 fn test_config_cache_invalidation_on_write() {
3488 let temp = tempfile::tempdir().unwrap();
3489 let dir = temp.path();
3490 let config_path = dir.join("pitchfork.toml");
3491 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3492
3493 super::invalidate_config_cache();
3494
3495 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3497 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3498 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3499
3500 let mut pt = PitchforkToml::read(&config_path).unwrap();
3502 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
3503 let _ = pt.write();
3505
3506 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3508 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
3509
3510 super::invalidate_config_cache();
3511 }
3512
3513 #[test]
3514 fn test_config_cache_size_invalidation() {
3515 let temp = tempfile::tempdir().unwrap();
3516 let dir = temp.path();
3517 let config_path = dir.join("pitchfork.toml");
3518 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3519
3520 super::invalidate_config_cache();
3521
3522 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3524 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3525 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3526
3527 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
3531 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
3532 let file = std::fs::OpenOptions::new()
3535 .write(true)
3536 .open(&config_path)
3537 .unwrap();
3538 let times = std::fs::FileTimes::new().set_modified(original_mtime);
3539 file.set_times(times).unwrap();
3540
3541 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3544 assert_eq!(
3545 pt2.daemons[&daemon_id].run, "echo different",
3546 "cache should invalidate on size change even with identical mtime"
3547 );
3548
3549 super::invalidate_config_cache();
3550 }
3551
3552 #[test]
3553 fn test_find_project_root_in_plain_dir_returns_none() {
3554 let temp = tempfile::tempdir().unwrap();
3555 assert_eq!(find_project_root(temp.path()), None);
3556 }
3557
3558 #[test]
3559 fn test_find_project_root_finds_git_marker() {
3560 let temp = tempfile::tempdir().unwrap();
3561 let repo = temp.path().join("my-repo");
3562 std::fs::create_dir(&repo).unwrap();
3563 std::fs::create_dir(repo.join(".git")).unwrap();
3564
3565 let sub = repo.join("sub/dir");
3566 std::fs::create_dir_all(&sub).unwrap();
3567
3568 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
3571 }
3572
3573 #[test]
3574 fn test_find_project_root_accepts_git_file_marker() {
3575 let temp = tempfile::tempdir().unwrap();
3578 let wt = temp.path().join("my-worktree");
3579 std::fs::create_dir(&wt).unwrap();
3580 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
3581
3582 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
3583 }
3584
3585 #[cfg(unix)]
3588 #[test]
3589 fn test_find_project_root_resolves_symlinked_start_dir() {
3590 use std::os::unix::fs::symlink;
3591
3592 let temp = tempfile::tempdir().unwrap();
3593 let repo = temp.path().join("real-repo");
3594 std::fs::create_dir(&repo).unwrap();
3595 std::fs::create_dir(repo.join(".git")).unwrap();
3596
3597 let sub = repo.join("sub/dir");
3598 std::fs::create_dir_all(&sub).unwrap();
3599 let link = temp.path().join("link-to-sub");
3600 symlink(&sub, &link).unwrap();
3601
3602 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
3603 }
3604
3605 #[test]
3608 fn test_all_merged_all_namespaces_discovers_worktrees() {
3609 let temp = tempfile::tempdir().unwrap();
3610 let repo = temp.path().join("my-repo");
3611 std::fs::create_dir(&repo).unwrap();
3612
3613 let git_init = std::process::Command::new("git")
3615 .args(["init", "-b", "main"])
3616 .current_dir(&repo)
3617 .output()
3618 .expect("git init");
3619 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
3620
3621 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
3622
3623 let git_commit = std::process::Command::new("git")
3624 .args([
3625 "-c",
3626 "user.name=pitchfork-test",
3627 "-c",
3628 "user.email=pitchfork-test@example.com",
3629 "add",
3630 "-A",
3631 ])
3632 .current_dir(&repo)
3633 .output()
3634 .expect("git add");
3635 assert!(git_commit.status.success());
3636
3637 let git_commit = std::process::Command::new("git")
3638 .args([
3639 "-c",
3640 "user.name=pitchfork-test",
3641 "-c",
3642 "user.email=pitchfork-test@example.com",
3643 "commit",
3644 "-m",
3645 "init",
3646 ])
3647 .current_dir(&repo)
3648 .output()
3649 .expect("git commit");
3650 assert!(
3651 git_commit.status.success(),
3652 "git commit failed: {:?}",
3653 git_commit
3654 );
3655
3656 let wt = temp.path().join("my-repo-feature");
3657 let git_wt = std::process::Command::new("git")
3658 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3659 .current_dir(&repo)
3660 .output()
3661 .expect("git worktree add");
3662 assert!(
3663 git_wt.status.success(),
3664 "git worktree add failed: {:?}",
3665 git_wt
3666 );
3667
3668 std::fs::write(
3670 repo.join("pitchfork.toml"),
3671 "[daemons.api]\nrun = \"echo main\"\n",
3672 )
3673 .unwrap();
3674 std::fs::write(
3676 wt.join("pitchfork.toml"),
3677 "[daemons.worker]\nrun = \"echo wt\"\n",
3678 )
3679 .unwrap();
3680
3681 super::invalidate_config_cache();
3682
3683 let pt = PitchforkToml::all_merged_all_namespaces_from_blocking(&wt).unwrap();
3685
3686 let main_id = DaemonId::new("my-repo", "api");
3687 let wt_id = DaemonId::new("my-repo-feature", "worker");
3688 assert!(
3689 pt.daemons.contains_key(&main_id),
3690 "main checkout daemon missing"
3691 );
3692 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3693
3694 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from_blocking(&repo).unwrap();
3696 assert!(pt_from_main.daemons.contains_key(&wt_id));
3697
3698 let _ = std::process::Command::new("git")
3700 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3701 .current_dir(&repo)
3702 .output();
3703 super::invalidate_config_cache();
3704 }
3705
3706 #[test]
3707 fn test_adhoc_id_uses_invocation_directory_namespace() {
3708 let temp = tempfile::tempdir().unwrap();
3709 let project = temp.path().join("feature-tree");
3710 std::fs::create_dir(&project).unwrap();
3711 std::fs::write(
3712 project.join("pitchfork.toml"),
3713 "[daemons.other]\nrun = \"true\"\n",
3714 )
3715 .unwrap();
3716
3717 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3718 assert_eq!(id, DaemonId::new("feature-tree", "api"));
3719 let qualified =
3720 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3721 assert_eq!(qualified, DaemonId::new("explicit", "api"));
3722 }
3723
3724 #[test]
3725 fn test_adhoc_id_falls_back_to_global_without_project_config() {
3726 let temp = tempfile::tempdir().unwrap();
3727 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3728 assert_eq!(id, DaemonId::new("global", "api"));
3729 }
3730}