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 crate::extra_configs::path_is_gone(&entry.dir)
2120 && crate::extra_configs::normalize(&entry.dir)
2121 != crate::extra_configs::normalize(&dir)
2122 {
2123 if crate::extra_configs::namespace_has_running_daemon(name) {
2124 miette::bail!(
2127 "namespace '{name}' has running daemons from {}, which no longer exists; stop them first",
2128 entry.dir.display()
2129 );
2130 }
2131 entry.config.clear();
2133 entry.label = None;
2134 }
2135 if !entry.config.is_empty()
2136 && crate::extra_configs::normalize(&entry.dir)
2137 != crate::extra_configs::normalize(&dir)
2138 {
2139 miette::bail!(
2140 "namespace '{name}' has external configuration attached to another directory"
2141 );
2142 }
2143 entry.dir = dir;
2144 } else {
2145 pt.namespaces.insert(
2146 name.to_string(),
2147 NamespaceEntry {
2148 dir,
2149 config: Vec::new(),
2150 label: None,
2151 },
2152 );
2153 }
2154 pt.write_unlocked()?;
2155 Ok(())
2156 }
2157
2158 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2160 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2161 if !global_path.exists() {
2162 return Ok(false);
2163 }
2164
2165 let _lock = xx::fslock::get(global_path, false)
2166 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2167
2168 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2169 crate::error::FileError::ReadError {
2170 path: global_path.to_path_buf(),
2171 source: e,
2172 }
2173 })?;
2174 let mut pt = Self::parse_str(&raw, global_path)?;
2175
2176 let removed = pt.namespaces.shift_remove(name).is_some();
2177 if removed {
2178 pt.write_unlocked()?;
2179 }
2180 Ok(removed)
2181 }
2182}
2183
2184#[derive(Debug, Default, Clone, PartialEq, Eq)]
2186pub struct PruneReport {
2187 pub namespaces: Vec<String>,
2189 pub slugs: Vec<String>,
2191 pub config_files: usize,
2193}
2194
2195impl PruneReport {
2196 pub fn is_empty(&self) -> bool {
2197 self.namespaces.is_empty() && self.slugs.is_empty() && self.config_files == 0
2198 }
2199}
2200
2201impl PitchforkToml {
2202 pub fn prune_stale_registrations(
2216 keep: &std::collections::HashSet<String>,
2217 only: &[String],
2218 ) -> Result<PruneReport> {
2219 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2220 let mut report = PruneReport::default();
2221 if !global_path.exists() {
2222 return Ok(report);
2223 }
2224 let _lock = xx::fslock::get(global_path, false)
2225 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2226 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2227 path: global_path.to_path_buf(),
2228 source: e,
2229 })?;
2230 let mut pt = Self::parse_str(&raw, global_path)?;
2231 let in_scope = |ns: &str| only.is_empty() || only.iter().any(|o| o == ns);
2232
2233 for (name, entry) in pt.namespaces.iter_mut() {
2234 if keep.contains(name) || !in_scope(name) {
2235 continue;
2236 }
2237 if crate::extra_configs::path_is_gone(&entry.dir) {
2238 report.namespaces.push(name.clone());
2239 continue;
2240 }
2241 let before = entry.config.len();
2242 entry
2243 .config
2244 .retain(|file| !crate::extra_configs::path_is_gone(file));
2245 let dropped = before - entry.config.len();
2246 if dropped > 0 {
2247 report.config_files += dropped;
2248 if entry.config.is_empty() {
2249 entry.label = None;
2251 }
2252 }
2253 }
2254 for name in &report.namespaces {
2255 pt.namespaces.shift_remove(name);
2256 }
2257
2258 if only.is_empty() || !report.namespaces.is_empty() {
2259 for (slug, entry) in &pt.slugs {
2260 if entry.namespace.as_ref().is_some_and(|ns| keep.contains(ns)) {
2263 continue;
2264 }
2265 let ns_pruned = entry
2266 .namespace
2267 .as_ref()
2268 .is_some_and(|ns| report.namespaces.contains(ns));
2269 let dir_gone = entry
2270 .dir
2271 .as_ref()
2272 .is_some_and(|d| crate::extra_configs::path_is_gone(d));
2273 let scoped_ok = only.is_empty() || ns_pruned;
2276 if scoped_ok && (ns_pruned || dir_gone) {
2277 report.slugs.push(slug.clone());
2278 }
2279 }
2280 for slug in &report.slugs {
2281 pt.slugs.shift_remove(slug);
2282 }
2283 }
2284
2285 if !report.is_empty() {
2286 pt.write_unlocked()?;
2287 if !report.slugs.is_empty() {
2288 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2289 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2290 }
2291 }
2292 Ok(report)
2293 }
2294}
2295
2296#[derive(Debug, Clone, JsonSchema, Default)]
2298pub struct PitchforkTomlDaemon {
2299 #[schemars(example = example_run_command())]
2303 pub run: RunCommand,
2304 #[schemars(default)]
2306 pub auto: Vec<PitchforkTomlAuto>,
2307 pub oneshot: Option<bool>,
2313 pub cron: Option<PitchforkTomlCron>,
2315 #[schemars(default)]
2318 pub retry: Retry,
2319 pub ready_delay: Option<u64>,
2321 pub ready_output: Option<ReadyOutput>,
2323 pub ready_http: Option<ReadyHttp>,
2325 pub ready_port: Option<ReadyPort>,
2329 pub ready_cmd: Option<ReadyCmd>,
2331 pub health_cmd: Option<HealthCmd>,
2333 pub health_http: Option<HealthHttp>,
2335 pub health_port: Option<HealthPort>,
2339 pub port: Option<PortConfig>,
2341 #[serde(skip_serializing_if = "Option::is_none", default)]
2344 pub proxy: Option<ProxyConfig>,
2345 pub proxy_tls: Option<ProxyTlsMode>,
2354 #[schemars(range(min = 1))]
2359 pub proxy_tls_port: Option<u16>,
2360 #[schemars(range(min = 1))]
2366 pub proxy_port: Option<u16>,
2367 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2378 pub boot_start: Option<bool>,
2380 #[schemars(default)]
2382 pub depends: Vec<DaemonId>,
2383 #[schemars(default)]
2385 pub watch: Vec<String>,
2386 #[schemars(default)]
2392 pub watch_mode: WatchMode,
2393 pub dir: Option<String>,
2395 pub env: Option<IndexMap<String, String>>,
2397 pub hooks: Option<PitchforkTomlHooks>,
2399 pub mise: Option<bool>,
2402 #[schemars(skip)]
2405 pub deferred_template_context: Option<String>,
2406 pub user: Option<String>,
2408 pub memory_limit: Option<MemoryLimit>,
2411 pub cpu_limit: Option<CpuLimit>,
2414 pub stop_signal: Option<StopConfig>,
2417 pub pty: Option<bool>,
2419 pub time_retention: Option<String>,
2422 pub line_retention: Option<i64>,
2425 pub archive_hook: Option<String>,
2428 pub logs: Option<PitchforkTomlDaemonLogs>,
2430 #[schemars(skip)]
2431 pub path: Option<PathBuf>,
2432}
2433
2434impl PitchforkTomlDaemon {
2435 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2441 self.proxy_tls_port.or(self.proxy_port)
2442 }
2443
2444 pub fn is_oneshot(&self) -> bool {
2446 self.oneshot.unwrap_or(false)
2447 }
2448
2449 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2452 [
2453 ("ready_delay", self.ready_delay.is_some()),
2454 ("ready_output", self.ready_output.is_some()),
2455 ("ready_http", self.ready_http.is_some()),
2456 ("ready_port", self.ready_port.is_some()),
2457 ("ready_cmd", self.ready_cmd.is_some()),
2458 ("health_cmd", self.health_cmd.is_some()),
2459 ("health_http", self.health_http.is_some()),
2460 ("health_port", self.health_port.is_some()),
2461 ]
2462 .into_iter()
2463 .filter(|(_, set)| *set)
2464 .map(|(name, _)| name)
2465 .collect()
2466 }
2467
2468 pub fn effective_user(&self) -> Option<String> {
2472 let daemon_user = self
2473 .user
2474 .as_deref()
2475 .map(str::trim)
2476 .filter(|u| !u.is_empty());
2477 daemon_user.map(str::to_owned).or_else(|| {
2478 let s = crate::settings::settings();
2479 let su = s.supervisor.user.trim();
2480 (!su.is_empty()).then(|| su.to_owned())
2481 })
2482 }
2483
2484 pub fn to_run_options(
2489 &self,
2490 id: &crate::daemon_id::DaemonId,
2491 cmd: Vec<String>,
2492 ) -> crate::daemon::RunOptions {
2493 use crate::daemon::RunOptions;
2494
2495 let effective_user = self.effective_user();
2496 let dir = crate::ipc::batch::resolve_daemon_dir(
2497 self.dir.as_deref(),
2498 self.path.as_deref(),
2499 effective_user.as_deref(),
2500 );
2501 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2504 id,
2505 &PitchforkToml::read_global_slugs(),
2506 );
2507
2508 RunOptions {
2509 id: id.clone(),
2510 cmd,
2511 run: self.run.shell_script().map(str::to_string),
2512 no_shell: self.run.is_argv(),
2513 replaces_ready_checks: false,
2514 requested_by_client: false,
2516 force: false,
2517 shell_pid: None,
2518 dir: Dir(dir),
2519 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2520 oneshot: self.is_oneshot(),
2521 cron_started: false,
2524 oneshot_wait: None,
2528 on_directory_enter: false,
2529 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2530 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2531 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2532 retry: self.retry,
2533 retry_count: 0,
2534 ready_delay: self.ready_delay,
2535 ready_output: self.ready_output.clone(),
2536 ready_http: self.ready_http.clone(),
2537 ready_port: self.ready_port.clone(),
2538 ready_cmd: self.ready_cmd.clone(),
2539 health_cmd: self.health_cmd.clone(),
2540 health_http: self.health_http.clone(),
2541 health_port: self.health_port.clone(),
2542 port: self.port.clone(),
2543 wait_ready: false,
2544 depends: self.depends.clone(),
2545 env: self.env.clone(),
2546 watch: self.watch.clone(),
2547 watch_mode: self.watch_mode,
2548 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2549 self.path.as_deref(),
2550 )),
2551 mise: self.mise,
2552 deferred_template_context: self.deferred_template_context.clone(),
2553 mise_bin: None,
2555 slug,
2556 proxy: None,
2557 user: self.user.clone(),
2558 memory_limit: self.memory_limit,
2559 cpu_limit: self.cpu_limit,
2560 stop_signal: self.stop_signal,
2561 archive_hook: self
2562 .logs
2563 .as_ref()
2564 .and_then(|l| l.archive_hook.clone())
2565 .or_else(|| self.archive_hook.clone()),
2566 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2567 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2568 pty: self.pty,
2569 proxy_idle_timeout_ms: None,
2571 replaces_saved_record: true,
2572 }
2573 }
2574}
2575fn example_run_command() -> &'static str {
2576 "exec node server.js"
2577}
2578
2579#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2582#[serde(untagged)]
2583pub enum RunCommand {
2584 Shell(String),
2587 Argv(#[schemars(length(min = 1))] Vec<String>),
2591}
2592
2593impl Default for RunCommand {
2594 fn default() -> Self {
2595 Self::Shell(String::new())
2596 }
2597}
2598
2599impl From<String> for RunCommand {
2600 fn from(run: String) -> Self {
2601 Self::Shell(run)
2602 }
2603}
2604
2605impl From<&str> for RunCommand {
2606 fn from(run: &str) -> Self {
2607 Self::Shell(run.to_string())
2608 }
2609}
2610
2611impl From<Vec<String>> for RunCommand {
2612 fn from(argv: Vec<String>) -> Self {
2613 Self::Argv(argv)
2614 }
2615}
2616
2617impl PartialEq<str> for RunCommand {
2618 fn eq(&self, other: &str) -> bool {
2619 matches!(self, Self::Shell(run) if run == other)
2620 }
2621}
2622
2623impl PartialEq<&str> for RunCommand {
2624 fn eq(&self, other: &&str) -> bool {
2625 self == *other
2626 }
2627}
2628
2629impl std::fmt::Display for RunCommand {
2632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2633 match self {
2634 Self::Shell(run) => f.write_str(run),
2635 Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2636 }
2637 }
2638}
2639
2640impl RunCommand {
2641 pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2644 match self {
2645 Self::Shell(run) => shell_words::split(run),
2646 Self::Argv(argv) => Ok(argv.clone()),
2647 }
2648 }
2649
2650 pub fn shell_script(&self) -> Option<&str> {
2652 match self {
2653 Self::Shell(run) => Some(run),
2654 Self::Argv(_) => None,
2655 }
2656 }
2657
2658 pub fn is_argv(&self) -> bool {
2659 matches!(self, Self::Argv(_))
2660 }
2661
2662 pub fn try_map<E>(
2664 &self,
2665 mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2666 ) -> std::result::Result<Self, E> {
2667 Ok(match self {
2668 Self::Shell(run) => Self::Shell(f(run)?),
2669 Self::Argv(argv) => Self::Argv(
2670 argv.iter()
2671 .map(|arg| f(arg))
2672 .collect::<std::result::Result<_, _>>()?,
2673 ),
2674 })
2675 }
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680 use super::*;
2681 use std::path::Path;
2682
2683 #[test]
2684 fn test_daemon_user_parses_and_flows_to_run_options() {
2685 let pt = PitchforkToml::parse_str(
2686 r#"
2687[daemons.api]
2688run = "node server.js"
2689user = "postgres"
2690"#,
2691 Path::new("/tmp/my-project/pitchfork.toml"),
2692 )
2693 .unwrap();
2694
2695 let id = DaemonId::new("my-project", "api");
2696 let daemon = pt.daemons.get(&id).unwrap();
2697 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2698
2699 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2700 assert_eq!(opts.user.as_deref(), Some("postgres"));
2701 assert!(opts.replaces_saved_record);
2703 }
2704
2705 #[test]
2706 fn test_new_local_file_writes_daemons_of_the_directory_namespace() {
2707 let temp = tempfile::tempdir().unwrap();
2708 std::fs::write(
2709 temp.path().join("pitchfork.toml"),
2710 "namespace = \"myns\"\n\n[daemons.web]\nrun = \"sleep 60\"\n",
2711 )
2712 .unwrap();
2713 let path = temp.path().join("pitchfork.local.toml");
2714 let mut pt = PitchforkToml::new(path.clone());
2715 let id = DaemonId::new(namespace_from_path(&path).unwrap(), "api");
2716 assert_eq!(id.namespace(), "myns");
2717 pt.daemons.insert(
2718 id.clone(),
2719 PitchforkTomlDaemon {
2720 run: "sleep 60".into(),
2721 ..PitchforkTomlDaemon::default()
2722 },
2723 );
2724
2725 pt.write().unwrap();
2726
2727 let parsed = PitchforkToml::read(&path).unwrap();
2728 assert!(parsed.daemons.contains_key(&id));
2729 }
2730
2731 #[test]
2732 fn test_daemon_user_write_roundtrip() {
2733 let temp = tempfile::tempdir().unwrap();
2734 let path = temp.path().join("pitchfork.toml");
2735 let mut pt = PitchforkToml::new(path.clone());
2736 pt.namespace = Some("test-project".to_string());
2737 pt.daemons.insert(
2738 DaemonId::new("test-project", "api"),
2739 PitchforkTomlDaemon {
2740 run: "node server.js".into(),
2741 user: Some("postgres".to_string()),
2742 ..PitchforkTomlDaemon::default()
2743 },
2744 );
2745
2746 pt.write().unwrap();
2747
2748 let raw = std::fs::read_to_string(&path).unwrap();
2749 assert!(raw.contains("user = \"postgres\""));
2750
2751 let parsed = PitchforkToml::read(&path).unwrap();
2752 let daemon = parsed
2753 .daemons
2754 .get(&DaemonId::new("test-project", "api"))
2755 .unwrap();
2756 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2757 }
2758
2759 fn awkward_argv() -> Vec<String> {
2761 [
2762 "node",
2763 "my server.js",
2764 "--name=\"a b\"",
2765 "it's",
2766 "a&b",
2767 "%PATH%",
2768 "$HOME",
2769 ]
2770 .map(str::to_string)
2771 .to_vec()
2772 }
2773
2774 #[test]
2775 fn test_run_array_parses_and_starts_without_a_shell() {
2776 let pt = PitchforkToml::parse_str(
2777 r#"
2778[daemons.api]
2779run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2780"#,
2781 Path::new("/tmp/my-project/pitchfork.toml"),
2782 )
2783 .unwrap();
2784
2785 let id = DaemonId::new("my-project", "api");
2786 let daemon = pt.daemons.get(&id).unwrap();
2787 assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2788
2789 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2790 assert_eq!(opts.cmd, awkward_argv());
2791 assert!(opts.no_shell);
2792 assert_eq!(opts.run, None);
2793 }
2794
2795 #[test]
2796 fn test_run_string_still_goes_through_the_shell() {
2797 let pt = PitchforkToml::parse_str(
2798 "[daemons.api]\nrun = \"exec node server.js\"\n",
2799 Path::new("/tmp/my-project/pitchfork.toml"),
2800 )
2801 .unwrap();
2802
2803 let id = DaemonId::new("my-project", "api");
2804 let daemon = pt.daemons.get(&id).unwrap();
2805 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2806 assert!(!opts.no_shell);
2807 assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2808 }
2809
2810 #[test]
2811 fn test_run_array_write_roundtrip() {
2812 let temp = tempfile::tempdir().unwrap();
2813 let path = temp.path().join("pitchfork.toml");
2814 let mut pt = PitchforkToml::new(path.clone());
2815 pt.namespace = Some("test-project".to_string());
2816 let id = DaemonId::new("test-project", "api");
2817 pt.daemons.insert(
2818 id.clone(),
2819 PitchforkTomlDaemon {
2820 run: RunCommand::Argv(awkward_argv()),
2821 ..PitchforkTomlDaemon::default()
2822 },
2823 );
2824 pt.daemons.insert(
2825 DaemonId::new("test-project", "worker"),
2826 PitchforkTomlDaemon {
2827 run: "exec ./worker --queue \"a b\"".into(),
2828 ..PitchforkTomlDaemon::default()
2829 },
2830 );
2831
2832 pt.write().unwrap();
2833
2834 let parsed = PitchforkToml::read(&path).unwrap();
2835 assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
2836 assert_eq!(
2837 parsed.daemons[&DaemonId::new("test-project", "worker")].run,
2838 "exec ./worker --queue \"a b\""
2839 );
2840 }
2841
2842 #[test]
2843 fn test_run_array_must_name_a_program() {
2844 let err = PitchforkToml::parse_str(
2845 "[daemons.api]\nrun = []\n",
2846 Path::new("/tmp/my-project/pitchfork.toml"),
2847 )
2848 .unwrap_err();
2849 assert!(
2850 err.chain()
2851 .any(|cause| cause.to_string().contains("empty run array")),
2852 "unexpected error: {err:?}"
2853 );
2854 }
2855
2856 #[test]
2857 fn test_run_array_rejects_exec() {
2858 let err = PitchforkToml::parse_str(
2859 "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
2860 Path::new("/tmp/my-project/pitchfork.toml"),
2861 )
2862 .unwrap_err();
2863 assert!(
2864 err.chain().any(|cause| cause
2865 .to_string()
2866 .contains("starts its run array with \"exec\"")),
2867 "unexpected error: {err:?}"
2868 );
2869 }
2870
2871 #[test]
2872 fn test_ready_cmd_array_parses_in_both_forms() {
2873 let pt = PitchforkToml::parse_str(
2874 r#"
2875[daemons.api]
2876run = "node server.js"
2877ready_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2878
2879[daemons.db]
2880run = "postgres"
2881ready_cmd = { run = ["pg_isready", "-h", "localhost"], timeout = "30s" }
2882
2883[daemons.cache]
2884run = "redis-server"
2885ready_cmd = "redis-cli ping"
2886"#,
2887 Path::new("/tmp/my-project/pitchfork.toml"),
2888 )
2889 .unwrap();
2890
2891 let ready = |name: &str| {
2892 pt.daemons[&DaemonId::new("my-project", name)]
2893 .ready_cmd
2894 .clone()
2895 .unwrap()
2896 };
2897 assert_eq!(ready("api").run, RunCommand::Argv(awkward_argv()));
2898 assert_eq!(ready("api").timeout, None);
2899 assert_eq!(
2900 ready("db").run,
2901 RunCommand::Argv(
2902 ["pg_isready", "-h", "localhost"]
2903 .map(str::to_string)
2904 .to_vec()
2905 )
2906 );
2907 assert_eq!(
2908 ready("db").timeout,
2909 Some(std::time::Duration::from_secs(30))
2910 );
2911 assert_eq!(ready("cache").run, "redis-cli ping");
2912 }
2913
2914 #[test]
2915 fn test_ready_cmd_array_write_roundtrip() {
2916 let temp = tempfile::tempdir().unwrap();
2917 let path = temp.path().join("pitchfork.toml");
2918 let mut pt = PitchforkToml::new(path.clone());
2919 pt.namespace = Some("test-project".to_string());
2920 let short = ReadyCmd::new(awkward_argv());
2921 let full = ReadyCmd {
2922 run: RunCommand::Argv(
2923 ["pg_isready", "-h", "localhost"]
2924 .map(str::to_string)
2925 .to_vec(),
2926 ),
2927 timeout: Some(std::time::Duration::from_secs(30)),
2928 };
2929 for (name, ready_cmd) in [("api", &short), ("db", &full)] {
2930 pt.daemons.insert(
2931 DaemonId::new("test-project", name),
2932 PitchforkTomlDaemon {
2933 run: "true".into(),
2934 ready_cmd: Some(ready_cmd.clone()),
2935 ..PitchforkTomlDaemon::default()
2936 },
2937 );
2938 }
2939
2940 pt.write().unwrap();
2941
2942 let parsed = PitchforkToml::read(&path).unwrap();
2943 let ready = |name: &str| {
2944 parsed.daemons[&DaemonId::new("test-project", name)]
2945 .ready_cmd
2946 .clone()
2947 };
2948 assert_eq!(ready("api"), Some(short));
2949 assert_eq!(ready("db"), Some(full));
2950 }
2951
2952 #[test]
2953 fn test_ready_cmd_array_must_name_a_program() {
2954 for ready_cmd in ["[]", "[\"\"]", "{ run = [\"\", \"-h\"] }"] {
2955 let err = PitchforkToml::parse_str(
2956 &format!("[daemons.api]\nrun = \"true\"\nready_cmd = {ready_cmd}\n"),
2957 Path::new("/tmp/my-project/pitchfork.toml"),
2958 )
2959 .unwrap_err();
2960 assert!(
2961 err.chain().any(|cause| cause
2962 .to_string()
2963 .contains("has no program in its ready_cmd array")),
2964 "unexpected error for {ready_cmd}: {err:?}"
2965 );
2966 }
2967 }
2968
2969 #[test]
2970 fn test_ready_cmd_array_rejects_exec() {
2971 let err = PitchforkToml::parse_str(
2972 "[daemons.api]\nrun = \"true\"\nready_cmd = { run = [\"exec\", \"pg_isready\"] }\n",
2973 Path::new("/tmp/my-project/pitchfork.toml"),
2974 )
2975 .unwrap_err();
2976 assert!(
2977 err.chain().any(|cause| cause
2978 .to_string()
2979 .contains("starts its ready_cmd array with \"exec\"")),
2980 "unexpected error: {err:?}"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_health_cmd_array_parses_and_round_trips() {
2986 let pt = PitchforkToml::parse_str(
2987 r#"
2988[daemons.api]
2989run = "node server.js"
2990health_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2991
2992[daemons.db]
2993run = "postgres"
2994health_cmd = { run = ["pg_isready", "-h", "localhost"], interval = "5s", retries = 2 }
2995
2996[daemons.cache]
2997run = "redis-server"
2998health_cmd = "redis-cli ping"
2999"#,
3000 Path::new("/tmp/my-project/pitchfork.toml"),
3001 )
3002 .unwrap();
3003 let health = |pt: &PitchforkToml, ns: &str, name: &str| {
3004 pt.daemons[&DaemonId::new(ns, name)]
3005 .health_cmd
3006 .clone()
3007 .unwrap()
3008 };
3009 assert_eq!(
3010 health(&pt, "my-project", "api").run,
3011 RunCommand::Argv(awkward_argv())
3012 );
3013 let db = health(&pt, "my-project", "db");
3014 assert_eq!(
3015 db.run,
3016 RunCommand::Argv(
3017 ["pg_isready", "-h", "localhost"]
3018 .map(str::to_string)
3019 .to_vec()
3020 )
3021 );
3022 assert_eq!(db.interval, Some(std::time::Duration::from_secs(5)));
3023 assert_eq!(db.retries, Some(2));
3024 assert_eq!(health(&pt, "my-project", "cache").run, "redis-cli ping");
3025
3026 let temp = tempfile::tempdir().unwrap();
3027 let path = temp.path().join("pitchfork.toml");
3028 let mut written = PitchforkToml::new(path.clone());
3029 written.namespace = Some("test-project".to_string());
3030 for name in ["api", "db"] {
3031 written.daemons.insert(
3032 DaemonId::new("test-project", name),
3033 PitchforkTomlDaemon {
3034 run: "true".into(),
3035 health_cmd: Some(health(&pt, "my-project", name)),
3036 ..PitchforkTomlDaemon::default()
3037 },
3038 );
3039 }
3040 written.write().unwrap();
3041 let parsed = PitchforkToml::read(&path).unwrap();
3042 for name in ["api", "db"] {
3043 assert_eq!(
3044 health(&parsed, "test-project", name),
3045 health(&pt, "my-project", name)
3046 );
3047 }
3048 }
3049
3050 #[test]
3051 fn test_health_cmd_array_must_name_a_program_and_not_exec() {
3052 for (health_cmd, expected) in [
3053 ("[]", "has no program in its health_cmd array"),
3054 ("[\"\"]", "has no program in its health_cmd array"),
3055 (
3056 "{ run = [\"exec\", \"pg_isready\"] }",
3057 "starts its health_cmd array with \"exec\"",
3058 ),
3059 ] {
3060 let err = PitchforkToml::parse_str(
3061 &format!("[daemons.api]\nrun = \"true\"\nhealth_cmd = {health_cmd}\n"),
3062 Path::new("/tmp/my-project/pitchfork.toml"),
3063 )
3064 .unwrap_err();
3065 assert!(
3066 err.chain()
3067 .any(|cause| cause.to_string().contains(expected)),
3068 "unexpected error for {health_cmd}: {err:?}"
3069 );
3070 }
3071 }
3072
3073 #[test]
3074 fn test_registry_dirs_expand_tilde() {
3075 let pt = PitchforkToml::parse_str(
3076 r#"
3077[slugs.api]
3078dir = "~/projects/api"
3079
3080[namespaces.web]
3081dir = "~/projects/web"
3082"#,
3083 Path::new("/tmp/config.toml"),
3084 )
3085 .unwrap();
3086
3087 assert_eq!(
3088 pt.slugs["api"].dir,
3089 Some(crate::env::HOME_DIR.join("projects/api"))
3090 );
3091 assert_eq!(
3092 pt.namespaces["web"].dir,
3093 crate::env::HOME_DIR.join("projects/web")
3094 );
3095 }
3096
3097 #[test]
3098 fn test_settings_write_roundtrip() {
3099 let temp = tempfile::tempdir().unwrap();
3100 let path = temp.path().join("pitchfork.toml");
3101 let mut pt = PitchforkToml::new(path.clone());
3102 pt.namespace = Some("test-project".to_string());
3103 pt.settings.web.auto_start = Some(true);
3104 pt.settings.general.log_level = Some("debug".to_string());
3105
3106 pt.write().unwrap();
3107
3108 let raw = std::fs::read_to_string(&path).unwrap();
3109 assert!(
3110 raw.contains("[settings.web]"),
3111 "settings.web section should be written, got:\n{raw}"
3112 );
3113 assert!(raw.contains("auto_start = true"));
3114 assert!(raw.contains("log_level = \"debug\""));
3115
3116 let parsed = PitchforkToml::read(&path).unwrap();
3117 assert_eq!(parsed.settings.web.auto_start, Some(true));
3118 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
3119 }
3120
3121 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
3122 SlugEntry {
3123 dir: None,
3124 namespace: Some(namespace.to_string()),
3125 daemon: daemon.map(str::to_string),
3126 }
3127 }
3128
3129 #[test]
3130 fn test_slug_is_ambiguous() {
3131 let mut slugs = IndexMap::new();
3132 slugs.insert("api".to_string(), slug_entry("my-project", None));
3133 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
3134
3135 slugs.insert("API".to_string(), slug_entry("other-project", None));
3136 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
3138 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
3139 }
3140
3141 #[test]
3142 fn test_find_slug_for_daemon_skips_case_collisions() {
3143 let id = DaemonId::new("my-project", "api");
3144 let mut slugs = IndexMap::new();
3145 slugs.insert("api".to_string(), slug_entry("my-project", None));
3146 assert_eq!(
3147 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3148 Some("api".to_string())
3149 );
3150
3151 slugs.insert("API".to_string(), slug_entry("other-project", None));
3154 assert_eq!(
3155 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3156 None
3157 );
3158 }
3159
3160 #[test]
3161 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
3162 let id = DaemonId::new("my-project", "api");
3163 let mut slugs = IndexMap::new();
3164 slugs.insert("api".to_string(), slug_entry("my-project", None));
3167 slugs.insert("API".to_string(), slug_entry("my-project", None));
3168 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
3169
3170 assert_eq!(
3171 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3172 Some("my-api".to_string())
3173 );
3174 }
3175
3176 #[test]
3177 fn test_settings_preserved_on_unrelated_write() {
3178 let temp = tempfile::tempdir().unwrap();
3181 let path = temp.path().join("pitchfork.toml");
3182 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
3183
3184 let mut pt = PitchforkToml::read(&path).unwrap();
3185 pt.slugs.insert(
3186 "api".to_string(),
3187 SlugEntry {
3188 dir: None,
3189 namespace: Some("myproject".to_string()),
3190 daemon: None,
3191 },
3192 );
3193 pt.namespaces.insert(
3194 "myproject".to_string(),
3195 NamespaceEntry {
3196 dir: PathBuf::from("/tmp/myproject"),
3197 config: Vec::new(),
3198 label: None,
3199 },
3200 );
3201 pt.write().unwrap();
3202
3203 let raw = std::fs::read_to_string(&path).unwrap();
3204 assert!(
3205 raw.contains("[settings.web]"),
3206 "existing settings must be preserved, got:\n{raw}"
3207 );
3208 assert!(raw.contains("auto_start = true"));
3209 assert!(raw.contains("[slugs.api]"));
3210
3211 let parsed = PitchforkToml::read(&path).unwrap();
3212 assert_eq!(parsed.settings.web.auto_start, Some(true));
3213 assert!(parsed.slugs.contains_key("api"));
3214 }
3215
3216 #[tokio::test]
3217 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
3218 let temp = tempfile::tempdir().unwrap();
3219 let path = temp.path().join("pitchfork.toml");
3220 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
3221 .await
3222 .unwrap();
3223
3224 let read_path = path.clone();
3225 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
3226 .await
3227 .unwrap()
3228 .unwrap();
3229 assert_eq!(pt.settings.general.worktree, Some(false));
3230 assert_eq!(pt.settings.proxy.worktree, None);
3231 tokio::task::spawn_blocking(move || pt.write())
3232 .await
3233 .unwrap()
3234 .unwrap();
3235
3236 let raw = tokio::fs::read_to_string(&path).await.unwrap();
3237 assert!(raw.contains("[settings.general]"), "{raw}");
3238 assert!(raw.contains("worktree = false"), "{raw}");
3239 assert!(!raw.contains("[settings.proxy]"), "{raw}");
3240
3241 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
3242 .await
3243 .unwrap()
3244 .unwrap();
3245 assert_eq!(parsed.settings.general.worktree, Some(false));
3246 }
3247
3248 #[test]
3249 fn test_config_cache_hit_and_invalidation() {
3250 let temp = tempfile::tempdir().unwrap();
3251 let dir = temp.path();
3252 let config_path = dir.join("pitchfork.toml");
3253 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3254
3255 super::invalidate_config_cache();
3257
3258 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3260 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3261 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3262
3263 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3265 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
3266
3267 std::thread::sleep(std::time::Duration::from_millis(50));
3270 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
3271
3272 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
3273 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
3274
3275 super::invalidate_config_cache();
3277 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
3278 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
3279
3280 super::invalidate_config_cache();
3282 }
3283
3284 #[test]
3285 fn test_config_cache_invalidation_on_write() {
3286 let temp = tempfile::tempdir().unwrap();
3287 let dir = temp.path();
3288 let config_path = dir.join("pitchfork.toml");
3289 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3290
3291 super::invalidate_config_cache();
3292
3293 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3295 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3296 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3297
3298 let mut pt = PitchforkToml::read(&config_path).unwrap();
3300 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
3301 let _ = pt.write();
3303
3304 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3306 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
3307
3308 super::invalidate_config_cache();
3309 }
3310
3311 #[test]
3312 fn test_config_cache_size_invalidation() {
3313 let temp = tempfile::tempdir().unwrap();
3314 let dir = temp.path();
3315 let config_path = dir.join("pitchfork.toml");
3316 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3317
3318 super::invalidate_config_cache();
3319
3320 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3322 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3323 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3324
3325 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
3329 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
3330 let file = std::fs::OpenOptions::new()
3333 .write(true)
3334 .open(&config_path)
3335 .unwrap();
3336 let times = std::fs::FileTimes::new().set_modified(original_mtime);
3337 file.set_times(times).unwrap();
3338
3339 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3342 assert_eq!(
3343 pt2.daemons[&daemon_id].run, "echo different",
3344 "cache should invalidate on size change even with identical mtime"
3345 );
3346
3347 super::invalidate_config_cache();
3348 }
3349
3350 #[test]
3351 fn test_find_project_root_in_plain_dir_returns_none() {
3352 let temp = tempfile::tempdir().unwrap();
3353 assert_eq!(find_project_root(temp.path()), None);
3354 }
3355
3356 #[test]
3357 fn test_find_project_root_finds_git_marker() {
3358 let temp = tempfile::tempdir().unwrap();
3359 let repo = temp.path().join("my-repo");
3360 std::fs::create_dir(&repo).unwrap();
3361 std::fs::create_dir(repo.join(".git")).unwrap();
3362
3363 let sub = repo.join("sub/dir");
3364 std::fs::create_dir_all(&sub).unwrap();
3365
3366 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
3369 }
3370
3371 #[test]
3372 fn test_find_project_root_accepts_git_file_marker() {
3373 let temp = tempfile::tempdir().unwrap();
3376 let wt = temp.path().join("my-worktree");
3377 std::fs::create_dir(&wt).unwrap();
3378 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
3379
3380 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
3381 }
3382
3383 #[cfg(unix)]
3386 #[test]
3387 fn test_find_project_root_resolves_symlinked_start_dir() {
3388 use std::os::unix::fs::symlink;
3389
3390 let temp = tempfile::tempdir().unwrap();
3391 let repo = temp.path().join("real-repo");
3392 std::fs::create_dir(&repo).unwrap();
3393 std::fs::create_dir(repo.join(".git")).unwrap();
3394
3395 let sub = repo.join("sub/dir");
3396 std::fs::create_dir_all(&sub).unwrap();
3397 let link = temp.path().join("link-to-sub");
3398 symlink(&sub, &link).unwrap();
3399
3400 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
3401 }
3402
3403 #[test]
3406 fn test_all_merged_all_namespaces_discovers_worktrees() {
3407 let temp = tempfile::tempdir().unwrap();
3408 let repo = temp.path().join("my-repo");
3409 std::fs::create_dir(&repo).unwrap();
3410
3411 let git_init = std::process::Command::new("git")
3413 .args(["init", "-b", "main"])
3414 .current_dir(&repo)
3415 .output()
3416 .expect("git init");
3417 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
3418
3419 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
3420
3421 let git_commit = std::process::Command::new("git")
3422 .args([
3423 "-c",
3424 "user.name=pitchfork-test",
3425 "-c",
3426 "user.email=pitchfork-test@example.com",
3427 "add",
3428 "-A",
3429 ])
3430 .current_dir(&repo)
3431 .output()
3432 .expect("git add");
3433 assert!(git_commit.status.success());
3434
3435 let git_commit = std::process::Command::new("git")
3436 .args([
3437 "-c",
3438 "user.name=pitchfork-test",
3439 "-c",
3440 "user.email=pitchfork-test@example.com",
3441 "commit",
3442 "-m",
3443 "init",
3444 ])
3445 .current_dir(&repo)
3446 .output()
3447 .expect("git commit");
3448 assert!(
3449 git_commit.status.success(),
3450 "git commit failed: {:?}",
3451 git_commit
3452 );
3453
3454 let wt = temp.path().join("my-repo-feature");
3455 let git_wt = std::process::Command::new("git")
3456 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3457 .current_dir(&repo)
3458 .output()
3459 .expect("git worktree add");
3460 assert!(
3461 git_wt.status.success(),
3462 "git worktree add failed: {:?}",
3463 git_wt
3464 );
3465
3466 std::fs::write(
3468 repo.join("pitchfork.toml"),
3469 "[daemons.api]\nrun = \"echo main\"\n",
3470 )
3471 .unwrap();
3472 std::fs::write(
3474 wt.join("pitchfork.toml"),
3475 "[daemons.worker]\nrun = \"echo wt\"\n",
3476 )
3477 .unwrap();
3478
3479 super::invalidate_config_cache();
3480
3481 let pt = PitchforkToml::all_merged_all_namespaces_from_blocking(&wt).unwrap();
3483
3484 let main_id = DaemonId::new("my-repo", "api");
3485 let wt_id = DaemonId::new("my-repo-feature", "worker");
3486 assert!(
3487 pt.daemons.contains_key(&main_id),
3488 "main checkout daemon missing"
3489 );
3490 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3491
3492 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from_blocking(&repo).unwrap();
3494 assert!(pt_from_main.daemons.contains_key(&wt_id));
3495
3496 let _ = std::process::Command::new("git")
3498 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3499 .current_dir(&repo)
3500 .output();
3501 super::invalidate_config_cache();
3502 }
3503
3504 #[test]
3505 fn test_adhoc_id_uses_invocation_directory_namespace() {
3506 let temp = tempfile::tempdir().unwrap();
3507 let project = temp.path().join("feature-tree");
3508 std::fs::create_dir(&project).unwrap();
3509 std::fs::write(
3510 project.join("pitchfork.toml"),
3511 "[daemons.other]\nrun = \"true\"\n",
3512 )
3513 .unwrap();
3514
3515 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3516 assert_eq!(id, DaemonId::new("feature-tree", "api"));
3517 let qualified =
3518 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3519 assert_eq!(qualified, DaemonId::new("explicit", "api"));
3520 }
3521
3522 #[test]
3523 fn test_adhoc_id_falls_back_to_global_without_project_config() {
3524 let temp = tempfile::tempdir().unwrap();
3525 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3526 assert_eq!(id, DaemonId::new("global", "api"));
3527 }
3528}