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