1use crate::daemon_id::DaemonId;
2use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
3use crate::settings::SettingsPartial;
4use crate::settings::settings;
5use crate::state_file::StateFile;
6use crate::{Result, env};
7use indexmap::IndexMap;
8use miette::Context;
9use once_cell::sync::Lazy;
10use schemars::JsonSchema;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex as StdMutex;
14use std::time::SystemTime;
15
16pub use crate::config_types::{
18 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
19 PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ReadyCmd,
20 ReadyHttp, ReadyOutput, ReadyPort, Retry, 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)]
184 pub health_cmd: Option<HealthCmd>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub health_http: Option<HealthHttp>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub health_port: Option<HealthPort>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub port: Option<PortConfig>,
192 #[serde(skip_serializing_if = "Vec::is_empty", default)]
194 pub expected_port: Vec<u16>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub auto_bump_port: Option<bool>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
200 pub port_bump_attempts: Option<u32>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub boot_start: Option<bool>,
203 #[serde(skip_serializing_if = "Vec::is_empty", default)]
204 pub depends: Vec<String>,
205 #[serde(skip_serializing_if = "Vec::is_empty", default)]
206 pub watch: Vec<String>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub watch_mode: Option<WatchMode>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub dir: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub env: Option<IndexMap<String, String>>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
214 pub hooks: Option<PitchforkTomlHooks>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub mise: Option<bool>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
219 pub user: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
222 pub memory_limit: Option<MemoryLimit>,
223 #[serde(skip_serializing_if = "Option::is_none", default)]
225 pub cpu_limit: Option<CpuLimit>,
226 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub stop_signal: Option<StopConfig>,
229 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub pty: Option<bool>,
232 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub time_retention: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none", default)]
239 pub line_retention: Option<i64>,
240 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub archive_hook: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none", default)]
246 pub logs: Option<PitchforkTomlDaemonLogs>,
247}
248
249#[derive(Debug, Clone, Default, JsonSchema)]
254#[schemars(title = "Pitchfork Configuration")]
255pub struct PitchforkToml {
256 #[serde(default)]
258 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
259 #[serde(skip_serializing_if = "Option::is_none", default)]
263 pub env: Option<IndexMap<String, String>>,
264 pub namespace: Option<String>,
269 #[serde(default)]
278 pub(crate) settings: SettingsPartial,
279 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
284 pub slugs: IndexMap<String, SlugEntry>,
285 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
287 pub groups: IndexMap<String, GroupEntry>,
288 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
291 pub namespaces: IndexMap<String, NamespaceEntry>,
292 #[schemars(skip)]
293 pub path: Option<PathBuf>,
294}
295
296pub(crate) fn is_global_config(path: &Path) -> bool {
297 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
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 parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
305 use toml::Value;
306
307 let doc: Value = toml::from_str(content)
308 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
309 let Some(value) = doc.get("namespace") else {
310 return Ok(None);
311 };
312
313 match value {
314 Value::String(s) => Ok(Some(s.clone())),
315 _ => Err(ConfigParseError::InvalidNamespace {
316 path: path.to_path_buf(),
317 namespace: value.to_string(),
318 reason: "top-level 'namespace' must be a string".to_string(),
319 }
320 .into()),
321 }
322}
323
324fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
325 if !path.exists() {
326 return Ok(None);
327 }
328 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
329 path: path.to_path_buf(),
330 source: e,
331 })?;
332 parse_namespace_override_from_content(path, &content)
333}
334
335fn project_config_dir(path: &Path) -> Option<&Path> {
336 if is_dot_config_pitchfork(path) {
337 path.parent().and_then(Path::parent)
338 } else {
339 path.parent()
340 }
341}
342
343fn project_config_family(path: &Path) -> Vec<PathBuf> {
344 let Some(dir) = project_config_dir(path) else {
345 return vec![path.to_path_buf()];
346 };
347 vec![
348 dir.join(".config/pitchfork.toml"),
349 dir.join(".config/pitchfork.local.toml"),
350 dir.join("pitchfork.toml"),
351 dir.join("pitchfork.local.toml"),
352 ]
353}
354
355fn directory_namespace_override(
358 path: &Path,
359 content_override: Option<&str>,
360) -> Result<Option<String>> {
361 if is_global_config(path) {
362 return match content_override {
363 Some(content) => parse_namespace_override_from_content(path, content),
364 None => read_namespace_override_from_file(path),
365 };
366 }
367
368 let mut selected: Option<(String, PathBuf)> = None;
369 for candidate in project_config_family(path) {
370 let explicit = if candidate == path {
371 match content_override {
372 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
373 None => read_namespace_override_from_file(&candidate)?,
374 }
375 } else {
376 read_namespace_override_from_file(&candidate)?
377 };
378 let Some(namespace) = explicit else { continue };
379 if let Some((selected_namespace, selected_path)) = &selected
380 && selected_namespace != &namespace
381 {
382 return Err(ConfigParseError::InvalidNamespace {
383 path: candidate,
384 namespace,
385 reason: format!(
386 "namespace does not match directory-level namespace '{}' declared in {}",
387 selected_namespace,
388 selected_path.display()
389 ),
390 }
391 .into());
392 }
393 selected = Some((namespace, candidate));
394 }
395 Ok(selected.map(|(namespace, _)| namespace))
396}
397
398fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
399 if let Err(e) = DaemonId::try_new(namespace, "probe") {
400 return Err(ConfigParseError::InvalidNamespace {
401 path: path.to_path_buf(),
402 namespace: namespace.to_string(),
403 reason: e.to_string(),
404 }
405 .into());
406 }
407 Ok(namespace.to_string())
408}
409
410fn derive_namespace_from_dir(path: &Path) -> Result<String> {
411 let dir_for_namespace = if is_dot_config_pitchfork(path) {
412 path.parent().and_then(|p| p.parent())
413 } else {
414 path.parent()
415 };
416
417 let raw_namespace = dir_for_namespace
418 .and_then(|p| p.file_name())
419 .and_then(|n| n.to_str())
420 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
421 .to_string();
422
423 validate_namespace(path, &raw_namespace).map_err(|e| {
424 ConfigParseError::InvalidNamespace {
425 path: path.to_path_buf(),
426 namespace: raw_namespace,
427 reason: format!(
428 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
429 ),
430 }
431 .into()
432 })
433}
434
435fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
436 if is_global_config(path) {
437 if let Some(ns) = explicit
438 && ns != "global"
439 {
440 return Err(ConfigParseError::InvalidNamespace {
441 path: path.to_path_buf(),
442 namespace: ns.to_string(),
443 reason: "global config files must use namespace 'global'".to_string(),
444 }
445 .into());
446 }
447 return Ok("global".to_string());
448 }
449
450 if let Some(ns) = explicit {
451 return validate_namespace(path, ns);
452 }
453
454 derive_namespace_from_dir(path)
455}
456
457fn namespace_from_file(path: &Path) -> Result<String> {
458 let explicit = directory_namespace_override(path, None)?;
459 namespace_from_path_with_override(path, explicit.as_deref())
460}
461
462pub fn namespace_from_path(path: &Path) -> Result<String> {
475 namespace_from_file(path)
476}
477
478fn find_project_root(dir: &Path) -> Option<PathBuf> {
486 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
490 let mut current = canonical_dir.as_path();
491 loop {
492 if current.join(".git").exists() || current.join(".jj").exists() {
493 return Some(current.to_path_buf());
494 }
495 current = current.parent()?;
496 }
497}
498
499struct ConfigCacheEntry {
518 config: PitchforkToml,
519 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
521}
522
523static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
530 Lazy::new(|| StdMutex::new(HashMap::new()));
531
532fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
538 if paths.len() != snapshot.len() {
539 return false;
540 }
541 paths
542 .iter()
543 .zip(snapshot.iter())
544 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
545}
546
547fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
549 let md = std::fs::metadata(path).ok()?;
550 Some((md.modified().ok()?, md.len()))
551}
552
553fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
555 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
556}
557
558pub fn invalidate_config_cache() {
575 if let Ok(mut cache) = CONFIG_CACHE.lock() {
576 cache.clear();
577 }
578}
579
580impl PitchforkToml {
581 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
594 if user_id.contains('/') {
596 return match DaemonId::parse(user_id) {
597 Ok(id) => Ok(vec![id]),
598 Err(e) => Err(e), };
600 }
601
602 let global_slugs = Self::read_global_slugs();
604 if let Some(entry) = global_slugs.get(user_id) {
605 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
607 if let Some(dir) = entry.resolve_dir()
608 && let Ok(project_config) = Self::all_merged_from(&dir)
609 {
610 let matches: Vec<DaemonId> = project_config
612 .daemons
613 .keys()
614 .filter(|id| id.name() == daemon_name)
615 .cloned()
616 .collect();
617 match matches.as_slice() {
618 [] => {}
619 [id] => return Ok(vec![id.clone()]),
620 _ => {
621 let mut candidates: Vec<String> =
622 matches.iter().map(|id| id.qualified()).collect();
623 candidates.sort();
624 return Err(miette::miette!(
625 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
626 user_id,
627 daemon_name,
628 candidates.join(", ")
629 ));
630 }
631 }
632 }
633 }
634
635 let matches: Vec<DaemonId> = self
637 .daemons
638 .keys()
639 .filter(|id| id.name() == user_id)
640 .cloned()
641 .collect();
642
643 if matches.is_empty() {
644 let state_matches = Self::find_in_state_file(user_id);
646 match state_matches.as_slice() {
647 [] => {}
648 [id] => return Ok(vec![id.clone()]),
649 _ => {
650 let mut candidates: Vec<String> =
651 state_matches.iter().map(|id| id.qualified()).collect();
652 candidates.sort();
653 return Err(miette::miette!(
654 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
655 user_id,
656 candidates.join(", ")
657 ));
658 }
659 }
660 let _ = DaemonId::try_new("global", user_id)?;
662 }
663 Ok(matches)
664 }
665
666 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
672 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
673 Ok(state) => state
674 .daemons
675 .keys()
676 .filter(|id| id.name() == short_name)
677 .cloned()
678 .collect(),
679 Err(e) => {
680 warn!("cannot read state file: {e}");
681 Vec::new()
682 }
683 }
684 }
685
686 #[allow(dead_code)]
707 pub fn resolve_daemon_id_prefer_local(
708 &self,
709 user_id: &str,
710 current_dir: &Path,
711 ) -> Result<DaemonId> {
712 if user_id.contains('/') {
714 return DaemonId::parse(user_id);
715 }
716
717 let current_namespace = Self::namespace_for_dir(current_dir)?;
721
722 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
723 }
724
725 fn resolve_daemon_id_with_namespace(
728 &self,
729 user_id: &str,
730 current_namespace: &str,
731 ) -> Result<DaemonId> {
732 let global_slugs = Self::read_global_slugs();
734 if let Some(entry) = global_slugs.get(user_id) {
735 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
736 if let Some(dir) = entry.resolve_dir()
737 && let Ok(project_config) = Self::all_merged_from(&dir)
738 {
739 let matches: Vec<DaemonId> = project_config
740 .daemons
741 .keys()
742 .filter(|id| id.name() == daemon_name)
743 .cloned()
744 .collect();
745 match matches.as_slice() {
746 [] => {}
747 [id] => return Ok(id.clone()),
748 _ => {
749 let mut candidates: Vec<String> =
750 matches.iter().map(|id| id.qualified()).collect();
751 candidates.sort();
752 return Err(miette::miette!(
753 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
754 user_id,
755 daemon_name,
756 candidates.join(", ")
757 ));
758 }
759 }
760 }
761 }
762
763 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
766 if self.daemons.contains_key(&preferred_id) {
767 return Ok(preferred_id);
768 }
769
770 let matches = self.resolve_daemon_id(user_id)?;
772
773 if matches.len() > 1 {
775 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
776 candidates.sort();
777 return Err(miette::miette!(
778 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
779 user_id,
780 candidates.join(", ")
781 ));
782 }
783
784 if let Some(id) = matches.into_iter().next() {
785 return Ok(id);
786 }
787
788 let global_id = DaemonId::try_new("global", user_id)?;
791 if self.daemons.contains_key(&global_id) {
792 return Ok(global_id);
793 }
794
795 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
796 Err(DependencyError::DaemonNotFound {
797 name: user_id.to_string(),
798 suggestion,
799 }
800 .into())
801 }
802
803 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
806 Ok(Self::list_paths_from(dir)
807 .iter()
808 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
810 .transpose()?
811 .unwrap_or_else(|| "global".to_string()))
812 }
813
814 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
824 if user_id.contains('/') {
825 return DaemonId::parse(user_id);
826 }
827
828 let config = Self::all_merged()?;
831 let ns = Self::namespace_for_dir(&env::CWD)?;
832 config.resolve_daemon_id_with_namespace(user_id, &ns)
833 }
834
835 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
841 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
842 }
843
844 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
845 if user_id.contains('/') {
846 return DaemonId::parse(user_id);
847 }
848
849 let ns = Self::namespace_for_dir(dir)?;
850 DaemonId::try_new(ns, user_id)
851 }
852
853 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
864 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
866 return user_ids
867 .iter()
868 .map(|s| DaemonId::parse(s.as_ref()))
869 .collect();
870 }
871
872 let config = Self::all_merged()?;
873 let ns = Self::namespace_for_dir(&env::CWD)?;
875 user_ids
876 .iter()
877 .map(|s| {
878 let id = s.as_ref();
879 if id.contains('/') {
880 DaemonId::parse(id)
881 } else {
882 config.resolve_daemon_id_with_namespace(id, &ns)
883 }
884 })
885 .collect()
886 }
887
888 pub fn resolve_ids_and_group<S: AsRef<str>>(
893 user_ids: &[S],
894 group_name: Option<&str>,
895 ) -> Result<Vec<DaemonId>> {
896 let config = Self::all_merged()?;
897 let ns = Self::namespace_for_dir(&env::CWD)?;
898 let mut ids = Vec::new();
899 let mut seen = std::collections::HashSet::new();
900
901 for id in user_ids {
902 let id_str = id.as_ref();
903 let daemon_id = if id_str.contains('/') {
904 DaemonId::parse(id_str)?
905 } else {
906 config.resolve_daemon_id_with_namespace(id_str, &ns)?
907 };
908 if seen.insert(daemon_id.clone()) {
909 ids.push(daemon_id);
910 }
911 }
912
913 if let Some(name) = group_name {
914 match config.groups.get(name) {
915 Some(group) => {
916 let missing: Vec<String> = group
917 .daemons
918 .iter()
919 .filter(|id| !config.daemons.contains_key(*id))
920 .map(|id| id.qualified())
921 .collect();
922 if !missing.is_empty() {
923 return Err(miette::miette!(
924 "group '{}' references undefined daemon{}: {}",
925 name,
926 if missing.len() > 1 { "s" } else { "" },
927 missing.join(", ")
928 ));
929 }
930 for daemon_id in &group.daemons {
931 if seen.insert(daemon_id.clone()) {
932 ids.push(daemon_id.clone());
933 }
934 }
935 }
936 None => {
937 let suggestion =
938 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
939 return Err(miette::miette!(
940 "group '{}' not found in configuration{}",
941 name,
942 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
943 ));
944 }
945 }
946 }
947
948 Ok(ids)
949 }
950
951 pub fn list_paths() -> Vec<PathBuf> {
954 Self::list_paths_from(&env::CWD)
955 }
956
957 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
968 let mut paths = Vec::new();
969 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
970 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
971
972 let mut project_paths = xx::file::find_up_all(
976 cwd,
977 &[
978 "pitchfork.local.toml",
979 "pitchfork.toml",
980 ".config/pitchfork.local.toml",
981 ".config/pitchfork.toml",
982 ],
983 );
984 project_paths.reverse();
985 paths.extend(project_paths);
986
987 paths
988 }
989
990 pub fn all_merged() -> Result<PitchforkToml> {
993 Self::all_merged_from(&env::CWD)
994 }
995 pub fn all_merged_all_namespaces() -> Result<Self> {
1002 Self::all_merged_all_namespaces_from(&env::CWD)
1003 }
1004
1005 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1008 let mut pt = Self::all_merged_from(start_dir)?;
1009
1010 let namespaces = Self::read_global_namespaces();
1011 for (ns_name, entry) in namespaces {
1012 match Self::all_merged_from(&entry.dir) {
1013 Ok(ns_config) => {
1014 for (daemon_id, daemon_config) in ns_config.daemons {
1015 if !pt.daemons.contains_key(&daemon_id) {
1016 pt.daemons.insert(daemon_id, daemon_config);
1017 }
1018 }
1019 pt.settings.merge_from(&ns_config.settings);
1022 }
1023 Err(e) => {
1024 log::warn!(
1025 "Failed to load namespace '{ns_name}' from {}: {e}",
1026 entry.dir.display()
1027 );
1028 }
1029 }
1030 }
1031
1032 if crate::settings::settings().general.worktree
1042 && let Some(project_root) = find_project_root(start_dir)
1043 {
1044 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1045 for wt in &worktrees {
1046 match Self::all_merged_from(&wt.path) {
1047 Ok(wt_config) => {
1048 for (daemon_id, daemon_config) in wt_config.daemons {
1049 if !pt.daemons.contains_key(&daemon_id) {
1050 pt.daemons.insert(daemon_id, daemon_config);
1051 }
1052 }
1053 pt.settings.merge_from(&wt_config.settings);
1054 }
1055 Err(e) => {
1056 log::warn!(
1057 "Failed to load worktree '{}' config from {}: {e}",
1058 wt.branch,
1059 wt.path.display()
1060 );
1061 }
1062 }
1063 }
1064 }
1065
1066 Ok(pt)
1067 }
1068
1069 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1087 let paths = Self::list_paths_from(cwd);
1088
1089 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1093
1094 {
1095 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1096 if let Some(entry) = cache.get(&cache_key)
1097 && meta_matches(&paths, &entry.source_meta)
1098 {
1099 return Ok(entry.config.clone());
1100 }
1101 }
1102
1103 let snapshot = snapshot_meta(&paths);
1109 let pt = Self::all_merged_from_uncached(&paths)?;
1110
1111 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1113 cache.insert(
1114 cache_key,
1115 ConfigCacheEntry {
1116 config: pt.clone(),
1117 source_meta: snapshot,
1118 },
1119 );
1120
1121 Ok(pt)
1122 }
1123
1124 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1129 use std::collections::HashMap as StdHashMap;
1130
1131 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1132
1133 let mut pt = Self::default();
1134 for p in paths {
1135 match Self::read(p) {
1136 Ok(pt2) => {
1137 if p.exists() && !is_global_config(p) {
1141 let ns = namespace_from_path(p)?;
1142 let origin_dir = if is_dot_config_pitchfork(p) {
1143 p.parent().and_then(|d| d.parent())
1144 } else {
1145 p.parent()
1146 }
1147 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1148 .unwrap_or_else(|| p.clone());
1149
1150 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1151 && *other_dir != origin_dir
1152 {
1153 return Err(crate::error::ConfigParseError::NamespaceCollision {
1154 path_a: other_path.clone(),
1155 path_b: p.clone(),
1156 ns,
1157 }
1158 .into());
1159 }
1160 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1161 }
1162
1163 pt.merge(pt2)
1164 }
1165 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1166 }
1167 }
1168 Ok(pt)
1169 }
1170}
1171
1172impl PitchforkToml {
1173 pub fn new(path: PathBuf) -> Self {
1174 Self {
1175 daemons: Default::default(),
1176 env: None,
1177 namespace: None,
1178 settings: SettingsPartial::default(),
1179 slugs: IndexMap::new(),
1180 groups: IndexMap::new(),
1181 namespaces: IndexMap::new(),
1182 path: Some(path),
1183 }
1184 }
1185
1186 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1194 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1195 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1196 if let Some(settings) = &mut raw_config.settings {
1197 settings.canonicalize_aliases();
1198 }
1199
1200 let explicit = directory_namespace_override(path, Some(content))?;
1201 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1202 let mut pt = Self::new(path.to_path_buf());
1203 pt.namespace = raw_config.namespace.clone();
1204
1205 for (short_name, raw_daemon) in raw_config.daemons {
1206 let id = match DaemonId::try_new(&namespace, &short_name) {
1207 Ok(id) => id,
1208 Err(e) => {
1209 return Err(ConfigParseError::InvalidDaemonName {
1210 name: short_name,
1211 path: path.to_path_buf(),
1212 reason: e.to_string(),
1213 }
1214 .into());
1215 }
1216 };
1217
1218 let mut depends = Vec::new();
1219 for dep in raw_daemon.depends {
1220 let dep_id = if dep.contains('/') {
1221 match DaemonId::parse(&dep) {
1222 Ok(id) => id,
1223 Err(e) => {
1224 return Err(ConfigParseError::InvalidDependency {
1225 daemon: short_name.clone(),
1226 dependency: dep,
1227 path: path.to_path_buf(),
1228 reason: e.to_string(),
1229 }
1230 .into());
1231 }
1232 }
1233 } else {
1234 match DaemonId::try_new(&namespace, &dep) {
1235 Ok(id) => id,
1236 Err(e) => {
1237 return Err(ConfigParseError::InvalidDependency {
1238 daemon: short_name.clone(),
1239 dependency: dep,
1240 path: path.to_path_buf(),
1241 reason: e.to_string(),
1242 }
1243 .into());
1244 }
1245 }
1246 };
1247 depends.push(dep_id);
1248 }
1249
1250 let has_deprecated = !raw_daemon.expected_port.is_empty()
1252 || raw_daemon.auto_bump_port.is_some()
1253 || raw_daemon.port_bump_attempts.is_some();
1254 let port = if let Some(port) = raw_daemon.port {
1255 if has_deprecated {
1256 warn!(
1257 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1258 );
1259 }
1260 Some(port)
1261 } else if has_deprecated {
1262 warn!(
1263 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1264 );
1265 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1266 PortBump(
1267 raw_daemon
1268 .port_bump_attempts
1269 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1270 )
1271 } else {
1272 PortBump(0)
1273 };
1274 Some(PortConfig {
1275 expect: raw_daemon.expected_port,
1276 bump,
1277 })
1278 } else {
1279 None
1280 };
1281
1282 let daemon = PitchforkTomlDaemon {
1283 run: raw_daemon.run,
1284 auto: raw_daemon.auto,
1285 cron: raw_daemon.cron,
1286 retry: raw_daemon.retry,
1287 ready_delay: raw_daemon.ready_delay,
1288 ready_output: raw_daemon.ready_output,
1289 ready_http: raw_daemon.ready_http,
1290 ready_port: raw_daemon.ready_port,
1291 ready_cmd: raw_daemon.ready_cmd,
1292 health_cmd: raw_daemon.health_cmd,
1293 health_http: raw_daemon.health_http,
1294 health_port: raw_daemon.health_port,
1295 port,
1296 boot_start: raw_daemon.boot_start,
1297 depends,
1298 watch: raw_daemon.watch,
1299 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1300 dir: raw_daemon.dir,
1301 env: raw_daemon.env,
1302 hooks: raw_daemon.hooks,
1303 mise: raw_daemon.mise,
1304 user: raw_daemon.user,
1305 memory_limit: raw_daemon.memory_limit,
1306 cpu_limit: raw_daemon.cpu_limit,
1307 stop_signal: raw_daemon.stop_signal,
1308 pty: raw_daemon.pty,
1309 time_retention: raw_daemon.time_retention,
1310 line_retention: raw_daemon.line_retention,
1311 archive_hook: raw_daemon.archive_hook,
1312 logs: raw_daemon.logs,
1313 path: Some(path.to_path_buf()),
1314 };
1315 pt.daemons.insert(id, daemon);
1316 }
1317
1318 if let Some(settings) = raw_config.settings {
1320 pt.settings = settings;
1321 }
1322
1323 pt.env = raw_config.env;
1325
1326 for (slug, entry) in raw_config.slugs {
1328 pt.slugs.insert(
1329 slug,
1330 SlugEntry {
1331 dir: entry.dir.map(env::expand_tilde),
1332 namespace: entry.namespace,
1333 daemon: entry.daemon,
1334 },
1335 );
1336 }
1337
1338 for (name, entry) in raw_config.namespaces {
1340 pt.namespaces.insert(
1341 name,
1342 NamespaceEntry {
1343 dir: env::expand_tilde(entry.dir),
1344 },
1345 );
1346 }
1347
1348 for (group_name, raw_group) in raw_config.groups {
1350 let mut daemons = Vec::new();
1351 for daemon_name in &raw_group.daemons {
1352 let id = if daemon_name.contains('/') {
1353 DaemonId::parse(daemon_name).map_err(|e| {
1354 ConfigParseError::InvalidDependency {
1355 daemon: group_name.clone(),
1356 dependency: daemon_name.clone(),
1357 path: path.to_path_buf(),
1358 reason: e.to_string(),
1359 }
1360 })?
1361 } else {
1362 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1363 ConfigParseError::InvalidDaemonName {
1364 name: daemon_name.clone(),
1365 path: path.to_path_buf(),
1366 reason: e.to_string(),
1367 }
1368 })?
1369 };
1370 daemons.push(id);
1371 }
1372 pt.groups.insert(group_name, GroupEntry { daemons });
1373 }
1374
1375 Ok(pt)
1376 }
1377
1378 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1379 let path = path.as_ref();
1380 if !path.exists() {
1381 return Ok(Self::new(path.to_path_buf()));
1382 }
1383 let _lock = xx::fslock::get(path, false)
1384 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1385 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1386 path: path.to_path_buf(),
1387 source: e,
1388 })?;
1389 Self::parse_str(&raw, path)
1390 }
1391
1392 pub fn write(&self) -> Result<()> {
1393 if let Some(path) = &self.path {
1394 let _lock = xx::fslock::get(path, false)
1395 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1396 self.write_unlocked()
1397 } else {
1398 Err(FileError::NoPath.into())
1399 }
1400 }
1401
1402 fn write_unlocked(&self) -> Result<()> {
1408 if let Some(path) = &self.path {
1409 let config_namespace = if path.exists() {
1411 namespace_from_path(path)?
1412 } else {
1413 namespace_from_path_with_override(path, self.namespace.as_deref())?
1414 };
1415
1416 let mut raw = PitchforkTomlRaw {
1420 namespace: self.namespace.clone(),
1421 env: self.env.clone(),
1422 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1423 ..PitchforkTomlRaw::default()
1424 };
1425 for (id, daemon) in &self.daemons {
1426 if id.namespace() != config_namespace {
1427 return Err(miette::miette!(
1428 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1429 id,
1430 path.display(),
1431 id.namespace(),
1432 config_namespace
1433 ));
1434 }
1435 let port = daemon.port.as_ref();
1436 let raw_daemon = PitchforkTomlDaemonRaw {
1437 run: daemon.run.clone(),
1438 auto: daemon.auto.clone(),
1439 cron: daemon.cron.clone(),
1440 retry: daemon.retry,
1441 ready_delay: daemon.ready_delay,
1442 ready_output: daemon.ready_output.clone(),
1443 ready_http: daemon.ready_http.clone(),
1444 ready_port: daemon.ready_port.clone(),
1445 ready_cmd: daemon.ready_cmd.clone(),
1446 health_cmd: daemon.health_cmd.clone(),
1447 health_http: daemon.health_http.clone(),
1448 health_port: daemon.health_port.clone(),
1449 port: port.cloned(),
1450 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1452 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1453 port_bump_attempts: port
1454 .filter(|p| p.auto_bump())
1455 .map(|p| p.max_bump_attempts()),
1456 boot_start: daemon.boot_start,
1457 depends: daemon
1460 .depends
1461 .iter()
1462 .map(|d| {
1463 if d.namespace() == config_namespace {
1464 d.name().to_string()
1465 } else {
1466 d.qualified()
1467 }
1468 })
1469 .collect(),
1470 watch: daemon.watch.clone(),
1471 watch_mode: match daemon.watch_mode {
1472 WatchMode::Native => None,
1473 mode => Some(mode),
1474 },
1475 dir: daemon.dir.clone(),
1476 env: daemon.env.clone(),
1477 hooks: daemon.hooks.clone(),
1478 mise: daemon.mise,
1479 user: daemon.user.clone(),
1480 memory_limit: daemon.memory_limit,
1481 cpu_limit: daemon.cpu_limit,
1482 stop_signal: daemon.stop_signal,
1483 pty: daemon.pty,
1484 time_retention: daemon.time_retention.clone(),
1485 line_retention: daemon.line_retention,
1486 archive_hook: daemon.archive_hook.clone(),
1487 logs: daemon.logs.clone(),
1488 };
1489 raw.daemons.insert(id.name().to_string(), raw_daemon);
1490 }
1491
1492 for (slug, entry) in &self.slugs {
1494 raw.slugs.insert(
1495 slug.clone(),
1496 SlugEntryRaw {
1497 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1498 namespace: entry.namespace.clone(),
1499 daemon: entry.daemon.clone(),
1500 },
1501 );
1502 }
1503
1504 for (name, group) in &self.groups {
1506 let raw_daemons: Vec<String> = group
1507 .daemons
1508 .iter()
1509 .map(|id| {
1510 if id.namespace() == config_namespace {
1511 id.name().to_string()
1512 } else {
1513 id.qualified()
1514 }
1515 })
1516 .collect();
1517 raw.groups.insert(
1518 name.clone(),
1519 GroupEntryRaw {
1520 daemons: raw_daemons,
1521 },
1522 );
1523 }
1524
1525 for (name, entry) in &self.namespaces {
1527 raw.namespaces.insert(
1528 name.clone(),
1529 NamespaceEntryRaw {
1530 dir: entry.dir.to_string_lossy().to_string(),
1531 },
1532 );
1533 }
1534
1535 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1536 path: path.clone(),
1537 source: e,
1538 })?;
1539 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1540 path: path.clone(),
1541 details: Some(e.to_string()),
1542 })?;
1543 invalidate_config_cache();
1544 Ok(())
1545 } else {
1546 Err(FileError::NoPath.into())
1547 }
1548 }
1549
1550 pub fn merge(&mut self, pt: Self) {
1555 for (id, d) in pt.daemons {
1556 self.daemons.insert(id, d);
1557 }
1558 if let Some(env) = pt.env {
1560 let merged = self.env.get_or_insert_with(IndexMap::new);
1561 for (k, v) in env {
1562 merged.insert(k, v);
1563 }
1564 }
1565 for (slug, entry) in pt.slugs {
1567 self.slugs.insert(slug, entry);
1568 }
1569 for (name, group) in pt.groups {
1571 self.groups.insert(name, group);
1572 }
1573 for (name, entry) in pt.namespaces {
1575 self.namespaces.insert(name, entry);
1576 }
1577 self.settings.merge_from(&pt.settings);
1579 }
1580
1581 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1586 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1587 Ok(pt) => pt.slugs,
1588 Err(_) => IndexMap::new(),
1589 }
1590 }
1591
1592 pub fn find_slug_for_daemon_in_registry(
1594 daemon_id: &DaemonId,
1595 global_slugs: &IndexMap<String, SlugEntry>,
1596 ) -> Option<String> {
1597 global_slugs
1598 .iter()
1599 .find(|(slug, entry)| {
1600 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1601 if daemon_id.name() != daemon_name {
1602 return false;
1603 }
1604
1605 match entry.resolve_namespace() {
1606 Some(namespace) => daemon_id.namespace() == namespace,
1607 None => false,
1608 }
1609 })
1610 .map(|(slug, _)| slug.clone())
1611 }
1612
1613 #[allow(dead_code)]
1615 pub fn is_slug_registered(slug: &str) -> bool {
1616 Self::read_global_slugs().contains_key(slug)
1617 }
1618
1619 pub fn add_slug_with_namespace(
1625 slug: &str,
1626 namespace: Option<&str>,
1627 daemon: Option<&str>,
1628 ) -> Result<()> {
1629 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1630
1631 if let Some(parent) = global_path.parent() {
1633 std::fs::create_dir_all(parent).map_err(|e| {
1634 miette::miette!(
1635 "Failed to create config directory {}: {e}",
1636 parent.display()
1637 )
1638 })?;
1639 }
1640
1641 let _lock = xx::fslock::get(global_path, false)
1642 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1643
1644 let mut pt = if global_path.exists() {
1645 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1646 path: global_path.to_path_buf(),
1647 source: e,
1648 })?;
1649 Self::parse_str(&raw, global_path)?
1650 } else {
1651 Self::new(global_path.to_path_buf())
1652 };
1653
1654 if let Some(ns) = namespace
1658 && !pt.namespaces.contains_key(ns)
1659 {
1660 let dir = pt
1665 .slugs
1666 .get(slug)
1667 .and_then(|e| {
1668 e.dir.clone().or_else(|| {
1669 e.namespace
1670 .as_ref()
1671 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1672 })
1673 })
1674 .or_else(|| env::CWD.as_path().canonicalize().ok());
1675 if let Some(ref d) = dir {
1676 pt.namespaces
1677 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1678 }
1679 }
1680
1681 pt.slugs.insert(
1682 slug.to_string(),
1683 SlugEntry {
1684 dir: None,
1685 namespace: namespace.map(str::to_string),
1686 daemon: daemon.map(str::to_string),
1687 },
1688 );
1689 pt.write_unlocked()?;
1690 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1696 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1697 Ok(())
1698 }
1699
1700 pub fn remove_slug(slug: &str) -> Result<bool> {
1702 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1703 if !global_path.exists() {
1704 return Ok(false);
1705 }
1706
1707 let _lock = xx::fslock::get(global_path, false)
1708 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1709
1710 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1711 path: global_path.to_path_buf(),
1712 source: e,
1713 })?;
1714 let mut pt = Self::parse_str(&raw, global_path)?;
1715
1716 let removed = pt.slugs.shift_remove(slug).is_some();
1717 if removed {
1718 pt.write_unlocked()?;
1719 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1725 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1726 }
1727 Ok(removed)
1728 }
1729 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1732 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1733 Ok(pt) => pt.namespaces,
1734 Err(_) => IndexMap::new(),
1735 }
1736 }
1737
1738 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1742 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1743
1744 if let Some(parent) = global_path.parent() {
1746 std::fs::create_dir_all(parent).map_err(|e| {
1747 miette::miette!(
1748 "Failed to create config directory {}: {e}",
1749 parent.display()
1750 )
1751 })?;
1752 }
1753
1754 let _lock = xx::fslock::get(global_path, false)
1755 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1756
1757 let mut pt = if global_path.exists() {
1758 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1759 crate::error::FileError::ReadError {
1760 path: global_path.to_path_buf(),
1761 source: e,
1762 }
1763 })?;
1764 Self::parse_str(&raw, global_path)?
1765 } else {
1766 Self::new(global_path.to_path_buf())
1767 };
1768
1769 pt.namespaces.insert(
1770 name.to_string(),
1771 NamespaceEntry {
1772 dir: env::expand_tilde(dir),
1773 },
1774 );
1775 pt.write_unlocked()?;
1776 Ok(())
1777 }
1778
1779 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1781 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1782 if !global_path.exists() {
1783 return Ok(false);
1784 }
1785
1786 let _lock = xx::fslock::get(global_path, false)
1787 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1788
1789 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1790 crate::error::FileError::ReadError {
1791 path: global_path.to_path_buf(),
1792 source: e,
1793 }
1794 })?;
1795 let mut pt = Self::parse_str(&raw, global_path)?;
1796
1797 let removed = pt.namespaces.shift_remove(name).is_some();
1798 if removed {
1799 pt.write_unlocked()?;
1800 }
1801 Ok(removed)
1802 }
1803}
1804
1805#[derive(Debug, Clone, JsonSchema, Default)]
1807pub struct PitchforkTomlDaemon {
1808 #[schemars(example = example_run_command())]
1810 pub run: String,
1811 #[schemars(default)]
1813 pub auto: Vec<PitchforkTomlAuto>,
1814 pub cron: Option<PitchforkTomlCron>,
1816 #[schemars(default)]
1819 pub retry: Retry,
1820 pub ready_delay: Option<u64>,
1822 pub ready_output: Option<ReadyOutput>,
1824 pub ready_http: Option<ReadyHttp>,
1826 pub ready_port: Option<ReadyPort>,
1830 pub ready_cmd: Option<ReadyCmd>,
1832 pub health_cmd: Option<HealthCmd>,
1834 pub health_http: Option<HealthHttp>,
1836 pub health_port: Option<HealthPort>,
1840 pub port: Option<PortConfig>,
1842 pub boot_start: Option<bool>,
1844 #[schemars(default)]
1846 pub depends: Vec<DaemonId>,
1847 #[schemars(default)]
1849 pub watch: Vec<String>,
1850 #[schemars(default)]
1856 pub watch_mode: WatchMode,
1857 pub dir: Option<String>,
1859 pub env: Option<IndexMap<String, String>>,
1861 pub hooks: Option<PitchforkTomlHooks>,
1863 pub mise: Option<bool>,
1866 pub user: Option<String>,
1868 pub memory_limit: Option<MemoryLimit>,
1871 pub cpu_limit: Option<CpuLimit>,
1874 pub stop_signal: Option<StopConfig>,
1877 pub pty: Option<bool>,
1879 pub time_retention: Option<String>,
1882 pub line_retention: Option<i64>,
1885 pub archive_hook: Option<String>,
1888 pub logs: Option<PitchforkTomlDaemonLogs>,
1890 #[schemars(skip)]
1891 pub path: Option<PathBuf>,
1892}
1893
1894impl PitchforkTomlDaemon {
1895 pub fn effective_user(&self) -> Option<String> {
1899 let daemon_user = self
1900 .user
1901 .as_deref()
1902 .map(str::trim)
1903 .filter(|u| !u.is_empty());
1904 daemon_user.map(str::to_owned).or_else(|| {
1905 let s = crate::settings::settings();
1906 let su = s.supervisor.user.trim();
1907 (!su.is_empty()).then(|| su.to_owned())
1908 })
1909 }
1910
1911 pub fn to_run_options(
1916 &self,
1917 id: &crate::daemon_id::DaemonId,
1918 cmd: Vec<String>,
1919 ) -> crate::daemon::RunOptions {
1920 use crate::daemon::RunOptions;
1921
1922 let effective_user = self.effective_user();
1923 let dir = crate::ipc::batch::resolve_daemon_dir(
1924 self.dir.as_deref(),
1925 self.path.as_deref(),
1926 effective_user.as_deref(),
1927 );
1928 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1929 .into_iter()
1930 .find(|(slug, entry)| {
1931 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1932 if daemon_name != id.name() {
1933 return false;
1934 }
1935
1936 match entry.resolve_namespace() {
1937 Some(namespace) => namespace == id.namespace(),
1938 None => false,
1939 }
1940 })
1941 .map(|(slug, _)| slug);
1942
1943 RunOptions {
1944 id: id.clone(),
1945 cmd,
1946 run: Some(self.run.clone()),
1947 force: false,
1948 shell_pid: None,
1949 dir: Dir(dir),
1950 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1951 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1952 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1953 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1954 retry: self.retry,
1955 retry_count: 0,
1956 ready_delay: self.ready_delay,
1957 ready_output: self.ready_output.clone(),
1958 ready_http: self.ready_http.clone(),
1959 ready_port: self.ready_port.clone(),
1960 ready_cmd: self.ready_cmd.clone(),
1961 health_cmd: self.health_cmd.clone(),
1962 health_http: self.health_http.clone(),
1963 health_port: self.health_port.clone(),
1964 port: self.port.clone(),
1965 wait_ready: false,
1966 depends: self.depends.clone(),
1967 env: self.env.clone(),
1968 watch: self.watch.clone(),
1969 watch_mode: self.watch_mode,
1970 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1971 self.path.as_deref(),
1972 )),
1973 mise: self.mise,
1974 slug,
1975 proxy: None,
1976 user: self.user.clone(),
1977 memory_limit: self.memory_limit,
1978 cpu_limit: self.cpu_limit,
1979 stop_signal: self.stop_signal,
1980 archive_hook: self
1981 .logs
1982 .as_ref()
1983 .and_then(|l| l.archive_hook.clone())
1984 .or_else(|| self.archive_hook.clone()),
1985 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1986 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1987 pty: self.pty,
1988 }
1989 }
1990}
1991fn example_run_command() -> &'static str {
1992 "exec node server.js"
1993}
1994
1995#[cfg(test)]
1996mod tests {
1997 use super::*;
1998 use std::path::Path;
1999
2000 #[test]
2001 fn test_daemon_user_parses_and_flows_to_run_options() {
2002 let pt = PitchforkToml::parse_str(
2003 r#"
2004[daemons.api]
2005run = "node server.js"
2006user = "postgres"
2007"#,
2008 Path::new("/tmp/my-project/pitchfork.toml"),
2009 )
2010 .unwrap();
2011
2012 let id = DaemonId::new("my-project", "api");
2013 let daemon = pt.daemons.get(&id).unwrap();
2014 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2015
2016 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2017 assert_eq!(opts.user.as_deref(), Some("postgres"));
2018 }
2019
2020 #[test]
2021 fn test_daemon_user_write_roundtrip() {
2022 let temp = tempfile::tempdir().unwrap();
2023 let path = temp.path().join("pitchfork.toml");
2024 let mut pt = PitchforkToml::new(path.clone());
2025 pt.namespace = Some("test-project".to_string());
2026 pt.daemons.insert(
2027 DaemonId::new("test-project", "api"),
2028 PitchforkTomlDaemon {
2029 run: "node server.js".to_string(),
2030 user: Some("postgres".to_string()),
2031 ..PitchforkTomlDaemon::default()
2032 },
2033 );
2034
2035 pt.write().unwrap();
2036
2037 let raw = std::fs::read_to_string(&path).unwrap();
2038 assert!(raw.contains("user = \"postgres\""));
2039
2040 let parsed = PitchforkToml::read(&path).unwrap();
2041 let daemon = parsed
2042 .daemons
2043 .get(&DaemonId::new("test-project", "api"))
2044 .unwrap();
2045 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2046 }
2047
2048 #[test]
2049 fn test_registry_dirs_expand_tilde() {
2050 let pt = PitchforkToml::parse_str(
2051 r#"
2052[slugs.api]
2053dir = "~/projects/api"
2054
2055[namespaces.web]
2056dir = "~/projects/web"
2057"#,
2058 Path::new("/tmp/config.toml"),
2059 )
2060 .unwrap();
2061
2062 assert_eq!(
2063 pt.slugs["api"].dir,
2064 Some(crate::env::HOME_DIR.join("projects/api"))
2065 );
2066 assert_eq!(
2067 pt.namespaces["web"].dir,
2068 crate::env::HOME_DIR.join("projects/web")
2069 );
2070 }
2071
2072 #[test]
2073 fn test_settings_write_roundtrip() {
2074 let temp = tempfile::tempdir().unwrap();
2075 let path = temp.path().join("pitchfork.toml");
2076 let mut pt = PitchforkToml::new(path.clone());
2077 pt.namespace = Some("test-project".to_string());
2078 pt.settings.web.auto_start = Some(true);
2079 pt.settings.general.log_level = Some("debug".to_string());
2080
2081 pt.write().unwrap();
2082
2083 let raw = std::fs::read_to_string(&path).unwrap();
2084 assert!(
2085 raw.contains("[settings.web]"),
2086 "settings.web section should be written, got:\n{raw}"
2087 );
2088 assert!(raw.contains("auto_start = true"));
2089 assert!(raw.contains("log_level = \"debug\""));
2090
2091 let parsed = PitchforkToml::read(&path).unwrap();
2092 assert_eq!(parsed.settings.web.auto_start, Some(true));
2093 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2094 }
2095
2096 #[test]
2097 fn test_settings_preserved_on_unrelated_write() {
2098 let temp = tempfile::tempdir().unwrap();
2101 let path = temp.path().join("pitchfork.toml");
2102 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2103
2104 let mut pt = PitchforkToml::read(&path).unwrap();
2105 pt.slugs.insert(
2106 "api".to_string(),
2107 SlugEntry {
2108 dir: None,
2109 namespace: Some("myproject".to_string()),
2110 daemon: None,
2111 },
2112 );
2113 pt.namespaces.insert(
2114 "myproject".to_string(),
2115 NamespaceEntry {
2116 dir: PathBuf::from("/tmp/myproject"),
2117 },
2118 );
2119 pt.write().unwrap();
2120
2121 let raw = std::fs::read_to_string(&path).unwrap();
2122 assert!(
2123 raw.contains("[settings.web]"),
2124 "existing settings must be preserved, got:\n{raw}"
2125 );
2126 assert!(raw.contains("auto_start = true"));
2127 assert!(raw.contains("[slugs.api]"));
2128
2129 let parsed = PitchforkToml::read(&path).unwrap();
2130 assert_eq!(parsed.settings.web.auto_start, Some(true));
2131 assert!(parsed.slugs.contains_key("api"));
2132 }
2133
2134 #[tokio::test]
2135 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2136 let temp = tempfile::tempdir().unwrap();
2137 let path = temp.path().join("pitchfork.toml");
2138 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2139 .await
2140 .unwrap();
2141
2142 let read_path = path.clone();
2143 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2144 .await
2145 .unwrap()
2146 .unwrap();
2147 assert_eq!(pt.settings.general.worktree, Some(false));
2148 assert_eq!(pt.settings.proxy.worktree, None);
2149 tokio::task::spawn_blocking(move || pt.write())
2150 .await
2151 .unwrap()
2152 .unwrap();
2153
2154 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2155 assert!(raw.contains("[settings.general]"), "{raw}");
2156 assert!(raw.contains("worktree = false"), "{raw}");
2157 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2158
2159 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2160 .await
2161 .unwrap()
2162 .unwrap();
2163 assert_eq!(parsed.settings.general.worktree, Some(false));
2164 }
2165
2166 #[test]
2167 fn test_config_cache_hit_and_invalidation() {
2168 let temp = tempfile::tempdir().unwrap();
2169 let dir = temp.path();
2170 let config_path = dir.join("pitchfork.toml");
2171 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2172
2173 super::invalidate_config_cache();
2175
2176 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2178 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2179 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2180
2181 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2183 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2184
2185 std::thread::sleep(std::time::Duration::from_millis(50));
2188 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2189
2190 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2191 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2192
2193 super::invalidate_config_cache();
2195 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2196 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2197
2198 super::invalidate_config_cache();
2200 }
2201
2202 #[test]
2203 fn test_config_cache_invalidation_on_write() {
2204 let temp = tempfile::tempdir().unwrap();
2205 let dir = temp.path();
2206 let config_path = dir.join("pitchfork.toml");
2207 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2208
2209 super::invalidate_config_cache();
2210
2211 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2213 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2214 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2215
2216 let mut pt = PitchforkToml::read(&config_path).unwrap();
2218 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2219 let _ = pt.write();
2221
2222 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2224 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2225
2226 super::invalidate_config_cache();
2227 }
2228
2229 #[test]
2230 fn test_config_cache_size_invalidation() {
2231 let temp = tempfile::tempdir().unwrap();
2232 let dir = temp.path();
2233 let config_path = dir.join("pitchfork.toml");
2234 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2235
2236 super::invalidate_config_cache();
2237
2238 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2240 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2241 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2242
2243 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2247 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2248 let file = std::fs::OpenOptions::new()
2251 .write(true)
2252 .open(&config_path)
2253 .unwrap();
2254 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2255 file.set_times(times).unwrap();
2256
2257 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2260 assert_eq!(
2261 pt2.daemons[&daemon_id].run, "echo different",
2262 "cache should invalidate on size change even with identical mtime"
2263 );
2264
2265 super::invalidate_config_cache();
2266 }
2267
2268 #[test]
2269 fn test_find_project_root_in_plain_dir_returns_none() {
2270 let temp = tempfile::tempdir().unwrap();
2271 assert_eq!(find_project_root(temp.path()), None);
2272 }
2273
2274 #[test]
2275 fn test_find_project_root_finds_git_marker() {
2276 let temp = tempfile::tempdir().unwrap();
2277 let repo = temp.path().join("my-repo");
2278 std::fs::create_dir(&repo).unwrap();
2279 std::fs::create_dir(repo.join(".git")).unwrap();
2280
2281 let sub = repo.join("sub/dir");
2282 std::fs::create_dir_all(&sub).unwrap();
2283
2284 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2287 }
2288
2289 #[test]
2290 fn test_find_project_root_accepts_git_file_marker() {
2291 let temp = tempfile::tempdir().unwrap();
2294 let wt = temp.path().join("my-worktree");
2295 std::fs::create_dir(&wt).unwrap();
2296 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2297
2298 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2299 }
2300
2301 #[cfg(unix)]
2304 #[test]
2305 fn test_find_project_root_resolves_symlinked_start_dir() {
2306 use std::os::unix::fs::symlink;
2307
2308 let temp = tempfile::tempdir().unwrap();
2309 let repo = temp.path().join("real-repo");
2310 std::fs::create_dir(&repo).unwrap();
2311 std::fs::create_dir(repo.join(".git")).unwrap();
2312
2313 let sub = repo.join("sub/dir");
2314 std::fs::create_dir_all(&sub).unwrap();
2315 let link = temp.path().join("link-to-sub");
2316 symlink(&sub, &link).unwrap();
2317
2318 assert_eq!(find_project_root(&link), Some(repo));
2319 }
2320
2321 #[test]
2324 fn test_all_merged_all_namespaces_discovers_worktrees() {
2325 let temp = tempfile::tempdir().unwrap();
2326 let repo = temp.path().join("my-repo");
2327 std::fs::create_dir(&repo).unwrap();
2328
2329 let git_init = std::process::Command::new("git")
2331 .args(["init", "-b", "main"])
2332 .current_dir(&repo)
2333 .output()
2334 .expect("git init");
2335 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2336
2337 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2338
2339 let git_commit = std::process::Command::new("git")
2340 .args([
2341 "-c",
2342 "user.name=pitchfork-test",
2343 "-c",
2344 "user.email=pitchfork-test@example.com",
2345 "add",
2346 "-A",
2347 ])
2348 .current_dir(&repo)
2349 .output()
2350 .expect("git add");
2351 assert!(git_commit.status.success());
2352
2353 let git_commit = std::process::Command::new("git")
2354 .args([
2355 "-c",
2356 "user.name=pitchfork-test",
2357 "-c",
2358 "user.email=pitchfork-test@example.com",
2359 "commit",
2360 "-m",
2361 "init",
2362 ])
2363 .current_dir(&repo)
2364 .output()
2365 .expect("git commit");
2366 assert!(
2367 git_commit.status.success(),
2368 "git commit failed: {:?}",
2369 git_commit
2370 );
2371
2372 let wt = temp.path().join("my-repo-feature");
2373 let git_wt = std::process::Command::new("git")
2374 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2375 .current_dir(&repo)
2376 .output()
2377 .expect("git worktree add");
2378 assert!(
2379 git_wt.status.success(),
2380 "git worktree add failed: {:?}",
2381 git_wt
2382 );
2383
2384 std::fs::write(
2386 repo.join("pitchfork.toml"),
2387 "[daemons.api]\nrun = \"echo main\"\n",
2388 )
2389 .unwrap();
2390 std::fs::write(
2392 wt.join("pitchfork.toml"),
2393 "[daemons.worker]\nrun = \"echo wt\"\n",
2394 )
2395 .unwrap();
2396
2397 super::invalidate_config_cache();
2398
2399 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2401
2402 let main_id = DaemonId::new("my-repo", "api");
2403 let wt_id = DaemonId::new("my-repo-feature", "worker");
2404 assert!(
2405 pt.daemons.contains_key(&main_id),
2406 "main checkout daemon missing"
2407 );
2408 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2409
2410 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2412 assert!(pt_from_main.daemons.contains_key(&wt_id));
2413
2414 let _ = std::process::Command::new("git")
2416 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2417 .current_dir(&repo)
2418 .output();
2419 super::invalidate_config_cache();
2420 }
2421
2422 #[test]
2423 fn test_adhoc_id_uses_invocation_directory_namespace() {
2424 let temp = tempfile::tempdir().unwrap();
2425 let project = temp.path().join("feature-tree");
2426 std::fs::create_dir(&project).unwrap();
2427 std::fs::write(
2428 project.join("pitchfork.toml"),
2429 "[daemons.other]\nrun = \"true\"\n",
2430 )
2431 .unwrap();
2432
2433 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
2434 assert_eq!(id, DaemonId::new("feature-tree", "api"));
2435 let qualified =
2436 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
2437 assert_eq!(qualified, DaemonId::new("explicit", "api"));
2438 }
2439
2440 #[test]
2441 fn test_adhoc_id_falls_back_to_global_without_project_config() {
2442 let temp = tempfile::tempdir().unwrap();
2443 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
2444 assert_eq!(id, DaemonId::new("global", "api"));
2445 }
2446}