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, MemoryLimit, OnOutputHook, PitchforkTomlAuto, PitchforkTomlCron,
19 PitchforkTomlHooks, PortBump, PortConfig, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry,
20 StopConfig, StopSignal, WatchMode,
21};
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
31pub struct SlugEntryRaw {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub dir: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub namespace: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub daemon: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct SlugEntry {
46 pub dir: Option<PathBuf>,
48 pub namespace: Option<String>,
50 pub daemon: Option<String>,
52}
53
54impl SlugEntry {
55 pub fn resolve_dir(&self) -> Option<PathBuf> {
58 self.dir.clone().or_else(|| {
59 self.namespace.as_ref().and_then(|ns| {
60 let namespaces = PitchforkToml::read_global_namespaces();
61 namespaces.get(ns).map(|entry| entry.dir.clone())
62 })
63 })
64 }
65
66 pub fn resolve_namespace(&self) -> Option<String> {
69 self.namespace.clone().or_else(|| {
70 self.resolve_dir()
71 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
72 })
73 }
74}
75
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
82pub struct GroupEntryRaw {
83 #[schemars(with = "Vec<DaemonId>")]
84 pub daemons: Vec<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct GroupEntry {
90 pub daemons: Vec<DaemonId>,
91}
92
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
99pub struct NamespaceEntryRaw {
100 pub dir: String,
102}
103
104#[derive(Debug, Clone)]
106pub struct NamespaceEntry {
107 pub dir: PathBuf,
109}
110
111#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
113struct PitchforkTomlRaw {
114 #[serde(skip_serializing_if = "Option::is_none", default)]
115 pub namespace: Option<String>,
116 #[serde(default)]
117 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
118 #[serde(skip_serializing_if = "Option::is_none", default)]
121 pub env: Option<IndexMap<String, String>>,
122 #[serde(default)]
123 pub settings: Option<SettingsPartial>,
124 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
127 pub slugs: IndexMap<String, SlugEntryRaw>,
128 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
130 pub groups: IndexMap<String, GroupEntryRaw>,
131 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
134 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
135}
136
137#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
142pub struct PitchforkTomlDaemonLogs {
143 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub log_format: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
149 pub time_retention: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none", default)]
152 pub line_retention: Option<i64>,
153 #[serde(skip_serializing_if = "Option::is_none", default)]
155 pub archive_hook: Option<String>,
156}
157
158#[derive(Debug, serde::Serialize, serde::Deserialize)]
165struct PitchforkTomlDaemonRaw {
166 pub run: String,
167 #[serde(skip_serializing_if = "Vec::is_empty", default)]
168 pub auto: Vec<PitchforkTomlAuto>,
169 #[serde(skip_serializing_if = "Option::is_none", default)]
170 pub cron: Option<PitchforkTomlCron>,
171 #[serde(default)]
172 pub retry: Retry,
173 #[serde(skip_serializing_if = "Option::is_none", default)]
174 pub ready_delay: Option<u64>,
175 #[serde(skip_serializing_if = "Option::is_none", default)]
176 pub ready_output: Option<ReadyOutput>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 pub ready_http: Option<ReadyHttp>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub ready_port: Option<ReadyPort>,
181 #[serde(skip_serializing_if = "Option::is_none", default)]
182 pub ready_cmd: Option<ReadyCmd>,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub port: Option<PortConfig>,
186 #[serde(skip_serializing_if = "Vec::is_empty", default)]
188 pub expected_port: Vec<u16>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub auto_bump_port: Option<bool>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub port_bump_attempts: Option<u32>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub boot_start: Option<bool>,
197 #[serde(skip_serializing_if = "Vec::is_empty", default)]
198 pub depends: Vec<String>,
199 #[serde(skip_serializing_if = "Vec::is_empty", default)]
200 pub watch: Vec<String>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub watch_mode: Option<WatchMode>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub dir: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub env: Option<IndexMap<String, String>>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub hooks: Option<PitchforkTomlHooks>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub mise: Option<bool>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub user: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub memory_limit: Option<MemoryLimit>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
219 pub cpu_limit: Option<CpuLimit>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
222 pub stop_signal: Option<StopConfig>,
223 #[serde(skip_serializing_if = "Option::is_none", default)]
225 pub pty: Option<bool>,
226 #[serde(skip_serializing_if = "Option::is_none", default)]
229 pub time_retention: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none", default)]
233 pub line_retention: Option<i64>,
234 #[serde(skip_serializing_if = "Option::is_none", default)]
237 pub archive_hook: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none", default)]
240 pub logs: Option<PitchforkTomlDaemonLogs>,
241}
242
243#[derive(Debug, Clone, Default, JsonSchema)]
248#[schemars(title = "Pitchfork Configuration")]
249pub struct PitchforkToml {
250 #[serde(default)]
252 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
253 #[serde(skip_serializing_if = "Option::is_none", default)]
257 pub env: Option<IndexMap<String, String>>,
258 pub namespace: Option<String>,
263 #[serde(default)]
272 pub(crate) settings: SettingsPartial,
273 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
278 pub slugs: IndexMap<String, SlugEntry>,
279 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
281 pub groups: IndexMap<String, GroupEntry>,
282 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
285 pub namespaces: IndexMap<String, NamespaceEntry>,
286 #[schemars(skip)]
287 pub path: Option<PathBuf>,
288}
289
290pub(crate) fn is_global_config(path: &Path) -> bool {
291 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
292}
293
294fn is_local_config(path: &Path) -> bool {
295 path.file_name()
296 .map(|n| n == "pitchfork.local.toml")
297 .unwrap_or(false)
298}
299
300pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
301 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
302}
303
304fn sibling_base_config(path: &Path) -> Option<PathBuf> {
305 if !is_local_config(path) {
306 return None;
307 }
308 path.parent().map(|p| p.join("pitchfork.toml"))
309}
310
311fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
312 use toml::Value;
313
314 let doc: Value = toml::from_str(content)
315 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
316 let Some(value) = doc.get("namespace") else {
317 return Ok(None);
318 };
319
320 match value {
321 Value::String(s) => Ok(Some(s.clone())),
322 _ => Err(ConfigParseError::InvalidNamespace {
323 path: path.to_path_buf(),
324 namespace: value.to_string(),
325 reason: "top-level 'namespace' must be a string".to_string(),
326 }
327 .into()),
328 }
329}
330
331fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
332 if !path.exists() {
333 return Ok(None);
334 }
335 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
336 path: path.to_path_buf(),
337 source: e,
338 })?;
339 parse_namespace_override_from_content(path, &content)
340}
341
342fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
343 if let Err(e) = DaemonId::try_new(namespace, "probe") {
344 return Err(ConfigParseError::InvalidNamespace {
345 path: path.to_path_buf(),
346 namespace: namespace.to_string(),
347 reason: e.to_string(),
348 }
349 .into());
350 }
351 Ok(namespace.to_string())
352}
353
354fn derive_namespace_from_dir(path: &Path) -> Result<String> {
355 let dir_for_namespace = if is_dot_config_pitchfork(path) {
356 path.parent().and_then(|p| p.parent())
357 } else {
358 path.parent()
359 };
360
361 let raw_namespace = dir_for_namespace
362 .and_then(|p| p.file_name())
363 .and_then(|n| n.to_str())
364 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
365 .to_string();
366
367 validate_namespace(path, &raw_namespace).map_err(|e| {
368 ConfigParseError::InvalidNamespace {
369 path: path.to_path_buf(),
370 namespace: raw_namespace,
371 reason: format!(
372 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
373 ),
374 }
375 .into()
376 })
377}
378
379fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
380 if is_global_config(path) {
381 if let Some(ns) = explicit
382 && ns != "global"
383 {
384 return Err(ConfigParseError::InvalidNamespace {
385 path: path.to_path_buf(),
386 namespace: ns.to_string(),
387 reason: "global config files must use namespace 'global'".to_string(),
388 }
389 .into());
390 }
391 return Ok("global".to_string());
392 }
393
394 if let Some(ns) = explicit {
395 return validate_namespace(path, ns);
396 }
397
398 derive_namespace_from_dir(path)
399}
400
401fn namespace_from_file(path: &Path) -> Result<String> {
402 let explicit = read_namespace_override_from_file(path)?;
403 let base_explicit = sibling_base_config(path)
404 .filter(|p| p.exists())
405 .map(|p| read_namespace_override_from_file(&p))
406 .transpose()?
407 .flatten();
408
409 if let (Some(local_ns), Some(base_ns)) = (explicit.as_deref(), base_explicit.as_deref())
410 && local_ns != base_ns
411 {
412 return Err(ConfigParseError::InvalidNamespace {
413 path: path.to_path_buf(),
414 namespace: local_ns.to_string(),
415 reason: format!(
416 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
417 ),
418 }
419 .into());
420 }
421
422 let effective_explicit = explicit.as_deref().or(base_explicit.as_deref());
423 namespace_from_path_with_override(path, effective_explicit)
424}
425
426pub fn namespace_from_path(path: &Path) -> Result<String> {
439 namespace_from_file(path)
440}
441
442fn find_project_root(dir: &Path) -> Option<PathBuf> {
450 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
454 let mut current = canonical_dir.as_path();
455 loop {
456 if current.join(".git").exists() || current.join(".jj").exists() {
457 return Some(current.to_path_buf());
458 }
459 current = current.parent()?;
460 }
461}
462
463struct ConfigCacheEntry {
482 config: PitchforkToml,
483 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
485}
486
487static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
494 Lazy::new(|| StdMutex::new(HashMap::new()));
495
496fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
502 if paths.len() != snapshot.len() {
503 return false;
504 }
505 paths
506 .iter()
507 .zip(snapshot.iter())
508 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
509}
510
511fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
513 let md = std::fs::metadata(path).ok()?;
514 Some((md.modified().ok()?, md.len()))
515}
516
517fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
519 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
520}
521
522pub fn invalidate_config_cache() {
539 if let Ok(mut cache) = CONFIG_CACHE.lock() {
540 cache.clear();
541 }
542}
543
544impl PitchforkToml {
545 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
558 if user_id.contains('/') {
560 return match DaemonId::parse(user_id) {
561 Ok(id) => Ok(vec![id]),
562 Err(e) => Err(e), };
564 }
565
566 let global_slugs = Self::read_global_slugs();
568 if let Some(entry) = global_slugs.get(user_id) {
569 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
571 if let Some(dir) = entry.resolve_dir()
572 && let Ok(project_config) = Self::all_merged_from(&dir)
573 {
574 let matches: Vec<DaemonId> = project_config
576 .daemons
577 .keys()
578 .filter(|id| id.name() == daemon_name)
579 .cloned()
580 .collect();
581 match matches.as_slice() {
582 [] => {}
583 [id] => return Ok(vec![id.clone()]),
584 _ => {
585 let mut candidates: Vec<String> =
586 matches.iter().map(|id| id.qualified()).collect();
587 candidates.sort();
588 return Err(miette::miette!(
589 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
590 user_id,
591 daemon_name,
592 candidates.join(", ")
593 ));
594 }
595 }
596 }
597 }
598
599 let matches: Vec<DaemonId> = self
601 .daemons
602 .keys()
603 .filter(|id| id.name() == user_id)
604 .cloned()
605 .collect();
606
607 if matches.is_empty() {
608 let state_matches = Self::find_in_state_file(user_id);
610 match state_matches.as_slice() {
611 [] => {}
612 [id] => return Ok(vec![id.clone()]),
613 _ => {
614 let mut candidates: Vec<String> =
615 state_matches.iter().map(|id| id.qualified()).collect();
616 candidates.sort();
617 return Err(miette::miette!(
618 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
619 user_id,
620 candidates.join(", ")
621 ));
622 }
623 }
624 let _ = DaemonId::try_new("global", user_id)?;
626 }
627 Ok(matches)
628 }
629
630 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
636 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
637 Ok(state) => state
638 .daemons
639 .keys()
640 .filter(|id| id.name() == short_name)
641 .cloned()
642 .collect(),
643 Err(e) => {
644 warn!("cannot read state file: {e}");
645 Vec::new()
646 }
647 }
648 }
649
650 #[allow(dead_code)]
671 pub fn resolve_daemon_id_prefer_local(
672 &self,
673 user_id: &str,
674 current_dir: &Path,
675 ) -> Result<DaemonId> {
676 if user_id.contains('/') {
678 return DaemonId::parse(user_id);
679 }
680
681 let current_namespace = Self::namespace_for_dir(current_dir)?;
685
686 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
687 }
688
689 fn resolve_daemon_id_with_namespace(
692 &self,
693 user_id: &str,
694 current_namespace: &str,
695 ) -> Result<DaemonId> {
696 let global_slugs = Self::read_global_slugs();
698 if let Some(entry) = global_slugs.get(user_id) {
699 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
700 if let Some(dir) = entry.resolve_dir()
701 && let Ok(project_config) = Self::all_merged_from(&dir)
702 {
703 let matches: Vec<DaemonId> = project_config
704 .daemons
705 .keys()
706 .filter(|id| id.name() == daemon_name)
707 .cloned()
708 .collect();
709 match matches.as_slice() {
710 [] => {}
711 [id] => return Ok(id.clone()),
712 _ => {
713 let mut candidates: Vec<String> =
714 matches.iter().map(|id| id.qualified()).collect();
715 candidates.sort();
716 return Err(miette::miette!(
717 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
718 user_id,
719 daemon_name,
720 candidates.join(", ")
721 ));
722 }
723 }
724 }
725 }
726
727 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
730 if self.daemons.contains_key(&preferred_id) {
731 return Ok(preferred_id);
732 }
733
734 let matches = self.resolve_daemon_id(user_id)?;
736
737 if matches.len() > 1 {
739 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
740 candidates.sort();
741 return Err(miette::miette!(
742 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
743 user_id,
744 candidates.join(", ")
745 ));
746 }
747
748 if let Some(id) = matches.into_iter().next() {
749 return Ok(id);
750 }
751
752 let global_id = DaemonId::try_new("global", user_id)?;
755 if self.daemons.contains_key(&global_id) {
756 return Ok(global_id);
757 }
758
759 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
760 Err(DependencyError::DaemonNotFound {
761 name: user_id.to_string(),
762 suggestion,
763 }
764 .into())
765 }
766
767 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
770 Ok(Self::list_paths_from(dir)
771 .iter()
772 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
774 .transpose()?
775 .unwrap_or_else(|| "global".to_string()))
776 }
777
778 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
788 if user_id.contains('/') {
789 return DaemonId::parse(user_id);
790 }
791
792 let config = Self::all_merged()?;
795 let ns = Self::namespace_for_dir(&env::CWD)?;
796 config.resolve_daemon_id_with_namespace(user_id, &ns)
797 }
798
799 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
805 if user_id.contains('/') {
806 return DaemonId::parse(user_id);
807 }
808
809 let config = Self::all_merged()?;
810 let ns = Self::namespace_for_dir(&env::CWD)?;
811
812 let preferred_id = DaemonId::try_new(&ns, user_id)?;
813 if config.daemons.contains_key(&preferred_id) {
814 return Ok(preferred_id);
815 }
816
817 let matches = config.resolve_daemon_id(user_id)?;
818 if matches.len() > 1 {
819 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
820 candidates.sort();
821 return Err(miette::miette!(
822 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
823 user_id,
824 candidates.join(", ")
825 ));
826 }
827 if let Some(id) = matches.into_iter().next() {
828 return Ok(id);
829 }
830
831 DaemonId::try_new("global", user_id)
832 }
833
834 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
845 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
847 return user_ids
848 .iter()
849 .map(|s| DaemonId::parse(s.as_ref()))
850 .collect();
851 }
852
853 let config = Self::all_merged()?;
854 let ns = Self::namespace_for_dir(&env::CWD)?;
856 user_ids
857 .iter()
858 .map(|s| {
859 let id = s.as_ref();
860 if id.contains('/') {
861 DaemonId::parse(id)
862 } else {
863 config.resolve_daemon_id_with_namespace(id, &ns)
864 }
865 })
866 .collect()
867 }
868
869 pub fn resolve_ids_and_group<S: AsRef<str>>(
874 user_ids: &[S],
875 group_name: Option<&str>,
876 ) -> Result<Vec<DaemonId>> {
877 let config = Self::all_merged()?;
878 let ns = Self::namespace_for_dir(&env::CWD)?;
879 let mut ids = Vec::new();
880 let mut seen = std::collections::HashSet::new();
881
882 for id in user_ids {
883 let id_str = id.as_ref();
884 let daemon_id = if id_str.contains('/') {
885 DaemonId::parse(id_str)?
886 } else {
887 config.resolve_daemon_id_with_namespace(id_str, &ns)?
888 };
889 if seen.insert(daemon_id.clone()) {
890 ids.push(daemon_id);
891 }
892 }
893
894 if let Some(name) = group_name {
895 match config.groups.get(name) {
896 Some(group) => {
897 let missing: Vec<String> = group
898 .daemons
899 .iter()
900 .filter(|id| !config.daemons.contains_key(*id))
901 .map(|id| id.qualified())
902 .collect();
903 if !missing.is_empty() {
904 return Err(miette::miette!(
905 "group '{}' references undefined daemon{}: {}",
906 name,
907 if missing.len() > 1 { "s" } else { "" },
908 missing.join(", ")
909 ));
910 }
911 for daemon_id in &group.daemons {
912 if seen.insert(daemon_id.clone()) {
913 ids.push(daemon_id.clone());
914 }
915 }
916 }
917 None => {
918 let suggestion =
919 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
920 return Err(miette::miette!(
921 "group '{}' not found in configuration{}",
922 name,
923 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
924 ));
925 }
926 }
927 }
928
929 Ok(ids)
930 }
931
932 pub fn list_paths() -> Vec<PathBuf> {
935 Self::list_paths_from(&env::CWD)
936 }
937
938 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
949 let mut paths = Vec::new();
950 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
951 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
952
953 let mut project_paths = xx::file::find_up_all(
957 cwd,
958 &[
959 "pitchfork.local.toml",
960 "pitchfork.toml",
961 ".config/pitchfork.local.toml",
962 ".config/pitchfork.toml",
963 ],
964 );
965 project_paths.reverse();
966 paths.extend(project_paths);
967
968 paths
969 }
970
971 pub fn all_merged() -> Result<PitchforkToml> {
974 Self::all_merged_from(&env::CWD)
975 }
976 pub fn all_merged_all_namespaces() -> Result<Self> {
983 Self::all_merged_all_namespaces_from(&env::CWD)
984 }
985
986 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
989 let mut pt = Self::all_merged_from(start_dir)?;
990
991 let namespaces = Self::read_global_namespaces();
992 for (ns_name, entry) in namespaces {
993 match Self::all_merged_from(&entry.dir) {
994 Ok(ns_config) => {
995 for (daemon_id, daemon_config) in ns_config.daemons {
996 if !pt.daemons.contains_key(&daemon_id) {
997 pt.daemons.insert(daemon_id, daemon_config);
998 }
999 }
1000 pt.settings.merge_from(&ns_config.settings);
1003 }
1004 Err(e) => {
1005 log::warn!(
1006 "Failed to load namespace '{ns_name}' from {}: {e}",
1007 entry.dir.display()
1008 );
1009 }
1010 }
1011 }
1012
1013 if let Some(project_root) = find_project_root(start_dir) {
1021 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1022 for wt in &worktrees {
1023 match Self::all_merged_from(&wt.path) {
1024 Ok(wt_config) => {
1025 for (daemon_id, daemon_config) in wt_config.daemons {
1026 if !pt.daemons.contains_key(&daemon_id) {
1027 pt.daemons.insert(daemon_id, daemon_config);
1028 }
1029 }
1030 pt.settings.merge_from(&wt_config.settings);
1031 }
1032 Err(e) => {
1033 log::warn!(
1034 "Failed to load worktree '{}' config from {}: {e}",
1035 wt.branch,
1036 wt.path.display()
1037 );
1038 }
1039 }
1040 }
1041 }
1042
1043 Ok(pt)
1044 }
1045
1046 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1064 let paths = Self::list_paths_from(cwd);
1065
1066 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1070
1071 {
1072 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1073 if let Some(entry) = cache.get(&cache_key)
1074 && meta_matches(&paths, &entry.source_meta)
1075 {
1076 return Ok(entry.config.clone());
1077 }
1078 }
1079
1080 let snapshot = snapshot_meta(&paths);
1086 let pt = Self::all_merged_from_uncached(&paths)?;
1087
1088 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1090 cache.insert(
1091 cache_key,
1092 ConfigCacheEntry {
1093 config: pt.clone(),
1094 source_meta: snapshot,
1095 },
1096 );
1097
1098 Ok(pt)
1099 }
1100
1101 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1106 use std::collections::HashMap as StdHashMap;
1107
1108 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1109
1110 let mut pt = Self::default();
1111 for p in paths {
1112 match Self::read(p) {
1113 Ok(pt2) => {
1114 if p.exists() && !is_global_config(p) {
1118 let ns = namespace_from_path(p)?;
1119 let origin_dir = if is_dot_config_pitchfork(p) {
1120 p.parent().and_then(|d| d.parent())
1121 } else {
1122 p.parent()
1123 }
1124 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1125 .unwrap_or_else(|| p.clone());
1126
1127 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1128 && *other_dir != origin_dir
1129 {
1130 return Err(crate::error::ConfigParseError::NamespaceCollision {
1131 path_a: other_path.clone(),
1132 path_b: p.clone(),
1133 ns,
1134 }
1135 .into());
1136 }
1137 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1138 }
1139
1140 pt.merge(pt2)
1141 }
1142 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1143 }
1144 }
1145 Ok(pt)
1146 }
1147}
1148
1149impl PitchforkToml {
1150 pub fn new(path: PathBuf) -> Self {
1151 Self {
1152 daemons: Default::default(),
1153 env: None,
1154 namespace: None,
1155 settings: SettingsPartial::default(),
1156 slugs: IndexMap::new(),
1157 groups: IndexMap::new(),
1158 namespaces: IndexMap::new(),
1159 path: Some(path),
1160 }
1161 }
1162
1163 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1171 let raw_config: PitchforkTomlRaw = toml::from_str(content)
1172 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1173
1174 let namespace = {
1175 let base_explicit = sibling_base_config(path)
1176 .filter(|p| p.exists())
1177 .map(|p| read_namespace_override_from_file(&p))
1178 .transpose()?
1179 .flatten();
1180
1181 if is_local_config(path)
1182 && let (Some(local_ns), Some(base_ns)) =
1183 (raw_config.namespace.as_deref(), base_explicit.as_deref())
1184 && local_ns != base_ns
1185 {
1186 return Err(ConfigParseError::InvalidNamespace {
1187 path: path.to_path_buf(),
1188 namespace: local_ns.to_string(),
1189 reason: format!(
1190 "namespace '{local_ns}' does not match sibling pitchfork.toml namespace '{base_ns}'"
1191 ),
1192 }
1193 .into());
1194 }
1195
1196 let explicit = raw_config.namespace.as_deref().or(base_explicit.as_deref());
1197 namespace_from_path_with_override(path, explicit)?
1198 };
1199 let mut pt = Self::new(path.to_path_buf());
1200 pt.namespace = raw_config.namespace.clone();
1201
1202 for (short_name, raw_daemon) in raw_config.daemons {
1203 let id = match DaemonId::try_new(&namespace, &short_name) {
1204 Ok(id) => id,
1205 Err(e) => {
1206 return Err(ConfigParseError::InvalidDaemonName {
1207 name: short_name,
1208 path: path.to_path_buf(),
1209 reason: e.to_string(),
1210 }
1211 .into());
1212 }
1213 };
1214
1215 let mut depends = Vec::new();
1216 for dep in raw_daemon.depends {
1217 let dep_id = if dep.contains('/') {
1218 match DaemonId::parse(&dep) {
1219 Ok(id) => id,
1220 Err(e) => {
1221 return Err(ConfigParseError::InvalidDependency {
1222 daemon: short_name.clone(),
1223 dependency: dep,
1224 path: path.to_path_buf(),
1225 reason: e.to_string(),
1226 }
1227 .into());
1228 }
1229 }
1230 } else {
1231 match DaemonId::try_new(&namespace, &dep) {
1232 Ok(id) => id,
1233 Err(e) => {
1234 return Err(ConfigParseError::InvalidDependency {
1235 daemon: short_name.clone(),
1236 dependency: dep,
1237 path: path.to_path_buf(),
1238 reason: e.to_string(),
1239 }
1240 .into());
1241 }
1242 }
1243 };
1244 depends.push(dep_id);
1245 }
1246
1247 let has_deprecated = !raw_daemon.expected_port.is_empty()
1249 || raw_daemon.auto_bump_port.is_some()
1250 || raw_daemon.port_bump_attempts.is_some();
1251 let port = if let Some(port) = raw_daemon.port {
1252 if has_deprecated {
1253 warn!(
1254 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1255 );
1256 }
1257 Some(port)
1258 } else if has_deprecated {
1259 warn!(
1260 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1261 );
1262 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1263 PortBump(
1264 raw_daemon
1265 .port_bump_attempts
1266 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1267 )
1268 } else {
1269 PortBump(0)
1270 };
1271 Some(PortConfig {
1272 expect: raw_daemon.expected_port,
1273 bump,
1274 })
1275 } else {
1276 None
1277 };
1278
1279 let daemon = PitchforkTomlDaemon {
1280 run: raw_daemon.run,
1281 auto: raw_daemon.auto,
1282 cron: raw_daemon.cron,
1283 retry: raw_daemon.retry,
1284 ready_delay: raw_daemon.ready_delay,
1285 ready_output: raw_daemon.ready_output,
1286 ready_http: raw_daemon.ready_http,
1287 ready_port: raw_daemon.ready_port,
1288 ready_cmd: raw_daemon.ready_cmd,
1289 port,
1290 boot_start: raw_daemon.boot_start,
1291 depends,
1292 watch: raw_daemon.watch,
1293 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1294 dir: raw_daemon.dir,
1295 env: raw_daemon.env,
1296 hooks: raw_daemon.hooks,
1297 mise: raw_daemon.mise,
1298 user: raw_daemon.user,
1299 memory_limit: raw_daemon.memory_limit,
1300 cpu_limit: raw_daemon.cpu_limit,
1301 stop_signal: raw_daemon.stop_signal,
1302 pty: raw_daemon.pty,
1303 time_retention: raw_daemon.time_retention,
1304 line_retention: raw_daemon.line_retention,
1305 archive_hook: raw_daemon.archive_hook,
1306 logs: raw_daemon.logs,
1307 path: Some(path.to_path_buf()),
1308 };
1309 pt.daemons.insert(id, daemon);
1310 }
1311
1312 if let Some(settings) = raw_config.settings {
1314 pt.settings = settings;
1315 }
1316
1317 pt.env = raw_config.env;
1319
1320 for (slug, entry) in raw_config.slugs {
1322 pt.slugs.insert(
1323 slug,
1324 SlugEntry {
1325 dir: entry.dir.map(env::expand_tilde),
1326 namespace: entry.namespace,
1327 daemon: entry.daemon,
1328 },
1329 );
1330 }
1331
1332 for (name, entry) in raw_config.namespaces {
1334 pt.namespaces.insert(
1335 name,
1336 NamespaceEntry {
1337 dir: env::expand_tilde(entry.dir),
1338 },
1339 );
1340 }
1341
1342 for (group_name, raw_group) in raw_config.groups {
1344 let mut daemons = Vec::new();
1345 for daemon_name in &raw_group.daemons {
1346 let id = if daemon_name.contains('/') {
1347 DaemonId::parse(daemon_name).map_err(|e| {
1348 ConfigParseError::InvalidDependency {
1349 daemon: group_name.clone(),
1350 dependency: daemon_name.clone(),
1351 path: path.to_path_buf(),
1352 reason: e.to_string(),
1353 }
1354 })?
1355 } else {
1356 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1357 ConfigParseError::InvalidDaemonName {
1358 name: daemon_name.clone(),
1359 path: path.to_path_buf(),
1360 reason: e.to_string(),
1361 }
1362 })?
1363 };
1364 daemons.push(id);
1365 }
1366 pt.groups.insert(group_name, GroupEntry { daemons });
1367 }
1368
1369 Ok(pt)
1370 }
1371
1372 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1373 let path = path.as_ref();
1374 if !path.exists() {
1375 return Ok(Self::new(path.to_path_buf()));
1376 }
1377 let _lock = xx::fslock::get(path, false)
1378 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1379 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1380 path: path.to_path_buf(),
1381 source: e,
1382 })?;
1383 Self::parse_str(&raw, path)
1384 }
1385
1386 pub fn write(&self) -> Result<()> {
1387 if let Some(path) = &self.path {
1388 let _lock = xx::fslock::get(path, false)
1389 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1390 self.write_unlocked()
1391 } else {
1392 Err(FileError::NoPath.into())
1393 }
1394 }
1395
1396 fn write_unlocked(&self) -> Result<()> {
1402 if let Some(path) = &self.path {
1403 let config_namespace = if path.exists() {
1405 namespace_from_path(path)?
1406 } else {
1407 namespace_from_path_with_override(path, self.namespace.as_deref())?
1408 };
1409
1410 let mut raw = PitchforkTomlRaw {
1414 namespace: self.namespace.clone(),
1415 env: self.env.clone(),
1416 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1417 ..PitchforkTomlRaw::default()
1418 };
1419 for (id, daemon) in &self.daemons {
1420 if id.namespace() != config_namespace {
1421 return Err(miette::miette!(
1422 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1423 id,
1424 path.display(),
1425 id.namespace(),
1426 config_namespace
1427 ));
1428 }
1429 let port = daemon.port.as_ref();
1430 let raw_daemon = PitchforkTomlDaemonRaw {
1431 run: daemon.run.clone(),
1432 auto: daemon.auto.clone(),
1433 cron: daemon.cron.clone(),
1434 retry: daemon.retry,
1435 ready_delay: daemon.ready_delay,
1436 ready_output: daemon.ready_output.clone(),
1437 ready_http: daemon.ready_http.clone(),
1438 ready_port: daemon.ready_port.clone(),
1439 ready_cmd: daemon.ready_cmd.clone(),
1440 port: port.cloned(),
1441 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1443 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1444 port_bump_attempts: port
1445 .filter(|p| p.auto_bump())
1446 .map(|p| p.max_bump_attempts()),
1447 boot_start: daemon.boot_start,
1448 depends: daemon
1451 .depends
1452 .iter()
1453 .map(|d| {
1454 if d.namespace() == config_namespace {
1455 d.name().to_string()
1456 } else {
1457 d.qualified()
1458 }
1459 })
1460 .collect(),
1461 watch: daemon.watch.clone(),
1462 watch_mode: match daemon.watch_mode {
1463 WatchMode::Native => None,
1464 mode => Some(mode),
1465 },
1466 dir: daemon.dir.clone(),
1467 env: daemon.env.clone(),
1468 hooks: daemon.hooks.clone(),
1469 mise: daemon.mise,
1470 user: daemon.user.clone(),
1471 memory_limit: daemon.memory_limit,
1472 cpu_limit: daemon.cpu_limit,
1473 stop_signal: daemon.stop_signal,
1474 pty: daemon.pty,
1475 time_retention: daemon.time_retention.clone(),
1476 line_retention: daemon.line_retention,
1477 archive_hook: daemon.archive_hook.clone(),
1478 logs: daemon.logs.clone(),
1479 };
1480 raw.daemons.insert(id.name().to_string(), raw_daemon);
1481 }
1482
1483 for (slug, entry) in &self.slugs {
1485 raw.slugs.insert(
1486 slug.clone(),
1487 SlugEntryRaw {
1488 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1489 namespace: entry.namespace.clone(),
1490 daemon: entry.daemon.clone(),
1491 },
1492 );
1493 }
1494
1495 for (name, group) in &self.groups {
1497 let raw_daemons: Vec<String> = group
1498 .daemons
1499 .iter()
1500 .map(|id| {
1501 if id.namespace() == config_namespace {
1502 id.name().to_string()
1503 } else {
1504 id.qualified()
1505 }
1506 })
1507 .collect();
1508 raw.groups.insert(
1509 name.clone(),
1510 GroupEntryRaw {
1511 daemons: raw_daemons,
1512 },
1513 );
1514 }
1515
1516 for (name, entry) in &self.namespaces {
1518 raw.namespaces.insert(
1519 name.clone(),
1520 NamespaceEntryRaw {
1521 dir: entry.dir.to_string_lossy().to_string(),
1522 },
1523 );
1524 }
1525
1526 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1527 path: path.clone(),
1528 source: e,
1529 })?;
1530 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1531 path: path.clone(),
1532 details: Some(e.to_string()),
1533 })?;
1534 invalidate_config_cache();
1535 Ok(())
1536 } else {
1537 Err(FileError::NoPath.into())
1538 }
1539 }
1540
1541 pub fn merge(&mut self, pt: Self) {
1546 for (id, d) in pt.daemons {
1547 self.daemons.insert(id, d);
1548 }
1549 if let Some(env) = pt.env {
1551 let merged = self.env.get_or_insert_with(IndexMap::new);
1552 for (k, v) in env {
1553 merged.insert(k, v);
1554 }
1555 }
1556 for (slug, entry) in pt.slugs {
1558 self.slugs.insert(slug, entry);
1559 }
1560 for (name, group) in pt.groups {
1562 self.groups.insert(name, group);
1563 }
1564 for (name, entry) in pt.namespaces {
1566 self.namespaces.insert(name, entry);
1567 }
1568 self.settings.merge_from(&pt.settings);
1570 }
1571
1572 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1577 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1578 Ok(pt) => pt.slugs,
1579 Err(_) => IndexMap::new(),
1580 }
1581 }
1582
1583 pub fn find_slug_for_daemon_in_registry(
1585 daemon_id: &DaemonId,
1586 global_slugs: &IndexMap<String, SlugEntry>,
1587 ) -> Option<String> {
1588 global_slugs
1589 .iter()
1590 .find(|(slug, entry)| {
1591 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1592 if daemon_id.name() != daemon_name {
1593 return false;
1594 }
1595
1596 match entry.resolve_namespace() {
1597 Some(namespace) => daemon_id.namespace() == namespace,
1598 None => false,
1599 }
1600 })
1601 .map(|(slug, _)| slug.clone())
1602 }
1603
1604 #[allow(dead_code)]
1606 pub fn is_slug_registered(slug: &str) -> bool {
1607 Self::read_global_slugs().contains_key(slug)
1608 }
1609
1610 pub fn add_slug_with_namespace(
1616 slug: &str,
1617 namespace: Option<&str>,
1618 daemon: Option<&str>,
1619 ) -> Result<()> {
1620 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1621
1622 if let Some(parent) = global_path.parent() {
1624 std::fs::create_dir_all(parent).map_err(|e| {
1625 miette::miette!(
1626 "Failed to create config directory {}: {e}",
1627 parent.display()
1628 )
1629 })?;
1630 }
1631
1632 let _lock = xx::fslock::get(global_path, false)
1633 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1634
1635 let mut pt = if global_path.exists() {
1636 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1637 path: global_path.to_path_buf(),
1638 source: e,
1639 })?;
1640 Self::parse_str(&raw, global_path)?
1641 } else {
1642 Self::new(global_path.to_path_buf())
1643 };
1644
1645 if let Some(ns) = namespace
1649 && !pt.namespaces.contains_key(ns)
1650 {
1651 let dir = pt
1656 .slugs
1657 .get(slug)
1658 .and_then(|e| {
1659 e.dir.clone().or_else(|| {
1660 e.namespace
1661 .as_ref()
1662 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1663 })
1664 })
1665 .or_else(|| env::CWD.as_path().canonicalize().ok());
1666 if let Some(ref d) = dir {
1667 pt.namespaces
1668 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1669 }
1670 }
1671
1672 pt.slugs.insert(
1673 slug.to_string(),
1674 SlugEntry {
1675 dir: None,
1676 namespace: namespace.map(str::to_string),
1677 daemon: daemon.map(str::to_string),
1678 },
1679 );
1680 pt.write_unlocked()?;
1681 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1687 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1688 Ok(())
1689 }
1690
1691 pub fn remove_slug(slug: &str) -> Result<bool> {
1693 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1694 if !global_path.exists() {
1695 return Ok(false);
1696 }
1697
1698 let _lock = xx::fslock::get(global_path, false)
1699 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1700
1701 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1702 path: global_path.to_path_buf(),
1703 source: e,
1704 })?;
1705 let mut pt = Self::parse_str(&raw, global_path)?;
1706
1707 let removed = pt.slugs.shift_remove(slug).is_some();
1708 if removed {
1709 pt.write_unlocked()?;
1710 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1716 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1717 }
1718 Ok(removed)
1719 }
1720 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1723 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1724 Ok(pt) => pt.namespaces,
1725 Err(_) => IndexMap::new(),
1726 }
1727 }
1728
1729 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1733 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1734
1735 if let Some(parent) = global_path.parent() {
1737 std::fs::create_dir_all(parent).map_err(|e| {
1738 miette::miette!(
1739 "Failed to create config directory {}: {e}",
1740 parent.display()
1741 )
1742 })?;
1743 }
1744
1745 let _lock = xx::fslock::get(global_path, false)
1746 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1747
1748 let mut pt = if global_path.exists() {
1749 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1750 crate::error::FileError::ReadError {
1751 path: global_path.to_path_buf(),
1752 source: e,
1753 }
1754 })?;
1755 Self::parse_str(&raw, global_path)?
1756 } else {
1757 Self::new(global_path.to_path_buf())
1758 };
1759
1760 pt.namespaces.insert(
1761 name.to_string(),
1762 NamespaceEntry {
1763 dir: env::expand_tilde(dir),
1764 },
1765 );
1766 pt.write_unlocked()?;
1767 Ok(())
1768 }
1769
1770 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1772 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1773 if !global_path.exists() {
1774 return Ok(false);
1775 }
1776
1777 let _lock = xx::fslock::get(global_path, false)
1778 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1779
1780 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1781 crate::error::FileError::ReadError {
1782 path: global_path.to_path_buf(),
1783 source: e,
1784 }
1785 })?;
1786 let mut pt = Self::parse_str(&raw, global_path)?;
1787
1788 let removed = pt.namespaces.shift_remove(name).is_some();
1789 if removed {
1790 pt.write_unlocked()?;
1791 }
1792 Ok(removed)
1793 }
1794}
1795
1796#[derive(Debug, Clone, JsonSchema, Default)]
1798pub struct PitchforkTomlDaemon {
1799 #[schemars(example = example_run_command())]
1801 pub run: String,
1802 #[schemars(default)]
1804 pub auto: Vec<PitchforkTomlAuto>,
1805 pub cron: Option<PitchforkTomlCron>,
1807 #[schemars(default)]
1810 pub retry: Retry,
1811 pub ready_delay: Option<u64>,
1813 pub ready_output: Option<ReadyOutput>,
1815 pub ready_http: Option<ReadyHttp>,
1817 pub ready_port: Option<ReadyPort>,
1821 pub ready_cmd: Option<ReadyCmd>,
1823 pub port: Option<PortConfig>,
1825 pub boot_start: Option<bool>,
1827 #[schemars(default)]
1829 pub depends: Vec<DaemonId>,
1830 #[schemars(default)]
1832 pub watch: Vec<String>,
1833 #[schemars(default)]
1839 pub watch_mode: WatchMode,
1840 pub dir: Option<String>,
1842 pub env: Option<IndexMap<String, String>>,
1844 pub hooks: Option<PitchforkTomlHooks>,
1846 pub mise: Option<bool>,
1849 pub user: Option<String>,
1851 pub memory_limit: Option<MemoryLimit>,
1854 pub cpu_limit: Option<CpuLimit>,
1857 pub stop_signal: Option<StopConfig>,
1860 pub pty: Option<bool>,
1862 pub time_retention: Option<String>,
1865 pub line_retention: Option<i64>,
1868 pub archive_hook: Option<String>,
1871 pub logs: Option<PitchforkTomlDaemonLogs>,
1873 #[schemars(skip)]
1874 pub path: Option<PathBuf>,
1875}
1876
1877impl PitchforkTomlDaemon {
1878 pub fn effective_user(&self) -> Option<String> {
1882 let daemon_user = self
1883 .user
1884 .as_deref()
1885 .map(str::trim)
1886 .filter(|u| !u.is_empty());
1887 daemon_user.map(str::to_owned).or_else(|| {
1888 let s = crate::settings::settings();
1889 let su = s.supervisor.user.trim();
1890 (!su.is_empty()).then(|| su.to_owned())
1891 })
1892 }
1893
1894 pub fn to_run_options(
1899 &self,
1900 id: &crate::daemon_id::DaemonId,
1901 cmd: Vec<String>,
1902 ) -> crate::daemon::RunOptions {
1903 use crate::daemon::RunOptions;
1904
1905 let effective_user = self.effective_user();
1906 let dir = crate::ipc::batch::resolve_daemon_dir(
1907 self.dir.as_deref(),
1908 self.path.as_deref(),
1909 effective_user.as_deref(),
1910 );
1911 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1912 .into_iter()
1913 .find(|(slug, entry)| {
1914 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1915 if daemon_name != id.name() {
1916 return false;
1917 }
1918
1919 match entry.resolve_namespace() {
1920 Some(namespace) => namespace == id.namespace(),
1921 None => false,
1922 }
1923 })
1924 .map(|(slug, _)| slug);
1925
1926 RunOptions {
1927 id: id.clone(),
1928 cmd,
1929 run: Some(self.run.clone()),
1930 force: false,
1931 shell_pid: None,
1932 dir: Dir(dir),
1933 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1934 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1935 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1936 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1937 retry: self.retry,
1938 retry_count: 0,
1939 ready_delay: self.ready_delay,
1940 ready_output: self.ready_output.clone(),
1941 ready_http: self.ready_http.clone(),
1942 ready_port: self.ready_port.clone(),
1943 ready_cmd: self.ready_cmd.clone(),
1944 port: self.port.clone(),
1945 wait_ready: false,
1946 depends: self.depends.clone(),
1947 env: self.env.clone(),
1948 watch: self.watch.clone(),
1949 watch_mode: self.watch_mode,
1950 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1951 self.path.as_deref(),
1952 )),
1953 mise: self.mise,
1954 slug,
1955 proxy: None,
1956 user: self.user.clone(),
1957 memory_limit: self.memory_limit,
1958 cpu_limit: self.cpu_limit,
1959 stop_signal: self.stop_signal,
1960 archive_hook: self
1961 .logs
1962 .as_ref()
1963 .and_then(|l| l.archive_hook.clone())
1964 .or_else(|| self.archive_hook.clone()),
1965 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1966 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1967 pty: self.pty,
1968 }
1969 }
1970}
1971fn example_run_command() -> &'static str {
1972 "exec node server.js"
1973}
1974
1975#[cfg(test)]
1976mod tests {
1977 use super::*;
1978 use std::path::Path;
1979
1980 #[test]
1981 fn test_daemon_user_parses_and_flows_to_run_options() {
1982 let pt = PitchforkToml::parse_str(
1983 r#"
1984[daemons.api]
1985run = "node server.js"
1986user = "postgres"
1987"#,
1988 Path::new("/tmp/my-project/pitchfork.toml"),
1989 )
1990 .unwrap();
1991
1992 let id = DaemonId::new("my-project", "api");
1993 let daemon = pt.daemons.get(&id).unwrap();
1994 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1995
1996 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1997 assert_eq!(opts.user.as_deref(), Some("postgres"));
1998 }
1999
2000 #[test]
2001 fn test_daemon_user_write_roundtrip() {
2002 let temp = tempfile::tempdir().unwrap();
2003 let path = temp.path().join("pitchfork.toml");
2004 let mut pt = PitchforkToml::new(path.clone());
2005 pt.namespace = Some("test-project".to_string());
2006 pt.daemons.insert(
2007 DaemonId::new("test-project", "api"),
2008 PitchforkTomlDaemon {
2009 run: "node server.js".to_string(),
2010 user: Some("postgres".to_string()),
2011 ..PitchforkTomlDaemon::default()
2012 },
2013 );
2014
2015 pt.write().unwrap();
2016
2017 let raw = std::fs::read_to_string(&path).unwrap();
2018 assert!(raw.contains("user = \"postgres\""));
2019
2020 let parsed = PitchforkToml::read(&path).unwrap();
2021 let daemon = parsed
2022 .daemons
2023 .get(&DaemonId::new("test-project", "api"))
2024 .unwrap();
2025 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2026 }
2027
2028 #[test]
2029 fn test_registry_dirs_expand_tilde() {
2030 let pt = PitchforkToml::parse_str(
2031 r#"
2032[slugs.api]
2033dir = "~/projects/api"
2034
2035[namespaces.web]
2036dir = "~/projects/web"
2037"#,
2038 Path::new("/tmp/config.toml"),
2039 )
2040 .unwrap();
2041
2042 assert_eq!(
2043 pt.slugs["api"].dir,
2044 Some(crate::env::HOME_DIR.join("projects/api"))
2045 );
2046 assert_eq!(
2047 pt.namespaces["web"].dir,
2048 crate::env::HOME_DIR.join("projects/web")
2049 );
2050 }
2051
2052 #[test]
2053 fn test_settings_write_roundtrip() {
2054 let temp = tempfile::tempdir().unwrap();
2055 let path = temp.path().join("pitchfork.toml");
2056 let mut pt = PitchforkToml::new(path.clone());
2057 pt.namespace = Some("test-project".to_string());
2058 pt.settings.web.auto_start = Some(true);
2059 pt.settings.general.log_level = Some("debug".to_string());
2060
2061 pt.write().unwrap();
2062
2063 let raw = std::fs::read_to_string(&path).unwrap();
2064 assert!(
2065 raw.contains("[settings.web]"),
2066 "settings.web section should be written, got:\n{raw}"
2067 );
2068 assert!(raw.contains("auto_start = true"));
2069 assert!(raw.contains("log_level = \"debug\""));
2070
2071 let parsed = PitchforkToml::read(&path).unwrap();
2072 assert_eq!(parsed.settings.web.auto_start, Some(true));
2073 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2074 }
2075
2076 #[test]
2077 fn test_settings_preserved_on_unrelated_write() {
2078 let temp = tempfile::tempdir().unwrap();
2081 let path = temp.path().join("pitchfork.toml");
2082 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2083
2084 let mut pt = PitchforkToml::read(&path).unwrap();
2085 pt.slugs.insert(
2086 "api".to_string(),
2087 SlugEntry {
2088 dir: None,
2089 namespace: Some("myproject".to_string()),
2090 daemon: None,
2091 },
2092 );
2093 pt.namespaces.insert(
2094 "myproject".to_string(),
2095 NamespaceEntry {
2096 dir: PathBuf::from("/tmp/myproject"),
2097 },
2098 );
2099 pt.write().unwrap();
2100
2101 let raw = std::fs::read_to_string(&path).unwrap();
2102 assert!(
2103 raw.contains("[settings.web]"),
2104 "existing settings must be preserved, got:\n{raw}"
2105 );
2106 assert!(raw.contains("auto_start = true"));
2107 assert!(raw.contains("[slugs.api]"));
2108
2109 let parsed = PitchforkToml::read(&path).unwrap();
2110 assert_eq!(parsed.settings.web.auto_start, Some(true));
2111 assert!(parsed.slugs.contains_key("api"));
2112 }
2113
2114 #[test]
2115 fn test_config_cache_hit_and_invalidation() {
2116 let temp = tempfile::tempdir().unwrap();
2117 let dir = temp.path();
2118 let config_path = dir.join("pitchfork.toml");
2119 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2120
2121 super::invalidate_config_cache();
2123
2124 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2126 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2127 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2128
2129 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2131 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2132
2133 std::thread::sleep(std::time::Duration::from_millis(50));
2136 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2137
2138 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2139 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2140
2141 super::invalidate_config_cache();
2143 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2144 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2145
2146 super::invalidate_config_cache();
2148 }
2149
2150 #[test]
2151 fn test_config_cache_invalidation_on_write() {
2152 let temp = tempfile::tempdir().unwrap();
2153 let dir = temp.path();
2154 let config_path = dir.join("pitchfork.toml");
2155 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2156
2157 super::invalidate_config_cache();
2158
2159 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2161 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2162 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2163
2164 let mut pt = PitchforkToml::read(&config_path).unwrap();
2166 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2167 let _ = pt.write();
2169
2170 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2172 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2173
2174 super::invalidate_config_cache();
2175 }
2176
2177 #[test]
2178 fn test_config_cache_size_invalidation() {
2179 let temp = tempfile::tempdir().unwrap();
2180 let dir = temp.path();
2181 let config_path = dir.join("pitchfork.toml");
2182 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2183
2184 super::invalidate_config_cache();
2185
2186 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2188 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2189 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2190
2191 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2195 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2196 let file = std::fs::OpenOptions::new()
2199 .write(true)
2200 .open(&config_path)
2201 .unwrap();
2202 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2203 file.set_times(times).unwrap();
2204
2205 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2208 assert_eq!(
2209 pt2.daemons[&daemon_id].run, "echo different",
2210 "cache should invalidate on size change even with identical mtime"
2211 );
2212
2213 super::invalidate_config_cache();
2214 }
2215
2216 #[test]
2217 fn test_find_project_root_in_plain_dir_returns_none() {
2218 let temp = tempfile::tempdir().unwrap();
2219 assert_eq!(find_project_root(temp.path()), None);
2220 }
2221
2222 #[test]
2223 fn test_find_project_root_finds_git_marker() {
2224 let temp = tempfile::tempdir().unwrap();
2225 let repo = temp.path().join("my-repo");
2226 std::fs::create_dir(&repo).unwrap();
2227 std::fs::create_dir(repo.join(".git")).unwrap();
2228
2229 let sub = repo.join("sub/dir");
2230 std::fs::create_dir_all(&sub).unwrap();
2231
2232 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2235 }
2236
2237 #[test]
2238 fn test_find_project_root_accepts_git_file_marker() {
2239 let temp = tempfile::tempdir().unwrap();
2242 let wt = temp.path().join("my-worktree");
2243 std::fs::create_dir(&wt).unwrap();
2244 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2245
2246 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2247 }
2248
2249 #[cfg(unix)]
2252 #[test]
2253 fn test_find_project_root_resolves_symlinked_start_dir() {
2254 use std::os::unix::fs::symlink;
2255
2256 let temp = tempfile::tempdir().unwrap();
2257 let repo = temp.path().join("real-repo");
2258 std::fs::create_dir(&repo).unwrap();
2259 std::fs::create_dir(repo.join(".git")).unwrap();
2260
2261 let sub = repo.join("sub/dir");
2262 std::fs::create_dir_all(&sub).unwrap();
2263 let link = temp.path().join("link-to-sub");
2264 symlink(&sub, &link).unwrap();
2265
2266 assert_eq!(find_project_root(&link), Some(repo));
2267 }
2268
2269 #[test]
2272 fn test_all_merged_all_namespaces_discovers_worktrees() {
2273 let temp = tempfile::tempdir().unwrap();
2274 let repo = temp.path().join("my-repo");
2275 std::fs::create_dir(&repo).unwrap();
2276
2277 let git_init = std::process::Command::new("git")
2279 .args(["init", "-b", "main"])
2280 .current_dir(&repo)
2281 .output()
2282 .expect("git init");
2283 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2284
2285 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2286
2287 let git_commit = std::process::Command::new("git")
2288 .args([
2289 "-c",
2290 "user.name=pitchfork-test",
2291 "-c",
2292 "user.email=pitchfork-test@example.com",
2293 "add",
2294 "-A",
2295 ])
2296 .current_dir(&repo)
2297 .output()
2298 .expect("git add");
2299 assert!(git_commit.status.success());
2300
2301 let git_commit = std::process::Command::new("git")
2302 .args([
2303 "-c",
2304 "user.name=pitchfork-test",
2305 "-c",
2306 "user.email=pitchfork-test@example.com",
2307 "commit",
2308 "-m",
2309 "init",
2310 ])
2311 .current_dir(&repo)
2312 .output()
2313 .expect("git commit");
2314 assert!(
2315 git_commit.status.success(),
2316 "git commit failed: {:?}",
2317 git_commit
2318 );
2319
2320 let wt = temp.path().join("my-repo-feature");
2321 let git_wt = std::process::Command::new("git")
2322 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2323 .current_dir(&repo)
2324 .output()
2325 .expect("git worktree add");
2326 assert!(
2327 git_wt.status.success(),
2328 "git worktree add failed: {:?}",
2329 git_wt
2330 );
2331
2332 std::fs::write(
2334 repo.join("pitchfork.toml"),
2335 "[daemons.api]\nrun = \"echo main\"\n",
2336 )
2337 .unwrap();
2338 std::fs::write(
2340 wt.join("pitchfork.toml"),
2341 "[daemons.worker]\nrun = \"echo wt\"\n",
2342 )
2343 .unwrap();
2344
2345 super::invalidate_config_cache();
2346
2347 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2349
2350 let main_id = DaemonId::new("my-repo", "api");
2351 let wt_id = DaemonId::new("my-repo-feature", "worker");
2352 assert!(
2353 pt.daemons.contains_key(&main_id),
2354 "main checkout daemon missing"
2355 );
2356 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2357
2358 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2360 assert!(pt_from_main.daemons.contains_key(&wt_id));
2361
2362 let _ = std::process::Command::new("git")
2364 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2365 .current_dir(&repo)
2366 .output();
2367 super::invalidate_config_cache();
2368 }
2369}