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
294pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
295 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
296}
297
298fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
299 use toml::Value;
300
301 let doc: Value = toml::from_str(content)
302 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
303 let Some(value) = doc.get("namespace") else {
304 return Ok(None);
305 };
306
307 match value {
308 Value::String(s) => Ok(Some(s.clone())),
309 _ => Err(ConfigParseError::InvalidNamespace {
310 path: path.to_path_buf(),
311 namespace: value.to_string(),
312 reason: "top-level 'namespace' must be a string".to_string(),
313 }
314 .into()),
315 }
316}
317
318fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
319 if !path.exists() {
320 return Ok(None);
321 }
322 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
323 path: path.to_path_buf(),
324 source: e,
325 })?;
326 parse_namespace_override_from_content(path, &content)
327}
328
329fn project_config_dir(path: &Path) -> Option<&Path> {
330 if is_dot_config_pitchfork(path) {
331 path.parent().and_then(Path::parent)
332 } else {
333 path.parent()
334 }
335}
336
337fn project_config_family(path: &Path) -> Vec<PathBuf> {
338 let Some(dir) = project_config_dir(path) else {
339 return vec![path.to_path_buf()];
340 };
341 vec![
342 dir.join(".config/pitchfork.toml"),
343 dir.join(".config/pitchfork.local.toml"),
344 dir.join("pitchfork.toml"),
345 dir.join("pitchfork.local.toml"),
346 ]
347}
348
349fn directory_namespace_override(
352 path: &Path,
353 content_override: Option<&str>,
354) -> Result<Option<String>> {
355 if is_global_config(path) {
356 return match content_override {
357 Some(content) => parse_namespace_override_from_content(path, content),
358 None => read_namespace_override_from_file(path),
359 };
360 }
361
362 let mut selected: Option<(String, PathBuf)> = None;
363 for candidate in project_config_family(path) {
364 let explicit = if candidate == path {
365 match content_override {
366 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
367 None => read_namespace_override_from_file(&candidate)?,
368 }
369 } else {
370 read_namespace_override_from_file(&candidate)?
371 };
372 let Some(namespace) = explicit else { continue };
373 if let Some((selected_namespace, selected_path)) = &selected
374 && selected_namespace != &namespace
375 {
376 return Err(ConfigParseError::InvalidNamespace {
377 path: candidate,
378 namespace,
379 reason: format!(
380 "namespace does not match directory-level namespace '{}' declared in {}",
381 selected_namespace,
382 selected_path.display()
383 ),
384 }
385 .into());
386 }
387 selected = Some((namespace, candidate));
388 }
389 Ok(selected.map(|(namespace, _)| namespace))
390}
391
392fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
393 if let Err(e) = DaemonId::try_new(namespace, "probe") {
394 return Err(ConfigParseError::InvalidNamespace {
395 path: path.to_path_buf(),
396 namespace: namespace.to_string(),
397 reason: e.to_string(),
398 }
399 .into());
400 }
401 Ok(namespace.to_string())
402}
403
404fn derive_namespace_from_dir(path: &Path) -> Result<String> {
405 let dir_for_namespace = if is_dot_config_pitchfork(path) {
406 path.parent().and_then(|p| p.parent())
407 } else {
408 path.parent()
409 };
410
411 let raw_namespace = dir_for_namespace
412 .and_then(|p| p.file_name())
413 .and_then(|n| n.to_str())
414 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
415 .to_string();
416
417 validate_namespace(path, &raw_namespace).map_err(|e| {
418 ConfigParseError::InvalidNamespace {
419 path: path.to_path_buf(),
420 namespace: raw_namespace,
421 reason: format!(
422 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
423 ),
424 }
425 .into()
426 })
427}
428
429fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
430 if is_global_config(path) {
431 if let Some(ns) = explicit
432 && ns != "global"
433 {
434 return Err(ConfigParseError::InvalidNamespace {
435 path: path.to_path_buf(),
436 namespace: ns.to_string(),
437 reason: "global config files must use namespace 'global'".to_string(),
438 }
439 .into());
440 }
441 return Ok("global".to_string());
442 }
443
444 if let Some(ns) = explicit {
445 return validate_namespace(path, ns);
446 }
447
448 derive_namespace_from_dir(path)
449}
450
451fn namespace_from_file(path: &Path) -> Result<String> {
452 let explicit = directory_namespace_override(path, None)?;
453 namespace_from_path_with_override(path, explicit.as_deref())
454}
455
456pub fn namespace_from_path(path: &Path) -> Result<String> {
469 namespace_from_file(path)
470}
471
472fn find_project_root(dir: &Path) -> Option<PathBuf> {
480 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
484 let mut current = canonical_dir.as_path();
485 loop {
486 if current.join(".git").exists() || current.join(".jj").exists() {
487 return Some(current.to_path_buf());
488 }
489 current = current.parent()?;
490 }
491}
492
493struct ConfigCacheEntry {
512 config: PitchforkToml,
513 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
515}
516
517static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
524 Lazy::new(|| StdMutex::new(HashMap::new()));
525
526fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
532 if paths.len() != snapshot.len() {
533 return false;
534 }
535 paths
536 .iter()
537 .zip(snapshot.iter())
538 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
539}
540
541fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
543 let md = std::fs::metadata(path).ok()?;
544 Some((md.modified().ok()?, md.len()))
545}
546
547fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
549 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
550}
551
552pub fn invalidate_config_cache() {
569 if let Ok(mut cache) = CONFIG_CACHE.lock() {
570 cache.clear();
571 }
572}
573
574impl PitchforkToml {
575 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
588 if user_id.contains('/') {
590 return match DaemonId::parse(user_id) {
591 Ok(id) => Ok(vec![id]),
592 Err(e) => Err(e), };
594 }
595
596 let global_slugs = Self::read_global_slugs();
598 if let Some(entry) = global_slugs.get(user_id) {
599 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
601 if let Some(dir) = entry.resolve_dir()
602 && let Ok(project_config) = Self::all_merged_from(&dir)
603 {
604 let matches: Vec<DaemonId> = project_config
606 .daemons
607 .keys()
608 .filter(|id| id.name() == daemon_name)
609 .cloned()
610 .collect();
611 match matches.as_slice() {
612 [] => {}
613 [id] => return Ok(vec![id.clone()]),
614 _ => {
615 let mut candidates: Vec<String> =
616 matches.iter().map(|id| id.qualified()).collect();
617 candidates.sort();
618 return Err(miette::miette!(
619 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
620 user_id,
621 daemon_name,
622 candidates.join(", ")
623 ));
624 }
625 }
626 }
627 }
628
629 let matches: Vec<DaemonId> = self
631 .daemons
632 .keys()
633 .filter(|id| id.name() == user_id)
634 .cloned()
635 .collect();
636
637 if matches.is_empty() {
638 let state_matches = Self::find_in_state_file(user_id);
640 match state_matches.as_slice() {
641 [] => {}
642 [id] => return Ok(vec![id.clone()]),
643 _ => {
644 let mut candidates: Vec<String> =
645 state_matches.iter().map(|id| id.qualified()).collect();
646 candidates.sort();
647 return Err(miette::miette!(
648 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
649 user_id,
650 candidates.join(", ")
651 ));
652 }
653 }
654 let _ = DaemonId::try_new("global", user_id)?;
656 }
657 Ok(matches)
658 }
659
660 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
666 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
667 Ok(state) => state
668 .daemons
669 .keys()
670 .filter(|id| id.name() == short_name)
671 .cloned()
672 .collect(),
673 Err(e) => {
674 warn!("cannot read state file: {e}");
675 Vec::new()
676 }
677 }
678 }
679
680 #[allow(dead_code)]
701 pub fn resolve_daemon_id_prefer_local(
702 &self,
703 user_id: &str,
704 current_dir: &Path,
705 ) -> Result<DaemonId> {
706 if user_id.contains('/') {
708 return DaemonId::parse(user_id);
709 }
710
711 let current_namespace = Self::namespace_for_dir(current_dir)?;
715
716 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
717 }
718
719 fn resolve_daemon_id_with_namespace(
722 &self,
723 user_id: &str,
724 current_namespace: &str,
725 ) -> Result<DaemonId> {
726 let global_slugs = Self::read_global_slugs();
728 if let Some(entry) = global_slugs.get(user_id) {
729 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
730 if let Some(dir) = entry.resolve_dir()
731 && let Ok(project_config) = Self::all_merged_from(&dir)
732 {
733 let matches: Vec<DaemonId> = project_config
734 .daemons
735 .keys()
736 .filter(|id| id.name() == daemon_name)
737 .cloned()
738 .collect();
739 match matches.as_slice() {
740 [] => {}
741 [id] => return Ok(id.clone()),
742 _ => {
743 let mut candidates: Vec<String> =
744 matches.iter().map(|id| id.qualified()).collect();
745 candidates.sort();
746 return Err(miette::miette!(
747 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
748 user_id,
749 daemon_name,
750 candidates.join(", ")
751 ));
752 }
753 }
754 }
755 }
756
757 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
760 if self.daemons.contains_key(&preferred_id) {
761 return Ok(preferred_id);
762 }
763
764 let matches = self.resolve_daemon_id(user_id)?;
766
767 if matches.len() > 1 {
769 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
770 candidates.sort();
771 return Err(miette::miette!(
772 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
773 user_id,
774 candidates.join(", ")
775 ));
776 }
777
778 if let Some(id) = matches.into_iter().next() {
779 return Ok(id);
780 }
781
782 let global_id = DaemonId::try_new("global", user_id)?;
785 if self.daemons.contains_key(&global_id) {
786 return Ok(global_id);
787 }
788
789 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
790 Err(DependencyError::DaemonNotFound {
791 name: user_id.to_string(),
792 suggestion,
793 }
794 .into())
795 }
796
797 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
800 Ok(Self::list_paths_from(dir)
801 .iter()
802 .rfind(|p| p.exists()) .map(|p| namespace_from_path(p))
804 .transpose()?
805 .unwrap_or_else(|| "global".to_string()))
806 }
807
808 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
818 if user_id.contains('/') {
819 return DaemonId::parse(user_id);
820 }
821
822 let config = Self::all_merged()?;
825 let ns = Self::namespace_for_dir(&env::CWD)?;
826 config.resolve_daemon_id_with_namespace(user_id, &ns)
827 }
828
829 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
835 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
836 }
837
838 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
839 if user_id.contains('/') {
840 return DaemonId::parse(user_id);
841 }
842
843 let ns = Self::namespace_for_dir(dir)?;
844 DaemonId::try_new(ns, user_id)
845 }
846
847 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
858 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
860 return user_ids
861 .iter()
862 .map(|s| DaemonId::parse(s.as_ref()))
863 .collect();
864 }
865
866 let config = Self::all_merged()?;
867 let ns = Self::namespace_for_dir(&env::CWD)?;
869 user_ids
870 .iter()
871 .map(|s| {
872 let id = s.as_ref();
873 if id.contains('/') {
874 DaemonId::parse(id)
875 } else {
876 config.resolve_daemon_id_with_namespace(id, &ns)
877 }
878 })
879 .collect()
880 }
881
882 pub fn resolve_ids_and_group<S: AsRef<str>>(
887 user_ids: &[S],
888 group_name: Option<&str>,
889 ) -> Result<Vec<DaemonId>> {
890 let config = Self::all_merged()?;
891 let ns = Self::namespace_for_dir(&env::CWD)?;
892 let mut ids = Vec::new();
893 let mut seen = std::collections::HashSet::new();
894
895 for id in user_ids {
896 let id_str = id.as_ref();
897 let daemon_id = if id_str.contains('/') {
898 DaemonId::parse(id_str)?
899 } else {
900 config.resolve_daemon_id_with_namespace(id_str, &ns)?
901 };
902 if seen.insert(daemon_id.clone()) {
903 ids.push(daemon_id);
904 }
905 }
906
907 if let Some(name) = group_name {
908 match config.groups.get(name) {
909 Some(group) => {
910 let missing: Vec<String> = group
911 .daemons
912 .iter()
913 .filter(|id| !config.daemons.contains_key(*id))
914 .map(|id| id.qualified())
915 .collect();
916 if !missing.is_empty() {
917 return Err(miette::miette!(
918 "group '{}' references undefined daemon{}: {}",
919 name,
920 if missing.len() > 1 { "s" } else { "" },
921 missing.join(", ")
922 ));
923 }
924 for daemon_id in &group.daemons {
925 if seen.insert(daemon_id.clone()) {
926 ids.push(daemon_id.clone());
927 }
928 }
929 }
930 None => {
931 let suggestion =
932 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
933 return Err(miette::miette!(
934 "group '{}' not found in configuration{}",
935 name,
936 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
937 ));
938 }
939 }
940 }
941
942 Ok(ids)
943 }
944
945 pub fn list_paths() -> Vec<PathBuf> {
948 Self::list_paths_from(&env::CWD)
949 }
950
951 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
962 let mut paths = Vec::new();
963 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
964 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
965
966 let mut project_paths = xx::file::find_up_all(
970 cwd,
971 &[
972 "pitchfork.local.toml",
973 "pitchfork.toml",
974 ".config/pitchfork.local.toml",
975 ".config/pitchfork.toml",
976 ],
977 );
978 project_paths.reverse();
979 paths.extend(project_paths);
980
981 paths
982 }
983
984 pub fn all_merged() -> Result<PitchforkToml> {
987 Self::all_merged_from(&env::CWD)
988 }
989 pub fn all_merged_all_namespaces() -> Result<Self> {
996 Self::all_merged_all_namespaces_from(&env::CWD)
997 }
998
999 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1002 let mut pt = Self::all_merged_from(start_dir)?;
1003
1004 let namespaces = Self::read_global_namespaces();
1005 for (ns_name, entry) in namespaces {
1006 match Self::all_merged_from(&entry.dir) {
1007 Ok(ns_config) => {
1008 for (daemon_id, daemon_config) in ns_config.daemons {
1009 if !pt.daemons.contains_key(&daemon_id) {
1010 pt.daemons.insert(daemon_id, daemon_config);
1011 }
1012 }
1013 pt.settings.merge_from(&ns_config.settings);
1016 }
1017 Err(e) => {
1018 log::warn!(
1019 "Failed to load namespace '{ns_name}' from {}: {e}",
1020 entry.dir.display()
1021 );
1022 }
1023 }
1024 }
1025
1026 if crate::settings::settings().general.worktree
1036 && let Some(project_root) = find_project_root(start_dir)
1037 {
1038 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1039 for wt in &worktrees {
1040 match Self::all_merged_from(&wt.path) {
1041 Ok(wt_config) => {
1042 for (daemon_id, daemon_config) in wt_config.daemons {
1043 if !pt.daemons.contains_key(&daemon_id) {
1044 pt.daemons.insert(daemon_id, daemon_config);
1045 }
1046 }
1047 pt.settings.merge_from(&wt_config.settings);
1048 }
1049 Err(e) => {
1050 log::warn!(
1051 "Failed to load worktree '{}' config from {}: {e}",
1052 wt.branch,
1053 wt.path.display()
1054 );
1055 }
1056 }
1057 }
1058 }
1059
1060 Ok(pt)
1061 }
1062
1063 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1081 let paths = Self::list_paths_from(cwd);
1082
1083 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1087
1088 {
1089 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1090 if let Some(entry) = cache.get(&cache_key)
1091 && meta_matches(&paths, &entry.source_meta)
1092 {
1093 return Ok(entry.config.clone());
1094 }
1095 }
1096
1097 let snapshot = snapshot_meta(&paths);
1103 let pt = Self::all_merged_from_uncached(&paths)?;
1104
1105 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1107 cache.insert(
1108 cache_key,
1109 ConfigCacheEntry {
1110 config: pt.clone(),
1111 source_meta: snapshot,
1112 },
1113 );
1114
1115 Ok(pt)
1116 }
1117
1118 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1123 use std::collections::HashMap as StdHashMap;
1124
1125 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1126
1127 let mut pt = Self::default();
1128 for p in paths {
1129 match Self::read(p) {
1130 Ok(pt2) => {
1131 if p.exists() && !is_global_config(p) {
1135 let ns = namespace_from_path(p)?;
1136 let origin_dir = if is_dot_config_pitchfork(p) {
1137 p.parent().and_then(|d| d.parent())
1138 } else {
1139 p.parent()
1140 }
1141 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1142 .unwrap_or_else(|| p.clone());
1143
1144 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1145 && *other_dir != origin_dir
1146 {
1147 return Err(crate::error::ConfigParseError::NamespaceCollision {
1148 path_a: other_path.clone(),
1149 path_b: p.clone(),
1150 ns,
1151 }
1152 .into());
1153 }
1154 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1155 }
1156
1157 pt.merge(pt2)
1158 }
1159 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1160 }
1161 }
1162 Ok(pt)
1163 }
1164}
1165
1166impl PitchforkToml {
1167 pub fn new(path: PathBuf) -> Self {
1168 Self {
1169 daemons: Default::default(),
1170 env: None,
1171 namespace: None,
1172 settings: SettingsPartial::default(),
1173 slugs: IndexMap::new(),
1174 groups: IndexMap::new(),
1175 namespaces: IndexMap::new(),
1176 path: Some(path),
1177 }
1178 }
1179
1180 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1188 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1189 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1190 if let Some(settings) = &mut raw_config.settings {
1191 settings.canonicalize_aliases();
1192 }
1193
1194 let explicit = directory_namespace_override(path, Some(content))?;
1195 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1196 let mut pt = Self::new(path.to_path_buf());
1197 pt.namespace = raw_config.namespace.clone();
1198
1199 for (short_name, raw_daemon) in raw_config.daemons {
1200 let id = match DaemonId::try_new(&namespace, &short_name) {
1201 Ok(id) => id,
1202 Err(e) => {
1203 return Err(ConfigParseError::InvalidDaemonName {
1204 name: short_name,
1205 path: path.to_path_buf(),
1206 reason: e.to_string(),
1207 }
1208 .into());
1209 }
1210 };
1211
1212 let mut depends = Vec::new();
1213 for dep in raw_daemon.depends {
1214 let dep_id = if dep.contains('/') {
1215 match DaemonId::parse(&dep) {
1216 Ok(id) => id,
1217 Err(e) => {
1218 return Err(ConfigParseError::InvalidDependency {
1219 daemon: short_name.clone(),
1220 dependency: dep,
1221 path: path.to_path_buf(),
1222 reason: e.to_string(),
1223 }
1224 .into());
1225 }
1226 }
1227 } else {
1228 match DaemonId::try_new(&namespace, &dep) {
1229 Ok(id) => id,
1230 Err(e) => {
1231 return Err(ConfigParseError::InvalidDependency {
1232 daemon: short_name.clone(),
1233 dependency: dep,
1234 path: path.to_path_buf(),
1235 reason: e.to_string(),
1236 }
1237 .into());
1238 }
1239 }
1240 };
1241 depends.push(dep_id);
1242 }
1243
1244 let has_deprecated = !raw_daemon.expected_port.is_empty()
1246 || raw_daemon.auto_bump_port.is_some()
1247 || raw_daemon.port_bump_attempts.is_some();
1248 let port = if let Some(port) = raw_daemon.port {
1249 if has_deprecated {
1250 warn!(
1251 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1252 );
1253 }
1254 Some(port)
1255 } else if has_deprecated {
1256 warn!(
1257 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1258 );
1259 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1260 PortBump(
1261 raw_daemon
1262 .port_bump_attempts
1263 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1264 )
1265 } else {
1266 PortBump(0)
1267 };
1268 Some(PortConfig {
1269 expect: raw_daemon.expected_port,
1270 bump,
1271 })
1272 } else {
1273 None
1274 };
1275
1276 let daemon = PitchforkTomlDaemon {
1277 run: raw_daemon.run,
1278 auto: raw_daemon.auto,
1279 cron: raw_daemon.cron,
1280 retry: raw_daemon.retry,
1281 ready_delay: raw_daemon.ready_delay,
1282 ready_output: raw_daemon.ready_output,
1283 ready_http: raw_daemon.ready_http,
1284 ready_port: raw_daemon.ready_port,
1285 ready_cmd: raw_daemon.ready_cmd,
1286 port,
1287 boot_start: raw_daemon.boot_start,
1288 depends,
1289 watch: raw_daemon.watch,
1290 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1291 dir: raw_daemon.dir,
1292 env: raw_daemon.env,
1293 hooks: raw_daemon.hooks,
1294 mise: raw_daemon.mise,
1295 user: raw_daemon.user,
1296 memory_limit: raw_daemon.memory_limit,
1297 cpu_limit: raw_daemon.cpu_limit,
1298 stop_signal: raw_daemon.stop_signal,
1299 pty: raw_daemon.pty,
1300 time_retention: raw_daemon.time_retention,
1301 line_retention: raw_daemon.line_retention,
1302 archive_hook: raw_daemon.archive_hook,
1303 logs: raw_daemon.logs,
1304 path: Some(path.to_path_buf()),
1305 };
1306 pt.daemons.insert(id, daemon);
1307 }
1308
1309 if let Some(settings) = raw_config.settings {
1311 pt.settings = settings;
1312 }
1313
1314 pt.env = raw_config.env;
1316
1317 for (slug, entry) in raw_config.slugs {
1319 pt.slugs.insert(
1320 slug,
1321 SlugEntry {
1322 dir: entry.dir.map(env::expand_tilde),
1323 namespace: entry.namespace,
1324 daemon: entry.daemon,
1325 },
1326 );
1327 }
1328
1329 for (name, entry) in raw_config.namespaces {
1331 pt.namespaces.insert(
1332 name,
1333 NamespaceEntry {
1334 dir: env::expand_tilde(entry.dir),
1335 },
1336 );
1337 }
1338
1339 for (group_name, raw_group) in raw_config.groups {
1341 let mut daemons = Vec::new();
1342 for daemon_name in &raw_group.daemons {
1343 let id = if daemon_name.contains('/') {
1344 DaemonId::parse(daemon_name).map_err(|e| {
1345 ConfigParseError::InvalidDependency {
1346 daemon: group_name.clone(),
1347 dependency: daemon_name.clone(),
1348 path: path.to_path_buf(),
1349 reason: e.to_string(),
1350 }
1351 })?
1352 } else {
1353 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1354 ConfigParseError::InvalidDaemonName {
1355 name: daemon_name.clone(),
1356 path: path.to_path_buf(),
1357 reason: e.to_string(),
1358 }
1359 })?
1360 };
1361 daemons.push(id);
1362 }
1363 pt.groups.insert(group_name, GroupEntry { daemons });
1364 }
1365
1366 Ok(pt)
1367 }
1368
1369 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1370 let path = path.as_ref();
1371 if !path.exists() {
1372 return Ok(Self::new(path.to_path_buf()));
1373 }
1374 let _lock = xx::fslock::get(path, false)
1375 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1376 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1377 path: path.to_path_buf(),
1378 source: e,
1379 })?;
1380 Self::parse_str(&raw, path)
1381 }
1382
1383 pub fn write(&self) -> Result<()> {
1384 if let Some(path) = &self.path {
1385 let _lock = xx::fslock::get(path, false)
1386 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1387 self.write_unlocked()
1388 } else {
1389 Err(FileError::NoPath.into())
1390 }
1391 }
1392
1393 fn write_unlocked(&self) -> Result<()> {
1399 if let Some(path) = &self.path {
1400 let config_namespace = if path.exists() {
1402 namespace_from_path(path)?
1403 } else {
1404 namespace_from_path_with_override(path, self.namespace.as_deref())?
1405 };
1406
1407 let mut raw = PitchforkTomlRaw {
1411 namespace: self.namespace.clone(),
1412 env: self.env.clone(),
1413 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1414 ..PitchforkTomlRaw::default()
1415 };
1416 for (id, daemon) in &self.daemons {
1417 if id.namespace() != config_namespace {
1418 return Err(miette::miette!(
1419 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1420 id,
1421 path.display(),
1422 id.namespace(),
1423 config_namespace
1424 ));
1425 }
1426 let port = daemon.port.as_ref();
1427 let raw_daemon = PitchforkTomlDaemonRaw {
1428 run: daemon.run.clone(),
1429 auto: daemon.auto.clone(),
1430 cron: daemon.cron.clone(),
1431 retry: daemon.retry,
1432 ready_delay: daemon.ready_delay,
1433 ready_output: daemon.ready_output.clone(),
1434 ready_http: daemon.ready_http.clone(),
1435 ready_port: daemon.ready_port.clone(),
1436 ready_cmd: daemon.ready_cmd.clone(),
1437 port: port.cloned(),
1438 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1440 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1441 port_bump_attempts: port
1442 .filter(|p| p.auto_bump())
1443 .map(|p| p.max_bump_attempts()),
1444 boot_start: daemon.boot_start,
1445 depends: daemon
1448 .depends
1449 .iter()
1450 .map(|d| {
1451 if d.namespace() == config_namespace {
1452 d.name().to_string()
1453 } else {
1454 d.qualified()
1455 }
1456 })
1457 .collect(),
1458 watch: daemon.watch.clone(),
1459 watch_mode: match daemon.watch_mode {
1460 WatchMode::Native => None,
1461 mode => Some(mode),
1462 },
1463 dir: daemon.dir.clone(),
1464 env: daemon.env.clone(),
1465 hooks: daemon.hooks.clone(),
1466 mise: daemon.mise,
1467 user: daemon.user.clone(),
1468 memory_limit: daemon.memory_limit,
1469 cpu_limit: daemon.cpu_limit,
1470 stop_signal: daemon.stop_signal,
1471 pty: daemon.pty,
1472 time_retention: daemon.time_retention.clone(),
1473 line_retention: daemon.line_retention,
1474 archive_hook: daemon.archive_hook.clone(),
1475 logs: daemon.logs.clone(),
1476 };
1477 raw.daemons.insert(id.name().to_string(), raw_daemon);
1478 }
1479
1480 for (slug, entry) in &self.slugs {
1482 raw.slugs.insert(
1483 slug.clone(),
1484 SlugEntryRaw {
1485 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1486 namespace: entry.namespace.clone(),
1487 daemon: entry.daemon.clone(),
1488 },
1489 );
1490 }
1491
1492 for (name, group) in &self.groups {
1494 let raw_daemons: Vec<String> = group
1495 .daemons
1496 .iter()
1497 .map(|id| {
1498 if id.namespace() == config_namespace {
1499 id.name().to_string()
1500 } else {
1501 id.qualified()
1502 }
1503 })
1504 .collect();
1505 raw.groups.insert(
1506 name.clone(),
1507 GroupEntryRaw {
1508 daemons: raw_daemons,
1509 },
1510 );
1511 }
1512
1513 for (name, entry) in &self.namespaces {
1515 raw.namespaces.insert(
1516 name.clone(),
1517 NamespaceEntryRaw {
1518 dir: entry.dir.to_string_lossy().to_string(),
1519 },
1520 );
1521 }
1522
1523 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1524 path: path.clone(),
1525 source: e,
1526 })?;
1527 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1528 path: path.clone(),
1529 details: Some(e.to_string()),
1530 })?;
1531 invalidate_config_cache();
1532 Ok(())
1533 } else {
1534 Err(FileError::NoPath.into())
1535 }
1536 }
1537
1538 pub fn merge(&mut self, pt: Self) {
1543 for (id, d) in pt.daemons {
1544 self.daemons.insert(id, d);
1545 }
1546 if let Some(env) = pt.env {
1548 let merged = self.env.get_or_insert_with(IndexMap::new);
1549 for (k, v) in env {
1550 merged.insert(k, v);
1551 }
1552 }
1553 for (slug, entry) in pt.slugs {
1555 self.slugs.insert(slug, entry);
1556 }
1557 for (name, group) in pt.groups {
1559 self.groups.insert(name, group);
1560 }
1561 for (name, entry) in pt.namespaces {
1563 self.namespaces.insert(name, entry);
1564 }
1565 self.settings.merge_from(&pt.settings);
1567 }
1568
1569 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1574 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1575 Ok(pt) => pt.slugs,
1576 Err(_) => IndexMap::new(),
1577 }
1578 }
1579
1580 pub fn find_slug_for_daemon_in_registry(
1582 daemon_id: &DaemonId,
1583 global_slugs: &IndexMap<String, SlugEntry>,
1584 ) -> Option<String> {
1585 global_slugs
1586 .iter()
1587 .find(|(slug, entry)| {
1588 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1589 if daemon_id.name() != daemon_name {
1590 return false;
1591 }
1592
1593 match entry.resolve_namespace() {
1594 Some(namespace) => daemon_id.namespace() == namespace,
1595 None => false,
1596 }
1597 })
1598 .map(|(slug, _)| slug.clone())
1599 }
1600
1601 #[allow(dead_code)]
1603 pub fn is_slug_registered(slug: &str) -> bool {
1604 Self::read_global_slugs().contains_key(slug)
1605 }
1606
1607 pub fn add_slug_with_namespace(
1613 slug: &str,
1614 namespace: Option<&str>,
1615 daemon: Option<&str>,
1616 ) -> Result<()> {
1617 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1618
1619 if let Some(parent) = global_path.parent() {
1621 std::fs::create_dir_all(parent).map_err(|e| {
1622 miette::miette!(
1623 "Failed to create config directory {}: {e}",
1624 parent.display()
1625 )
1626 })?;
1627 }
1628
1629 let _lock = xx::fslock::get(global_path, false)
1630 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1631
1632 let mut pt = if global_path.exists() {
1633 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1634 path: global_path.to_path_buf(),
1635 source: e,
1636 })?;
1637 Self::parse_str(&raw, global_path)?
1638 } else {
1639 Self::new(global_path.to_path_buf())
1640 };
1641
1642 if let Some(ns) = namespace
1646 && !pt.namespaces.contains_key(ns)
1647 {
1648 let dir = pt
1653 .slugs
1654 .get(slug)
1655 .and_then(|e| {
1656 e.dir.clone().or_else(|| {
1657 e.namespace
1658 .as_ref()
1659 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1660 })
1661 })
1662 .or_else(|| env::CWD.as_path().canonicalize().ok());
1663 if let Some(ref d) = dir {
1664 pt.namespaces
1665 .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1666 }
1667 }
1668
1669 pt.slugs.insert(
1670 slug.to_string(),
1671 SlugEntry {
1672 dir: None,
1673 namespace: namespace.map(str::to_string),
1674 daemon: daemon.map(str::to_string),
1675 },
1676 );
1677 pt.write_unlocked()?;
1678 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1684 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1685 Ok(())
1686 }
1687
1688 pub fn remove_slug(slug: &str) -> Result<bool> {
1690 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1691 if !global_path.exists() {
1692 return Ok(false);
1693 }
1694
1695 let _lock = xx::fslock::get(global_path, false)
1696 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1697
1698 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1699 path: global_path.to_path_buf(),
1700 source: e,
1701 })?;
1702 let mut pt = Self::parse_str(&raw, global_path)?;
1703
1704 let removed = pt.slugs.shift_remove(slug).is_some();
1705 if removed {
1706 pt.write_unlocked()?;
1707 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1713 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1714 }
1715 Ok(removed)
1716 }
1717 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1720 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1721 Ok(pt) => pt.namespaces,
1722 Err(_) => IndexMap::new(),
1723 }
1724 }
1725
1726 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1730 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1731
1732 if let Some(parent) = global_path.parent() {
1734 std::fs::create_dir_all(parent).map_err(|e| {
1735 miette::miette!(
1736 "Failed to create config directory {}: {e}",
1737 parent.display()
1738 )
1739 })?;
1740 }
1741
1742 let _lock = xx::fslock::get(global_path, false)
1743 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1744
1745 let mut pt = if global_path.exists() {
1746 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1747 crate::error::FileError::ReadError {
1748 path: global_path.to_path_buf(),
1749 source: e,
1750 }
1751 })?;
1752 Self::parse_str(&raw, global_path)?
1753 } else {
1754 Self::new(global_path.to_path_buf())
1755 };
1756
1757 pt.namespaces.insert(
1758 name.to_string(),
1759 NamespaceEntry {
1760 dir: env::expand_tilde(dir),
1761 },
1762 );
1763 pt.write_unlocked()?;
1764 Ok(())
1765 }
1766
1767 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1769 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1770 if !global_path.exists() {
1771 return Ok(false);
1772 }
1773
1774 let _lock = xx::fslock::get(global_path, false)
1775 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1776
1777 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1778 crate::error::FileError::ReadError {
1779 path: global_path.to_path_buf(),
1780 source: e,
1781 }
1782 })?;
1783 let mut pt = Self::parse_str(&raw, global_path)?;
1784
1785 let removed = pt.namespaces.shift_remove(name).is_some();
1786 if removed {
1787 pt.write_unlocked()?;
1788 }
1789 Ok(removed)
1790 }
1791}
1792
1793#[derive(Debug, Clone, JsonSchema, Default)]
1795pub struct PitchforkTomlDaemon {
1796 #[schemars(example = example_run_command())]
1798 pub run: String,
1799 #[schemars(default)]
1801 pub auto: Vec<PitchforkTomlAuto>,
1802 pub cron: Option<PitchforkTomlCron>,
1804 #[schemars(default)]
1807 pub retry: Retry,
1808 pub ready_delay: Option<u64>,
1810 pub ready_output: Option<ReadyOutput>,
1812 pub ready_http: Option<ReadyHttp>,
1814 pub ready_port: Option<ReadyPort>,
1818 pub ready_cmd: Option<ReadyCmd>,
1820 pub port: Option<PortConfig>,
1822 pub boot_start: Option<bool>,
1824 #[schemars(default)]
1826 pub depends: Vec<DaemonId>,
1827 #[schemars(default)]
1829 pub watch: Vec<String>,
1830 #[schemars(default)]
1836 pub watch_mode: WatchMode,
1837 pub dir: Option<String>,
1839 pub env: Option<IndexMap<String, String>>,
1841 pub hooks: Option<PitchforkTomlHooks>,
1843 pub mise: Option<bool>,
1846 pub user: Option<String>,
1848 pub memory_limit: Option<MemoryLimit>,
1851 pub cpu_limit: Option<CpuLimit>,
1854 pub stop_signal: Option<StopConfig>,
1857 pub pty: Option<bool>,
1859 pub time_retention: Option<String>,
1862 pub line_retention: Option<i64>,
1865 pub archive_hook: Option<String>,
1868 pub logs: Option<PitchforkTomlDaemonLogs>,
1870 #[schemars(skip)]
1871 pub path: Option<PathBuf>,
1872}
1873
1874impl PitchforkTomlDaemon {
1875 pub fn effective_user(&self) -> Option<String> {
1879 let daemon_user = self
1880 .user
1881 .as_deref()
1882 .map(str::trim)
1883 .filter(|u| !u.is_empty());
1884 daemon_user.map(str::to_owned).or_else(|| {
1885 let s = crate::settings::settings();
1886 let su = s.supervisor.user.trim();
1887 (!su.is_empty()).then(|| su.to_owned())
1888 })
1889 }
1890
1891 pub fn to_run_options(
1896 &self,
1897 id: &crate::daemon_id::DaemonId,
1898 cmd: Vec<String>,
1899 ) -> crate::daemon::RunOptions {
1900 use crate::daemon::RunOptions;
1901
1902 let effective_user = self.effective_user();
1903 let dir = crate::ipc::batch::resolve_daemon_dir(
1904 self.dir.as_deref(),
1905 self.path.as_deref(),
1906 effective_user.as_deref(),
1907 );
1908 let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1909 .into_iter()
1910 .find(|(slug, entry)| {
1911 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1912 if daemon_name != id.name() {
1913 return false;
1914 }
1915
1916 match entry.resolve_namespace() {
1917 Some(namespace) => namespace == id.namespace(),
1918 None => false,
1919 }
1920 })
1921 .map(|(slug, _)| slug);
1922
1923 RunOptions {
1924 id: id.clone(),
1925 cmd,
1926 run: Some(self.run.clone()),
1927 force: false,
1928 shell_pid: None,
1929 dir: Dir(dir),
1930 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1931 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1932 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1933 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1934 retry: self.retry,
1935 retry_count: 0,
1936 ready_delay: self.ready_delay,
1937 ready_output: self.ready_output.clone(),
1938 ready_http: self.ready_http.clone(),
1939 ready_port: self.ready_port.clone(),
1940 ready_cmd: self.ready_cmd.clone(),
1941 port: self.port.clone(),
1942 wait_ready: false,
1943 depends: self.depends.clone(),
1944 env: self.env.clone(),
1945 watch: self.watch.clone(),
1946 watch_mode: self.watch_mode,
1947 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1948 self.path.as_deref(),
1949 )),
1950 mise: self.mise,
1951 slug,
1952 proxy: None,
1953 user: self.user.clone(),
1954 memory_limit: self.memory_limit,
1955 cpu_limit: self.cpu_limit,
1956 stop_signal: self.stop_signal,
1957 archive_hook: self
1958 .logs
1959 .as_ref()
1960 .and_then(|l| l.archive_hook.clone())
1961 .or_else(|| self.archive_hook.clone()),
1962 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
1963 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1964 pty: self.pty,
1965 }
1966 }
1967}
1968fn example_run_command() -> &'static str {
1969 "exec node server.js"
1970}
1971
1972#[cfg(test)]
1973mod tests {
1974 use super::*;
1975 use std::path::Path;
1976
1977 #[test]
1978 fn test_daemon_user_parses_and_flows_to_run_options() {
1979 let pt = PitchforkToml::parse_str(
1980 r#"
1981[daemons.api]
1982run = "node server.js"
1983user = "postgres"
1984"#,
1985 Path::new("/tmp/my-project/pitchfork.toml"),
1986 )
1987 .unwrap();
1988
1989 let id = DaemonId::new("my-project", "api");
1990 let daemon = pt.daemons.get(&id).unwrap();
1991 assert_eq!(daemon.user.as_deref(), Some("postgres"));
1992
1993 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1994 assert_eq!(opts.user.as_deref(), Some("postgres"));
1995 }
1996
1997 #[test]
1998 fn test_daemon_user_write_roundtrip() {
1999 let temp = tempfile::tempdir().unwrap();
2000 let path = temp.path().join("pitchfork.toml");
2001 let mut pt = PitchforkToml::new(path.clone());
2002 pt.namespace = Some("test-project".to_string());
2003 pt.daemons.insert(
2004 DaemonId::new("test-project", "api"),
2005 PitchforkTomlDaemon {
2006 run: "node server.js".to_string(),
2007 user: Some("postgres".to_string()),
2008 ..PitchforkTomlDaemon::default()
2009 },
2010 );
2011
2012 pt.write().unwrap();
2013
2014 let raw = std::fs::read_to_string(&path).unwrap();
2015 assert!(raw.contains("user = \"postgres\""));
2016
2017 let parsed = PitchforkToml::read(&path).unwrap();
2018 let daemon = parsed
2019 .daemons
2020 .get(&DaemonId::new("test-project", "api"))
2021 .unwrap();
2022 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2023 }
2024
2025 #[test]
2026 fn test_registry_dirs_expand_tilde() {
2027 let pt = PitchforkToml::parse_str(
2028 r#"
2029[slugs.api]
2030dir = "~/projects/api"
2031
2032[namespaces.web]
2033dir = "~/projects/web"
2034"#,
2035 Path::new("/tmp/config.toml"),
2036 )
2037 .unwrap();
2038
2039 assert_eq!(
2040 pt.slugs["api"].dir,
2041 Some(crate::env::HOME_DIR.join("projects/api"))
2042 );
2043 assert_eq!(
2044 pt.namespaces["web"].dir,
2045 crate::env::HOME_DIR.join("projects/web")
2046 );
2047 }
2048
2049 #[test]
2050 fn test_settings_write_roundtrip() {
2051 let temp = tempfile::tempdir().unwrap();
2052 let path = temp.path().join("pitchfork.toml");
2053 let mut pt = PitchforkToml::new(path.clone());
2054 pt.namespace = Some("test-project".to_string());
2055 pt.settings.web.auto_start = Some(true);
2056 pt.settings.general.log_level = Some("debug".to_string());
2057
2058 pt.write().unwrap();
2059
2060 let raw = std::fs::read_to_string(&path).unwrap();
2061 assert!(
2062 raw.contains("[settings.web]"),
2063 "settings.web section should be written, got:\n{raw}"
2064 );
2065 assert!(raw.contains("auto_start = true"));
2066 assert!(raw.contains("log_level = \"debug\""));
2067
2068 let parsed = PitchforkToml::read(&path).unwrap();
2069 assert_eq!(parsed.settings.web.auto_start, Some(true));
2070 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2071 }
2072
2073 #[test]
2074 fn test_settings_preserved_on_unrelated_write() {
2075 let temp = tempfile::tempdir().unwrap();
2078 let path = temp.path().join("pitchfork.toml");
2079 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2080
2081 let mut pt = PitchforkToml::read(&path).unwrap();
2082 pt.slugs.insert(
2083 "api".to_string(),
2084 SlugEntry {
2085 dir: None,
2086 namespace: Some("myproject".to_string()),
2087 daemon: None,
2088 },
2089 );
2090 pt.namespaces.insert(
2091 "myproject".to_string(),
2092 NamespaceEntry {
2093 dir: PathBuf::from("/tmp/myproject"),
2094 },
2095 );
2096 pt.write().unwrap();
2097
2098 let raw = std::fs::read_to_string(&path).unwrap();
2099 assert!(
2100 raw.contains("[settings.web]"),
2101 "existing settings must be preserved, got:\n{raw}"
2102 );
2103 assert!(raw.contains("auto_start = true"));
2104 assert!(raw.contains("[slugs.api]"));
2105
2106 let parsed = PitchforkToml::read(&path).unwrap();
2107 assert_eq!(parsed.settings.web.auto_start, Some(true));
2108 assert!(parsed.slugs.contains_key("api"));
2109 }
2110
2111 #[tokio::test]
2112 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2113 let temp = tempfile::tempdir().unwrap();
2114 let path = temp.path().join("pitchfork.toml");
2115 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2116 .await
2117 .unwrap();
2118
2119 let read_path = path.clone();
2120 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2121 .await
2122 .unwrap()
2123 .unwrap();
2124 assert_eq!(pt.settings.general.worktree, Some(false));
2125 assert_eq!(pt.settings.proxy.worktree, None);
2126 tokio::task::spawn_blocking(move || pt.write())
2127 .await
2128 .unwrap()
2129 .unwrap();
2130
2131 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2132 assert!(raw.contains("[settings.general]"), "{raw}");
2133 assert!(raw.contains("worktree = false"), "{raw}");
2134 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2135
2136 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2137 .await
2138 .unwrap()
2139 .unwrap();
2140 assert_eq!(parsed.settings.general.worktree, Some(false));
2141 }
2142
2143 #[test]
2144 fn test_config_cache_hit_and_invalidation() {
2145 let temp = tempfile::tempdir().unwrap();
2146 let dir = temp.path();
2147 let config_path = dir.join("pitchfork.toml");
2148 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2149
2150 super::invalidate_config_cache();
2152
2153 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2155 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2156 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2157
2158 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2160 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2161
2162 std::thread::sleep(std::time::Duration::from_millis(50));
2165 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2166
2167 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2168 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2169
2170 super::invalidate_config_cache();
2172 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2173 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2174
2175 super::invalidate_config_cache();
2177 }
2178
2179 #[test]
2180 fn test_config_cache_invalidation_on_write() {
2181 let temp = tempfile::tempdir().unwrap();
2182 let dir = temp.path();
2183 let config_path = dir.join("pitchfork.toml");
2184 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2185
2186 super::invalidate_config_cache();
2187
2188 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2190 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2191 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2192
2193 let mut pt = PitchforkToml::read(&config_path).unwrap();
2195 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2196 let _ = pt.write();
2198
2199 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2201 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2202
2203 super::invalidate_config_cache();
2204 }
2205
2206 #[test]
2207 fn test_config_cache_size_invalidation() {
2208 let temp = tempfile::tempdir().unwrap();
2209 let dir = temp.path();
2210 let config_path = dir.join("pitchfork.toml");
2211 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2212
2213 super::invalidate_config_cache();
2214
2215 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2217 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2218 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2219
2220 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2224 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2225 let file = std::fs::OpenOptions::new()
2228 .write(true)
2229 .open(&config_path)
2230 .unwrap();
2231 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2232 file.set_times(times).unwrap();
2233
2234 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2237 assert_eq!(
2238 pt2.daemons[&daemon_id].run, "echo different",
2239 "cache should invalidate on size change even with identical mtime"
2240 );
2241
2242 super::invalidate_config_cache();
2243 }
2244
2245 #[test]
2246 fn test_find_project_root_in_plain_dir_returns_none() {
2247 let temp = tempfile::tempdir().unwrap();
2248 assert_eq!(find_project_root(temp.path()), None);
2249 }
2250
2251 #[test]
2252 fn test_find_project_root_finds_git_marker() {
2253 let temp = tempfile::tempdir().unwrap();
2254 let repo = temp.path().join("my-repo");
2255 std::fs::create_dir(&repo).unwrap();
2256 std::fs::create_dir(repo.join(".git")).unwrap();
2257
2258 let sub = repo.join("sub/dir");
2259 std::fs::create_dir_all(&sub).unwrap();
2260
2261 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2264 }
2265
2266 #[test]
2267 fn test_find_project_root_accepts_git_file_marker() {
2268 let temp = tempfile::tempdir().unwrap();
2271 let wt = temp.path().join("my-worktree");
2272 std::fs::create_dir(&wt).unwrap();
2273 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2274
2275 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2276 }
2277
2278 #[cfg(unix)]
2281 #[test]
2282 fn test_find_project_root_resolves_symlinked_start_dir() {
2283 use std::os::unix::fs::symlink;
2284
2285 let temp = tempfile::tempdir().unwrap();
2286 let repo = temp.path().join("real-repo");
2287 std::fs::create_dir(&repo).unwrap();
2288 std::fs::create_dir(repo.join(".git")).unwrap();
2289
2290 let sub = repo.join("sub/dir");
2291 std::fs::create_dir_all(&sub).unwrap();
2292 let link = temp.path().join("link-to-sub");
2293 symlink(&sub, &link).unwrap();
2294
2295 assert_eq!(find_project_root(&link), Some(repo));
2296 }
2297
2298 #[test]
2301 fn test_all_merged_all_namespaces_discovers_worktrees() {
2302 let temp = tempfile::tempdir().unwrap();
2303 let repo = temp.path().join("my-repo");
2304 std::fs::create_dir(&repo).unwrap();
2305
2306 let git_init = std::process::Command::new("git")
2308 .args(["init", "-b", "main"])
2309 .current_dir(&repo)
2310 .output()
2311 .expect("git init");
2312 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2313
2314 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2315
2316 let git_commit = std::process::Command::new("git")
2317 .args([
2318 "-c",
2319 "user.name=pitchfork-test",
2320 "-c",
2321 "user.email=pitchfork-test@example.com",
2322 "add",
2323 "-A",
2324 ])
2325 .current_dir(&repo)
2326 .output()
2327 .expect("git add");
2328 assert!(git_commit.status.success());
2329
2330 let git_commit = std::process::Command::new("git")
2331 .args([
2332 "-c",
2333 "user.name=pitchfork-test",
2334 "-c",
2335 "user.email=pitchfork-test@example.com",
2336 "commit",
2337 "-m",
2338 "init",
2339 ])
2340 .current_dir(&repo)
2341 .output()
2342 .expect("git commit");
2343 assert!(
2344 git_commit.status.success(),
2345 "git commit failed: {:?}",
2346 git_commit
2347 );
2348
2349 let wt = temp.path().join("my-repo-feature");
2350 let git_wt = std::process::Command::new("git")
2351 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2352 .current_dir(&repo)
2353 .output()
2354 .expect("git worktree add");
2355 assert!(
2356 git_wt.status.success(),
2357 "git worktree add failed: {:?}",
2358 git_wt
2359 );
2360
2361 std::fs::write(
2363 repo.join("pitchfork.toml"),
2364 "[daemons.api]\nrun = \"echo main\"\n",
2365 )
2366 .unwrap();
2367 std::fs::write(
2369 wt.join("pitchfork.toml"),
2370 "[daemons.worker]\nrun = \"echo wt\"\n",
2371 )
2372 .unwrap();
2373
2374 super::invalidate_config_cache();
2375
2376 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2378
2379 let main_id = DaemonId::new("my-repo", "api");
2380 let wt_id = DaemonId::new("my-repo-feature", "worker");
2381 assert!(
2382 pt.daemons.contains_key(&main_id),
2383 "main checkout daemon missing"
2384 );
2385 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2386
2387 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2389 assert!(pt_from_main.daemons.contains_key(&wt_id));
2390
2391 let _ = std::process::Command::new("git")
2393 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2394 .current_dir(&repo)
2395 .output();
2396 super::invalidate_config_cache();
2397 }
2398
2399 #[test]
2400 fn test_adhoc_id_uses_invocation_directory_namespace() {
2401 let temp = tempfile::tempdir().unwrap();
2402 let project = temp.path().join("feature-tree");
2403 std::fs::create_dir(&project).unwrap();
2404 std::fs::write(
2405 project.join("pitchfork.toml"),
2406 "[daemons.other]\nrun = \"true\"\n",
2407 )
2408 .unwrap();
2409
2410 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
2411 assert_eq!(id, DaemonId::new("feature-tree", "api"));
2412 let qualified =
2413 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
2414 assert_eq!(qualified, DaemonId::new("explicit", "api"));
2415 }
2416
2417 #[test]
2418 fn test_adhoc_id_falls_back_to_global_without_project_config() {
2419 let temp = tempfile::tempdir().unwrap();
2420 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
2421 assert_eq!(id, DaemonId::new("global", "api"));
2422 }
2423}