1use crate::daemon_id::DaemonId;
2use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
3use crate::settings::SettingsPartial;
4use crate::settings::settings;
5use crate::state_file::StateFile;
6use crate::{Result, env};
7use indexmap::IndexMap;
8use miette::Context;
9use once_cell::sync::Lazy;
10use schemars::JsonSchema;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex as StdMutex;
14use std::time::SystemTime;
15
16pub use crate::config_types::{
18 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
19 PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ProxyConfig,
20 ProxyIdleTimeout, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig,
21 StopSignal, WatchMode,
22};
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
32pub struct SlugEntryRaw {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub dir: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub namespace: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub daemon: Option<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct SlugEntry {
47 pub dir: Option<PathBuf>,
49 pub namespace: Option<String>,
51 pub daemon: Option<String>,
53}
54
55impl SlugEntry {
56 pub fn resolve_dir(&self) -> Option<PathBuf> {
59 self.dir.clone().or_else(|| {
60 self.namespace.as_ref().and_then(|ns| {
61 let namespaces = PitchforkToml::read_global_namespaces();
62 namespaces.get(ns).map(|entry| entry.dir.clone())
63 })
64 })
65 }
66
67 pub fn resolve_namespace(&self) -> Option<String> {
70 self.namespace.clone().or_else(|| {
71 self.resolve_dir()
72 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
73 })
74 }
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
83pub struct GroupEntryRaw {
84 #[schemars(with = "Vec<DaemonId>")]
85 pub daemons: Vec<String>,
86}
87
88#[derive(Debug, Clone)]
90pub struct GroupEntry {
91 pub daemons: Vec<DaemonId>,
92}
93
94#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
100pub struct NamespaceEntryRaw {
101 pub dir: String,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub config: Vec<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub label: Option<String>,
111}
112
113#[derive(Debug, Clone)]
115pub struct NamespaceEntry {
116 pub dir: PathBuf,
118 pub config: Vec<PathBuf>,
119 pub label: Option<String>,
121}
122
123#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
125struct PitchforkTomlRaw {
126 #[serde(skip_serializing_if = "Option::is_none", default)]
127 pub namespace: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none", default)]
130 pub worktree_label: Option<String>,
131 #[serde(default)]
132 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
133 #[serde(skip_serializing_if = "Option::is_none", default)]
136 pub env: Option<IndexMap<String, String>>,
137 #[serde(default)]
138 pub settings: Option<SettingsPartial>,
139 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
142 pub slugs: IndexMap<String, SlugEntryRaw>,
143 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
145 pub groups: IndexMap<String, GroupEntryRaw>,
146 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
149 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
150}
151
152#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
157pub struct PitchforkTomlDaemonLogs {
158 #[serde(skip_serializing_if = "Option::is_none", default)]
161 pub log_format: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none", default)]
164 pub time_retention: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none", default)]
167 pub line_retention: Option<i64>,
168 #[serde(skip_serializing_if = "Option::is_none", default)]
170 pub archive_hook: Option<String>,
171}
172
173#[derive(Debug, serde::Serialize, serde::Deserialize)]
180struct PitchforkTomlDaemonRaw {
181 pub run: RunCommand,
182 #[serde(skip_serializing_if = "Vec::is_empty", default)]
183 pub auto: Vec<PitchforkTomlAuto>,
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub oneshot: Option<bool>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
187 pub cron: Option<PitchforkTomlCron>,
188 #[serde(default)]
189 pub retry: Retry,
190 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub ready_delay: Option<u64>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
193 pub ready_output: Option<ReadyOutput>,
194 #[serde(skip_serializing_if = "Option::is_none", default)]
195 pub ready_http: Option<ReadyHttp>,
196 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub ready_port: Option<ReadyPort>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
199 pub ready_cmd: Option<ReadyCmd>,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub health_cmd: Option<HealthCmd>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
203 pub health_http: Option<HealthHttp>,
204 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub health_port: Option<HealthPort>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub port: Option<PortConfig>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
211 pub proxy: Option<ProxyConfig>,
212 #[serde(skip_serializing_if = "Vec::is_empty", default)]
214 pub expected_port: Vec<u16>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
217 pub auto_bump_port: Option<bool>,
218 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub port_bump_attempts: Option<u32>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
223 pub proxy_tls: Option<ProxyTlsMode>,
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub proxy_tls_port: Option<u16>,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
230 pub proxy_port: Option<u16>,
231 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
234 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub boot_start: Option<bool>,
236 #[serde(skip_serializing_if = "Vec::is_empty", default)]
237 pub depends: Vec<String>,
238 #[serde(skip_serializing_if = "Vec::is_empty", default)]
239 pub watch: Vec<String>,
240 #[serde(skip_serializing_if = "Option::is_none", default)]
241 pub watch_mode: Option<WatchMode>,
242 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub dir: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none", default)]
245 pub env: Option<IndexMap<String, String>>,
246 #[serde(skip_serializing_if = "Option::is_none", default)]
247 pub hooks: Option<PitchforkTomlHooks>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
249 pub mise: Option<bool>,
250 #[serde(skip_serializing_if = "Option::is_none", default)]
252 pub user: Option<String>,
253 #[serde(skip_serializing_if = "Option::is_none", default)]
255 pub memory_limit: Option<MemoryLimit>,
256 #[serde(skip_serializing_if = "Option::is_none", default)]
258 pub cpu_limit: Option<CpuLimit>,
259 #[serde(skip_serializing_if = "Option::is_none", default)]
261 pub stop_signal: Option<StopConfig>,
262 #[serde(skip_serializing_if = "Option::is_none", default)]
264 pub pty: Option<bool>,
265 #[serde(skip_serializing_if = "Option::is_none", default)]
268 pub time_retention: Option<String>,
269 #[serde(skip_serializing_if = "Option::is_none", default)]
272 pub line_retention: Option<i64>,
273 #[serde(skip_serializing_if = "Option::is_none", default)]
276 pub archive_hook: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none", default)]
279 pub logs: Option<PitchforkTomlDaemonLogs>,
280}
281
282#[derive(Debug, Clone, Default, JsonSchema)]
287#[schemars(title = "Pitchfork Configuration")]
288pub struct PitchforkToml {
289 #[serde(default)]
291 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
292 #[serde(skip_serializing_if = "Option::is_none", default)]
296 pub env: Option<IndexMap<String, String>>,
297 pub namespace: Option<String>,
302 #[schemars(default, with = "Option<String>")]
306 pub worktree_label: Option<String>,
307 #[serde(default)]
316 pub(crate) settings: SettingsPartial,
317 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
322 pub slugs: IndexMap<String, SlugEntry>,
323 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
325 pub groups: IndexMap<String, GroupEntry>,
326 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
329 pub namespaces: IndexMap<String, NamespaceEntry>,
330 #[schemars(skip)]
331 pub path: Option<PathBuf>,
332 #[schemars(skip)]
340 pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
341}
342
343impl PitchforkToml {
344 pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
347 self.foreign_env
348 .get(id)
349 .map_or(self.env.as_ref(), |env| env.as_ref())
350 }
351}
352
353pub fn is_global_config(path: &Path) -> bool {
354 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
355}
356
357pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
358 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
359}
360
361fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
362 use toml::Value;
363
364 let doc: Value = toml::from_str(content)
365 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
366 let Some(value) = doc.get("namespace") else {
367 return Ok(None);
368 };
369
370 match value {
371 Value::String(s) => Ok(Some(s.clone())),
372 _ => Err(ConfigParseError::InvalidNamespace {
373 path: path.to_path_buf(),
374 namespace: value.to_string(),
375 reason: "top-level 'namespace' must be a string".to_string(),
376 }
377 .into()),
378 }
379}
380
381fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
382 if !path.exists() {
383 return Ok(None);
384 }
385 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
386 path: path.to_path_buf(),
387 source: e,
388 })?;
389 parse_namespace_override_from_content(path, &content)
390}
391
392pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
393 crate::extra_configs::project_dir(path).or_else(|| {
394 if is_dot_config_pitchfork(path) {
395 path.parent().and_then(Path::parent).map(Path::to_path_buf)
396 } else {
397 path.parent().map(Path::to_path_buf)
398 }
399 })
400}
401
402fn project_config_family(path: &Path) -> Vec<PathBuf> {
403 let Some(dir) = project_dir_for_config(path) else {
404 return vec![path.to_path_buf()];
405 };
406 vec![
407 dir.join(".config/pitchfork.toml"),
408 dir.join(".config/pitchfork.local.toml"),
409 dir.join("pitchfork.toml"),
410 dir.join("pitchfork.local.toml"),
411 ]
412}
413
414fn directory_namespace_override(
417 path: &Path,
418 content_override: Option<&str>,
419) -> Result<Option<String>> {
420 if is_global_config(path) {
421 return match content_override {
422 Some(content) => parse_namespace_override_from_content(path, content),
423 None => read_namespace_override_from_file(path),
424 };
425 }
426
427 let mut selected: Option<(String, PathBuf)> = None;
428 for candidate in project_config_family(path) {
429 let explicit = if candidate == path {
430 match content_override {
431 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
432 None => read_namespace_override_from_file(&candidate)?,
433 }
434 } else {
435 read_namespace_override_from_file(&candidate)?
436 };
437 let Some(namespace) = explicit else { continue };
438 if let Some((selected_namespace, selected_path)) = &selected
439 && selected_namespace != &namespace
440 {
441 return Err(ConfigParseError::InvalidNamespace {
442 path: candidate,
443 namespace,
444 reason: format!(
445 "namespace does not match directory-level namespace '{}' declared in {}",
446 selected_namespace,
447 selected_path.display()
448 ),
449 }
450 .into());
451 }
452 selected = Some((namespace, candidate));
453 }
454 Ok(selected.map(|(namespace, _)| namespace))
455}
456
457fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
458 if let Err(e) = DaemonId::try_new(namespace, "probe") {
459 return Err(ConfigParseError::InvalidNamespace {
460 path: path.to_path_buf(),
461 namespace: namespace.to_string(),
462 reason: e.to_string(),
463 }
464 .into());
465 }
466 Ok(namespace.to_string())
467}
468
469fn derive_namespace_from_dir(path: &Path) -> Result<String> {
470 let dir_for_namespace = project_dir_for_config(path);
471 if let Some(namespace) = dir_for_namespace
472 .as_deref()
473 .and_then(crate::extra_configs::namespace_for_dir)
474 {
475 return validate_namespace(path, &namespace);
476 }
477 let raw_namespace = dir_for_namespace
478 .as_deref()
479 .and_then(|p| p.file_name())
480 .and_then(|n| n.to_str())
481 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
482 .to_string();
483
484 validate_namespace(path, &raw_namespace).map_err(|e| {
485 ConfigParseError::InvalidNamespace {
486 path: path.to_path_buf(),
487 namespace: raw_namespace,
488 reason: format!(
489 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
490 ),
491 }
492 .into()
493 })
494}
495
496fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
497 if is_global_config(path) {
498 if let Some(ns) = explicit
499 && ns != "global"
500 {
501 return Err(ConfigParseError::InvalidNamespace {
502 path: path.to_path_buf(),
503 namespace: ns.to_string(),
504 reason: "global config files must use namespace 'global'".to_string(),
505 }
506 .into());
507 }
508 return Ok("global".to_string());
509 }
510
511 if let Some(ns) = explicit {
512 return validate_namespace(path, ns);
513 }
514
515 derive_namespace_from_dir(path)
516}
517
518fn namespace_from_file(path: &Path) -> Result<String> {
519 let explicit = directory_namespace_override(path, None)?;
520 namespace_from_path_with_override(path, explicit.as_deref())
521}
522
523pub fn namespace_from_path(path: &Path) -> Result<String> {
536 namespace_from_file(path)
537}
538
539fn find_project_root(dir: &Path) -> Option<PathBuf> {
547 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
551 let mut current = canonical_dir.as_path();
552 loop {
553 if current.join(".git").exists() || current.join(".jj").exists() {
554 return Some(current.to_path_buf());
555 }
556 current = current.parent()?;
557 }
558}
559
560struct ConfigCacheEntry {
579 config: PitchforkToml,
580 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
582}
583
584static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
591 Lazy::new(|| StdMutex::new(HashMap::new()));
592
593fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
599 if paths.len() != snapshot.len() {
600 return false;
601 }
602 paths
603 .iter()
604 .zip(snapshot.iter())
605 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
606}
607
608pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
610 let md = std::fs::metadata(path).ok()?;
611 Some((md.modified().ok()?, md.len()))
612}
613
614fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
616 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
617}
618
619pub fn invalidate_config_cache() {
636 crate::extra_configs::invalidate();
637 crate::web::routes::api::projects::invalidate_group_cache();
640 if let Ok(mut cache) = CONFIG_CACHE.lock() {
641 cache.clear();
642 }
643}
644
645impl PitchforkToml {
646 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
659 if user_id.contains('/') {
661 return match DaemonId::parse(user_id) {
662 Ok(id) => Ok(vec![id]),
663 Err(e) => Err(e), };
665 }
666
667 let global_slugs = Self::read_global_slugs();
669 if let Some(entry) = global_slugs.get(user_id) {
670 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
672 if let Some(dir) = entry.resolve_dir()
673 && let Ok(project_config) = Self::all_merged_from(&dir)
674 {
675 let matches: Vec<DaemonId> = project_config
677 .daemons
678 .keys()
679 .filter(|id| id.name() == daemon_name)
680 .cloned()
681 .collect();
682 match matches.as_slice() {
683 [] => {}
684 [id] => return Ok(vec![id.clone()]),
685 _ => {
686 let mut candidates: Vec<String> =
687 matches.iter().map(|id| id.qualified()).collect();
688 candidates.sort();
689 return Err(miette::miette!(
690 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
691 user_id,
692 daemon_name,
693 candidates.join(", ")
694 ));
695 }
696 }
697 }
698 }
699
700 let matches: Vec<DaemonId> = self
702 .daemons
703 .keys()
704 .filter(|id| id.name() == user_id)
705 .cloned()
706 .collect();
707
708 if matches.is_empty() {
709 let state_matches = Self::find_in_state_file(user_id);
711 match state_matches.as_slice() {
712 [] => {}
713 [id] => return Ok(vec![id.clone()]),
714 _ => {
715 let mut candidates: Vec<String> =
716 state_matches.iter().map(|id| id.qualified()).collect();
717 candidates.sort();
718 return Err(miette::miette!(
719 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
720 user_id,
721 candidates.join(", ")
722 ));
723 }
724 }
725 let _ = DaemonId::try_new("global", user_id)?;
727 }
728 Ok(matches)
729 }
730
731 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
737 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
738 Ok(state) => state
739 .daemons
740 .keys()
741 .filter(|id| id.name() == short_name)
742 .cloned()
743 .collect(),
744 Err(e) => {
745 warn!("cannot read state file: {e}");
746 Vec::new()
747 }
748 }
749 }
750
751 #[allow(dead_code)]
772 pub fn resolve_daemon_id_prefer_local(
773 &self,
774 user_id: &str,
775 current_dir: &Path,
776 ) -> Result<DaemonId> {
777 if user_id.contains('/') {
779 return DaemonId::parse(user_id);
780 }
781
782 let current_namespace = Self::namespace_for_dir(current_dir)?;
786
787 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
788 }
789
790 fn resolve_daemon_id_with_namespace(
793 &self,
794 user_id: &str,
795 current_namespace: &str,
796 ) -> Result<DaemonId> {
797 let global_slugs = Self::read_global_slugs();
799 if let Some(entry) = global_slugs.get(user_id) {
800 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
801 if let Some(dir) = entry.resolve_dir()
802 && let Ok(project_config) = Self::all_merged_from(&dir)
803 {
804 let matches: Vec<DaemonId> = project_config
805 .daemons
806 .keys()
807 .filter(|id| id.name() == daemon_name)
808 .cloned()
809 .collect();
810 match matches.as_slice() {
811 [] => {}
812 [id] => return Ok(id.clone()),
813 _ => {
814 let mut candidates: Vec<String> =
815 matches.iter().map(|id| id.qualified()).collect();
816 candidates.sort();
817 return Err(miette::miette!(
818 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
819 user_id,
820 daemon_name,
821 candidates.join(", ")
822 ));
823 }
824 }
825 }
826 }
827
828 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
831 if self.daemons.contains_key(&preferred_id) {
832 return Ok(preferred_id);
833 }
834
835 let matches = self.resolve_daemon_id(user_id)?;
837
838 if matches.len() > 1 {
840 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
841 candidates.sort();
842 return Err(miette::miette!(
843 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
844 user_id,
845 candidates.join(", ")
846 ));
847 }
848
849 if let Some(id) = matches.into_iter().next() {
850 return Ok(id);
851 }
852
853 let global_id = DaemonId::try_new("global", user_id)?;
856 if self.daemons.contains_key(&global_id) {
857 return Ok(global_id);
858 }
859
860 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
861 Err(DependencyError::DaemonNotFound {
862 name: user_id.to_string(),
863 suggestion,
864 }
865 .into())
866 }
867
868 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
870 namespace_from_path(&dir.join("pitchfork.toml"))
871 }
872
873 pub fn project_worktree_label(dir: &Path) -> Option<String> {
881 let mut label = None;
882 let candidates = project_config_family(&dir.join("pitchfork.toml"))
883 .into_iter()
884 .chain(crate::extra_configs::configs_for_dir(dir));
885 for candidate in candidates {
886 if !candidate.exists() {
887 continue;
888 }
889 if let Ok(pt) = Self::read(&candidate)
890 && let Some(found) = pt.worktree_label
891 {
892 label = Some(found);
893 }
894 }
895 label
896 }
897
898 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
900 directory_namespace_override(&dir.join("pitchfork.toml"), None)
901 }
902
903 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
905 Ok(Self::list_paths_from(dir)
906 .iter()
907 .filter(|p| p.exists())
908 .max_by_key(|p| {
909 if is_global_config(p) {
910 0
911 } else {
912 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
913 }
914 })
915 .map(|p| namespace_from_path(p))
916 .transpose()?
917 .unwrap_or_else(|| "global".to_string()))
918 }
919
920 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
930 if user_id.contains('/') {
931 return DaemonId::parse(user_id);
932 }
933
934 let config = Self::all_merged()?;
937 let ns = Self::namespace_for_dir(&env::CWD)?;
938 config.resolve_daemon_id_with_namespace(user_id, &ns)
939 }
940
941 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
947 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
948 }
949
950 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
951 if user_id.contains('/') {
952 return DaemonId::parse(user_id);
953 }
954
955 let ns = Self::namespace_for_dir(dir)?;
956 DaemonId::try_new(ns, user_id)
957 }
958
959 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
970 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
972 return user_ids
973 .iter()
974 .map(|s| DaemonId::parse(s.as_ref()))
975 .collect();
976 }
977
978 let config = Self::all_merged()?;
979 let ns = Self::namespace_for_dir(&env::CWD)?;
981 user_ids
982 .iter()
983 .map(|s| {
984 let id = s.as_ref();
985 if id.contains('/') {
986 DaemonId::parse(id)
987 } else {
988 config.resolve_daemon_id_with_namespace(id, &ns)
989 }
990 })
991 .collect()
992 }
993
994 pub fn resolve_ids_and_group<S: AsRef<str>>(
999 user_ids: &[S],
1000 group_name: Option<&str>,
1001 ) -> Result<Vec<DaemonId>> {
1002 let config = Self::all_merged()?;
1003 let ns = Self::namespace_for_dir(&env::CWD)?;
1004 let mut ids = Vec::new();
1005 let mut seen = std::collections::HashSet::new();
1006
1007 for id in user_ids {
1008 let id_str = id.as_ref();
1009 let daemon_id = if id_str.contains('/') {
1010 DaemonId::parse(id_str)?
1011 } else {
1012 config.resolve_daemon_id_with_namespace(id_str, &ns)?
1013 };
1014 if seen.insert(daemon_id.clone()) {
1015 ids.push(daemon_id);
1016 }
1017 }
1018
1019 if let Some(name) = group_name {
1020 match config.groups.get(name) {
1021 Some(group) => {
1022 let missing: Vec<String> = group
1023 .daemons
1024 .iter()
1025 .filter(|id| !config.daemons.contains_key(*id))
1026 .map(|id| id.qualified())
1027 .collect();
1028 if !missing.is_empty() {
1029 return Err(miette::miette!(
1030 "group '{}' references undefined daemon{}: {}",
1031 name,
1032 if missing.len() > 1 { "s" } else { "" },
1033 missing.join(", ")
1034 ));
1035 }
1036 for daemon_id in &group.daemons {
1037 if seen.insert(daemon_id.clone()) {
1038 ids.push(daemon_id.clone());
1039 }
1040 }
1041 }
1042 None => {
1043 let suggestion =
1044 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
1045 return Err(miette::miette!(
1046 "group '{}' not found in configuration{}",
1047 name,
1048 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
1049 ));
1050 }
1051 }
1052 }
1053
1054 Ok(ids)
1055 }
1056
1057 pub fn list_paths() -> Vec<PathBuf> {
1060 Self::list_paths_from(&env::CWD)
1061 }
1062
1063 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1074 let mut paths = Vec::new();
1075 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1076 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1077
1078 let mut project_paths = xx::file::find_up_all(
1082 cwd,
1083 &[
1084 "pitchfork.local.toml",
1085 "pitchfork.toml",
1086 ".config/pitchfork.local.toml",
1087 ".config/pitchfork.toml",
1088 ],
1089 );
1090 project_paths.reverse();
1091 paths.extend(project_paths);
1092 paths.extend(crate::extra_configs::paths_for(cwd));
1093
1094 paths
1095 }
1096
1097 pub fn all_merged() -> Result<PitchforkToml> {
1100 Self::all_merged_from(&env::CWD)
1101 }
1102 pub fn all_merged_all_namespaces() -> Result<Self> {
1109 Self::all_merged_all_namespaces_from(&env::CWD)
1110 }
1111
1112 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1115 let mut pt = Self::all_merged_from(start_dir)?;
1116
1117 let namespaces = Self::read_global_namespaces();
1118 for (ns_name, entry) in namespaces {
1119 match Self::all_merged_from(&entry.dir) {
1120 Ok(ns_config) => {
1121 for (daemon_id, daemon_config) in ns_config.daemons {
1122 if !pt.daemons.contains_key(&daemon_id) {
1123 pt.foreign_env
1124 .insert(daemon_id.clone(), ns_config.env.clone());
1125 pt.daemons.insert(daemon_id, daemon_config);
1126 }
1127 }
1128 pt.settings.merge_from(&ns_config.settings);
1131 }
1132 Err(e) => {
1133 log::warn!(
1134 "Failed to load namespace '{ns_name}' from {}: {e}",
1135 entry.dir.display()
1136 );
1137 }
1138 }
1139 }
1140
1141 if crate::settings::settings().general.worktree
1151 && let Some(project_root) = find_project_root(start_dir)
1152 {
1153 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1154 for wt in &worktrees {
1155 match Self::all_merged_from(&wt.path) {
1156 Ok(wt_config) => {
1157 for (daemon_id, daemon_config) in wt_config.daemons {
1158 if !pt.daemons.contains_key(&daemon_id) {
1159 pt.daemons.insert(daemon_id, daemon_config);
1160 }
1161 }
1162 pt.settings.merge_from(&wt_config.settings);
1163 }
1164 Err(e) => {
1165 log::warn!(
1166 "Failed to load worktree '{}' config from {}: {e}",
1167 wt.branch,
1168 wt.path.display()
1169 );
1170 }
1171 }
1172 }
1173 }
1174
1175 Ok(pt)
1176 }
1177
1178 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1196 let paths = Self::list_paths_from(cwd);
1197
1198 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1202
1203 {
1204 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1205 if let Some(entry) = cache.get(&cache_key)
1206 && meta_matches(&paths, &entry.source_meta)
1207 {
1208 return Ok(entry.config.clone());
1209 }
1210 }
1211
1212 let snapshot = snapshot_meta(&paths);
1218 let pt = Self::all_merged_from_uncached(&paths)?;
1219
1220 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1222 cache.insert(
1223 cache_key,
1224 ConfigCacheEntry {
1225 config: pt.clone(),
1226 source_meta: snapshot,
1227 },
1228 );
1229
1230 Ok(pt)
1231 }
1232
1233 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1238 use std::collections::HashMap as StdHashMap;
1239
1240 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1241
1242 let mut pt = Self::default();
1243 for p in paths {
1244 match Self::read(p) {
1245 Ok(pt2) => {
1246 if p.exists() && !is_global_config(p) {
1250 let ns = namespace_from_path(p)?;
1251 let origin_dir = project_dir_for_config(p)
1252 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1253 .unwrap_or_else(|| p.clone());
1254
1255 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1256 && *other_dir != origin_dir
1257 {
1258 return Err(crate::error::ConfigParseError::NamespaceCollision {
1259 path_a: other_path.clone(),
1260 path_b: p.clone(),
1261 ns,
1262 }
1263 .into());
1264 }
1265 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1266 }
1267
1268 pt.merge(pt2)
1269 }
1270 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1271 }
1272 }
1273 Ok(pt)
1274 }
1275}
1276
1277impl PitchforkToml {
1278 pub fn new(path: PathBuf) -> Self {
1279 Self {
1280 daemons: Default::default(),
1281 env: None,
1282 namespace: None,
1283 worktree_label: None,
1284 settings: SettingsPartial::default(),
1285 slugs: IndexMap::new(),
1286 groups: IndexMap::new(),
1287 namespaces: IndexMap::new(),
1288 path: Some(path),
1289 foreign_env: IndexMap::new(),
1290 }
1291 }
1292
1293 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1301 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1302 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1303 if let Some(settings) = &mut raw_config.settings {
1304 settings.canonicalize_aliases();
1305 }
1306
1307 let explicit = directory_namespace_override(path, Some(content))?;
1308 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1309 let mut pt = Self::new(path.to_path_buf());
1310 pt.namespace = raw_config.namespace.clone();
1311 pt.worktree_label = raw_config.worktree_label.clone();
1312
1313 for (short_name, raw_daemon) in raw_config.daemons {
1314 let id = match DaemonId::try_new(&namespace, &short_name) {
1315 Ok(id) => id,
1316 Err(e) => {
1317 return Err(ConfigParseError::InvalidDaemonName {
1318 name: short_name,
1319 path: path.to_path_buf(),
1320 reason: e.to_string(),
1321 }
1322 .into());
1323 }
1324 };
1325
1326 let mut depends = Vec::new();
1327 for dep in raw_daemon.depends {
1328 let dep_id = if dep.contains('/') {
1329 match DaemonId::parse(&dep) {
1330 Ok(id) => id,
1331 Err(e) => {
1332 return Err(ConfigParseError::InvalidDependency {
1333 daemon: short_name.clone(),
1334 dependency: dep,
1335 path: path.to_path_buf(),
1336 reason: e.to_string(),
1337 }
1338 .into());
1339 }
1340 }
1341 } else {
1342 match DaemonId::try_new(&namespace, &dep) {
1343 Ok(id) => id,
1344 Err(e) => {
1345 return Err(ConfigParseError::InvalidDependency {
1346 daemon: short_name.clone(),
1347 dependency: dep,
1348 path: path.to_path_buf(),
1349 reason: e.to_string(),
1350 }
1351 .into());
1352 }
1353 }
1354 };
1355 depends.push(dep_id);
1356 }
1357
1358 let has_deprecated = !raw_daemon.expected_port.is_empty()
1360 || raw_daemon.auto_bump_port.is_some()
1361 || raw_daemon.port_bump_attempts.is_some();
1362 let port = if let Some(port) = raw_daemon.port {
1363 if has_deprecated {
1364 warn!(
1365 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1366 );
1367 }
1368 Some(port)
1369 } else if has_deprecated {
1370 warn!(
1371 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1372 );
1373 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1374 PortBump(
1375 raw_daemon
1376 .port_bump_attempts
1377 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1378 )
1379 } else {
1380 PortBump(0)
1381 };
1382 Some(PortConfig {
1383 expect: raw_daemon.expected_port,
1384 bump,
1385 })
1386 } else {
1387 None
1388 };
1389
1390 if let (Some(explicit), Some(short)) =
1392 (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1393 && explicit != short
1394 {
1395 warn!(
1396 "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1397 disagree; using proxy_tls_port"
1398 );
1399 }
1400 let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1401
1402 for (key, value) in [
1405 ("proxy_tls_port", raw_daemon.proxy_tls_port),
1406 ("proxy_port", raw_daemon.proxy_port),
1407 ] {
1408 if value == Some(0) {
1409 return Err(ConfigParseError::ProxyPortZero {
1410 daemon: short_name.clone(),
1411 key,
1412 path: path.to_path_buf(),
1413 }
1414 .into());
1415 }
1416 }
1417
1418 if let Some(port_want) = proxy_tls_port {
1421 let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1422 if !declared.contains(&port_want) {
1423 return Err(ConfigParseError::ProxyPortNotDeclared {
1424 daemon: short_name.clone(),
1425 port: port_want,
1426 declared,
1427 path: path.to_path_buf(),
1428 }
1429 .into());
1430 }
1431 }
1432
1433 if raw_daemon
1436 .proxy_tls
1437 .is_some_and(ProxyTlsMode::is_passthrough)
1438 && port
1439 .as_ref()
1440 .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1441 {
1442 return Err(ConfigParseError::PassthroughWithoutPort {
1443 daemon: short_name.clone(),
1444 path: path.to_path_buf(),
1445 }
1446 .into());
1447 }
1448
1449 if let RunCommand::Argv(argv) = &raw_daemon.run {
1450 match argv.first().map(String::as_str) {
1451 None => {
1452 return Err(ConfigParseError::EmptyRunArgv {
1453 daemon: short_name.clone(),
1454 path: path.to_path_buf(),
1455 }
1456 .into());
1457 }
1458 Some("exec") => {
1461 return Err(ConfigParseError::ExecInRunArgv {
1462 daemon: short_name.clone(),
1463 path: path.to_path_buf(),
1464 }
1465 .into());
1466 }
1467 Some(_) => {}
1468 }
1469 }
1470
1471 let daemon = PitchforkTomlDaemon {
1472 run: raw_daemon.run,
1473 auto: raw_daemon.auto,
1474 oneshot: raw_daemon.oneshot,
1475 cron: raw_daemon.cron,
1476 retry: raw_daemon.retry,
1477 ready_delay: raw_daemon.ready_delay,
1478 ready_output: raw_daemon.ready_output,
1479 ready_http: raw_daemon.ready_http,
1480 ready_port: raw_daemon.ready_port,
1481 ready_cmd: raw_daemon.ready_cmd,
1482 health_cmd: raw_daemon.health_cmd,
1483 health_http: raw_daemon.health_http,
1484 health_port: raw_daemon.health_port,
1485 port,
1486 proxy: raw_daemon.proxy,
1487 proxy_tls: raw_daemon.proxy_tls,
1488 proxy_tls_port: raw_daemon.proxy_tls_port,
1489 proxy_port: raw_daemon.proxy_port,
1490 proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1491 boot_start: raw_daemon.boot_start,
1492 depends,
1493 watch: raw_daemon.watch,
1494 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1495 dir: raw_daemon.dir,
1496 env: raw_daemon.env,
1497 hooks: raw_daemon.hooks,
1498 mise: raw_daemon.mise,
1499 user: raw_daemon.user,
1500 memory_limit: raw_daemon.memory_limit,
1501 cpu_limit: raw_daemon.cpu_limit,
1502 stop_signal: raw_daemon.stop_signal,
1503 pty: raw_daemon.pty,
1504 time_retention: raw_daemon.time_retention,
1505 line_retention: raw_daemon.line_retention,
1506 archive_hook: raw_daemon.archive_hook,
1507 logs: raw_daemon.logs,
1508 path: Some(path.to_path_buf()),
1509 };
1510 if daemon.is_oneshot() {
1511 let conflicts = daemon.oneshot_conflicts();
1512 if !conflicts.is_empty() {
1513 return Err(ConfigParseError::OneshotConflict {
1514 daemon: short_name.clone(),
1515 path: path.to_path_buf(),
1516 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1517 }
1518 .into());
1519 }
1520 }
1521 pt.daemons.insert(id, daemon);
1522 }
1523
1524 if let Some(settings) = raw_config.settings {
1526 pt.settings = settings;
1527 }
1528
1529 pt.env = raw_config.env;
1531
1532 for (slug, entry) in raw_config.slugs {
1534 pt.slugs.insert(
1535 slug,
1536 SlugEntry {
1537 dir: entry.dir.map(env::expand_tilde),
1538 namespace: entry.namespace,
1539 daemon: entry.daemon,
1540 },
1541 );
1542 }
1543
1544 for (name, entry) in raw_config.namespaces {
1546 pt.namespaces.insert(
1547 name,
1548 NamespaceEntry {
1549 config: entry
1550 .config
1551 .iter()
1552 .map(|p| {
1553 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1554 })
1555 .collect(),
1556 dir: env::expand_tilde(entry.dir),
1557 label: entry.label,
1558 },
1559 );
1560 }
1561
1562 for (group_name, raw_group) in raw_config.groups {
1564 let mut daemons = Vec::new();
1565 for daemon_name in &raw_group.daemons {
1566 let id = if daemon_name.contains('/') {
1567 DaemonId::parse(daemon_name).map_err(|e| {
1568 ConfigParseError::InvalidDependency {
1569 daemon: group_name.clone(),
1570 dependency: daemon_name.clone(),
1571 path: path.to_path_buf(),
1572 reason: e.to_string(),
1573 }
1574 })?
1575 } else {
1576 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1577 ConfigParseError::InvalidDaemonName {
1578 name: daemon_name.clone(),
1579 path: path.to_path_buf(),
1580 reason: e.to_string(),
1581 }
1582 })?
1583 };
1584 daemons.push(id);
1585 }
1586 pt.groups.insert(group_name, GroupEntry { daemons });
1587 }
1588
1589 Ok(pt)
1590 }
1591
1592 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1593 let path = path.as_ref();
1594 if !path.exists() {
1595 return Ok(Self::new(path.to_path_buf()));
1596 }
1597 let _lock = xx::fslock::get(path, false)
1598 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1599 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1600 path: path.to_path_buf(),
1601 source: e,
1602 })?;
1603 Self::parse_str(&raw, path)
1604 }
1605
1606 pub fn write(&self) -> Result<()> {
1607 if let Some(path) = &self.path {
1608 let _lock = xx::fslock::get(path, false)
1609 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1610 self.write_unlocked()
1611 } else {
1612 Err(FileError::NoPath.into())
1613 }
1614 }
1615
1616 pub(crate) fn write_unlocked(&self) -> Result<()> {
1622 if let Some(path) = &self.path {
1623 let config_namespace = if path.exists() {
1625 namespace_from_path(path)?
1626 } else {
1627 namespace_from_path_with_override(path, self.namespace.as_deref())?
1628 };
1629
1630 let mut raw = PitchforkTomlRaw {
1634 namespace: self.namespace.clone(),
1635 worktree_label: self.worktree_label.clone(),
1636 env: self.env.clone(),
1637 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1638 ..PitchforkTomlRaw::default()
1639 };
1640 for (id, daemon) in &self.daemons {
1641 if id.namespace() != config_namespace {
1642 return Err(miette::miette!(
1643 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1644 id,
1645 path.display(),
1646 id.namespace(),
1647 config_namespace
1648 ));
1649 }
1650 let port = daemon.port.as_ref();
1651 let raw_daemon = PitchforkTomlDaemonRaw {
1652 run: daemon.run.clone(),
1653 auto: daemon.auto.clone(),
1654 oneshot: daemon.oneshot,
1655 cron: daemon.cron.clone(),
1656 retry: daemon.retry,
1657 ready_delay: daemon.ready_delay,
1658 ready_output: daemon.ready_output.clone(),
1659 ready_http: daemon.ready_http.clone(),
1660 ready_port: daemon.ready_port.clone(),
1661 ready_cmd: daemon.ready_cmd.clone(),
1662 health_cmd: daemon.health_cmd.clone(),
1663 health_http: daemon.health_http.clone(),
1664 health_port: daemon.health_port.clone(),
1665 port: port.cloned(),
1666 proxy: daemon.proxy.clone(),
1667 proxy_tls: daemon.proxy_tls,
1668 proxy_tls_port: daemon.proxy_tls_port,
1669 proxy_port: daemon.proxy_port,
1670 proxy_idle_timeout: daemon.proxy_idle_timeout,
1671 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1673 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1674 port_bump_attempts: port
1675 .filter(|p| p.auto_bump())
1676 .map(|p| p.max_bump_attempts()),
1677 boot_start: daemon.boot_start,
1678 depends: daemon
1681 .depends
1682 .iter()
1683 .map(|d| {
1684 if d.namespace() == config_namespace {
1685 d.name().to_string()
1686 } else {
1687 d.qualified()
1688 }
1689 })
1690 .collect(),
1691 watch: daemon.watch.clone(),
1692 watch_mode: match daemon.watch_mode {
1693 WatchMode::Native => None,
1694 mode => Some(mode),
1695 },
1696 dir: daemon.dir.clone(),
1697 env: daemon.env.clone(),
1698 hooks: daemon.hooks.clone(),
1699 mise: daemon.mise,
1700 user: daemon.user.clone(),
1701 memory_limit: daemon.memory_limit,
1702 cpu_limit: daemon.cpu_limit,
1703 stop_signal: daemon.stop_signal,
1704 pty: daemon.pty,
1705 time_retention: daemon.time_retention.clone(),
1706 line_retention: daemon.line_retention,
1707 archive_hook: daemon.archive_hook.clone(),
1708 logs: daemon.logs.clone(),
1709 };
1710 raw.daemons.insert(id.name().to_string(), raw_daemon);
1711 }
1712
1713 for (slug, entry) in &self.slugs {
1715 raw.slugs.insert(
1716 slug.clone(),
1717 SlugEntryRaw {
1718 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1719 namespace: entry.namespace.clone(),
1720 daemon: entry.daemon.clone(),
1721 },
1722 );
1723 }
1724
1725 for (name, group) in &self.groups {
1727 let raw_daemons: Vec<String> = group
1728 .daemons
1729 .iter()
1730 .map(|id| {
1731 if id.namespace() == config_namespace {
1732 id.name().to_string()
1733 } else {
1734 id.qualified()
1735 }
1736 })
1737 .collect();
1738 raw.groups.insert(
1739 name.clone(),
1740 GroupEntryRaw {
1741 daemons: raw_daemons,
1742 },
1743 );
1744 }
1745
1746 for (name, entry) in &self.namespaces {
1748 raw.namespaces.insert(
1749 name.clone(),
1750 NamespaceEntryRaw {
1751 dir: entry.dir.to_string_lossy().to_string(),
1752 config: entry
1753 .config
1754 .iter()
1755 .map(|p| p.to_string_lossy().into_owned())
1756 .collect(),
1757 label: entry.label.clone(),
1758 },
1759 );
1760 }
1761
1762 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1763 path: path.clone(),
1764 source: e,
1765 })?;
1766 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1767 path: path.clone(),
1768 details: Some(e.to_string()),
1769 })?;
1770 invalidate_config_cache();
1771 Ok(())
1772 } else {
1773 Err(FileError::NoPath.into())
1774 }
1775 }
1776
1777 pub fn merge(&mut self, pt: Self) {
1782 if pt.worktree_label.is_some() {
1783 self.worktree_label = pt.worktree_label.clone();
1784 }
1785 for (id, d) in pt.daemons {
1786 self.daemons.insert(id, d);
1787 }
1788 if let Some(env) = pt.env {
1790 let merged = self.env.get_or_insert_with(IndexMap::new);
1791 for (k, v) in env {
1792 merged.insert(k, v);
1793 }
1794 }
1795 for (slug, entry) in pt.slugs {
1797 self.slugs.insert(slug, entry);
1798 }
1799 for (name, group) in pt.groups {
1801 self.groups.insert(name, group);
1802 }
1803 for (name, entry) in pt.namespaces {
1805 self.namespaces.insert(name, entry);
1806 }
1807 self.settings.merge_from(&pt.settings);
1809 }
1810
1811 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1816 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1817 Ok(pt) => pt.slugs,
1818 Err(_) => IndexMap::new(),
1819 }
1820 }
1821
1822 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1829 global_slugs
1830 .keys()
1831 .filter(|k| k.eq_ignore_ascii_case(slug))
1832 .count()
1833 > 1
1834 }
1835
1836 pub fn find_slug_for_daemon_in_registry(
1841 daemon_id: &DaemonId,
1842 global_slugs: &IndexMap<String, SlugEntry>,
1843 ) -> Option<String> {
1844 global_slugs
1845 .iter()
1846 .find(|(slug, entry)| {
1847 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1848 if daemon_id.name() != daemon_name {
1849 return false;
1850 }
1851
1852 if Self::slug_is_ambiguous(slug, global_slugs) {
1855 return false;
1856 }
1857
1858 match entry.resolve_namespace() {
1859 Some(namespace) => daemon_id.namespace() == namespace,
1860 None => false,
1861 }
1862 })
1863 .map(|(slug, _)| slug.clone())
1864 }
1865
1866 #[allow(dead_code)]
1868 pub fn is_slug_registered(slug: &str) -> bool {
1869 Self::read_global_slugs().contains_key(slug)
1870 }
1871
1872 pub fn add_slug_with_namespace(
1878 slug: &str,
1879 namespace: Option<&str>,
1880 daemon: Option<&str>,
1881 ) -> Result<()> {
1882 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1883
1884 if let Some(parent) = global_path.parent() {
1886 std::fs::create_dir_all(parent).map_err(|e| {
1887 miette::miette!(
1888 "Failed to create config directory {}: {e}",
1889 parent.display()
1890 )
1891 })?;
1892 }
1893
1894 let _lock = xx::fslock::get(global_path, false)
1895 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1896
1897 let mut pt = if global_path.exists() {
1898 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1899 path: global_path.to_path_buf(),
1900 source: e,
1901 })?;
1902 Self::parse_str(&raw, global_path)?
1903 } else {
1904 Self::new(global_path.to_path_buf())
1905 };
1906
1907 if let Some(ns) = namespace
1911 && !pt.namespaces.contains_key(ns)
1912 {
1913 let dir = pt
1918 .slugs
1919 .get(slug)
1920 .and_then(|e| {
1921 e.dir.clone().or_else(|| {
1922 e.namespace
1923 .as_ref()
1924 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1925 })
1926 })
1927 .or_else(|| env::CWD.as_path().canonicalize().ok());
1928 if let Some(ref d) = dir {
1929 pt.namespaces.insert(
1930 ns.to_string(),
1931 NamespaceEntry {
1932 dir: d.clone(),
1933 config: Vec::new(),
1934 label: None,
1935 },
1936 );
1937 }
1938 }
1939
1940 pt.slugs.insert(
1941 slug.to_string(),
1942 SlugEntry {
1943 dir: None,
1944 namespace: namespace.map(str::to_string),
1945 daemon: daemon.map(str::to_string),
1946 },
1947 );
1948 pt.write_unlocked()?;
1949 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1955 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1956 Ok(())
1957 }
1958
1959 pub fn remove_slug(slug: &str) -> Result<bool> {
1961 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1962 if !global_path.exists() {
1963 return Ok(false);
1964 }
1965
1966 let _lock = xx::fslock::get(global_path, false)
1967 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1968
1969 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1970 path: global_path.to_path_buf(),
1971 source: e,
1972 })?;
1973 let mut pt = Self::parse_str(&raw, global_path)?;
1974
1975 let removed = pt.slugs.shift_remove(slug).is_some();
1976 if removed {
1977 pt.write_unlocked()?;
1978 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1984 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1985 }
1986 Ok(removed)
1987 }
1988 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1991 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1992 Ok(pt) => pt.namespaces,
1993 Err(_) => IndexMap::new(),
1994 }
1995 }
1996
1997 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
2001 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2002
2003 if let Some(parent) = global_path.parent() {
2005 std::fs::create_dir_all(parent).map_err(|e| {
2006 miette::miette!(
2007 "Failed to create config directory {}: {e}",
2008 parent.display()
2009 )
2010 })?;
2011 }
2012
2013 let _lock = xx::fslock::get(global_path, false)
2014 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2015
2016 let mut pt = if global_path.exists() {
2017 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2018 crate::error::FileError::ReadError {
2019 path: global_path.to_path_buf(),
2020 source: e,
2021 }
2022 })?;
2023 Self::parse_str(&raw, global_path)?
2024 } else {
2025 Self::new(global_path.to_path_buf())
2026 };
2027
2028 let dir = env::expand_tilde(dir);
2029 if let Some(entry) = pt.namespaces.get_mut(name) {
2030 if !entry.config.is_empty()
2031 && crate::extra_configs::normalize(&entry.dir)
2032 != crate::extra_configs::normalize(&dir)
2033 {
2034 miette::bail!(
2035 "namespace '{name}' has external configuration attached to another directory"
2036 );
2037 }
2038 entry.dir = dir;
2039 } else {
2040 pt.namespaces.insert(
2041 name.to_string(),
2042 NamespaceEntry {
2043 dir,
2044 config: Vec::new(),
2045 label: None,
2046 },
2047 );
2048 }
2049 pt.write_unlocked()?;
2050 Ok(())
2051 }
2052
2053 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2055 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2056 if !global_path.exists() {
2057 return Ok(false);
2058 }
2059
2060 let _lock = xx::fslock::get(global_path, false)
2061 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2062
2063 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2064 crate::error::FileError::ReadError {
2065 path: global_path.to_path_buf(),
2066 source: e,
2067 }
2068 })?;
2069 let mut pt = Self::parse_str(&raw, global_path)?;
2070
2071 let removed = pt.namespaces.shift_remove(name).is_some();
2072 if removed {
2073 pt.write_unlocked()?;
2074 }
2075 Ok(removed)
2076 }
2077}
2078
2079#[derive(Debug, Clone, JsonSchema, Default)]
2081pub struct PitchforkTomlDaemon {
2082 #[schemars(example = example_run_command())]
2086 pub run: RunCommand,
2087 #[schemars(default)]
2089 pub auto: Vec<PitchforkTomlAuto>,
2090 pub oneshot: Option<bool>,
2096 pub cron: Option<PitchforkTomlCron>,
2098 #[schemars(default)]
2101 pub retry: Retry,
2102 pub ready_delay: Option<u64>,
2104 pub ready_output: Option<ReadyOutput>,
2106 pub ready_http: Option<ReadyHttp>,
2108 pub ready_port: Option<ReadyPort>,
2112 pub ready_cmd: Option<ReadyCmd>,
2114 pub health_cmd: Option<HealthCmd>,
2116 pub health_http: Option<HealthHttp>,
2118 pub health_port: Option<HealthPort>,
2122 pub port: Option<PortConfig>,
2124 #[serde(skip_serializing_if = "Option::is_none", default)]
2127 pub proxy: Option<ProxyConfig>,
2128 pub proxy_tls: Option<ProxyTlsMode>,
2137 #[schemars(range(min = 1))]
2142 pub proxy_tls_port: Option<u16>,
2143 #[schemars(range(min = 1))]
2149 pub proxy_port: Option<u16>,
2150 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2161 pub boot_start: Option<bool>,
2163 #[schemars(default)]
2165 pub depends: Vec<DaemonId>,
2166 #[schemars(default)]
2168 pub watch: Vec<String>,
2169 #[schemars(default)]
2175 pub watch_mode: WatchMode,
2176 pub dir: Option<String>,
2178 pub env: Option<IndexMap<String, String>>,
2180 pub hooks: Option<PitchforkTomlHooks>,
2182 pub mise: Option<bool>,
2185 pub user: Option<String>,
2187 pub memory_limit: Option<MemoryLimit>,
2190 pub cpu_limit: Option<CpuLimit>,
2193 pub stop_signal: Option<StopConfig>,
2196 pub pty: Option<bool>,
2198 pub time_retention: Option<String>,
2201 pub line_retention: Option<i64>,
2204 pub archive_hook: Option<String>,
2207 pub logs: Option<PitchforkTomlDaemonLogs>,
2209 #[schemars(skip)]
2210 pub path: Option<PathBuf>,
2211}
2212
2213impl PitchforkTomlDaemon {
2214 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2220 self.proxy_tls_port.or(self.proxy_port)
2221 }
2222
2223 pub fn is_oneshot(&self) -> bool {
2225 self.oneshot.unwrap_or(false)
2226 }
2227
2228 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2231 [
2232 ("ready_delay", self.ready_delay.is_some()),
2233 ("ready_output", self.ready_output.is_some()),
2234 ("ready_http", self.ready_http.is_some()),
2235 ("ready_port", self.ready_port.is_some()),
2236 ("ready_cmd", self.ready_cmd.is_some()),
2237 ("health_cmd", self.health_cmd.is_some()),
2238 ("health_http", self.health_http.is_some()),
2239 ("health_port", self.health_port.is_some()),
2240 ]
2241 .into_iter()
2242 .filter(|(_, set)| *set)
2243 .map(|(name, _)| name)
2244 .collect()
2245 }
2246
2247 pub fn effective_user(&self) -> Option<String> {
2251 let daemon_user = self
2252 .user
2253 .as_deref()
2254 .map(str::trim)
2255 .filter(|u| !u.is_empty());
2256 daemon_user.map(str::to_owned).or_else(|| {
2257 let s = crate::settings::settings();
2258 let su = s.supervisor.user.trim();
2259 (!su.is_empty()).then(|| su.to_owned())
2260 })
2261 }
2262
2263 pub fn to_run_options(
2268 &self,
2269 id: &crate::daemon_id::DaemonId,
2270 cmd: Vec<String>,
2271 ) -> crate::daemon::RunOptions {
2272 use crate::daemon::RunOptions;
2273
2274 let effective_user = self.effective_user();
2275 let dir = crate::ipc::batch::resolve_daemon_dir(
2276 self.dir.as_deref(),
2277 self.path.as_deref(),
2278 effective_user.as_deref(),
2279 );
2280 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2283 id,
2284 &PitchforkToml::read_global_slugs(),
2285 );
2286
2287 RunOptions {
2288 id: id.clone(),
2289 cmd,
2290 run: self.run.shell_script().map(str::to_string),
2291 no_shell: self.run.is_argv(),
2292 requested_by_client: false,
2294 force: false,
2295 shell_pid: None,
2296 dir: Dir(dir),
2297 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2298 oneshot: self.is_oneshot(),
2299 cron_started: false,
2302 oneshot_wait: None,
2306 on_directory_enter: false,
2307 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2308 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2309 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2310 retry: self.retry,
2311 retry_count: 0,
2312 ready_delay: self.ready_delay,
2313 ready_output: self.ready_output.clone(),
2314 ready_http: self.ready_http.clone(),
2315 ready_port: self.ready_port.clone(),
2316 ready_cmd: self.ready_cmd.clone(),
2317 health_cmd: self.health_cmd.clone(),
2318 health_http: self.health_http.clone(),
2319 health_port: self.health_port.clone(),
2320 port: self.port.clone(),
2321 wait_ready: false,
2322 depends: self.depends.clone(),
2323 env: self.env.clone(),
2324 watch: self.watch.clone(),
2325 watch_mode: self.watch_mode,
2326 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2327 self.path.as_deref(),
2328 )),
2329 mise: self.mise,
2330 slug,
2331 proxy: None,
2332 user: self.user.clone(),
2333 memory_limit: self.memory_limit,
2334 cpu_limit: self.cpu_limit,
2335 stop_signal: self.stop_signal,
2336 archive_hook: self
2337 .logs
2338 .as_ref()
2339 .and_then(|l| l.archive_hook.clone())
2340 .or_else(|| self.archive_hook.clone()),
2341 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2342 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2343 pty: self.pty,
2344 proxy_idle_timeout_ms: None,
2346 }
2347 }
2348}
2349fn example_run_command() -> &'static str {
2350 "exec node server.js"
2351}
2352
2353#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2356#[serde(untagged)]
2357pub enum RunCommand {
2358 Shell(String),
2361 Argv(#[schemars(length(min = 1))] Vec<String>),
2365}
2366
2367impl Default for RunCommand {
2368 fn default() -> Self {
2369 Self::Shell(String::new())
2370 }
2371}
2372
2373impl From<String> for RunCommand {
2374 fn from(run: String) -> Self {
2375 Self::Shell(run)
2376 }
2377}
2378
2379impl From<&str> for RunCommand {
2380 fn from(run: &str) -> Self {
2381 Self::Shell(run.to_string())
2382 }
2383}
2384
2385impl From<Vec<String>> for RunCommand {
2386 fn from(argv: Vec<String>) -> Self {
2387 Self::Argv(argv)
2388 }
2389}
2390
2391impl PartialEq<str> for RunCommand {
2392 fn eq(&self, other: &str) -> bool {
2393 matches!(self, Self::Shell(run) if run == other)
2394 }
2395}
2396
2397impl PartialEq<&str> for RunCommand {
2398 fn eq(&self, other: &&str) -> bool {
2399 self == *other
2400 }
2401}
2402
2403impl std::fmt::Display for RunCommand {
2406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2407 match self {
2408 Self::Shell(run) => f.write_str(run),
2409 Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2410 }
2411 }
2412}
2413
2414impl RunCommand {
2415 pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2418 match self {
2419 Self::Shell(run) => shell_words::split(run),
2420 Self::Argv(argv) => Ok(argv.clone()),
2421 }
2422 }
2423
2424 pub fn shell_script(&self) -> Option<&str> {
2426 match self {
2427 Self::Shell(run) => Some(run),
2428 Self::Argv(_) => None,
2429 }
2430 }
2431
2432 pub fn is_argv(&self) -> bool {
2433 matches!(self, Self::Argv(_))
2434 }
2435
2436 pub fn try_map<E>(
2438 &self,
2439 mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2440 ) -> std::result::Result<Self, E> {
2441 Ok(match self {
2442 Self::Shell(run) => Self::Shell(f(run)?),
2443 Self::Argv(argv) => Self::Argv(
2444 argv.iter()
2445 .map(|arg| f(arg))
2446 .collect::<std::result::Result<_, _>>()?,
2447 ),
2448 })
2449 }
2450}
2451
2452#[cfg(test)]
2453mod tests {
2454 use super::*;
2455 use std::path::Path;
2456
2457 #[test]
2458 fn test_daemon_user_parses_and_flows_to_run_options() {
2459 let pt = PitchforkToml::parse_str(
2460 r#"
2461[daemons.api]
2462run = "node server.js"
2463user = "postgres"
2464"#,
2465 Path::new("/tmp/my-project/pitchfork.toml"),
2466 )
2467 .unwrap();
2468
2469 let id = DaemonId::new("my-project", "api");
2470 let daemon = pt.daemons.get(&id).unwrap();
2471 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2472
2473 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2474 assert_eq!(opts.user.as_deref(), Some("postgres"));
2475 }
2476
2477 #[test]
2478 fn test_daemon_user_write_roundtrip() {
2479 let temp = tempfile::tempdir().unwrap();
2480 let path = temp.path().join("pitchfork.toml");
2481 let mut pt = PitchforkToml::new(path.clone());
2482 pt.namespace = Some("test-project".to_string());
2483 pt.daemons.insert(
2484 DaemonId::new("test-project", "api"),
2485 PitchforkTomlDaemon {
2486 run: "node server.js".into(),
2487 user: Some("postgres".to_string()),
2488 ..PitchforkTomlDaemon::default()
2489 },
2490 );
2491
2492 pt.write().unwrap();
2493
2494 let raw = std::fs::read_to_string(&path).unwrap();
2495 assert!(raw.contains("user = \"postgres\""));
2496
2497 let parsed = PitchforkToml::read(&path).unwrap();
2498 let daemon = parsed
2499 .daemons
2500 .get(&DaemonId::new("test-project", "api"))
2501 .unwrap();
2502 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2503 }
2504
2505 fn awkward_argv() -> Vec<String> {
2507 [
2508 "node",
2509 "my server.js",
2510 "--name=\"a b\"",
2511 "it's",
2512 "a&b",
2513 "%PATH%",
2514 "$HOME",
2515 ]
2516 .map(str::to_string)
2517 .to_vec()
2518 }
2519
2520 #[test]
2521 fn test_run_array_parses_and_starts_without_a_shell() {
2522 let pt = PitchforkToml::parse_str(
2523 r#"
2524[daemons.api]
2525run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2526"#,
2527 Path::new("/tmp/my-project/pitchfork.toml"),
2528 )
2529 .unwrap();
2530
2531 let id = DaemonId::new("my-project", "api");
2532 let daemon = pt.daemons.get(&id).unwrap();
2533 assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2534
2535 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2536 assert_eq!(opts.cmd, awkward_argv());
2537 assert!(opts.no_shell);
2538 assert_eq!(opts.run, None);
2539 }
2540
2541 #[test]
2542 fn test_run_string_still_goes_through_the_shell() {
2543 let pt = PitchforkToml::parse_str(
2544 "[daemons.api]\nrun = \"exec node server.js\"\n",
2545 Path::new("/tmp/my-project/pitchfork.toml"),
2546 )
2547 .unwrap();
2548
2549 let id = DaemonId::new("my-project", "api");
2550 let daemon = pt.daemons.get(&id).unwrap();
2551 let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2552 assert!(!opts.no_shell);
2553 assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2554 }
2555
2556 #[test]
2557 fn test_run_array_write_roundtrip() {
2558 let temp = tempfile::tempdir().unwrap();
2559 let path = temp.path().join("pitchfork.toml");
2560 let mut pt = PitchforkToml::new(path.clone());
2561 pt.namespace = Some("test-project".to_string());
2562 let id = DaemonId::new("test-project", "api");
2563 pt.daemons.insert(
2564 id.clone(),
2565 PitchforkTomlDaemon {
2566 run: RunCommand::Argv(awkward_argv()),
2567 ..PitchforkTomlDaemon::default()
2568 },
2569 );
2570 pt.daemons.insert(
2571 DaemonId::new("test-project", "worker"),
2572 PitchforkTomlDaemon {
2573 run: "exec ./worker --queue \"a b\"".into(),
2574 ..PitchforkTomlDaemon::default()
2575 },
2576 );
2577
2578 pt.write().unwrap();
2579
2580 let parsed = PitchforkToml::read(&path).unwrap();
2581 assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
2582 assert_eq!(
2583 parsed.daemons[&DaemonId::new("test-project", "worker")].run,
2584 "exec ./worker --queue \"a b\""
2585 );
2586 }
2587
2588 #[test]
2589 fn test_run_array_must_name_a_program() {
2590 let err = PitchforkToml::parse_str(
2591 "[daemons.api]\nrun = []\n",
2592 Path::new("/tmp/my-project/pitchfork.toml"),
2593 )
2594 .unwrap_err();
2595 assert!(
2596 err.chain()
2597 .any(|cause| cause.to_string().contains("empty run array")),
2598 "unexpected error: {err:?}"
2599 );
2600 }
2601
2602 #[test]
2603 fn test_run_array_rejects_exec() {
2604 let err = PitchforkToml::parse_str(
2605 "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
2606 Path::new("/tmp/my-project/pitchfork.toml"),
2607 )
2608 .unwrap_err();
2609 assert!(
2610 err.chain().any(|cause| cause
2611 .to_string()
2612 .contains("starts its run array with \"exec\"")),
2613 "unexpected error: {err:?}"
2614 );
2615 }
2616
2617 #[test]
2618 fn test_registry_dirs_expand_tilde() {
2619 let pt = PitchforkToml::parse_str(
2620 r#"
2621[slugs.api]
2622dir = "~/projects/api"
2623
2624[namespaces.web]
2625dir = "~/projects/web"
2626"#,
2627 Path::new("/tmp/config.toml"),
2628 )
2629 .unwrap();
2630
2631 assert_eq!(
2632 pt.slugs["api"].dir,
2633 Some(crate::env::HOME_DIR.join("projects/api"))
2634 );
2635 assert_eq!(
2636 pt.namespaces["web"].dir,
2637 crate::env::HOME_DIR.join("projects/web")
2638 );
2639 }
2640
2641 #[test]
2642 fn test_settings_write_roundtrip() {
2643 let temp = tempfile::tempdir().unwrap();
2644 let path = temp.path().join("pitchfork.toml");
2645 let mut pt = PitchforkToml::new(path.clone());
2646 pt.namespace = Some("test-project".to_string());
2647 pt.settings.web.auto_start = Some(true);
2648 pt.settings.general.log_level = Some("debug".to_string());
2649
2650 pt.write().unwrap();
2651
2652 let raw = std::fs::read_to_string(&path).unwrap();
2653 assert!(
2654 raw.contains("[settings.web]"),
2655 "settings.web section should be written, got:\n{raw}"
2656 );
2657 assert!(raw.contains("auto_start = true"));
2658 assert!(raw.contains("log_level = \"debug\""));
2659
2660 let parsed = PitchforkToml::read(&path).unwrap();
2661 assert_eq!(parsed.settings.web.auto_start, Some(true));
2662 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2663 }
2664
2665 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2666 SlugEntry {
2667 dir: None,
2668 namespace: Some(namespace.to_string()),
2669 daemon: daemon.map(str::to_string),
2670 }
2671 }
2672
2673 #[test]
2674 fn test_slug_is_ambiguous() {
2675 let mut slugs = IndexMap::new();
2676 slugs.insert("api".to_string(), slug_entry("my-project", None));
2677 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
2678
2679 slugs.insert("API".to_string(), slug_entry("other-project", None));
2680 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
2682 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
2683 }
2684
2685 #[test]
2686 fn test_find_slug_for_daemon_skips_case_collisions() {
2687 let id = DaemonId::new("my-project", "api");
2688 let mut slugs = IndexMap::new();
2689 slugs.insert("api".to_string(), slug_entry("my-project", None));
2690 assert_eq!(
2691 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2692 Some("api".to_string())
2693 );
2694
2695 slugs.insert("API".to_string(), slug_entry("other-project", None));
2698 assert_eq!(
2699 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2700 None
2701 );
2702 }
2703
2704 #[test]
2705 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
2706 let id = DaemonId::new("my-project", "api");
2707 let mut slugs = IndexMap::new();
2708 slugs.insert("api".to_string(), slug_entry("my-project", None));
2711 slugs.insert("API".to_string(), slug_entry("my-project", None));
2712 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
2713
2714 assert_eq!(
2715 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2716 Some("my-api".to_string())
2717 );
2718 }
2719
2720 #[test]
2721 fn test_settings_preserved_on_unrelated_write() {
2722 let temp = tempfile::tempdir().unwrap();
2725 let path = temp.path().join("pitchfork.toml");
2726 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2727
2728 let mut pt = PitchforkToml::read(&path).unwrap();
2729 pt.slugs.insert(
2730 "api".to_string(),
2731 SlugEntry {
2732 dir: None,
2733 namespace: Some("myproject".to_string()),
2734 daemon: None,
2735 },
2736 );
2737 pt.namespaces.insert(
2738 "myproject".to_string(),
2739 NamespaceEntry {
2740 dir: PathBuf::from("/tmp/myproject"),
2741 config: Vec::new(),
2742 label: None,
2743 },
2744 );
2745 pt.write().unwrap();
2746
2747 let raw = std::fs::read_to_string(&path).unwrap();
2748 assert!(
2749 raw.contains("[settings.web]"),
2750 "existing settings must be preserved, got:\n{raw}"
2751 );
2752 assert!(raw.contains("auto_start = true"));
2753 assert!(raw.contains("[slugs.api]"));
2754
2755 let parsed = PitchforkToml::read(&path).unwrap();
2756 assert_eq!(parsed.settings.web.auto_start, Some(true));
2757 assert!(parsed.slugs.contains_key("api"));
2758 }
2759
2760 #[tokio::test]
2761 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2762 let temp = tempfile::tempdir().unwrap();
2763 let path = temp.path().join("pitchfork.toml");
2764 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2765 .await
2766 .unwrap();
2767
2768 let read_path = path.clone();
2769 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2770 .await
2771 .unwrap()
2772 .unwrap();
2773 assert_eq!(pt.settings.general.worktree, Some(false));
2774 assert_eq!(pt.settings.proxy.worktree, None);
2775 tokio::task::spawn_blocking(move || pt.write())
2776 .await
2777 .unwrap()
2778 .unwrap();
2779
2780 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2781 assert!(raw.contains("[settings.general]"), "{raw}");
2782 assert!(raw.contains("worktree = false"), "{raw}");
2783 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2784
2785 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2786 .await
2787 .unwrap()
2788 .unwrap();
2789 assert_eq!(parsed.settings.general.worktree, Some(false));
2790 }
2791
2792 #[test]
2793 fn test_config_cache_hit_and_invalidation() {
2794 let temp = tempfile::tempdir().unwrap();
2795 let dir = temp.path();
2796 let config_path = dir.join("pitchfork.toml");
2797 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2798
2799 super::invalidate_config_cache();
2801
2802 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2804 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2805 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2806
2807 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2809 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2810
2811 std::thread::sleep(std::time::Duration::from_millis(50));
2814 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2815
2816 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2817 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2818
2819 super::invalidate_config_cache();
2821 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2822 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2823
2824 super::invalidate_config_cache();
2826 }
2827
2828 #[test]
2829 fn test_config_cache_invalidation_on_write() {
2830 let temp = tempfile::tempdir().unwrap();
2831 let dir = temp.path();
2832 let config_path = dir.join("pitchfork.toml");
2833 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2834
2835 super::invalidate_config_cache();
2836
2837 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2839 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2840 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2841
2842 let mut pt = PitchforkToml::read(&config_path).unwrap();
2844 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
2845 let _ = pt.write();
2847
2848 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2850 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2851
2852 super::invalidate_config_cache();
2853 }
2854
2855 #[test]
2856 fn test_config_cache_size_invalidation() {
2857 let temp = tempfile::tempdir().unwrap();
2858 let dir = temp.path();
2859 let config_path = dir.join("pitchfork.toml");
2860 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2861
2862 super::invalidate_config_cache();
2863
2864 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2866 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2867 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2868
2869 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2873 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2874 let file = std::fs::OpenOptions::new()
2877 .write(true)
2878 .open(&config_path)
2879 .unwrap();
2880 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2881 file.set_times(times).unwrap();
2882
2883 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2886 assert_eq!(
2887 pt2.daemons[&daemon_id].run, "echo different",
2888 "cache should invalidate on size change even with identical mtime"
2889 );
2890
2891 super::invalidate_config_cache();
2892 }
2893
2894 #[test]
2895 fn test_find_project_root_in_plain_dir_returns_none() {
2896 let temp = tempfile::tempdir().unwrap();
2897 assert_eq!(find_project_root(temp.path()), None);
2898 }
2899
2900 #[test]
2901 fn test_find_project_root_finds_git_marker() {
2902 let temp = tempfile::tempdir().unwrap();
2903 let repo = temp.path().join("my-repo");
2904 std::fs::create_dir(&repo).unwrap();
2905 std::fs::create_dir(repo.join(".git")).unwrap();
2906
2907 let sub = repo.join("sub/dir");
2908 std::fs::create_dir_all(&sub).unwrap();
2909
2910 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2913 }
2914
2915 #[test]
2916 fn test_find_project_root_accepts_git_file_marker() {
2917 let temp = tempfile::tempdir().unwrap();
2920 let wt = temp.path().join("my-worktree");
2921 std::fs::create_dir(&wt).unwrap();
2922 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2923
2924 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2925 }
2926
2927 #[cfg(unix)]
2930 #[test]
2931 fn test_find_project_root_resolves_symlinked_start_dir() {
2932 use std::os::unix::fs::symlink;
2933
2934 let temp = tempfile::tempdir().unwrap();
2935 let repo = temp.path().join("real-repo");
2936 std::fs::create_dir(&repo).unwrap();
2937 std::fs::create_dir(repo.join(".git")).unwrap();
2938
2939 let sub = repo.join("sub/dir");
2940 std::fs::create_dir_all(&sub).unwrap();
2941 let link = temp.path().join("link-to-sub");
2942 symlink(&sub, &link).unwrap();
2943
2944 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
2945 }
2946
2947 #[test]
2950 fn test_all_merged_all_namespaces_discovers_worktrees() {
2951 let temp = tempfile::tempdir().unwrap();
2952 let repo = temp.path().join("my-repo");
2953 std::fs::create_dir(&repo).unwrap();
2954
2955 let git_init = std::process::Command::new("git")
2957 .args(["init", "-b", "main"])
2958 .current_dir(&repo)
2959 .output()
2960 .expect("git init");
2961 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2962
2963 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2964
2965 let git_commit = std::process::Command::new("git")
2966 .args([
2967 "-c",
2968 "user.name=pitchfork-test",
2969 "-c",
2970 "user.email=pitchfork-test@example.com",
2971 "add",
2972 "-A",
2973 ])
2974 .current_dir(&repo)
2975 .output()
2976 .expect("git add");
2977 assert!(git_commit.status.success());
2978
2979 let git_commit = std::process::Command::new("git")
2980 .args([
2981 "-c",
2982 "user.name=pitchfork-test",
2983 "-c",
2984 "user.email=pitchfork-test@example.com",
2985 "commit",
2986 "-m",
2987 "init",
2988 ])
2989 .current_dir(&repo)
2990 .output()
2991 .expect("git commit");
2992 assert!(
2993 git_commit.status.success(),
2994 "git commit failed: {:?}",
2995 git_commit
2996 );
2997
2998 let wt = temp.path().join("my-repo-feature");
2999 let git_wt = std::process::Command::new("git")
3000 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3001 .current_dir(&repo)
3002 .output()
3003 .expect("git worktree add");
3004 assert!(
3005 git_wt.status.success(),
3006 "git worktree add failed: {:?}",
3007 git_wt
3008 );
3009
3010 std::fs::write(
3012 repo.join("pitchfork.toml"),
3013 "[daemons.api]\nrun = \"echo main\"\n",
3014 )
3015 .unwrap();
3016 std::fs::write(
3018 wt.join("pitchfork.toml"),
3019 "[daemons.worker]\nrun = \"echo wt\"\n",
3020 )
3021 .unwrap();
3022
3023 super::invalidate_config_cache();
3024
3025 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
3027
3028 let main_id = DaemonId::new("my-repo", "api");
3029 let wt_id = DaemonId::new("my-repo-feature", "worker");
3030 assert!(
3031 pt.daemons.contains_key(&main_id),
3032 "main checkout daemon missing"
3033 );
3034 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3035
3036 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
3038 assert!(pt_from_main.daemons.contains_key(&wt_id));
3039
3040 let _ = std::process::Command::new("git")
3042 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3043 .current_dir(&repo)
3044 .output();
3045 super::invalidate_config_cache();
3046 }
3047
3048 #[test]
3049 fn test_adhoc_id_uses_invocation_directory_namespace() {
3050 let temp = tempfile::tempdir().unwrap();
3051 let project = temp.path().join("feature-tree");
3052 std::fs::create_dir(&project).unwrap();
3053 std::fs::write(
3054 project.join("pitchfork.toml"),
3055 "[daemons.other]\nrun = \"true\"\n",
3056 )
3057 .unwrap();
3058
3059 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3060 assert_eq!(id, DaemonId::new("feature-tree", "api"));
3061 let qualified =
3062 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3063 assert_eq!(qualified, DaemonId::new("explicit", "api"));
3064 }
3065
3066 #[test]
3067 fn test_adhoc_id_falls_back_to_global_without_project_config() {
3068 let temp = tempfile::tempdir().unwrap();
3069 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3070 assert_eq!(id, DaemonId::new("global", "api"));
3071 }
3072}