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 ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig, StopSignal, WatchMode,
21};
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
31pub struct SlugEntryRaw {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub dir: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub namespace: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub daemon: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct SlugEntry {
46 pub dir: Option<PathBuf>,
48 pub namespace: Option<String>,
50 pub daemon: Option<String>,
52}
53
54impl SlugEntry {
55 pub fn resolve_dir(&self) -> Option<PathBuf> {
58 self.dir.clone().or_else(|| {
59 self.namespace.as_ref().and_then(|ns| {
60 let namespaces = PitchforkToml::read_global_namespaces();
61 namespaces.get(ns).map(|entry| entry.dir.clone())
62 })
63 })
64 }
65
66 pub fn resolve_namespace(&self) -> Option<String> {
69 self.namespace.clone().or_else(|| {
70 self.resolve_dir()
71 .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
72 })
73 }
74}
75
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
82pub struct GroupEntryRaw {
83 #[schemars(with = "Vec<DaemonId>")]
84 pub daemons: Vec<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct GroupEntry {
90 pub daemons: Vec<DaemonId>,
91}
92
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
99pub struct NamespaceEntryRaw {
100 pub dir: String,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub config: Vec<String>,
105}
106
107#[derive(Debug, Clone)]
109pub struct NamespaceEntry {
110 pub dir: PathBuf,
112 pub config: Vec<PathBuf>,
113}
114
115#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
117struct PitchforkTomlRaw {
118 #[serde(skip_serializing_if = "Option::is_none", default)]
119 pub namespace: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none", default)]
122 pub worktree_label: Option<String>,
123 #[serde(default)]
124 pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
125 #[serde(skip_serializing_if = "Option::is_none", default)]
128 pub env: Option<IndexMap<String, String>>,
129 #[serde(default)]
130 pub settings: Option<SettingsPartial>,
131 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
134 pub slugs: IndexMap<String, SlugEntryRaw>,
135 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
137 pub groups: IndexMap<String, GroupEntryRaw>,
138 #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
141 pub namespaces: IndexMap<String, NamespaceEntryRaw>,
142}
143
144#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
149pub struct PitchforkTomlDaemonLogs {
150 #[serde(skip_serializing_if = "Option::is_none", default)]
153 pub log_format: Option<String>,
154 #[serde(skip_serializing_if = "Option::is_none", default)]
156 pub time_retention: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none", default)]
159 pub line_retention: Option<i64>,
160 #[serde(skip_serializing_if = "Option::is_none", default)]
162 pub archive_hook: Option<String>,
163}
164
165#[derive(Debug, serde::Serialize, serde::Deserialize)]
172struct PitchforkTomlDaemonRaw {
173 pub run: String,
174 #[serde(skip_serializing_if = "Vec::is_empty", default)]
175 pub auto: Vec<PitchforkTomlAuto>,
176 #[serde(skip_serializing_if = "Option::is_none", default)]
177 pub oneshot: Option<bool>,
178 #[serde(skip_serializing_if = "Option::is_none", default)]
179 pub cron: Option<PitchforkTomlCron>,
180 #[serde(default)]
181 pub retry: Retry,
182 #[serde(skip_serializing_if = "Option::is_none", default)]
183 pub ready_delay: Option<u64>,
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub ready_output: Option<ReadyOutput>,
186 #[serde(skip_serializing_if = "Option::is_none", default)]
187 pub ready_http: Option<ReadyHttp>,
188 #[serde(skip_serializing_if = "Option::is_none", default)]
189 pub ready_port: Option<ReadyPort>,
190 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub ready_cmd: Option<ReadyCmd>,
192 #[serde(skip_serializing_if = "Option::is_none", default)]
193 pub health_cmd: Option<HealthCmd>,
194 #[serde(skip_serializing_if = "Option::is_none", default)]
195 pub health_http: Option<HealthHttp>,
196 #[serde(skip_serializing_if = "Option::is_none", default)]
197 pub health_port: Option<HealthPort>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
200 pub port: Option<PortConfig>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
203 pub proxy: Option<ProxyConfig>,
204 #[serde(skip_serializing_if = "Vec::is_empty", default)]
206 pub expected_port: Vec<u16>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub auto_bump_port: Option<bool>,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub port_bump_attempts: Option<u32>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
214 pub boot_start: Option<bool>,
215 #[serde(skip_serializing_if = "Vec::is_empty", default)]
216 pub depends: Vec<String>,
217 #[serde(skip_serializing_if = "Vec::is_empty", default)]
218 pub watch: Vec<String>,
219 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub watch_mode: Option<WatchMode>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
222 pub dir: Option<String>,
223 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub env: Option<IndexMap<String, String>>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
226 pub hooks: Option<PitchforkTomlHooks>,
227 #[serde(skip_serializing_if = "Option::is_none", default)]
228 pub mise: Option<bool>,
229 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub user: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none", default)]
234 pub memory_limit: Option<MemoryLimit>,
235 #[serde(skip_serializing_if = "Option::is_none", default)]
237 pub cpu_limit: Option<CpuLimit>,
238 #[serde(skip_serializing_if = "Option::is_none", default)]
240 pub stop_signal: Option<StopConfig>,
241 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub pty: Option<bool>,
244 #[serde(skip_serializing_if = "Option::is_none", default)]
247 pub time_retention: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none", default)]
251 pub line_retention: Option<i64>,
252 #[serde(skip_serializing_if = "Option::is_none", default)]
255 pub archive_hook: Option<String>,
256 #[serde(skip_serializing_if = "Option::is_none", default)]
258 pub logs: Option<PitchforkTomlDaemonLogs>,
259}
260
261#[derive(Debug, Clone, Default, JsonSchema)]
266#[schemars(title = "Pitchfork Configuration")]
267pub struct PitchforkToml {
268 #[serde(default)]
270 pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
271 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub env: Option<IndexMap<String, String>>,
276 pub namespace: Option<String>,
281 #[schemars(default, with = "Option<String>")]
285 pub worktree_label: Option<String>,
286 #[serde(default)]
295 pub(crate) settings: SettingsPartial,
296 #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
301 pub slugs: IndexMap<String, SlugEntry>,
302 #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
304 pub groups: IndexMap<String, GroupEntry>,
305 #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
308 pub namespaces: IndexMap<String, NamespaceEntry>,
309 #[schemars(skip)]
310 pub path: Option<PathBuf>,
311}
312
313pub fn is_global_config(path: &Path) -> bool {
314 path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
315}
316
317pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
318 path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
319}
320
321fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
322 use toml::Value;
323
324 let doc: Value = toml::from_str(content)
325 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
326 let Some(value) = doc.get("namespace") else {
327 return Ok(None);
328 };
329
330 match value {
331 Value::String(s) => Ok(Some(s.clone())),
332 _ => Err(ConfigParseError::InvalidNamespace {
333 path: path.to_path_buf(),
334 namespace: value.to_string(),
335 reason: "top-level 'namespace' must be a string".to_string(),
336 }
337 .into()),
338 }
339}
340
341fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
342 if !path.exists() {
343 return Ok(None);
344 }
345 let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
346 path: path.to_path_buf(),
347 source: e,
348 })?;
349 parse_namespace_override_from_content(path, &content)
350}
351
352pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
353 crate::extra_configs::project_dir(path).or_else(|| {
354 if is_dot_config_pitchfork(path) {
355 path.parent().and_then(Path::parent).map(Path::to_path_buf)
356 } else {
357 path.parent().map(Path::to_path_buf)
358 }
359 })
360}
361
362fn project_config_family(path: &Path) -> Vec<PathBuf> {
363 let Some(dir) = project_dir_for_config(path) else {
364 return vec![path.to_path_buf()];
365 };
366 vec![
367 dir.join(".config/pitchfork.toml"),
368 dir.join(".config/pitchfork.local.toml"),
369 dir.join("pitchfork.toml"),
370 dir.join("pitchfork.local.toml"),
371 ]
372}
373
374fn directory_namespace_override(
377 path: &Path,
378 content_override: Option<&str>,
379) -> Result<Option<String>> {
380 if is_global_config(path) {
381 return match content_override {
382 Some(content) => parse_namespace_override_from_content(path, content),
383 None => read_namespace_override_from_file(path),
384 };
385 }
386
387 let mut selected: Option<(String, PathBuf)> = None;
388 for candidate in project_config_family(path) {
389 let explicit = if candidate == path {
390 match content_override {
391 Some(content) => parse_namespace_override_from_content(&candidate, content)?,
392 None => read_namespace_override_from_file(&candidate)?,
393 }
394 } else {
395 read_namespace_override_from_file(&candidate)?
396 };
397 let Some(namespace) = explicit else { continue };
398 if let Some((selected_namespace, selected_path)) = &selected
399 && selected_namespace != &namespace
400 {
401 return Err(ConfigParseError::InvalidNamespace {
402 path: candidate,
403 namespace,
404 reason: format!(
405 "namespace does not match directory-level namespace '{}' declared in {}",
406 selected_namespace,
407 selected_path.display()
408 ),
409 }
410 .into());
411 }
412 selected = Some((namespace, candidate));
413 }
414 Ok(selected.map(|(namespace, _)| namespace))
415}
416
417fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
418 if let Err(e) = DaemonId::try_new(namespace, "probe") {
419 return Err(ConfigParseError::InvalidNamespace {
420 path: path.to_path_buf(),
421 namespace: namespace.to_string(),
422 reason: e.to_string(),
423 }
424 .into());
425 }
426 Ok(namespace.to_string())
427}
428
429fn derive_namespace_from_dir(path: &Path) -> Result<String> {
430 let dir_for_namespace = project_dir_for_config(path);
431 if let Some(namespace) = dir_for_namespace
432 .as_deref()
433 .and_then(crate::extra_configs::namespace_for_dir)
434 {
435 return validate_namespace(path, &namespace);
436 }
437 let raw_namespace = dir_for_namespace
438 .as_deref()
439 .and_then(|p| p.file_name())
440 .and_then(|n| n.to_str())
441 .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
442 .to_string();
443
444 validate_namespace(path, &raw_namespace).map_err(|e| {
445 ConfigParseError::InvalidNamespace {
446 path: path.to_path_buf(),
447 namespace: raw_namespace,
448 reason: format!(
449 "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
450 ),
451 }
452 .into()
453 })
454}
455
456fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
457 if is_global_config(path) {
458 if let Some(ns) = explicit
459 && ns != "global"
460 {
461 return Err(ConfigParseError::InvalidNamespace {
462 path: path.to_path_buf(),
463 namespace: ns.to_string(),
464 reason: "global config files must use namespace 'global'".to_string(),
465 }
466 .into());
467 }
468 return Ok("global".to_string());
469 }
470
471 if let Some(ns) = explicit {
472 return validate_namespace(path, ns);
473 }
474
475 derive_namespace_from_dir(path)
476}
477
478fn namespace_from_file(path: &Path) -> Result<String> {
479 let explicit = directory_namespace_override(path, None)?;
480 namespace_from_path_with_override(path, explicit.as_deref())
481}
482
483pub fn namespace_from_path(path: &Path) -> Result<String> {
496 namespace_from_file(path)
497}
498
499fn find_project_root(dir: &Path) -> Option<PathBuf> {
507 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
511 let mut current = canonical_dir.as_path();
512 loop {
513 if current.join(".git").exists() || current.join(".jj").exists() {
514 return Some(current.to_path_buf());
515 }
516 current = current.parent()?;
517 }
518}
519
520struct ConfigCacheEntry {
539 config: PitchforkToml,
540 source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
542}
543
544static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
551 Lazy::new(|| StdMutex::new(HashMap::new()));
552
553fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
559 if paths.len() != snapshot.len() {
560 return false;
561 }
562 paths
563 .iter()
564 .zip(snapshot.iter())
565 .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
566}
567
568pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
570 let md = std::fs::metadata(path).ok()?;
571 Some((md.modified().ok()?, md.len()))
572}
573
574fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
576 paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
577}
578
579pub fn invalidate_config_cache() {
596 crate::extra_configs::invalidate();
597 if let Ok(mut cache) = CONFIG_CACHE.lock() {
598 cache.clear();
599 }
600}
601
602impl PitchforkToml {
603 pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
616 if user_id.contains('/') {
618 return match DaemonId::parse(user_id) {
619 Ok(id) => Ok(vec![id]),
620 Err(e) => Err(e), };
622 }
623
624 let global_slugs = Self::read_global_slugs();
626 if let Some(entry) = global_slugs.get(user_id) {
627 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
629 if let Some(dir) = entry.resolve_dir()
630 && let Ok(project_config) = Self::all_merged_from(&dir)
631 {
632 let matches: Vec<DaemonId> = project_config
634 .daemons
635 .keys()
636 .filter(|id| id.name() == daemon_name)
637 .cloned()
638 .collect();
639 match matches.as_slice() {
640 [] => {}
641 [id] => return Ok(vec![id.clone()]),
642 _ => {
643 let mut candidates: Vec<String> =
644 matches.iter().map(|id| id.qualified()).collect();
645 candidates.sort();
646 return Err(miette::miette!(
647 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
648 user_id,
649 daemon_name,
650 candidates.join(", ")
651 ));
652 }
653 }
654 }
655 }
656
657 let matches: Vec<DaemonId> = self
659 .daemons
660 .keys()
661 .filter(|id| id.name() == user_id)
662 .cloned()
663 .collect();
664
665 if matches.is_empty() {
666 let state_matches = Self::find_in_state_file(user_id);
668 match state_matches.as_slice() {
669 [] => {}
670 [id] => return Ok(vec![id.clone()]),
671 _ => {
672 let mut candidates: Vec<String> =
673 state_matches.iter().map(|id| id.qualified()).collect();
674 candidates.sort();
675 return Err(miette::miette!(
676 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
677 user_id,
678 candidates.join(", ")
679 ));
680 }
681 }
682 let _ = DaemonId::try_new("global", user_id)?;
684 }
685 Ok(matches)
686 }
687
688 fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
694 match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
695 Ok(state) => state
696 .daemons
697 .keys()
698 .filter(|id| id.name() == short_name)
699 .cloned()
700 .collect(),
701 Err(e) => {
702 warn!("cannot read state file: {e}");
703 Vec::new()
704 }
705 }
706 }
707
708 #[allow(dead_code)]
729 pub fn resolve_daemon_id_prefer_local(
730 &self,
731 user_id: &str,
732 current_dir: &Path,
733 ) -> Result<DaemonId> {
734 if user_id.contains('/') {
736 return DaemonId::parse(user_id);
737 }
738
739 let current_namespace = Self::namespace_for_dir(current_dir)?;
743
744 self.resolve_daemon_id_with_namespace(user_id, ¤t_namespace)
745 }
746
747 fn resolve_daemon_id_with_namespace(
750 &self,
751 user_id: &str,
752 current_namespace: &str,
753 ) -> Result<DaemonId> {
754 let global_slugs = Self::read_global_slugs();
756 if let Some(entry) = global_slugs.get(user_id) {
757 let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
758 if let Some(dir) = entry.resolve_dir()
759 && let Ok(project_config) = Self::all_merged_from(&dir)
760 {
761 let matches: Vec<DaemonId> = project_config
762 .daemons
763 .keys()
764 .filter(|id| id.name() == daemon_name)
765 .cloned()
766 .collect();
767 match matches.as_slice() {
768 [] => {}
769 [id] => return Ok(id.clone()),
770 _ => {
771 let mut candidates: Vec<String> =
772 matches.iter().map(|id| id.qualified()).collect();
773 candidates.sort();
774 return Err(miette::miette!(
775 "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
776 user_id,
777 daemon_name,
778 candidates.join(", ")
779 ));
780 }
781 }
782 }
783 }
784
785 let preferred_id = DaemonId::try_new(current_namespace, user_id)?;
788 if self.daemons.contains_key(&preferred_id) {
789 return Ok(preferred_id);
790 }
791
792 let matches = self.resolve_daemon_id(user_id)?;
794
795 if matches.len() > 1 {
797 let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
798 candidates.sort();
799 return Err(miette::miette!(
800 "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
801 user_id,
802 candidates.join(", ")
803 ));
804 }
805
806 if let Some(id) = matches.into_iter().next() {
807 return Ok(id);
808 }
809
810 let global_id = DaemonId::try_new("global", user_id)?;
813 if self.daemons.contains_key(&global_id) {
814 return Ok(global_id);
815 }
816
817 let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
818 Err(DependencyError::DaemonNotFound {
819 name: user_id.to_string(),
820 suggestion,
821 }
822 .into())
823 }
824
825 pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
827 namespace_from_path(&dir.join("pitchfork.toml"))
828 }
829
830 pub fn project_worktree_label(dir: &Path) -> Option<String> {
838 let mut label = None;
839 let candidates = project_config_family(&dir.join("pitchfork.toml"))
840 .into_iter()
841 .chain(crate::extra_configs::configs_for_dir(dir));
842 for candidate in candidates {
843 if !candidate.exists() {
844 continue;
845 }
846 if let Ok(pt) = Self::read(&candidate)
847 && let Some(found) = pt.worktree_label
848 {
849 label = Some(found);
850 }
851 }
852 label
853 }
854
855 pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
857 directory_namespace_override(&dir.join("pitchfork.toml"), None)
858 }
859
860 pub fn namespace_for_dir(dir: &Path) -> Result<String> {
862 Ok(Self::list_paths_from(dir)
863 .iter()
864 .filter(|p| p.exists())
865 .max_by_key(|p| {
866 if is_global_config(p) {
867 0
868 } else {
869 project_dir_for_config(p).map_or(0, |dir| dir.components().count())
870 }
871 })
872 .map(|p| namespace_from_path(p))
873 .transpose()?
874 .unwrap_or_else(|| "global".to_string()))
875 }
876
877 pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
887 if user_id.contains('/') {
888 return DaemonId::parse(user_id);
889 }
890
891 let config = Self::all_merged()?;
894 let ns = Self::namespace_for_dir(&env::CWD)?;
895 config.resolve_daemon_id_with_namespace(user_id, &ns)
896 }
897
898 pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
904 Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
905 }
906
907 fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
908 if user_id.contains('/') {
909 return DaemonId::parse(user_id);
910 }
911
912 let ns = Self::namespace_for_dir(dir)?;
913 DaemonId::try_new(ns, user_id)
914 }
915
916 pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
927 if user_ids.iter().all(|s| s.as_ref().contains('/')) {
929 return user_ids
930 .iter()
931 .map(|s| DaemonId::parse(s.as_ref()))
932 .collect();
933 }
934
935 let config = Self::all_merged()?;
936 let ns = Self::namespace_for_dir(&env::CWD)?;
938 user_ids
939 .iter()
940 .map(|s| {
941 let id = s.as_ref();
942 if id.contains('/') {
943 DaemonId::parse(id)
944 } else {
945 config.resolve_daemon_id_with_namespace(id, &ns)
946 }
947 })
948 .collect()
949 }
950
951 pub fn resolve_ids_and_group<S: AsRef<str>>(
956 user_ids: &[S],
957 group_name: Option<&str>,
958 ) -> Result<Vec<DaemonId>> {
959 let config = Self::all_merged()?;
960 let ns = Self::namespace_for_dir(&env::CWD)?;
961 let mut ids = Vec::new();
962 let mut seen = std::collections::HashSet::new();
963
964 for id in user_ids {
965 let id_str = id.as_ref();
966 let daemon_id = if id_str.contains('/') {
967 DaemonId::parse(id_str)?
968 } else {
969 config.resolve_daemon_id_with_namespace(id_str, &ns)?
970 };
971 if seen.insert(daemon_id.clone()) {
972 ids.push(daemon_id);
973 }
974 }
975
976 if let Some(name) = group_name {
977 match config.groups.get(name) {
978 Some(group) => {
979 let missing: Vec<String> = group
980 .daemons
981 .iter()
982 .filter(|id| !config.daemons.contains_key(*id))
983 .map(|id| id.qualified())
984 .collect();
985 if !missing.is_empty() {
986 return Err(miette::miette!(
987 "group '{}' references undefined daemon{}: {}",
988 name,
989 if missing.len() > 1 { "s" } else { "" },
990 missing.join(", ")
991 ));
992 }
993 for daemon_id in &group.daemons {
994 if seen.insert(daemon_id.clone()) {
995 ids.push(daemon_id.clone());
996 }
997 }
998 }
999 None => {
1000 let suggestion =
1001 find_similar_daemon(name, config.groups.keys().map(|s| s.as_str()));
1002 return Err(miette::miette!(
1003 "group '{}' not found in configuration{}",
1004 name,
1005 suggestion.map(|s| format!(", {s}")).unwrap_or_default()
1006 ));
1007 }
1008 }
1009 }
1010
1011 Ok(ids)
1012 }
1013
1014 pub fn list_paths() -> Vec<PathBuf> {
1017 Self::list_paths_from(&env::CWD)
1018 }
1019
1020 pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1031 let mut paths = Vec::new();
1032 paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1033 paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1034
1035 let mut project_paths = xx::file::find_up_all(
1039 cwd,
1040 &[
1041 "pitchfork.local.toml",
1042 "pitchfork.toml",
1043 ".config/pitchfork.local.toml",
1044 ".config/pitchfork.toml",
1045 ],
1046 );
1047 project_paths.reverse();
1048 paths.extend(project_paths);
1049 paths.extend(crate::extra_configs::paths_for(cwd));
1050
1051 paths
1052 }
1053
1054 pub fn all_merged() -> Result<PitchforkToml> {
1057 Self::all_merged_from(&env::CWD)
1058 }
1059 pub fn all_merged_all_namespaces() -> Result<Self> {
1066 Self::all_merged_all_namespaces_from(&env::CWD)
1067 }
1068
1069 pub(crate) fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1072 let mut pt = Self::all_merged_from(start_dir)?;
1073
1074 let namespaces = Self::read_global_namespaces();
1075 for (ns_name, entry) in namespaces {
1076 match Self::all_merged_from(&entry.dir) {
1077 Ok(ns_config) => {
1078 for (daemon_id, daemon_config) in ns_config.daemons {
1079 if !pt.daemons.contains_key(&daemon_id) {
1080 pt.daemons.insert(daemon_id, daemon_config);
1081 }
1082 }
1083 pt.settings.merge_from(&ns_config.settings);
1086 }
1087 Err(e) => {
1088 log::warn!(
1089 "Failed to load namespace '{ns_name}' from {}: {e}",
1090 entry.dir.display()
1091 );
1092 }
1093 }
1094 }
1095
1096 if crate::settings::settings().general.worktree
1106 && let Some(project_root) = find_project_root(start_dir)
1107 {
1108 let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1109 for wt in &worktrees {
1110 match Self::all_merged_from(&wt.path) {
1111 Ok(wt_config) => {
1112 for (daemon_id, daemon_config) in wt_config.daemons {
1113 if !pt.daemons.contains_key(&daemon_id) {
1114 pt.daemons.insert(daemon_id, daemon_config);
1115 }
1116 }
1117 pt.settings.merge_from(&wt_config.settings);
1118 }
1119 Err(e) => {
1120 log::warn!(
1121 "Failed to load worktree '{}' config from {}: {e}",
1122 wt.branch,
1123 wt.path.display()
1124 );
1125 }
1126 }
1127 }
1128 }
1129
1130 Ok(pt)
1131 }
1132
1133 pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1151 let paths = Self::list_paths_from(cwd);
1152
1153 let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1157
1158 {
1159 let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1160 if let Some(entry) = cache.get(&cache_key)
1161 && meta_matches(&paths, &entry.source_meta)
1162 {
1163 return Ok(entry.config.clone());
1164 }
1165 }
1166
1167 let snapshot = snapshot_meta(&paths);
1173 let pt = Self::all_merged_from_uncached(&paths)?;
1174
1175 let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1177 cache.insert(
1178 cache_key,
1179 ConfigCacheEntry {
1180 config: pt.clone(),
1181 source_meta: snapshot,
1182 },
1183 );
1184
1185 Ok(pt)
1186 }
1187
1188 fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1193 use std::collections::HashMap as StdHashMap;
1194
1195 let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1196
1197 let mut pt = Self::default();
1198 for p in paths {
1199 match Self::read(p) {
1200 Ok(pt2) => {
1201 if p.exists() && !is_global_config(p) {
1205 let ns = namespace_from_path(p)?;
1206 let origin_dir = project_dir_for_config(p)
1207 .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1208 .unwrap_or_else(|| p.clone());
1209
1210 if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1211 && *other_dir != origin_dir
1212 {
1213 return Err(crate::error::ConfigParseError::NamespaceCollision {
1214 path_a: other_path.clone(),
1215 path_b: p.clone(),
1216 ns,
1217 }
1218 .into());
1219 }
1220 ns_to_origin.insert(ns, (p.clone(), origin_dir));
1221 }
1222
1223 pt.merge(pt2)
1224 }
1225 Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1226 }
1227 }
1228 Ok(pt)
1229 }
1230}
1231
1232impl PitchforkToml {
1233 pub fn new(path: PathBuf) -> Self {
1234 Self {
1235 daemons: Default::default(),
1236 env: None,
1237 namespace: None,
1238 worktree_label: None,
1239 settings: SettingsPartial::default(),
1240 slugs: IndexMap::new(),
1241 groups: IndexMap::new(),
1242 namespaces: IndexMap::new(),
1243 path: Some(path),
1244 }
1245 }
1246
1247 pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1255 let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1256 .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1257 if let Some(settings) = &mut raw_config.settings {
1258 settings.canonicalize_aliases();
1259 }
1260
1261 let explicit = directory_namespace_override(path, Some(content))?;
1262 let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1263 let mut pt = Self::new(path.to_path_buf());
1264 pt.namespace = raw_config.namespace.clone();
1265 pt.worktree_label = raw_config.worktree_label.clone();
1266
1267 for (short_name, raw_daemon) in raw_config.daemons {
1268 let id = match DaemonId::try_new(&namespace, &short_name) {
1269 Ok(id) => id,
1270 Err(e) => {
1271 return Err(ConfigParseError::InvalidDaemonName {
1272 name: short_name,
1273 path: path.to_path_buf(),
1274 reason: e.to_string(),
1275 }
1276 .into());
1277 }
1278 };
1279
1280 let mut depends = Vec::new();
1281 for dep in raw_daemon.depends {
1282 let dep_id = if dep.contains('/') {
1283 match DaemonId::parse(&dep) {
1284 Ok(id) => id,
1285 Err(e) => {
1286 return Err(ConfigParseError::InvalidDependency {
1287 daemon: short_name.clone(),
1288 dependency: dep,
1289 path: path.to_path_buf(),
1290 reason: e.to_string(),
1291 }
1292 .into());
1293 }
1294 }
1295 } else {
1296 match DaemonId::try_new(&namespace, &dep) {
1297 Ok(id) => id,
1298 Err(e) => {
1299 return Err(ConfigParseError::InvalidDependency {
1300 daemon: short_name.clone(),
1301 dependency: dep,
1302 path: path.to_path_buf(),
1303 reason: e.to_string(),
1304 }
1305 .into());
1306 }
1307 }
1308 };
1309 depends.push(dep_id);
1310 }
1311
1312 let has_deprecated = !raw_daemon.expected_port.is_empty()
1314 || raw_daemon.auto_bump_port.is_some()
1315 || raw_daemon.port_bump_attempts.is_some();
1316 let port = if let Some(port) = raw_daemon.port {
1317 if has_deprecated {
1318 warn!(
1319 "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1320 );
1321 }
1322 Some(port)
1323 } else if has_deprecated {
1324 warn!(
1325 "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead"
1326 );
1327 let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1328 PortBump(
1329 raw_daemon
1330 .port_bump_attempts
1331 .unwrap_or_else(|| settings().default_port_bump_attempts()),
1332 )
1333 } else {
1334 PortBump(0)
1335 };
1336 Some(PortConfig {
1337 expect: raw_daemon.expected_port,
1338 bump,
1339 })
1340 } else {
1341 None
1342 };
1343
1344 let daemon = PitchforkTomlDaemon {
1345 run: raw_daemon.run,
1346 auto: raw_daemon.auto,
1347 oneshot: raw_daemon.oneshot,
1348 cron: raw_daemon.cron,
1349 retry: raw_daemon.retry,
1350 ready_delay: raw_daemon.ready_delay,
1351 ready_output: raw_daemon.ready_output,
1352 ready_http: raw_daemon.ready_http,
1353 ready_port: raw_daemon.ready_port,
1354 ready_cmd: raw_daemon.ready_cmd,
1355 health_cmd: raw_daemon.health_cmd,
1356 health_http: raw_daemon.health_http,
1357 health_port: raw_daemon.health_port,
1358 port,
1359 proxy: raw_daemon.proxy,
1360 boot_start: raw_daemon.boot_start,
1361 depends,
1362 watch: raw_daemon.watch,
1363 watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1364 dir: raw_daemon.dir,
1365 env: raw_daemon.env,
1366 hooks: raw_daemon.hooks,
1367 mise: raw_daemon.mise,
1368 user: raw_daemon.user,
1369 memory_limit: raw_daemon.memory_limit,
1370 cpu_limit: raw_daemon.cpu_limit,
1371 stop_signal: raw_daemon.stop_signal,
1372 pty: raw_daemon.pty,
1373 time_retention: raw_daemon.time_retention,
1374 line_retention: raw_daemon.line_retention,
1375 archive_hook: raw_daemon.archive_hook,
1376 logs: raw_daemon.logs,
1377 path: Some(path.to_path_buf()),
1378 };
1379 if daemon.is_oneshot() {
1380 let conflicts = daemon.oneshot_conflicts();
1381 if !conflicts.is_empty() {
1382 return Err(ConfigParseError::OneshotConflict {
1383 daemon: short_name.clone(),
1384 path: path.to_path_buf(),
1385 conflicts: conflicts.into_iter().map(str::to_string).collect(),
1386 }
1387 .into());
1388 }
1389 }
1390 pt.daemons.insert(id, daemon);
1391 }
1392
1393 if let Some(settings) = raw_config.settings {
1395 pt.settings = settings;
1396 }
1397
1398 pt.env = raw_config.env;
1400
1401 for (slug, entry) in raw_config.slugs {
1403 pt.slugs.insert(
1404 slug,
1405 SlugEntry {
1406 dir: entry.dir.map(env::expand_tilde),
1407 namespace: entry.namespace,
1408 daemon: entry.daemon,
1409 },
1410 );
1411 }
1412
1413 for (name, entry) in raw_config.namespaces {
1415 pt.namespaces.insert(
1416 name,
1417 NamespaceEntry {
1418 config: entry
1419 .config
1420 .iter()
1421 .map(|p| {
1422 crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1423 })
1424 .collect(),
1425 dir: env::expand_tilde(entry.dir),
1426 },
1427 );
1428 }
1429
1430 for (group_name, raw_group) in raw_config.groups {
1432 let mut daemons = Vec::new();
1433 for daemon_name in &raw_group.daemons {
1434 let id = if daemon_name.contains('/') {
1435 DaemonId::parse(daemon_name).map_err(|e| {
1436 ConfigParseError::InvalidDependency {
1437 daemon: group_name.clone(),
1438 dependency: daemon_name.clone(),
1439 path: path.to_path_buf(),
1440 reason: e.to_string(),
1441 }
1442 })?
1443 } else {
1444 DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1445 ConfigParseError::InvalidDaemonName {
1446 name: daemon_name.clone(),
1447 path: path.to_path_buf(),
1448 reason: e.to_string(),
1449 }
1450 })?
1451 };
1452 daemons.push(id);
1453 }
1454 pt.groups.insert(group_name, GroupEntry { daemons });
1455 }
1456
1457 Ok(pt)
1458 }
1459
1460 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1461 let path = path.as_ref();
1462 if !path.exists() {
1463 return Ok(Self::new(path.to_path_buf()));
1464 }
1465 let _lock = xx::fslock::get(path, false)
1466 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1467 let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1468 path: path.to_path_buf(),
1469 source: e,
1470 })?;
1471 Self::parse_str(&raw, path)
1472 }
1473
1474 pub fn write(&self) -> Result<()> {
1475 if let Some(path) = &self.path {
1476 let _lock = xx::fslock::get(path, false)
1477 .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1478 self.write_unlocked()
1479 } else {
1480 Err(FileError::NoPath.into())
1481 }
1482 }
1483
1484 pub(crate) fn write_unlocked(&self) -> Result<()> {
1490 if let Some(path) = &self.path {
1491 let config_namespace = if path.exists() {
1493 namespace_from_path(path)?
1494 } else {
1495 namespace_from_path_with_override(path, self.namespace.as_deref())?
1496 };
1497
1498 let mut raw = PitchforkTomlRaw {
1502 namespace: self.namespace.clone(),
1503 worktree_label: self.worktree_label.clone(),
1504 env: self.env.clone(),
1505 settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1506 ..PitchforkTomlRaw::default()
1507 };
1508 for (id, daemon) in &self.daemons {
1509 if id.namespace() != config_namespace {
1510 return Err(miette::miette!(
1511 "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1512 id,
1513 path.display(),
1514 id.namespace(),
1515 config_namespace
1516 ));
1517 }
1518 let port = daemon.port.as_ref();
1519 let raw_daemon = PitchforkTomlDaemonRaw {
1520 run: daemon.run.clone(),
1521 auto: daemon.auto.clone(),
1522 oneshot: daemon.oneshot,
1523 cron: daemon.cron.clone(),
1524 retry: daemon.retry,
1525 ready_delay: daemon.ready_delay,
1526 ready_output: daemon.ready_output.clone(),
1527 ready_http: daemon.ready_http.clone(),
1528 ready_port: daemon.ready_port.clone(),
1529 ready_cmd: daemon.ready_cmd.clone(),
1530 health_cmd: daemon.health_cmd.clone(),
1531 health_http: daemon.health_http.clone(),
1532 health_port: daemon.health_port.clone(),
1533 port: port.cloned(),
1534 proxy: daemon.proxy.clone(),
1535 expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1537 auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1538 port_bump_attempts: port
1539 .filter(|p| p.auto_bump())
1540 .map(|p| p.max_bump_attempts()),
1541 boot_start: daemon.boot_start,
1542 depends: daemon
1545 .depends
1546 .iter()
1547 .map(|d| {
1548 if d.namespace() == config_namespace {
1549 d.name().to_string()
1550 } else {
1551 d.qualified()
1552 }
1553 })
1554 .collect(),
1555 watch: daemon.watch.clone(),
1556 watch_mode: match daemon.watch_mode {
1557 WatchMode::Native => None,
1558 mode => Some(mode),
1559 },
1560 dir: daemon.dir.clone(),
1561 env: daemon.env.clone(),
1562 hooks: daemon.hooks.clone(),
1563 mise: daemon.mise,
1564 user: daemon.user.clone(),
1565 memory_limit: daemon.memory_limit,
1566 cpu_limit: daemon.cpu_limit,
1567 stop_signal: daemon.stop_signal,
1568 pty: daemon.pty,
1569 time_retention: daemon.time_retention.clone(),
1570 line_retention: daemon.line_retention,
1571 archive_hook: daemon.archive_hook.clone(),
1572 logs: daemon.logs.clone(),
1573 };
1574 raw.daemons.insert(id.name().to_string(), raw_daemon);
1575 }
1576
1577 for (slug, entry) in &self.slugs {
1579 raw.slugs.insert(
1580 slug.clone(),
1581 SlugEntryRaw {
1582 dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1583 namespace: entry.namespace.clone(),
1584 daemon: entry.daemon.clone(),
1585 },
1586 );
1587 }
1588
1589 for (name, group) in &self.groups {
1591 let raw_daemons: Vec<String> = group
1592 .daemons
1593 .iter()
1594 .map(|id| {
1595 if id.namespace() == config_namespace {
1596 id.name().to_string()
1597 } else {
1598 id.qualified()
1599 }
1600 })
1601 .collect();
1602 raw.groups.insert(
1603 name.clone(),
1604 GroupEntryRaw {
1605 daemons: raw_daemons,
1606 },
1607 );
1608 }
1609
1610 for (name, entry) in &self.namespaces {
1612 raw.namespaces.insert(
1613 name.clone(),
1614 NamespaceEntryRaw {
1615 dir: entry.dir.to_string_lossy().to_string(),
1616 config: entry
1617 .config
1618 .iter()
1619 .map(|p| p.to_string_lossy().into_owned())
1620 .collect(),
1621 },
1622 );
1623 }
1624
1625 let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1626 path: path.clone(),
1627 source: e,
1628 })?;
1629 xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1630 path: path.clone(),
1631 details: Some(e.to_string()),
1632 })?;
1633 invalidate_config_cache();
1634 Ok(())
1635 } else {
1636 Err(FileError::NoPath.into())
1637 }
1638 }
1639
1640 pub fn merge(&mut self, pt: Self) {
1645 if pt.worktree_label.is_some() {
1646 self.worktree_label = pt.worktree_label.clone();
1647 }
1648 for (id, d) in pt.daemons {
1649 self.daemons.insert(id, d);
1650 }
1651 if let Some(env) = pt.env {
1653 let merged = self.env.get_or_insert_with(IndexMap::new);
1654 for (k, v) in env {
1655 merged.insert(k, v);
1656 }
1657 }
1658 for (slug, entry) in pt.slugs {
1660 self.slugs.insert(slug, entry);
1661 }
1662 for (name, group) in pt.groups {
1664 self.groups.insert(name, group);
1665 }
1666 for (name, entry) in pt.namespaces {
1668 self.namespaces.insert(name, entry);
1669 }
1670 self.settings.merge_from(&pt.settings);
1672 }
1673
1674 pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1679 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1680 Ok(pt) => pt.slugs,
1681 Err(_) => IndexMap::new(),
1682 }
1683 }
1684
1685 pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1692 global_slugs
1693 .keys()
1694 .filter(|k| k.eq_ignore_ascii_case(slug))
1695 .count()
1696 > 1
1697 }
1698
1699 pub fn find_slug_for_daemon_in_registry(
1704 daemon_id: &DaemonId,
1705 global_slugs: &IndexMap<String, SlugEntry>,
1706 ) -> Option<String> {
1707 global_slugs
1708 .iter()
1709 .find(|(slug, entry)| {
1710 let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1711 if daemon_id.name() != daemon_name {
1712 return false;
1713 }
1714
1715 if Self::slug_is_ambiguous(slug, global_slugs) {
1718 return false;
1719 }
1720
1721 match entry.resolve_namespace() {
1722 Some(namespace) => daemon_id.namespace() == namespace,
1723 None => false,
1724 }
1725 })
1726 .map(|(slug, _)| slug.clone())
1727 }
1728
1729 #[allow(dead_code)]
1731 pub fn is_slug_registered(slug: &str) -> bool {
1732 Self::read_global_slugs().contains_key(slug)
1733 }
1734
1735 pub fn add_slug_with_namespace(
1741 slug: &str,
1742 namespace: Option<&str>,
1743 daemon: Option<&str>,
1744 ) -> Result<()> {
1745 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1746
1747 if let Some(parent) = global_path.parent() {
1749 std::fs::create_dir_all(parent).map_err(|e| {
1750 miette::miette!(
1751 "Failed to create config directory {}: {e}",
1752 parent.display()
1753 )
1754 })?;
1755 }
1756
1757 let _lock = xx::fslock::get(global_path, false)
1758 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1759
1760 let mut pt = if global_path.exists() {
1761 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1762 path: global_path.to_path_buf(),
1763 source: e,
1764 })?;
1765 Self::parse_str(&raw, global_path)?
1766 } else {
1767 Self::new(global_path.to_path_buf())
1768 };
1769
1770 if let Some(ns) = namespace
1774 && !pt.namespaces.contains_key(ns)
1775 {
1776 let dir = pt
1781 .slugs
1782 .get(slug)
1783 .and_then(|e| {
1784 e.dir.clone().or_else(|| {
1785 e.namespace
1786 .as_ref()
1787 .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
1788 })
1789 })
1790 .or_else(|| env::CWD.as_path().canonicalize().ok());
1791 if let Some(ref d) = dir {
1792 pt.namespaces.insert(
1793 ns.to_string(),
1794 NamespaceEntry {
1795 dir: d.clone(),
1796 config: Vec::new(),
1797 },
1798 );
1799 }
1800 }
1801
1802 pt.slugs.insert(
1803 slug.to_string(),
1804 SlugEntry {
1805 dir: None,
1806 namespace: namespace.map(str::to_string),
1807 daemon: daemon.map(str::to_string),
1808 },
1809 );
1810 pt.write_unlocked()?;
1811 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1817 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1818 Ok(())
1819 }
1820
1821 pub fn remove_slug(slug: &str) -> Result<bool> {
1823 let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1824 if !global_path.exists() {
1825 return Ok(false);
1826 }
1827
1828 let _lock = xx::fslock::get(global_path, false)
1829 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1830
1831 let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1832 path: global_path.to_path_buf(),
1833 source: e,
1834 })?;
1835 let mut pt = Self::parse_str(&raw, global_path)?;
1836
1837 let removed = pt.slugs.shift_remove(slug).is_some();
1838 if removed {
1839 pt.write_unlocked()?;
1840 let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
1846 crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
1847 }
1848 Ok(removed)
1849 }
1850 pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1853 match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1854 Ok(pt) => pt.namespaces,
1855 Err(_) => IndexMap::new(),
1856 }
1857 }
1858
1859 pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1863 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1864
1865 if let Some(parent) = global_path.parent() {
1867 std::fs::create_dir_all(parent).map_err(|e| {
1868 miette::miette!(
1869 "Failed to create config directory {}: {e}",
1870 parent.display()
1871 )
1872 })?;
1873 }
1874
1875 let _lock = xx::fslock::get(global_path, false)
1876 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1877
1878 let mut pt = if global_path.exists() {
1879 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1880 crate::error::FileError::ReadError {
1881 path: global_path.to_path_buf(),
1882 source: e,
1883 }
1884 })?;
1885 Self::parse_str(&raw, global_path)?
1886 } else {
1887 Self::new(global_path.to_path_buf())
1888 };
1889
1890 let dir = env::expand_tilde(dir);
1891 if let Some(entry) = pt.namespaces.get_mut(name) {
1892 if !entry.config.is_empty()
1893 && crate::extra_configs::normalize(&entry.dir)
1894 != crate::extra_configs::normalize(&dir)
1895 {
1896 miette::bail!(
1897 "namespace '{name}' has external configuration attached to another directory"
1898 );
1899 }
1900 entry.dir = dir;
1901 } else {
1902 pt.namespaces.insert(
1903 name.to_string(),
1904 NamespaceEntry {
1905 dir,
1906 config: Vec::new(),
1907 },
1908 );
1909 }
1910 pt.write_unlocked()?;
1911 Ok(())
1912 }
1913
1914 pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1916 let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1917 if !global_path.exists() {
1918 return Ok(false);
1919 }
1920
1921 let _lock = xx::fslock::get(global_path, false)
1922 .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1923
1924 let raw = std::fs::read_to_string(global_path).map_err(|e| {
1925 crate::error::FileError::ReadError {
1926 path: global_path.to_path_buf(),
1927 source: e,
1928 }
1929 })?;
1930 let mut pt = Self::parse_str(&raw, global_path)?;
1931
1932 let removed = pt.namespaces.shift_remove(name).is_some();
1933 if removed {
1934 pt.write_unlocked()?;
1935 }
1936 Ok(removed)
1937 }
1938}
1939
1940#[derive(Debug, Clone, JsonSchema, Default)]
1942pub struct PitchforkTomlDaemon {
1943 #[schemars(example = example_run_command())]
1945 pub run: String,
1946 #[schemars(default)]
1948 pub auto: Vec<PitchforkTomlAuto>,
1949 pub oneshot: Option<bool>,
1955 pub cron: Option<PitchforkTomlCron>,
1957 #[schemars(default)]
1960 pub retry: Retry,
1961 pub ready_delay: Option<u64>,
1963 pub ready_output: Option<ReadyOutput>,
1965 pub ready_http: Option<ReadyHttp>,
1967 pub ready_port: Option<ReadyPort>,
1971 pub ready_cmd: Option<ReadyCmd>,
1973 pub health_cmd: Option<HealthCmd>,
1975 pub health_http: Option<HealthHttp>,
1977 pub health_port: Option<HealthPort>,
1981 pub port: Option<PortConfig>,
1983 #[serde(skip_serializing_if = "Option::is_none", default)]
1986 pub proxy: Option<ProxyConfig>,
1987 pub boot_start: Option<bool>,
1989 #[schemars(default)]
1991 pub depends: Vec<DaemonId>,
1992 #[schemars(default)]
1994 pub watch: Vec<String>,
1995 #[schemars(default)]
2001 pub watch_mode: WatchMode,
2002 pub dir: Option<String>,
2004 pub env: Option<IndexMap<String, String>>,
2006 pub hooks: Option<PitchforkTomlHooks>,
2008 pub mise: Option<bool>,
2011 pub user: Option<String>,
2013 pub memory_limit: Option<MemoryLimit>,
2016 pub cpu_limit: Option<CpuLimit>,
2019 pub stop_signal: Option<StopConfig>,
2022 pub pty: Option<bool>,
2024 pub time_retention: Option<String>,
2027 pub line_retention: Option<i64>,
2030 pub archive_hook: Option<String>,
2033 pub logs: Option<PitchforkTomlDaemonLogs>,
2035 #[schemars(skip)]
2036 pub path: Option<PathBuf>,
2037}
2038
2039impl PitchforkTomlDaemon {
2040 pub fn is_oneshot(&self) -> bool {
2042 self.oneshot.unwrap_or(false)
2043 }
2044
2045 pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2048 [
2049 ("ready_delay", self.ready_delay.is_some()),
2050 ("ready_output", self.ready_output.is_some()),
2051 ("ready_http", self.ready_http.is_some()),
2052 ("ready_port", self.ready_port.is_some()),
2053 ("ready_cmd", self.ready_cmd.is_some()),
2054 ("health_cmd", self.health_cmd.is_some()),
2055 ("health_http", self.health_http.is_some()),
2056 ("health_port", self.health_port.is_some()),
2057 ]
2058 .into_iter()
2059 .filter(|(_, set)| *set)
2060 .map(|(name, _)| name)
2061 .collect()
2062 }
2063
2064 pub fn effective_user(&self) -> Option<String> {
2068 let daemon_user = self
2069 .user
2070 .as_deref()
2071 .map(str::trim)
2072 .filter(|u| !u.is_empty());
2073 daemon_user.map(str::to_owned).or_else(|| {
2074 let s = crate::settings::settings();
2075 let su = s.supervisor.user.trim();
2076 (!su.is_empty()).then(|| su.to_owned())
2077 })
2078 }
2079
2080 pub fn to_run_options(
2085 &self,
2086 id: &crate::daemon_id::DaemonId,
2087 cmd: Vec<String>,
2088 ) -> crate::daemon::RunOptions {
2089 use crate::daemon::RunOptions;
2090
2091 let effective_user = self.effective_user();
2092 let dir = crate::ipc::batch::resolve_daemon_dir(
2093 self.dir.as_deref(),
2094 self.path.as_deref(),
2095 effective_user.as_deref(),
2096 );
2097 let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2100 id,
2101 &PitchforkToml::read_global_slugs(),
2102 );
2103
2104 RunOptions {
2105 id: id.clone(),
2106 cmd,
2107 run: Some(self.run.clone()),
2108 force: false,
2109 shell_pid: None,
2110 dir: Dir(dir),
2111 autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2112 oneshot: self.is_oneshot(),
2113 oneshot_wait: None,
2117 on_directory_enter: false,
2118 cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2119 cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2120 cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2121 retry: self.retry,
2122 retry_count: 0,
2123 ready_delay: self.ready_delay,
2124 ready_output: self.ready_output.clone(),
2125 ready_http: self.ready_http.clone(),
2126 ready_port: self.ready_port.clone(),
2127 ready_cmd: self.ready_cmd.clone(),
2128 health_cmd: self.health_cmd.clone(),
2129 health_http: self.health_http.clone(),
2130 health_port: self.health_port.clone(),
2131 port: self.port.clone(),
2132 wait_ready: false,
2133 depends: self.depends.clone(),
2134 env: self.env.clone(),
2135 watch: self.watch.clone(),
2136 watch_mode: self.watch_mode,
2137 watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2138 self.path.as_deref(),
2139 )),
2140 mise: self.mise,
2141 slug,
2142 proxy: None,
2143 user: self.user.clone(),
2144 memory_limit: self.memory_limit,
2145 cpu_limit: self.cpu_limit,
2146 stop_signal: self.stop_signal,
2147 archive_hook: self
2148 .logs
2149 .as_ref()
2150 .and_then(|l| l.archive_hook.clone())
2151 .or_else(|| self.archive_hook.clone()),
2152 log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2153 on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2154 pty: self.pty,
2155 }
2156 }
2157}
2158fn example_run_command() -> &'static str {
2159 "exec node server.js"
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164 use super::*;
2165 use std::path::Path;
2166
2167 #[test]
2168 fn test_daemon_user_parses_and_flows_to_run_options() {
2169 let pt = PitchforkToml::parse_str(
2170 r#"
2171[daemons.api]
2172run = "node server.js"
2173user = "postgres"
2174"#,
2175 Path::new("/tmp/my-project/pitchfork.toml"),
2176 )
2177 .unwrap();
2178
2179 let id = DaemonId::new("my-project", "api");
2180 let daemon = pt.daemons.get(&id).unwrap();
2181 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2182
2183 let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2184 assert_eq!(opts.user.as_deref(), Some("postgres"));
2185 }
2186
2187 #[test]
2188 fn test_daemon_user_write_roundtrip() {
2189 let temp = tempfile::tempdir().unwrap();
2190 let path = temp.path().join("pitchfork.toml");
2191 let mut pt = PitchforkToml::new(path.clone());
2192 pt.namespace = Some("test-project".to_string());
2193 pt.daemons.insert(
2194 DaemonId::new("test-project", "api"),
2195 PitchforkTomlDaemon {
2196 run: "node server.js".to_string(),
2197 user: Some("postgres".to_string()),
2198 ..PitchforkTomlDaemon::default()
2199 },
2200 );
2201
2202 pt.write().unwrap();
2203
2204 let raw = std::fs::read_to_string(&path).unwrap();
2205 assert!(raw.contains("user = \"postgres\""));
2206
2207 let parsed = PitchforkToml::read(&path).unwrap();
2208 let daemon = parsed
2209 .daemons
2210 .get(&DaemonId::new("test-project", "api"))
2211 .unwrap();
2212 assert_eq!(daemon.user.as_deref(), Some("postgres"));
2213 }
2214
2215 #[test]
2216 fn test_registry_dirs_expand_tilde() {
2217 let pt = PitchforkToml::parse_str(
2218 r#"
2219[slugs.api]
2220dir = "~/projects/api"
2221
2222[namespaces.web]
2223dir = "~/projects/web"
2224"#,
2225 Path::new("/tmp/config.toml"),
2226 )
2227 .unwrap();
2228
2229 assert_eq!(
2230 pt.slugs["api"].dir,
2231 Some(crate::env::HOME_DIR.join("projects/api"))
2232 );
2233 assert_eq!(
2234 pt.namespaces["web"].dir,
2235 crate::env::HOME_DIR.join("projects/web")
2236 );
2237 }
2238
2239 #[test]
2240 fn test_settings_write_roundtrip() {
2241 let temp = tempfile::tempdir().unwrap();
2242 let path = temp.path().join("pitchfork.toml");
2243 let mut pt = PitchforkToml::new(path.clone());
2244 pt.namespace = Some("test-project".to_string());
2245 pt.settings.web.auto_start = Some(true);
2246 pt.settings.general.log_level = Some("debug".to_string());
2247
2248 pt.write().unwrap();
2249
2250 let raw = std::fs::read_to_string(&path).unwrap();
2251 assert!(
2252 raw.contains("[settings.web]"),
2253 "settings.web section should be written, got:\n{raw}"
2254 );
2255 assert!(raw.contains("auto_start = true"));
2256 assert!(raw.contains("log_level = \"debug\""));
2257
2258 let parsed = PitchforkToml::read(&path).unwrap();
2259 assert_eq!(parsed.settings.web.auto_start, Some(true));
2260 assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2261 }
2262
2263 fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2264 SlugEntry {
2265 dir: None,
2266 namespace: Some(namespace.to_string()),
2267 daemon: daemon.map(str::to_string),
2268 }
2269 }
2270
2271 #[test]
2272 fn test_slug_is_ambiguous() {
2273 let mut slugs = IndexMap::new();
2274 slugs.insert("api".to_string(), slug_entry("my-project", None));
2275 assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
2276
2277 slugs.insert("API".to_string(), slug_entry("other-project", None));
2278 assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
2280 assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
2281 }
2282
2283 #[test]
2284 fn test_find_slug_for_daemon_skips_case_collisions() {
2285 let id = DaemonId::new("my-project", "api");
2286 let mut slugs = IndexMap::new();
2287 slugs.insert("api".to_string(), slug_entry("my-project", None));
2288 assert_eq!(
2289 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2290 Some("api".to_string())
2291 );
2292
2293 slugs.insert("API".to_string(), slug_entry("other-project", None));
2296 assert_eq!(
2297 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2298 None
2299 );
2300 }
2301
2302 #[test]
2303 fn test_find_slug_for_daemon_prefers_a_routable_alias() {
2304 let id = DaemonId::new("my-project", "api");
2305 let mut slugs = IndexMap::new();
2306 slugs.insert("api".to_string(), slug_entry("my-project", None));
2309 slugs.insert("API".to_string(), slug_entry("my-project", None));
2310 slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
2311
2312 assert_eq!(
2313 PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
2314 Some("my-api".to_string())
2315 );
2316 }
2317
2318 #[test]
2319 fn test_settings_preserved_on_unrelated_write() {
2320 let temp = tempfile::tempdir().unwrap();
2323 let path = temp.path().join("pitchfork.toml");
2324 std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
2325
2326 let mut pt = PitchforkToml::read(&path).unwrap();
2327 pt.slugs.insert(
2328 "api".to_string(),
2329 SlugEntry {
2330 dir: None,
2331 namespace: Some("myproject".to_string()),
2332 daemon: None,
2333 },
2334 );
2335 pt.namespaces.insert(
2336 "myproject".to_string(),
2337 NamespaceEntry {
2338 dir: PathBuf::from("/tmp/myproject"),
2339 config: Vec::new(),
2340 },
2341 );
2342 pt.write().unwrap();
2343
2344 let raw = std::fs::read_to_string(&path).unwrap();
2345 assert!(
2346 raw.contains("[settings.web]"),
2347 "existing settings must be preserved, got:\n{raw}"
2348 );
2349 assert!(raw.contains("auto_start = true"));
2350 assert!(raw.contains("[slugs.api]"));
2351
2352 let parsed = PitchforkToml::read(&path).unwrap();
2353 assert_eq!(parsed.settings.web.auto_start, Some(true));
2354 assert!(parsed.slugs.contains_key("api"));
2355 }
2356
2357 #[tokio::test]
2358 async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
2359 let temp = tempfile::tempdir().unwrap();
2360 let path = temp.path().join("pitchfork.toml");
2361 tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
2362 .await
2363 .unwrap();
2364
2365 let read_path = path.clone();
2366 let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
2367 .await
2368 .unwrap()
2369 .unwrap();
2370 assert_eq!(pt.settings.general.worktree, Some(false));
2371 assert_eq!(pt.settings.proxy.worktree, None);
2372 tokio::task::spawn_blocking(move || pt.write())
2373 .await
2374 .unwrap()
2375 .unwrap();
2376
2377 let raw = tokio::fs::read_to_string(&path).await.unwrap();
2378 assert!(raw.contains("[settings.general]"), "{raw}");
2379 assert!(raw.contains("worktree = false"), "{raw}");
2380 assert!(!raw.contains("[settings.proxy]"), "{raw}");
2381
2382 let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
2383 .await
2384 .unwrap()
2385 .unwrap();
2386 assert_eq!(parsed.settings.general.worktree, Some(false));
2387 }
2388
2389 #[test]
2390 fn test_config_cache_hit_and_invalidation() {
2391 let temp = tempfile::tempdir().unwrap();
2392 let dir = temp.path();
2393 let config_path = dir.join("pitchfork.toml");
2394 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2395
2396 super::invalidate_config_cache();
2398
2399 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2401 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2402 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2403
2404 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2406 assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
2407
2408 std::thread::sleep(std::time::Duration::from_millis(50));
2411 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
2412
2413 let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
2414 assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
2415
2416 super::invalidate_config_cache();
2418 let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
2419 assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
2420
2421 super::invalidate_config_cache();
2423 }
2424
2425 #[test]
2426 fn test_config_cache_invalidation_on_write() {
2427 let temp = tempfile::tempdir().unwrap();
2428 let dir = temp.path();
2429 let config_path = dir.join("pitchfork.toml");
2430 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2431
2432 super::invalidate_config_cache();
2433
2434 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2436 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2437 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2438
2439 let mut pt = PitchforkToml::read(&config_path).unwrap();
2441 pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".to_string();
2442 let _ = pt.write();
2444
2445 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2447 assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
2448
2449 super::invalidate_config_cache();
2450 }
2451
2452 #[test]
2453 fn test_config_cache_size_invalidation() {
2454 let temp = tempfile::tempdir().unwrap();
2455 let dir = temp.path();
2456 let config_path = dir.join("pitchfork.toml");
2457 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
2458
2459 super::invalidate_config_cache();
2460
2461 let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
2463 let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
2464 assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
2465
2466 let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
2470 std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
2471 let file = std::fs::OpenOptions::new()
2474 .write(true)
2475 .open(&config_path)
2476 .unwrap();
2477 let times = std::fs::FileTimes::new().set_modified(original_mtime);
2478 file.set_times(times).unwrap();
2479
2480 let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
2483 assert_eq!(
2484 pt2.daemons[&daemon_id].run, "echo different",
2485 "cache should invalidate on size change even with identical mtime"
2486 );
2487
2488 super::invalidate_config_cache();
2489 }
2490
2491 #[test]
2492 fn test_find_project_root_in_plain_dir_returns_none() {
2493 let temp = tempfile::tempdir().unwrap();
2494 assert_eq!(find_project_root(temp.path()), None);
2495 }
2496
2497 #[test]
2498 fn test_find_project_root_finds_git_marker() {
2499 let temp = tempfile::tempdir().unwrap();
2500 let repo = temp.path().join("my-repo");
2501 std::fs::create_dir(&repo).unwrap();
2502 std::fs::create_dir(repo.join(".git")).unwrap();
2503
2504 let sub = repo.join("sub/dir");
2505 std::fs::create_dir_all(&sub).unwrap();
2506
2507 assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
2510 }
2511
2512 #[test]
2513 fn test_find_project_root_accepts_git_file_marker() {
2514 let temp = tempfile::tempdir().unwrap();
2517 let wt = temp.path().join("my-worktree");
2518 std::fs::create_dir(&wt).unwrap();
2519 std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
2520
2521 assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
2522 }
2523
2524 #[cfg(unix)]
2527 #[test]
2528 fn test_find_project_root_resolves_symlinked_start_dir() {
2529 use std::os::unix::fs::symlink;
2530
2531 let temp = tempfile::tempdir().unwrap();
2532 let repo = temp.path().join("real-repo");
2533 std::fs::create_dir(&repo).unwrap();
2534 std::fs::create_dir(repo.join(".git")).unwrap();
2535
2536 let sub = repo.join("sub/dir");
2537 std::fs::create_dir_all(&sub).unwrap();
2538 let link = temp.path().join("link-to-sub");
2539 symlink(&sub, &link).unwrap();
2540
2541 assert_eq!(find_project_root(&link), Some(repo));
2542 }
2543
2544 #[test]
2547 fn test_all_merged_all_namespaces_discovers_worktrees() {
2548 let temp = tempfile::tempdir().unwrap();
2549 let repo = temp.path().join("my-repo");
2550 std::fs::create_dir(&repo).unwrap();
2551
2552 let git_init = std::process::Command::new("git")
2554 .args(["init", "-b", "main"])
2555 .current_dir(&repo)
2556 .output()
2557 .expect("git init");
2558 assert!(git_init.status.success(), "git init failed: {:?}", git_init);
2559
2560 std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
2561
2562 let git_commit = std::process::Command::new("git")
2563 .args([
2564 "-c",
2565 "user.name=pitchfork-test",
2566 "-c",
2567 "user.email=pitchfork-test@example.com",
2568 "add",
2569 "-A",
2570 ])
2571 .current_dir(&repo)
2572 .output()
2573 .expect("git add");
2574 assert!(git_commit.status.success());
2575
2576 let git_commit = std::process::Command::new("git")
2577 .args([
2578 "-c",
2579 "user.name=pitchfork-test",
2580 "-c",
2581 "user.email=pitchfork-test@example.com",
2582 "commit",
2583 "-m",
2584 "init",
2585 ])
2586 .current_dir(&repo)
2587 .output()
2588 .expect("git commit");
2589 assert!(
2590 git_commit.status.success(),
2591 "git commit failed: {:?}",
2592 git_commit
2593 );
2594
2595 let wt = temp.path().join("my-repo-feature");
2596 let git_wt = std::process::Command::new("git")
2597 .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
2598 .current_dir(&repo)
2599 .output()
2600 .expect("git worktree add");
2601 assert!(
2602 git_wt.status.success(),
2603 "git worktree add failed: {:?}",
2604 git_wt
2605 );
2606
2607 std::fs::write(
2609 repo.join("pitchfork.toml"),
2610 "[daemons.api]\nrun = \"echo main\"\n",
2611 )
2612 .unwrap();
2613 std::fs::write(
2615 wt.join("pitchfork.toml"),
2616 "[daemons.worker]\nrun = \"echo wt\"\n",
2617 )
2618 .unwrap();
2619
2620 super::invalidate_config_cache();
2621
2622 let pt = PitchforkToml::all_merged_all_namespaces_from(&wt).unwrap();
2624
2625 let main_id = DaemonId::new("my-repo", "api");
2626 let wt_id = DaemonId::new("my-repo-feature", "worker");
2627 assert!(
2628 pt.daemons.contains_key(&main_id),
2629 "main checkout daemon missing"
2630 );
2631 assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
2632
2633 let pt_from_main = PitchforkToml::all_merged_all_namespaces_from(&repo).unwrap();
2635 assert!(pt_from_main.daemons.contains_key(&wt_id));
2636
2637 let _ = std::process::Command::new("git")
2639 .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
2640 .current_dir(&repo)
2641 .output();
2642 super::invalidate_config_cache();
2643 }
2644
2645 #[test]
2646 fn test_adhoc_id_uses_invocation_directory_namespace() {
2647 let temp = tempfile::tempdir().unwrap();
2648 let project = temp.path().join("feature-tree");
2649 std::fs::create_dir(&project).unwrap();
2650 std::fs::write(
2651 project.join("pitchfork.toml"),
2652 "[daemons.other]\nrun = \"true\"\n",
2653 )
2654 .unwrap();
2655
2656 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
2657 assert_eq!(id, DaemonId::new("feature-tree", "api"));
2658 let qualified =
2659 PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
2660 assert_eq!(qualified, DaemonId::new("explicit", "api"));
2661 }
2662
2663 #[test]
2664 fn test_adhoc_id_falls_back_to_global_without_project_config() {
2665 let temp = tempfile::tempdir().unwrap();
2666 let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
2667 assert_eq!(id, DaemonId::new("global", "api"));
2668 }
2669}