1use crate::daemon_id::DaemonId;
2use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
3use crate::settings::SettingsPartial;
4use crate::settings::settings;
5use crate::state_file::StateFile;
6use crate::{Result, env};
7use indexmap::IndexMap;
8use miette::Context;
9use once_cell::sync::Lazy;
10use schemars::JsonSchema;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13use std::sync::Mutex as StdMutex;
14use std::time::SystemTime;
15
16pub use crate::config_types::{
18 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
19 PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ProxyConfig,
20 ProxyIdleTimeout, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig,
21 StopSignal, WatchMode,
22};
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
32pub struct SlugEntryRaw {
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub dir: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub namespace: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none", default)]
41 pub daemon: Option<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct SlugEntry {
47 pub dir: Option<PathBuf>,
49 pub namespace: Option<String>,
51 pub daemon: Option<String>,
53}
54
55impl SlugEntry {
56 pub fn resolve_dir(&self) -> Option<PathBuf> {
59 self.dir.clone().or_else(|| {
60 self.namespace.as_ref().and_then(|ns| {
61 let namespaces = PitchforkToml::read_global_namespaces();
62 namespaces.get(ns).map(|entry| entry.dir.clone())
63 })
64 })
65 }
66
67 pub fn resolve_namespace(&self) -> Option<String> {
70 self.namespace.clone().or_else(|| {
71 self.resolve_dir()
72 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
73 })
74 }
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
83pub struct GroupEntryRaw {
84 #[schemars(with = "Vec<DaemonId>")]
85 pub daemons: Vec<String>,
86}
87
88#[derive(Debug, Clone)]
90pub struct GroupEntry {
91 pub daemons: Vec<DaemonId>,
92}
93
94#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
100pub struct NamespaceEntryRaw {
101 pub dir: String,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub config: Vec<String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct NamespaceEntry {
111 pub dir: PathBuf,
113 pub config: Vec<PathBuf>,
114}
115
116#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
118struct PitchforkTomlRaw {
119 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub namespace: Option<String>,
121 #[serde(skip_serializing_if = "Option::is_none", default)]
123 pub worktree_label: Option<String>,
124 #[serde(default)]
125 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
129 pub env: Option<IndexMap<String, String>>,
130 #[serde(default)]
131 pub settings: Option<SettingsPartial>,
132 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
135 pub slugs: IndexMap<String, SlugEntryRaw>,
136 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
138 pub groups: IndexMap<String, GroupEntryRaw>,
139 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
142 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
143}
144
145#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
150pub struct PitchforkTomlDaemonLogs {
151 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub log_format: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub time_retention: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub line_retention: Option<i64>,
161 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub archive_hook: Option<String>,
164}
165
166#[derive(Debug, serde::Serialize, serde::Deserialize)]
173struct PitchforkTomlDaemonRaw {
174 pub run: String,
175 #[serde(skip_serializing_if = "Vec::is_empty", default)]
176 pub auto: Vec<PitchforkTomlAuto>,
177 #[serde(skip_serializing_if = "Option::is_none", default)]
178 pub oneshot: Option<bool>,
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub cron: Option<PitchforkTomlCron>,
181 #[serde(default)]
182 pub retry: Retry,
183 #[serde(skip_serializing_if = "Option::is_none", default)]
184 pub ready_delay: Option<u64>,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
186 pub ready_output: Option<ReadyOutput>,
187 #[serde(skip_serializing_if = "Option::is_none", default)]
188 pub ready_http: Option<ReadyHttp>,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
190 pub ready_port: Option<ReadyPort>,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
192 pub ready_cmd: Option<ReadyCmd>,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
194 pub health_cmd: Option<HealthCmd>,
195 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub health_http: Option<HealthHttp>,
197 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub health_port: Option<HealthPort>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub port: Option<PortConfig>,
202 #[serde(skip_serializing_if = "Option::is_none", default)]
204 pub proxy: Option<ProxyConfig>,
205 #[serde(skip_serializing_if = "Vec::is_empty", default)]
207 pub expected_port: Vec<u16>,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub auto_bump_port: Option<bool>,
211 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub port_bump_attempts: Option<u32>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
216 pub proxy_tls: Option<ProxyTlsMode>,
217 #[serde(skip_serializing_if = "Option::is_none", default)]
219 pub proxy_tls_port: Option<u16>,
220 #[serde(skip_serializing_if = "Option::is_none", default)]
223 pub proxy_port: Option<u16>,
224 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub boot_start: Option<bool>,
229 #[serde(skip_serializing_if = "Vec::is_empty", default)]
230 pub depends: Vec<String>,
231 #[serde(skip_serializing_if = "Vec::is_empty", default)]
232 pub watch: Vec<String>,
233 #[serde(skip_serializing_if = "Option::is_none", default)]
234 pub watch_mode: Option<WatchMode>,
235 #[serde(skip_serializing_if = "Option::is_none", default)]
236 pub dir: Option<String>,
237 #[serde(skip_serializing_if = "Option::is_none", default)]
238 pub env: Option<IndexMap<String, String>>,
239 #[serde(skip_serializing_if = "Option::is_none", default)]
240 pub hooks: Option<PitchforkTomlHooks>,
241 #[serde(skip_serializing_if = "Option::is_none", default)]
242 pub mise: Option<bool>,
243 #[serde(skip_serializing_if = "Option::is_none", default)]
245 pub user: Option<String>,
246 #[serde(skip_serializing_if = "Option::is_none", default)]
248 pub memory_limit: Option<MemoryLimit>,
249 #[serde(skip_serializing_if = "Option::is_none", default)]
251 pub cpu_limit: Option<CpuLimit>,
252 #[serde(skip_serializing_if = "Option::is_none", default)]
254 pub stop_signal: Option<StopConfig>,
255 #[serde(skip_serializing_if = "Option::is_none", default)]
257 pub pty: Option<bool>,
258 #[serde(skip_serializing_if = "Option::is_none", default)]
261 pub time_retention: Option<String>,
262 #[serde(skip_serializing_if = "Option::is_none", default)]
265 pub line_retention: Option<i64>,
266 #[serde(skip_serializing_if = "Option::is_none", default)]
269 pub archive_hook: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none", default)]
272 pub logs: Option<PitchforkTomlDaemonLogs>,
273}
274
275#[derive(Debug, Clone, Default, JsonSchema)]
280#[schemars(title = "Pitchfork Configuration")]
281pub struct PitchforkToml {
282 #[serde(default)]
284 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
285 #[serde(skip_serializing_if = "Option::is_none", default)]
289 pub env: Option<IndexMap<String, String>>,
290 pub namespace: Option<String>,
295 #[schemars(default, with = "Option<String>")]
299 pub worktree_label: Option<String>,
300 #[serde(default)]
309 pub(crate) settings: SettingsPartial,
310 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
315 pub slugs: IndexMap<String, SlugEntry>,
316 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
318 pub groups: IndexMap<String, GroupEntry>,
319 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
322 pub namespaces: IndexMap<String, NamespaceEntry>,
323 #[schemars(skip)]
324 pub path: Option<PathBuf>,
325 #[schemars(skip)]
333 pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
334}
335
336impl PitchforkToml {
337 pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
340 self.foreign_env
341 .get(id)
342 .map_or(self.env.as_ref(), |env| env.as_ref())
343 }
344}
345
346pub fn is_global_config(path: &Path) -> bool {
347 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
348}
349
350pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
351 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
352}
353
354fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
355 use toml::Value;
356
357 let doc: Value = toml::from_str(content)
358 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
359 let Some(value) = doc.get("namespace") else {
360 return Ok(None);
361 };
362
363 match value {
364 Value::String(s) => Ok(Some(s.clone())),
365 _ => Err(ConfigParseError::InvalidNamespace {
366 path: path.to_path_buf(),
367 namespace: value.to_string(),
368 reason: "top-level 'namespace' must be a string".to_string(),
369 }
370 .into()),
371 }
372}
373
374fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
375 if !path.exists() {
376 return Ok(None);
377 }
378 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
379 path: path.to_path_buf(),
380 source: e,
381 })?;
382 parse_namespace_override_from_content(path, &content)
383}
384
385pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
386 crate::extra_configs::project_dir(path).or_else(|| {
387 if is_dot_config_pitchfork(path) {
388 path.parent().and_then(Path::parent).map(Path::to_path_buf)
389 } else {
390 path.parent().map(Path::to_path_buf)
391 }
392 })
393}
394
395fn project_config_family(path: &Path) -> Vec<PathBuf> {
396 let Some(dir) = project_dir_for_config(path) else {
397 return vec![path.to_path_buf()];
398 };
399 vec![
400 dir.join(".config/pitchfork.toml"),
401 dir.join(".config/pitchfork.local.toml"),
402 dir.join("pitchfork.toml"),
403 dir.join("pitchfork.local.toml"),
404 ]
405}
406
407fn directory_namespace_override(
410 path: &Path,
411 content_override: Option<&str>,
412) -> Result<Option<String>> {
413 if is_global_config(path) {
414 return match content_override {
415 Some(content) => parse_namespace_override_from_content(path, content),
416 None => read_namespace_override_from_file(path),
417 };
418 }
419
420 let mut selected: Option<(String, PathBuf)> = None;
421 for candidate in project_config_family(path) {
422 let explicit = if candidate == path {
423 match content_override {
424 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
425 None => read_namespace_override_from_file(&candidate)?,
426 }
427 } else {
428 read_namespace_override_from_file(&candidate)?
429 };
430 let Some(namespace) = explicit else { continue };
431 if let Some((selected_namespace, selected_path)) = &selected
432 && selected_namespace != &namespace
433 {
434 return Err(ConfigParseError::InvalidNamespace {
435 path: candidate,
436 namespace,
437 reason: format!(
438 "namespace does not match directory-level namespace '{}' declared in {}",
439 selected_namespace,
440 selected_path.display()
441 ),
442 }
443 .into());
444 }
445 selected = Some((namespace, candidate));
446 }
447 Ok(selected.map(|(namespace, _)| namespace))
448}
449
450fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
451 if let Err(e) = DaemonId::try_new(namespace, "probe") {
452 return Err(ConfigParseError::InvalidNamespace {
453 path: path.to_path_buf(),
454 namespace: namespace.to_string(),
455 reason: e.to_string(),
456 }
457 .into());
458 }
459 Ok(namespace.to_string())
460}
461
462fn derive_namespace_from_dir(path: &Path) -> Result<String> {
463 let dir_for_namespace = project_dir_for_config(path);
464 if let Some(namespace) = dir_for_namespace
465 .as_deref()
466 .and_then(crate::extra_configs::namespace_for_dir)
467 {
468 return validate_namespace(path, &namespace);
469 }
470 let raw_namespace = dir_for_namespace
471 .as_deref()
472 .and_then(|p| p.file_name())
473 .and_then(|n| n.to_str())
474 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
475 .to_string();
476
477 validate_namespace(path, &raw_namespace).map_err(|e| {
478 ConfigParseError::InvalidNamespace {
479 path: path.to_path_buf(),
480 namespace: raw_namespace,
481 reason: format!(
482 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
483 ),
484 }
485 .into()
486 })
487}
488
489fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
490 if is_global_config(path) {
491 if let Some(ns) = explicit
492 && ns != "global"
493 {
494 return Err(ConfigParseError::InvalidNamespace {
495 path: path.to_path_buf(),
496 namespace: ns.to_string(),
497 reason: "global config files must use namespace 'global'".to_string(),
498 }
499 .into());
500 }
501 return Ok("global".to_string());
502 }
503
504 if let Some(ns) = explicit {
505 return validate_namespace(path, ns);
506 }
507
508 derive_namespace_from_dir(path)
509}
510
511fn namespace_from_file(path: &Path) -> Result<String> {
512 let explicit = directory_namespace_override(path, None)?;
513 namespace_from_path_with_override(path, explicit.as_deref())
514}
515
516pub fn namespace_from_path(path: &Path) -> Result<String> {
529 namespace_from_file(path)
530}
531
532fn find_project_root(dir: &Path) -> Option<PathBuf> {
540 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
544 let mut current = canonical_dir.as_path();
545 loop {
546 if current.join(".git").exists() || current.join(".jj").exists() {
547 return Some(current.to_path_buf());
548 }
549 current = current.parent()?;
550 }
551}
552
553struct ConfigCacheEntry {
572 config: PitchforkToml,
573 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
575}
576
577static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
584 Lazy::new(|| StdMutex::new(HashMap::new()));
585
586fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
592 if paths.len() != snapshot.len() {
593 return false;
594 }
595 paths
596 .iter()
597 .zip(snapshot.iter())
598 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
599}
600
601pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
603 let md = std::fs::metadata(path).ok()?;
604 Some((md.modified().ok()?, md.len()))
605}
606
607fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
609 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
610}
611
612pub fn invalidate_config_cache() {
629 crate::extra_configs::invalidate();
630 crate::web::routes::api::projects::invalidate_group_cache();
633 if let Ok(mut cache) = CONFIG_CACHE.lock() {
634 cache.clear();
635 }
636}
637
638impl PitchforkToml {
639 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
652 if user_id.contains('/') {
654 return match DaemonId::parse(user_id) {
655 Ok(id) => Ok(vec![id]),
656 Err(e) => Err(e), };
658 }
659
660 let global_slugs = Self::read_global_slugs();
662 if let Some(entry) = global_slugs.get(user_id) {
663 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
665 if let Some(dir) = entry.resolve_dir()
666 && let Ok(project_config) = Self::all_merged_from(&dir)
667 {
668 let matches: Vec<DaemonId> = project_config
670 .daemons
671 .keys()
672 .filter(|id| id.name() == daemon_name)
673 .cloned()
674 .collect();
675 match matches.as_slice() {
676 [] => {}
677 [id] => return Ok(vec![id.clone()]),
678 _ => {
679 let mut candidates: Vec<String> =
680 matches.iter().map(|id| id.qualified()).collect();
681 candidates.sort();
682 return Err(miette::miette!(
683 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
684 user_id,
685 daemon_name,
686 candidates.join(", ")
687 ));
688 }
689 }
690 }
691 }
692
693 let matches: Vec<DaemonId> = self
695 .daemons
696 .keys()
697 .filter(|id| id.name() == user_id)
698 .cloned()
699 .collect();
700
701 if matches.is_empty() {
702 let state_matches = Self::find_in_state_file(user_id);
704 match state_matches.as_slice() {
705 [] => {}
706 [id] => return Ok(vec![id.clone()]),
707 _ => {
708 let mut candidates: Vec<String> =
709 state_matches.iter().map(|id| id.qualified()).collect();
710 candidates.sort();
711 return Err(miette::miette!(
712 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
713 user_id,
714 candidates.join(", ")
715 ));
716 }
717 }
718 let _ = DaemonId::try_new("global", user_id)?;
720 }
721 Ok(matches)
722 }
723
724 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
730 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
731 Ok(state) => state
732 .daemons
733 .keys()
734 .filter(|id| id.name() == short_name)
735 .cloned()
736 .collect(),
737 Err(e) => {
738 warn!("cannot read state file: {e}");
739 Vec::new()
740 }
741 }
742 }
743
744 #[allow(dead_code)]
765 pub fn resolve_daemon_id_prefer_local(
766 &self,
767 user_id: &str,
768 current_dir: &Path,
769 ) -> Result<DaemonId> {
770 if user_id.contains('/') {
772 return DaemonId::parse(user_id);
773 }
774
775 let current_namespace = Self::namespace_for_dir(current_dir)?;
779
780 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
781 }
782
783 fn resolve_daemon_id_with_namespace(
786 &self,
787 user_id: &str,
788 current_namespace: &str,
789 ) -> Result<DaemonId> {
790 let global_slugs = Self::read_global_slugs();
792 if let Some(entry) = global_slugs.get(user_id) {
793 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
794 if let Some(dir) = entry.resolve_dir()
795 && let Ok(project_config) = Self::all_merged_from(&dir)
796 {
797 let matches: Vec<DaemonId> = project_config
798 .daemons
799 .keys()
800 .filter(|id| id.name() == daemon_name)
801 .cloned()
802 .collect();
803 match matches.as_slice() {
804 [] => {}
805 [id] => return Ok(id.clone()),
806 _ => {
807 let mut candidates: Vec<String> =
808 matches.iter().map(|id| id.qualified()).collect();
809 candidates.sort();
810 return Err(miette::miette!(
811 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
812 user_id,
813 daemon_name,
814 candidates.join(", ")
815 ));
816 }
817 }
818 }
819 }
820
821 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
824 if self.daemons.contains_key(&preferred_id) {
825 return Ok(preferred_id);
826 }
827
828 let matches = self.resolve_daemon_id(user_id)?;
830
831 if matches.len() > 1 {
833 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
834 candidates.sort();
835 return Err(miette::miette!(
836 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
837 user_id,
838 candidates.join(", ")
839 ));
840 }
841
842 if let Some(id) = matches.into_iter().next() {
843 return Ok(id);
844 }
845
846 let global_id = DaemonId::try_new("global", user_id)?;
849 if self.daemons.contains_key(&global_id) {
850 return Ok(global_id);
851 }
852
853 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
854 Err(DependencyError::DaemonNotFound {
855 name: user_id.to_string(),
856 suggestion,
857 }
858 .into())
859 }
860
861 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
863 namespace_from_path(&dir.join("pitchfork.toml"))
864 }
865
866 pub fn project_worktree_label(dir: &Path) -> Option<String> {
874 let mut label = None;
875 let candidates = project_config_family(&dir.join("pitchfork.toml"))
876 .into_iter()
877 .chain(crate::extra_configs::configs_for_dir(dir));
878 for candidate in candidates {
879 if !candidate.exists() {
880 continue;
881 }
882 if let Ok(pt) = Self::read(&candidate)
883 && let Some(found) = pt.worktree_label
884 {
885 label = Some(found);
886 }
887 }
888 label
889 }
890
891 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
893 directory_namespace_override(&dir.join("pitchfork.toml"), None)
894 }
895
896 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
898 Ok(Self::list_paths_from(dir)
899 .iter()
900 .filter(|p| p.exists())
901 .max_by_key(|p| {
902 if is_global_config(p) {
903 0
904 } else {
905 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
906 }
907 })
908 .map(|p| namespace_from_path(p))
909 .transpose()?
910 .unwrap_or_else(|| "global".to_string()))
911 }
912
913 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
923 if user_id.contains('/') {
924 return DaemonId::parse(user_id);
925 }
926
927 let config = Self::all_merged()?;
930 let ns = Self::namespace_for_dir(&env::CWD)?;
931 config.resolve_daemon_id_with_namespace(user_id, &ns)
932 }
933
934 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
940 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
941 }
942
943 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
944 if user_id.contains('/') {
945 return DaemonId::parse(user_id);
946 }
947
948 let ns = Self::namespace_for_dir(dir)?;
949 DaemonId::try_new(ns, user_id)
950 }
951
952 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
963 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
965 return user_ids
966 .iter()
967 .map(|s| DaemonId::parse(s.as_ref()))
968 .collect();
969 }
970
971 let config = Self::all_merged()?;
972 let ns = Self::namespace_for_dir(&env::CWD)?;
974 user_ids
975 .iter()
976 .map(|s| {
977 let id = s.as_ref();
978 if id.contains('/') {
979 DaemonId::parse(id)
980 } else {
981 config.resolve_daemon_id_with_namespace(id, &ns)
982 }
983 })
984 .collect()
985 }
986
987 pub fn resolve_ids_and_group<S: AsRef<str>>(
992 user_ids: &[S],
993 group_name: Option<&str>,
994 ) -> Result<Vec<DaemonId>> {
995 let config = Self::all_merged()?;
996 let ns = Self::namespace_for_dir(&env::CWD)?;
997 let mut ids = Vec::new();
998 let mut seen = std::collections::HashSet::new();
999
1000 for id in user_ids {
1001 let id_str = id.as_ref();
1002 let daemon_id = if id_str.contains('/') {
1003 DaemonId::parse(id_str)?
1004 } else {
1005 config.resolve_daemon_id_with_namespace(id_str, &ns)?
1006 };
1007 if seen.insert(daemon_id.clone()) {
1008 ids.push(daemon_id);
1009 }
1010 }
1011
1012 if let Some(name) = group_name {
1013 match config.groups.get(name) {
1014 Some(group) => {
1015 let missing: Vec<String> = group
1016 .daemons
1017 .iter()
1018 .filter(|id| !config.daemons.contains_key(*id))
1019 .map(|id| id.qualified())
1020 .collect();
1021 if !missing.is_empty() {
1022 return Err(miette::miette!(
1023 "group '{}' references undefined daemon{}: {}",
1024 name,
1025 if missing.len() > 1 { "s" } else { "" },
1026 missing.join(", ")
1027 ));
1028 }
1029 for daemon_id in &group.daemons {
1030 if seen.insert(daemon_id.clone()) {
1031 ids.push(daemon_id.clone());
1032 }
1033 }
1034 }
1035 None => {
1036 let suggestion =
1037 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
1038 return Err(miette::miette!(
1039 "group '{}' not found in configuration{}",
1040 name,
1041 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
1042 ));
1043 }
1044 }
1045 }
1046
1047 Ok(ids)
1048 }
1049
1050 pub fn list_paths() -> Vec<PathBuf> {
1053 Self::list_paths_from(&env::CWD)
1054 }
1055
1056 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1067 let mut paths = Vec::new();
1068 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1069 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1070
1071 let mut project_paths = xx::file::find_up_all(
1075 cwd,
1076 &[
1077 "pitchfork.local.toml",
1078 "pitchfork.toml",
1079 ".config/pitchfork.local.toml",
1080 ".config/pitchfork.toml",
1081 ],
1082 );
1083 project_paths.reverse();
1084 paths.extend(project_paths);
1085 paths.extend(crate::extra_configs::paths_for(cwd));
1086
1087 paths
1088 }
1089
1090 pub fn all_merged() -> Result<PitchforkToml> {
1093 Self::all_merged_from(&env::CWD)
1094 }
1095 pub fn all_merged_all_namespaces() -> Result<Self> {
1102 Self::all_merged_all_namespaces_from(&env::CWD)
1103 }
1104
1105 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1108 let mut pt = Self::all_merged_from(start_dir)?;
1109
1110 let namespaces = Self::read_global_namespaces();
1111 for (ns_name, entry) in namespaces {
1112 match Self::all_merged_from(&entry.dir) {
1113 Ok(ns_config) => {
1114 for (daemon_id, daemon_config) in ns_config.daemons {
1115 if !pt.daemons.contains_key(&daemon_id) {
1116 pt.foreign_env
1117 .insert(daemon_id.clone(), ns_config.env.clone());
1118 pt.daemons.insert(daemon_id, daemon_config);
1119 }
1120 }
1121 pt.settings.merge_from(&ns_config.settings);
1124 }
1125 Err(e) => {
1126 log::warn!(
1127 "Failed to load namespace '{ns_name}' from {}: {e}",
1128 entry.dir.display()
1129 );
1130 }
1131 }
1132 }
1133
1134 if crate::settings::settings().general.worktree
1144 && let Some(project_root) = find_project_root(start_dir)
1145 {
1146 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1147 for wt in &worktrees {
1148 match Self::all_merged_from(&wt.path) {
1149 Ok(wt_config) => {
1150 for (daemon_id, daemon_config) in wt_config.daemons {
1151 if !pt.daemons.contains_key(&daemon_id) {
1152 pt.daemons.insert(daemon_id, daemon_config);
1153 }
1154 }
1155 pt.settings.merge_from(&wt_config.settings);
1156 }
1157 Err(e) => {
1158 log::warn!(
1159 "Failed to load worktree '{}' config from {}: {e}",
1160 wt.branch,
1161 wt.path.display()
1162 );
1163 }
1164 }
1165 }
1166 }
1167
1168 Ok(pt)
1169 }
1170
1171 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1189 let paths = Self::list_paths_from(cwd);
1190
1191 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1195
1196 {
1197 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1198 if let Some(entry) = cache.get(&cache_key)
1199 && meta_matches(&paths, &entry.source_meta)
1200 {
1201 return Ok(entry.config.clone());
1202 }
1203 }
1204
1205 let snapshot = snapshot_meta(&paths);
1211 let pt = Self::all_merged_from_uncached(&paths)?;
1212
1213 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1215 cache.insert(
1216 cache_key,
1217 ConfigCacheEntry {
1218 config: pt.clone(),
1219 source_meta: snapshot,
1220 },
1221 );
1222
1223 Ok(pt)
1224 }
1225
1226 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1231 use std::collections::HashMap as StdHashMap;
1232
1233 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1234
1235 let mut pt = Self::default();
1236 for p in paths {
1237 match Self::read(p) {
1238 Ok(pt2) => {
1239 if p.exists() && !is_global_config(p) {
1243 let ns = namespace_from_path(p)?;
1244 let origin_dir = project_dir_for_config(p)
1245 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1246 .unwrap_or_else(|| p.clone());
1247
1248 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1249 && *other_dir != origin_dir
1250 {
1251 return Err(crate::error::ConfigParseError::NamespaceCollision {
1252 path_a: other_path.clone(),
1253 path_b: p.clone(),
1254 ns,
1255 }
1256 .into());
1257 }
1258 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1259 }
1260
1261 pt.merge(pt2)
1262 }
1263 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1264 }
1265 }
1266 Ok(pt)
1267 }
1268}
1269
1270impl PitchforkToml {
1271 pub fn new(path: PathBuf) -> Self {
1272 Self {
1273 daemons: Default::default(),
1274 env: None,
1275 namespace: None,
1276 worktree_label: None,
1277 settings: SettingsPartial::default(),
1278 slugs: IndexMap::new(),
1279 groups: IndexMap::new(),
1280 namespaces: IndexMap::new(),
1281 path: Some(path),
1282 foreign_env: IndexMap::new(),
1283 }
1284 }
1285
1286 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1294 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1295 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1296 if let Some(settings) = &mut raw_config.settings {
1297 settings.canonicalize_aliases();
1298 }
1299
1300 let explicit = directory_namespace_override(path, Some(content))?;
1301 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1302 let mut pt = Self::new(path.to_path_buf());
1303 pt.namespace = raw_config.namespace.clone();
1304 pt.worktree_label = raw_config.worktree_label.clone();
1305
1306 for (short_name, raw_daemon) in raw_config.daemons {
1307 let id = match DaemonId::try_new(&namespace, &short_name) {
1308 Ok(id) => id,
1309 Err(e) => {
1310 return Err(ConfigParseError::InvalidDaemonName {
1311 name: short_name,
1312 path: path.to_path_buf(),
1313 reason: e.to_string(),
1314 }
1315 .into());
1316 }
1317 };
1318
1319 let mut depends = Vec::new();
1320 for dep in raw_daemon.depends {
1321 let dep_id = if dep.contains('/') {
1322 match DaemonId::parse(&dep) {
1323 Ok(id) => id,
1324 Err(e) => {
1325 return Err(ConfigParseError::InvalidDependency {
1326 daemon: short_name.clone(),
1327 dependency: dep,
1328 path: path.to_path_buf(),
1329 reason: e.to_string(),
1330 }
1331 .into());
1332 }
1333 }
1334 } else {
1335 match DaemonId::try_new(&namespace, &dep) {
1336 Ok(id) => id,
1337 Err(e) => {
1338 return Err(ConfigParseError::InvalidDependency {
1339 daemon: short_name.clone(),
1340 dependency: dep,
1341 path: path.to_path_buf(),
1342 reason: e.to_string(),
1343 }
1344 .into());
1345 }
1346 }
1347 };
1348 depends.push(dep_id);
1349 }
1350
1351 let has_deprecated = !raw_daemon.expected_port.is_empty()
1353 || raw_daemon.auto_bump_port.is_some()
1354 || raw_daemon.port_bump_attempts.is_some();
1355 let port = if let Some(port) = raw_daemon.port {
1356 if has_deprecated {
1357 warn!(
1358 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1359 );
1360 }
1361 Some(port)
1362 } else if has_deprecated {
1363 warn!(
1364 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1365 );
1366 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1367 PortBump(
1368 raw_daemon
1369 .port_bump_attempts
1370 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1371 )
1372 } else {
1373 PortBump(0)
1374 };
1375 Some(PortConfig {
1376 expect: raw_daemon.expected_port,
1377 bump,
1378 })
1379 } else {
1380 None
1381 };
1382
1383 if let (Some(explicit), Some(short)) =
1385 (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1386 && explicit != short
1387 {
1388 warn!(
1389 "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1390 disagree; using proxy_tls_port"
1391 );
1392 }
1393 let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1394
1395 for (key, value) in [
1398 ("proxy_tls_port", raw_daemon.proxy_tls_port),
1399 ("proxy_port", raw_daemon.proxy_port),
1400 ] {
1401 if value == Some(0) {
1402 return Err(ConfigParseError::ProxyPortZero {
1403 daemon: short_name.clone(),
1404 key,
1405 path: path.to_path_buf(),
1406 }
1407 .into());
1408 }
1409 }
1410
1411 if let Some(port_want) = proxy_tls_port {
1414 let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1415 if !declared.contains(&port_want) {
1416 return Err(ConfigParseError::ProxyPortNotDeclared {
1417 daemon: short_name.clone(),
1418 port: port_want,
1419 declared,
1420 path: path.to_path_buf(),
1421 }
1422 .into());
1423 }
1424 }
1425
1426 if raw_daemon
1429 .proxy_tls
1430 .is_some_and(ProxyTlsMode::is_passthrough)
1431 && port
1432 .as_ref()
1433 .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1434 {
1435 return Err(ConfigParseError::PassthroughWithoutPort {
1436 daemon: short_name.clone(),
1437 path: path.to_path_buf(),
1438 }
1439 .into());
1440 }
1441
1442 let daemon = PitchforkTomlDaemon {
1443 run: raw_daemon.run,
1444 auto: raw_daemon.auto,
1445 oneshot: raw_daemon.oneshot,
1446 cron: raw_daemon.cron,
1447 retry: raw_daemon.retry,
1448 ready_delay: raw_daemon.ready_delay,
1449 ready_output: raw_daemon.ready_output,
1450 ready_http: raw_daemon.ready_http,
1451 ready_port: raw_daemon.ready_port,
1452 ready_cmd: raw_daemon.ready_cmd,
1453 health_cmd: raw_daemon.health_cmd,
1454 health_http: raw_daemon.health_http,
1455 health_port: raw_daemon.health_port,
1456 port,
1457 proxy: raw_daemon.proxy,
1458 proxy_tls: raw_daemon.proxy_tls,
1459 proxy_tls_port: raw_daemon.proxy_tls_port,
1460 proxy_port: raw_daemon.proxy_port,
1461 proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1462 boot_start: raw_daemon.boot_start,
1463 depends,
1464 watch: raw_daemon.watch,
1465 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1466 dir: raw_daemon.dir,
1467 env: raw_daemon.env,
1468 hooks: raw_daemon.hooks,
1469 mise: raw_daemon.mise,
1470 user: raw_daemon.user,
1471 memory_limit: raw_daemon.memory_limit,
1472 cpu_limit: raw_daemon.cpu_limit,
1473 stop_signal: raw_daemon.stop_signal,
1474 pty: raw_daemon.pty,
1475 time_retention: raw_daemon.time_retention,
1476 line_retention: raw_daemon.line_retention,
1477 archive_hook: raw_daemon.archive_hook,
1478 logs: raw_daemon.logs,
1479 path: Some(path.to_path_buf()),
1480 };
1481 if daemon.is_oneshot() {
1482 let conflicts = daemon.oneshot_conflicts();
1483 if !conflicts.is_empty() {
1484 return Err(ConfigParseError::OneshotConflict {
1485 daemon: short_name.clone(),
1486 path: path.to_path_buf(),
1487 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1488 }
1489 .into());
1490 }
1491 }
1492 pt.daemons.insert(id, daemon);
1493 }
1494
1495 if let Some(settings) = raw_config.settings {
1497 pt.settings = settings;
1498 }
1499
1500 pt.env = raw_config.env;
1502
1503 for (slug, entry) in raw_config.slugs {
1505 pt.slugs.insert(
1506 slug,
1507 SlugEntry {
1508 dir: entry.dir.map(env::expand_tilde),
1509 namespace: entry.namespace,
1510 daemon: entry.daemon,
1511 },
1512 );
1513 }
1514
1515 for (name, entry) in raw_config.namespaces {
1517 pt.namespaces.insert(
1518 name,
1519 NamespaceEntry {
1520 config: entry
1521 .config
1522 .iter()
1523 .map(|p| {
1524 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1525 })
1526 .collect(),
1527 dir: env::expand_tilde(entry.dir),
1528 },
1529 );
1530 }
1531
1532 for (group_name, raw_group) in raw_config.groups {
1534 let mut daemons = Vec::new();
1535 for daemon_name in &raw_group.daemons {
1536 let id = if daemon_name.contains('/') {
1537 DaemonId::parse(daemon_name).map_err(|e| {
1538 ConfigParseError::InvalidDependency {
1539 daemon: group_name.clone(),
1540 dependency: daemon_name.clone(),
1541 path: path.to_path_buf(),
1542 reason: e.to_string(),
1543 }
1544 })?
1545 } else {
1546 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1547 ConfigParseError::InvalidDaemonName {
1548 name: daemon_name.clone(),
1549 path: path.to_path_buf(),
1550 reason: e.to_string(),
1551 }
1552 })?
1553 };
1554 daemons.push(id);
1555 }
1556 pt.groups.insert(group_name, GroupEntry { daemons });
1557 }
1558
1559 Ok(pt)
1560 }
1561
1562 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1563 let path = path.as_ref();
1564 if !path.exists() {
1565 return Ok(Self::new(path.to_path_buf()));
1566 }
1567 let _lock = xx::fslock::get(path, false)
1568 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1569 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1570 path: path.to_path_buf(),
1571 source: e,
1572 })?;
1573 Self::parse_str(&raw, path)
1574 }
1575
1576 pub fn write(&self) -> Result<()> {
1577 if let Some(path) = &self.path {
1578 let _lock = xx::fslock::get(path, false)
1579 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1580 self.write_unlocked()
1581 } else {
1582 Err(FileError::NoPath.into())
1583 }
1584 }
1585
1586 pub(crate) fn write_unlocked(&self) -> Result<()> {
1592 if let Some(path) = &self.path {
1593 let config_namespace = if path.exists() {
1595 namespace_from_path(path)?
1596 } else {
1597 namespace_from_path_with_override(path, self.namespace.as_deref())?
1598 };
1599
1600 let mut raw = PitchforkTomlRaw {
1604 namespace: self.namespace.clone(),
1605 worktree_label: self.worktree_label.clone(),
1606 env: self.env.clone(),
1607 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1608 ..PitchforkTomlRaw::default()
1609 };
1610 for (id, daemon) in &self.daemons {
1611 if id.namespace() != config_namespace {
1612 return Err(miette::miette!(
1613 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1614 id,
1615 path.display(),
1616 id.namespace(),
1617 config_namespace
1618 ));
1619 }
1620 let port = daemon.port.as_ref();
1621 let raw_daemon = PitchforkTomlDaemonRaw {
1622 run: daemon.run.clone(),
1623 auto: daemon.auto.clone(),
1624 oneshot: daemon.oneshot,
1625 cron: daemon.cron.clone(),
1626 retry: daemon.retry,
1627 ready_delay: daemon.ready_delay,
1628 ready_output: daemon.ready_output.clone(),
1629 ready_http: daemon.ready_http.clone(),
1630 ready_port: daemon.ready_port.clone(),
1631 ready_cmd: daemon.ready_cmd.clone(),
1632 health_cmd: daemon.health_cmd.clone(),
1633 health_http: daemon.health_http.clone(),
1634 health_port: daemon.health_port.clone(),
1635 port: port.cloned(),
1636 proxy: daemon.proxy.clone(),
1637 proxy_tls: daemon.proxy_tls,
1638 proxy_tls_port: daemon.proxy_tls_port,
1639 proxy_port: daemon.proxy_port,
1640 proxy_idle_timeout: daemon.proxy_idle_timeout,
1641 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1643 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1644 port_bump_attempts: port
1645 .filter(|p| p.auto_bump())
1646 .map(|p| p.max_bump_attempts()),
1647 boot_start: daemon.boot_start,
1648 depends: daemon
1651 .depends
1652 .iter()
1653 .map(|d| {
1654 if d.namespace() == config_namespace {
1655 d.name().to_string()
1656 } else {
1657 d.qualified()
1658 }
1659 })
1660 .collect(),
1661 watch: daemon.watch.clone(),
1662 watch_mode: match daemon.watch_mode {
1663 WatchMode::Native => None,
1664 mode => Some(mode),
1665 },
1666 dir: daemon.dir.clone(),
1667 env: daemon.env.clone(),
1668 hooks: daemon.hooks.clone(),
1669 mise: daemon.mise,
1670 user: daemon.user.clone(),
1671 memory_limit: daemon.memory_limit,
1672 cpu_limit: daemon.cpu_limit,
1673 stop_signal: daemon.stop_signal,
1674 pty: daemon.pty,
1675 time_retention: daemon.time_retention.clone(),
1676 line_retention: daemon.line_retention,
1677 archive_hook: daemon.archive_hook.clone(),
1678 logs: daemon.logs.clone(),
1679 };
1680 raw.daemons.insert(id.name().to_string(), raw_daemon);
1681 }
1682
1683 for (slug, entry) in &self.slugs {
1685 raw.slugs.insert(
1686 slug.clone(),
1687 SlugEntryRaw {
1688 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1689 namespace: entry.namespace.clone(),
1690 daemon: entry.daemon.clone(),
1691 },
1692 );
1693 }
1694
1695 for (name, group) in &self.groups {
1697 let raw_daemons: Vec<String> = group
1698 .daemons
1699 .iter()
1700 .map(|id| {
1701 if id.namespace() == config_namespace {
1702 id.name().to_string()
1703 } else {
1704 id.qualified()
1705 }
1706 })
1707 .collect();
1708 raw.groups.insert(
1709 name.clone(),
1710 GroupEntryRaw {
1711 daemons: raw_daemons,
1712 },
1713 );
1714 }
1715
1716 for (name, entry) in &self.namespaces {
1718 raw.namespaces.insert(
1719 name.clone(),
1720 NamespaceEntryRaw {
1721 dir: entry.dir.to_string_lossy().to_string(),
1722 config: entry
1723 .config
1724 .iter()
1725 .map(|p| p.to_string_lossy().into_owned())
1726 .collect(),
1727 },
1728 );
1729 }
1730
1731 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1732 path: path.clone(),
1733 source: e,
1734 })?;
1735 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1736 path: path.clone(),
1737 details: Some(e.to_string()),
1738 })?;
1739 invalidate_config_cache();
1740 Ok(())
1741 } else {
1742 Err(FileError::NoPath.into())
1743 }
1744 }
1745
1746 pub fn merge(&mut self, pt: Self) {
1751 if pt.worktree_label.is_some() {
1752 self.worktree_label = pt.worktree_label.clone();
1753 }
1754 for (id, d) in pt.daemons {
1755 self.daemons.insert(id, d);
1756 }
1757 if let Some(env) = pt.env {
1759 let merged = self.env.get_or_insert_with(IndexMap::new);
1760 for (k, v) in env {
1761 merged.insert(k, v);
1762 }
1763 }
1764 for (slug, entry) in pt.slugs {
1766 self.slugs.insert(slug, entry);
1767 }
1768 for (name, group) in pt.groups {
1770 self.groups.insert(name, group);
1771 }
1772 for (name, entry) in pt.namespaces {
1774 self.namespaces.insert(name, entry);
1775 }
1776 self.settings.merge_from(&pt.settings);
1778 }
1779
1780 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1785 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1786 Ok(pt) => pt.slugs,
1787 Err(_) => IndexMap::new(),
1788 }
1789 }
1790
1791 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1798 global_slugs
1799 .keys()
1800 .filter(|k| k.eq_ignore_ascii_case(slug))
1801 .count()
1802 > 1
1803 }
1804
1805 pub fn find_slug_for_daemon_in_registry(
1810 daemon_id: &DaemonId,
1811 global_slugs: &IndexMap<String, SlugEntry>,
1812 ) -> Option<String> {
1813 global_slugs
1814 .iter()
1815 .find(|(slug, entry)| {
1816 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1817 if daemon_id.name() != daemon_name {
1818 return false;
1819 }
1820
1821 if Self::slug_is_ambiguous(slug, global_slugs) {
1824 return false;
1825 }
1826
1827 match entry.resolve_namespace() {
1828 Some(namespace) => daemon_id.namespace() == namespace,
1829 None => false,
1830 }
1831 })
1832 .map(|(slug, _)| slug.clone())
1833 }
1834
1835 #[allow(dead_code)]
1837 pub fn is_slug_registered(slug: &str) -> bool {
1838 Self::read_global_slugs().contains_key(slug)
1839 }
1840
1841 pub fn add_slug_with_namespace(
1847 slug: &str,
1848 namespace: Option<&str>,
1849 daemon: Option<&str>,
1850 ) -> Result<()> {
1851 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1852
1853 if let Some(parent) = global_path.parent() {
1855 std::fs::create_dir_all(parent).map_err(|e| {
1856 miette::miette!(
1857 "Failed to create config directory {}: {e}",
1858 parent.display()
1859 )
1860 })?;
1861 }
1862
1863 let _lock = xx::fslock::get(global_path, false)
1864 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1865
1866 let mut pt = if global_path.exists() {
1867 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1868 path: global_path.to_path_buf(),
1869 source: e,
1870 })?;
1871 Self::parse_str(&raw, global_path)?
1872 } else {
1873 Self::new(global_path.to_path_buf())
1874 };
1875
1876 if let Some(ns) = namespace
1880 && !pt.namespaces.contains_key(ns)
1881 {
1882 let dir = pt
1887 .slugs
1888 .get(slug)
1889 .and_then(|e| {
1890 e.dir.clone().or_else(|| {
1891 e.namespace
1892 .as_ref()
1893 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1894 })
1895 })
1896 .or_else(|| env::CWD.as_path().canonicalize().ok());
1897 if let Some(ref d) = dir {
1898 pt.namespaces.insert(
1899 ns.to_string(),
1900 NamespaceEntry {
1901 dir: d.clone(),
1902 config: Vec::new(),
1903 },
1904 );
1905 }
1906 }
1907
1908 pt.slugs.insert(
1909 slug.to_string(),
1910 SlugEntry {
1911 dir: None,
1912 namespace: namespace.map(str::to_string),
1913 daemon: daemon.map(str::to_string),
1914 },
1915 );
1916 pt.write_unlocked()?;
1917 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1923 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1924 Ok(())
1925 }
1926
1927 pub fn remove_slug(slug: &str) -> Result<bool> {
1929 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1930 if !global_path.exists() {
1931 return Ok(false);
1932 }
1933
1934 let _lock = xx::fslock::get(global_path, false)
1935 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1936
1937 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1938 path: global_path.to_path_buf(),
1939 source: e,
1940 })?;
1941 let mut pt = Self::parse_str(&raw, global_path)?;
1942
1943 let removed = pt.slugs.shift_remove(slug).is_some();
1944 if removed {
1945 pt.write_unlocked()?;
1946 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1952 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1953 }
1954 Ok(removed)
1955 }
1956 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1959 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1960 Ok(pt) => pt.namespaces,
1961 Err(_) => IndexMap::new(),
1962 }
1963 }
1964
1965 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1969 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1970
1971 if let Some(parent) = global_path.parent() {
1973 std::fs::create_dir_all(parent).map_err(|e| {
1974 miette::miette!(
1975 "Failed to create config directory {}: {e}",
1976 parent.display()
1977 )
1978 })?;
1979 }
1980
1981 let _lock = xx::fslock::get(global_path, false)
1982 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1983
1984 let mut pt = if global_path.exists() {
1985 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1986 crate::error::FileError::ReadError {
1987 path: global_path.to_path_buf(),
1988 source: e,
1989 }
1990 })?;
1991 Self::parse_str(&raw, global_path)?
1992 } else {
1993 Self::new(global_path.to_path_buf())
1994 };
1995
1996 let dir = env::expand_tilde(dir);
1997 if let Some(entry) = pt.namespaces.get_mut(name) {
1998 if !entry.config.is_empty()
1999 && crate::extra_configs::normalize(&entry.dir)
2000 != crate::extra_configs::normalize(&dir)
2001 {
2002 miette::bail!(
2003 "namespace '{name}' has external configuration attached to another directory"
2004 );
2005 }
2006 entry.dir = dir;
2007 } else {
2008 pt.namespaces.insert(
2009 name.to_string(),
2010 NamespaceEntry {
2011 dir,
2012 config: Vec::new(),
2013 },
2014 );
2015 }
2016 pt.write_unlocked()?;
2017 Ok(())
2018 }
2019
2020 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2022 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2023 if !global_path.exists() {
2024 return Ok(false);
2025 }
2026
2027 let _lock = xx::fslock::get(global_path, false)
2028 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2029
2030 let raw = std::fs::read_to_string(global_path).map_err(|e| {
2031 crate::error::FileError::ReadError {
2032 path: global_path.to_path_buf(),
2033 source: e,
2034 }
2035 })?;
2036 let mut pt = Self::parse_str(&raw, global_path)?;
2037
2038 let removed = pt.namespaces.shift_remove(name).is_some();
2039 if removed {
2040 pt.write_unlocked()?;
2041 }
2042 Ok(removed)
2043 }
2044}
2045
2046#[derive(Debug, Clone, JsonSchema, Default)]
2048pub struct PitchforkTomlDaemon {
2049 #[schemars(example = example_run_command())]
2051 pub run: String,
2052 #[schemars(default)]
2054 pub auto: Vec<PitchforkTomlAuto>,
2055 pub oneshot: Option<bool>,
2061 pub cron: Option<PitchforkTomlCron>,
2063 #[schemars(default)]
2066 pub retry: Retry,
2067 pub ready_delay: Option<u64>,
2069 pub ready_output: Option<ReadyOutput>,
2071 pub ready_http: Option<ReadyHttp>,
2073 pub ready_port: Option<ReadyPort>,
2077 pub ready_cmd: Option<ReadyCmd>,
2079 pub health_cmd: Option<HealthCmd>,
2081 pub health_http: Option<HealthHttp>,
2083 pub health_port: Option<HealthPort>,
2087 pub port: Option<PortConfig>,
2089 #[serde(skip_serializing_if = "Option::is_none", default)]
2092 pub proxy: Option<ProxyConfig>,
2093 pub proxy_tls: Option<ProxyTlsMode>,
2102 #[schemars(range(min = 1))]
2107 pub proxy_tls_port: Option<u16>,
2108 #[schemars(range(min = 1))]
2114 pub proxy_port: Option<u16>,
2115 pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2126 pub boot_start: Option<bool>,
2128 #[schemars(default)]
2130 pub depends: Vec<DaemonId>,
2131 #[schemars(default)]
2133 pub watch: Vec<String>,
2134 #[schemars(default)]
2140 pub watch_mode: WatchMode,
2141 pub dir: Option<String>,
2143 pub env: Option<IndexMap<String, String>>,
2145 pub hooks: Option<PitchforkTomlHooks>,
2147 pub mise: Option<bool>,
2150 pub user: Option<String>,
2152 pub memory_limit: Option<MemoryLimit>,
2155 pub cpu_limit: Option<CpuLimit>,
2158 pub stop_signal: Option<StopConfig>,
2161 pub pty: Option<bool>,
2163 pub time_retention: Option<String>,
2166 pub line_retention: Option<i64>,
2169 pub archive_hook: Option<String>,
2172 pub logs: Option<PitchforkTomlDaemonLogs>,
2174 #[schemars(skip)]
2175 pub path: Option<PathBuf>,
2176}
2177
2178impl PitchforkTomlDaemon {
2179 pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2185 self.proxy_tls_port.or(self.proxy_port)
2186 }
2187
2188 pub fn is_oneshot(&self) -> bool {
2190 self.oneshot.unwrap_or(false)
2191 }
2192
2193 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2196 [
2197 ("ready_delay", self.ready_delay.is_some()),
2198 ("ready_output", self.ready_output.is_some()),
2199 ("ready_http", self.ready_http.is_some()),
2200 ("ready_port", self.ready_port.is_some()),
2201 ("ready_cmd", self.ready_cmd.is_some()),
2202 ("health_cmd", self.health_cmd.is_some()),
2203 ("health_http", self.health_http.is_some()),
2204 ("health_port", self.health_port.is_some()),
2205 ]
2206 .into_iter()
2207 .filter(|(_, set)| *set)
2208 .map(|(name, _)| name)
2209 .collect()
2210 }
2211
2212 pub fn effective_user(&self) -> Option<String> {
2216 let daemon_user = self
2217 .user
2218 .as_deref()
2219 .map(str::trim)
2220 .filter(|u| !u.is_empty());
2221 daemon_user.map(str::to_owned).or_else(|| {
2222 let s = crate::settings::settings();
2223 let su = s.supervisor.user.trim();
2224 (!su.is_empty()).then(|| su.to_owned())
2225 })
2226 }
2227
2228 pub fn to_run_options(
2233 &self,
2234 id: &crate::daemon_id::DaemonId,
2235 cmd: Vec<String>,
2236 ) -> crate::daemon::RunOptions {
2237 use crate::daemon::RunOptions;
2238
2239 let effective_user = self.effective_user();
2240 let dir = crate::ipc::batch::resolve_daemon_dir(
2241 self.dir.as_deref(),
2242 self.path.as_deref(),
2243 effective_user.as_deref(),
2244 );
2245 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2248 id,
2249 &PitchforkToml::read_global_slugs(),
2250 );
2251
2252 RunOptions {
2253 id: id.clone(),
2254 cmd,
2255 run: Some(self.run.clone()),
2256 force: false,
2257 shell_pid: None,
2258 dir: Dir(dir),
2259 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2260 oneshot: self.is_oneshot(),
2261 oneshot_wait: None,
2265 on_directory_enter: false,
2266 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2267 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2268 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2269 retry: self.retry,
2270 retry_count: 0,
2271 ready_delay: self.ready_delay,
2272 ready_output: self.ready_output.clone(),
2273 ready_http: self.ready_http.clone(),
2274 ready_port: self.ready_port.clone(),
2275 ready_cmd: self.ready_cmd.clone(),
2276 health_cmd: self.health_cmd.clone(),
2277 health_http: self.health_http.clone(),
2278 health_port: self.health_port.clone(),
2279 port: self.port.clone(),
2280 wait_ready: false,
2281 depends: self.depends.clone(),
2282 env: self.env.clone(),
2283 watch: self.watch.clone(),
2284 watch_mode: self.watch_mode,
2285 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2286 self.path.as_deref(),
2287 )),
2288 mise: self.mise,
2289 slug,
2290 proxy: None,
2291 user: self.user.clone(),
2292 memory_limit: self.memory_limit,
2293 cpu_limit: self.cpu_limit,
2294 stop_signal: self.stop_signal,
2295 archive_hook: self
2296 .logs
2297 .as_ref()
2298 .and_then(|l| l.archive_hook.clone())
2299 .or_else(|| self.archive_hook.clone()),
2300 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2301 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2302 pty: self.pty,
2303 proxy_idle_timeout_ms: None,
2305 }
2306 }
2307}
2308fn example_run_command() -> &'static str {
2309 "exec node server.js"
2310}
2311
2312#[cfg(test)]
2313mod tests {
2314 use super::*;
2315 use std::path::Path;
2316
2317 #[test]
2318 fn test_daemon_user_parses_and_flows_to_run_options() {
2319 let pt = PitchforkToml::parse_str(
2320 r#"
2321[daemons.api]
2322run = "node server.js"
2323user = "postgres"
2324"#,
2325 Path::new("/tmp/my-project/pitchfork.toml"),
2326 )
2327 .unwrap();
2328
2329 let id = DaemonId::new("my-project", "api");
2330 let daemon = pt.daemons.get(&id).unwrap();
2331 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2332
2333 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2334 assert_eq!(opts.user.as_deref(), Some("postgres"));
2335 }
2336
2337 #[test]
2338 fn test_daemon_user_write_roundtrip() {
2339 let temp = tempfile::tempdir().unwrap();
2340 let path = temp.path().join("pitchfork.toml");
2341 let mut pt = PitchforkToml::new(path.clone());
2342 pt.namespace = Some("test-project".to_string());
2343 pt.daemons.insert(
2344 DaemonId::new("test-project", "api"),
2345 PitchforkTomlDaemon {
2346 run: "node server.js".to_string(),
2347 user: Some("postgres".to_string()),
2348 ..PitchforkTomlDaemon::default()
2349 },
2350 );
2351
2352 pt.write().unwrap();
2353
2354 let raw = std::fs::read_to_string(&path).unwrap();
2355 assert!(raw.contains("user = \"postgres\""));
2356
2357 let parsed = PitchforkToml::read(&path).unwrap();
2358 let daemon = parsed
2359 .daemons
2360 .get(&DaemonId::new("test-project", "api"))
2361 .unwrap();
2362 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2363 }
2364
2365 #[test]
2366 fn test_registry_dirs_expand_tilde() {
2367 let pt = PitchforkToml::parse_str(
2368 r#"
2369[slugs.api]
2370dir = "~/projects/api"
2371
2372[namespaces.web]
2373dir = "~/projects/web"
2374"#,
2375 Path::new("/tmp/config.toml"),
2376 )
2377 .unwrap();
2378
2379 assert_eq!(
2380 pt.slugs["api"].dir,
2381 Some(crate::env::HOME_DIR.join("projects/api"))
2382 );
2383 assert_eq!(
2384 pt.namespaces["web"].dir,
2385 crate::env::HOME_DIR.join("projects/web")
2386 );
2387 }
2388
2389 #[test]
2390 fn test_settings_write_roundtrip() {
2391 let temp = tempfile::tempdir().unwrap();
2392 let path = temp.path().join("pitchfork.toml");
2393 let mut pt = PitchforkToml::new(path.clone());
2394 pt.namespace = Some("test-project".to_string());
2395 pt.settings.web.auto_start = Some(true);
2396 pt.settings.general.log_level = Some("debug".to_string());
2397
2398 pt.write().unwrap();
2399
2400 let raw = std::fs::read_to_string(&path).unwrap();
2401 assert!(
2402 raw.contains("[settings.web]"),
2403 "settings.web section should be written, got:\n{raw}"
2404 );
2405 assert!(raw.contains("auto_start = true"));
2406 assert!(raw.contains("log_level = \"debug\""));
2407
2408 let parsed = PitchforkToml::read(&path).unwrap();
2409 assert_eq!(parsed.settings.web.auto_start, Some(true));
2410 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2411 }
2412
2413 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2414 SlugEntry {
2415 dir: None,
2416 namespace: Some(namespace.to_string()),
2417 daemon: daemon.map(str::to_string),
2418 }
2419 }
2420
2421 #[test]
2422 fn test_slug_is_ambiguous() {
2423 let mut slugs = IndexMap::new();
2424 slugs.insert("api".to_string(), slug_entry("my-project", None));
2425 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
2426
2427 slugs.insert("API".to_string(), slug_entry("other-project", None));
2428 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
2430 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
2431 }
2432
2433 #[test]
2434 fn test_find_slug_for_daemon_skips_case_collisions() {
2435 let id = DaemonId::new("my-project", "api");
2436 let mut slugs = IndexMap::new();
2437 slugs.insert("api".to_string(), slug_entry("my-project", None));
2438 assert_eq!(
2439 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2440 Some("api".to_string())
2441 );
2442
2443 slugs.insert("API".to_string(), slug_entry("other-project", None));
2446 assert_eq!(
2447 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2448 None
2449 );
2450 }
2451
2452 #[test]
2453 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
2454 let id = DaemonId::new("my-project", "api");
2455 let mut slugs = IndexMap::new();
2456 slugs.insert("api".to_string(), slug_entry("my-project", None));
2459 slugs.insert("API".to_string(), slug_entry("my-project", None));
2460 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
2461
2462 assert_eq!(
2463 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2464 Some("my-api".to_string())
2465 );
2466 }
2467
2468 #[test]
2469 fn test_settings_preserved_on_unrelated_write() {
2470 let temp = tempfile::tempdir().unwrap();
2473 let path = temp.path().join("pitchfork.toml");
2474 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2475
2476 let mut pt = PitchforkToml::read(&path).unwrap();
2477 pt.slugs.insert(
2478 "api".to_string(),
2479 SlugEntry {
2480 dir: None,
2481 namespace: Some("myproject".to_string()),
2482 daemon: None,
2483 },
2484 );
2485 pt.namespaces.insert(
2486 "myproject".to_string(),
2487 NamespaceEntry {
2488 dir: PathBuf::from("/tmp/myproject"),
2489 config: Vec::new(),
2490 },
2491 );
2492 pt.write().unwrap();
2493
2494 let raw = std::fs::read_to_string(&path).unwrap();
2495 assert!(
2496 raw.contains("[settings.web]"),
2497 "existing settings must be preserved, got:\n{raw}"
2498 );
2499 assert!(raw.contains("auto_start = true"));
2500 assert!(raw.contains("[slugs.api]"));
2501
2502 let parsed = PitchforkToml::read(&path).unwrap();
2503 assert_eq!(parsed.settings.web.auto_start, Some(true));
2504 assert!(parsed.slugs.contains_key("api"));
2505 }
2506
2507 #[tokio::test]
2508 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2509 let temp = tempfile::tempdir().unwrap();
2510 let path = temp.path().join("pitchfork.toml");
2511 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2512 .await
2513 .unwrap();
2514
2515 let read_path = path.clone();
2516 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2517 .await
2518 .unwrap()
2519 .unwrap();
2520 assert_eq!(pt.settings.general.worktree, Some(false));
2521 assert_eq!(pt.settings.proxy.worktree, None);
2522 tokio::task::spawn_blocking(move || pt.write())
2523 .await
2524 .unwrap()
2525 .unwrap();
2526
2527 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2528 assert!(raw.contains("[settings.general]"), "{raw}");
2529 assert!(raw.contains("worktree = false"), "{raw}");
2530 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2531
2532 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2533 .await
2534 .unwrap()
2535 .unwrap();
2536 assert_eq!(parsed.settings.general.worktree, Some(false));
2537 }
2538
2539 #[test]
2540 fn test_config_cache_hit_and_invalidation() {
2541 let temp = tempfile::tempdir().unwrap();
2542 let dir = temp.path();
2543 let config_path = dir.join("pitchfork.toml");
2544 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2545
2546 super::invalidate_config_cache();
2548
2549 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2551 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2552 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2553
2554 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2556 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2557
2558 std::thread::sleep(std::time::Duration::from_millis(50));
2561 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2562
2563 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2564 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2565
2566 super::invalidate_config_cache();
2568 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2569 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2570
2571 super::invalidate_config_cache();
2573 }
2574
2575 #[test]
2576 fn test_config_cache_invalidation_on_write() {
2577 let temp = tempfile::tempdir().unwrap();
2578 let dir = temp.path();
2579 let config_path = dir.join("pitchfork.toml");
2580 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2581
2582 super::invalidate_config_cache();
2583
2584 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2586 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2587 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2588
2589 let mut pt = PitchforkToml::read(&config_path).unwrap();
2591 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2592 let _ = pt.write();
2594
2595 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2597 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2598
2599 super::invalidate_config_cache();
2600 }
2601
2602 #[test]
2603 fn test_config_cache_size_invalidation() {
2604 let temp = tempfile::tempdir().unwrap();
2605 let dir = temp.path();
2606 let config_path = dir.join("pitchfork.toml");
2607 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2608
2609 super::invalidate_config_cache();
2610
2611 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2613 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2614 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2615
2616 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2620 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2621 let file = std::fs::OpenOptions::new()
2624 .write(true)
2625 .open(&config_path)
2626 .unwrap();
2627 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2628 file.set_times(times).unwrap();
2629
2630 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2633 assert_eq!(
2634 pt2.daemons[&daemon_id].run, "echo different",
2635 "cache should invalidate on size change even with identical mtime"
2636 );
2637
2638 super::invalidate_config_cache();
2639 }
2640
2641 #[test]
2642 fn test_find_project_root_in_plain_dir_returns_none() {
2643 let temp = tempfile::tempdir().unwrap();
2644 assert_eq!(find_project_root(temp.path()), None);
2645 }
2646
2647 #[test]
2648 fn test_find_project_root_finds_git_marker() {
2649 let temp = tempfile::tempdir().unwrap();
2650 let repo = temp.path().join("my-repo");
2651 std::fs::create_dir(&repo).unwrap();
2652 std::fs::create_dir(repo.join(".git")).unwrap();
2653
2654 let sub = repo.join("sub/dir");
2655 std::fs::create_dir_all(&sub).unwrap();
2656
2657 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2660 }
2661
2662 #[test]
2663 fn test_find_project_root_accepts_git_file_marker() {
2664 let temp = tempfile::tempdir().unwrap();
2667 let wt = temp.path().join("my-worktree");
2668 std::fs::create_dir(&wt).unwrap();
2669 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2670
2671 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2672 }
2673
2674 #[cfg(unix)]
2677 #[test]
2678 fn test_find_project_root_resolves_symlinked_start_dir() {
2679 use std::os::unix::fs::symlink;
2680
2681 let temp = tempfile::tempdir().unwrap();
2682 let repo = temp.path().join("real-repo");
2683 std::fs::create_dir(&repo).unwrap();
2684 std::fs::create_dir(repo.join(".git")).unwrap();
2685
2686 let sub = repo.join("sub/dir");
2687 std::fs::create_dir_all(&sub).unwrap();
2688 let link = temp.path().join("link-to-sub");
2689 symlink(&sub, &link).unwrap();
2690
2691 assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
2692 }
2693
2694 #[test]
2697 fn test_all_merged_all_namespaces_discovers_worktrees() {
2698 let temp = tempfile::tempdir().unwrap();
2699 let repo = temp.path().join("my-repo");
2700 std::fs::create_dir(&repo).unwrap();
2701
2702 let git_init = std::process::Command::new("git")
2704 .args(["init", "-b", "main"])
2705 .current_dir(&repo)
2706 .output()
2707 .expect("git init");
2708 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2709
2710 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2711
2712 let git_commit = std::process::Command::new("git")
2713 .args([
2714 "-c",
2715 "user.name=pitchfork-test",
2716 "-c",
2717 "user.email=pitchfork-test@example.com",
2718 "add",
2719 "-A",
2720 ])
2721 .current_dir(&repo)
2722 .output()
2723 .expect("git add");
2724 assert!(git_commit.status.success());
2725
2726 let git_commit = std::process::Command::new("git")
2727 .args([
2728 "-c",
2729 "user.name=pitchfork-test",
2730 "-c",
2731 "user.email=pitchfork-test@example.com",
2732 "commit",
2733 "-m",
2734 "init",
2735 ])
2736 .current_dir(&repo)
2737 .output()
2738 .expect("git commit");
2739 assert!(
2740 git_commit.status.success(),
2741 "git commit failed: {:?}",
2742 git_commit
2743 );
2744
2745 let wt = temp.path().join("my-repo-feature");
2746 let git_wt = std::process::Command::new("git")
2747 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2748 .current_dir(&repo)
2749 .output()
2750 .expect("git worktree add");
2751 assert!(
2752 git_wt.status.success(),
2753 "git worktree add failed: {:?}",
2754 git_wt
2755 );
2756
2757 std::fs::write(
2759 repo.join("pitchfork.toml"),
2760 "[daemons.api]\nrun = \"echo main\"\n",
2761 )
2762 .unwrap();
2763 std::fs::write(
2765 wt.join("pitchfork.toml"),
2766 "[daemons.worker]\nrun = \"echo wt\"\n",
2767 )
2768 .unwrap();
2769
2770 super::invalidate_config_cache();
2771
2772 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2774
2775 let main_id = DaemonId::new("my-repo", "api");
2776 let wt_id = DaemonId::new("my-repo-feature", "worker");
2777 assert!(
2778 pt.daemons.contains_key(&main_id),
2779 "main checkout daemon missing"
2780 );
2781 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2782
2783 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2785 assert!(pt_from_main.daemons.contains_key(&wt_id));
2786
2787 let _ = std::process::Command::new("git")
2789 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2790 .current_dir(&repo)
2791 .output();
2792 super::invalidate_config_cache();
2793 }
2794
2795 #[test]
2796 fn test_adhoc_id_uses_invocation_directory_namespace() {
2797 let temp = tempfile::tempdir().unwrap();
2798 let project = temp.path().join("feature-tree");
2799 std::fs::create_dir(&project).unwrap();
2800 std::fs::write(
2801 project.join("pitchfork.toml"),
2802 "[daemons.other]\nrun = \"true\"\n",
2803 )
2804 .unwrap();
2805
2806 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
2807 assert_eq!(id, DaemonId::new("feature-tree", "api"));
2808 let qualified =
2809 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
2810 assert_eq!(qualified, DaemonId::new("explicit", "api"));
2811 }
2812
2813 #[test]
2814 fn test_adhoc_id_falls_back_to_global_without_project_config() {
2815 let temp = tempfile::tempdir().unwrap();
2816 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
2817 assert_eq!(id, DaemonId::new("global", "api"));
2818 }
2819}