Skip to main content

pitchfork_cli/
pitchfork_toml.rs

1use crate::daemon_id::DaemonId;
2use crate::deprecated_at;
3use crate::error::{ConfigParseError, DependencyError, FileError, find_similar_daemon};
4use crate::settings::SettingsPartial;
5use crate::settings::settings;
6use crate::state_file::StateFile;
7use crate::{Result, env};
8use indexmap::IndexMap;
9use miette::Context;
10use once_cell::sync::Lazy;
11use schemars::JsonSchema;
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14use std::sync::Mutex as StdMutex;
15use std::time::SystemTime;
16
17// Re-export config value types so existing `use crate::pitchfork_toml::X` paths keep working.
18pub use crate::config_types::{
19    CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, OnOutputHook,
20    PitchforkTomlAuto, PitchforkTomlCron, PitchforkTomlHooks, PortBump, PortConfig, ProxyConfig,
21    ProxyIdleTimeout, ProxyTlsMode, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig,
22    StopSignal, WatchMode,
23};
24
25/// Raw slug entry as read from TOML (uses String for dir path).
26/// Format in global config:
27/// ```toml
28/// [slugs]
29/// api = { dir = "/home/user/my-api", daemon = "server" }
30/// docs = { dir = "/home/user/docs-site" }  # daemon defaults to slug name
31/// ```
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
33pub struct SlugEntryRaw {
34    /// Project directory containing the pitchfork.toml
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub dir: Option<String>,
37    /// Namespace reference (alternative to dir)
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub namespace: Option<String>,
40    /// Daemon name within that project (defaults to slug name if omitted)
41    #[serde(skip_serializing_if = "Option::is_none", default)]
42    pub daemon: Option<String>,
43}
44
45/// Resolved slug entry with PathBuf.
46#[derive(Debug, Clone)]
47pub struct SlugEntry {
48    /// Project directory containing the pitchfork.toml
49    pub dir: Option<PathBuf>,
50    /// Namespace reference (alternative to dir)
51    pub namespace: Option<String>,
52    /// Daemon name within that project (defaults to slug name if omitted)
53    pub daemon: Option<String>,
54}
55
56impl SlugEntry {
57    /// Resolve the project directory.
58    /// If `dir` is set, use it. Otherwise look up `namespace` in the global namespace registry.
59    pub fn resolve_dir(&self) -> Option<PathBuf> {
60        self.dir.clone().or_else(|| {
61            self.namespace.as_ref().and_then(|ns| {
62                let namespaces = PitchforkToml::read_global_namespaces();
63                namespaces.get(ns).map(|entry| entry.dir.clone())
64            })
65        })
66    }
67
68    /// Resolve the namespace name.
69    /// If `namespace` is set, use it. Otherwise derive from `dir` via `namespace_for_dir`.
70    pub fn resolve_namespace(&self) -> Option<String> {
71        self.namespace.clone().or_else(|| {
72            self.resolve_dir()
73                .and_then(|dir| PitchforkToml::namespace_for_dir(&dir).ok())
74        })
75    }
76}
77
78/// Raw group entry as read from TOML.
79/// ```toml
80/// [groups.backend]
81/// daemons = ["api", "worker"]
82///
83/// [groups.all]
84/// groups = ["backend"]
85/// daemons = ["postgres"]
86/// ```
87#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, JsonSchema)]
88pub struct GroupEntryRaw {
89    /// Daemons in this group: short names or fully qualified IDs.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    #[schemars(with = "Vec<DaemonId>")]
92    pub daemons: Vec<String>,
93    /// Names of other groups whose daemons this group also contains.
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub groups: Vec<String>,
96}
97
98/// Resolved group entry with qualified DaemonIds.
99///
100/// `groups` stays unexpanded: it names groups that may be defined in a
101/// different config file, so it is resolved by [`expand_group`] once the
102/// configs are merged.
103#[derive(Debug, Clone, Default)]
104pub struct GroupEntry {
105    pub daemons: Vec<DaemonId>,
106    pub groups: Vec<String>,
107}
108
109/// Expand a group into its deduplicated daemons, following `groups` includes.
110///
111/// Daemons of included groups come first, in the order the groups are listed,
112/// followed by the group's own `daemons`. A daemon reachable more than once is
113/// kept at its first position. Including an undefined group, or a group that
114/// includes itself directly or through other groups, is an error.
115pub fn expand_group(groups: &IndexMap<String, GroupEntry>, name: &str) -> Result<Vec<DaemonId>> {
116    fn visit(
117        groups: &IndexMap<String, GroupEntry>,
118        name: &str,
119        path: &mut Vec<String>,
120        done: &mut HashSet<String>,
121        seen: &mut HashSet<DaemonId>,
122        out: &mut Vec<DaemonId>,
123    ) -> Result<()> {
124        // Everything a finished group contributes is already in `out`, so
125        // revisiting it through another include only repeats work.
126        if done.contains(name) {
127            return Ok(());
128        }
129        let Some(group) = groups.get(name) else {
130            let suggestion = find_similar_daemon(name, groups.keys().map(|s| s.as_str()));
131            return Err(match path.last() {
132                Some(parent) => miette::miette!(
133                    "group '{parent}' includes undefined group '{name}'{}",
134                    suggestion.map(|s| format!(", {s}")).unwrap_or_default()
135                ),
136                None => miette::miette!(
137                    "group '{name}' not found in configuration{}",
138                    suggestion.map(|s| format!(", {s}")).unwrap_or_default()
139                ),
140            });
141        };
142        if path.iter().any(|p| p == name) {
143            return Err(miette::miette!(
144                "group include cycle: {} -> {name}",
145                path.join(" -> ")
146            ));
147        }
148        path.push(name.to_string());
149        for included in &group.groups {
150            visit(groups, included, path, done, seen, out)?;
151        }
152        path.pop();
153        for id in &group.daemons {
154            if seen.insert(id.clone()) {
155                out.push(id.clone());
156            }
157        }
158        done.insert(name.to_string());
159        Ok(())
160    }
161
162    let mut out = Vec::new();
163    visit(
164        groups,
165        name,
166        &mut Vec::new(),
167        &mut HashSet::new(),
168        &mut HashSet::new(),
169        &mut out,
170    )?;
171    Ok(out)
172}
173
174/// Raw namespace entry as read from TOML.
175/// ```toml
176/// [namespaces.myproject]
177/// dir = "/home/user/projects/myproject"
178/// ```
179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
180pub struct NamespaceEntryRaw {
181    /// Project directory containing the pitchfork.toml
182    pub dir: String,
183    /// Additional configuration files, relative to dir or absolute.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub config: Vec<String>,
186    /// Hostname label for the project, used instead of the namespace when the
187    /// namespace is only an identifier (for example a generated
188    /// `shop-528f92b13a6784f0`). Must already be a valid DNS label.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub label: Option<String>,
191}
192
193/// Resolved namespace entry with PathBuf.
194#[derive(Debug, Clone)]
195pub struct NamespaceEntry {
196    /// Project directory containing the pitchfork.toml
197    pub dir: PathBuf,
198    pub config: Vec<PathBuf>,
199    /// Hostname label for the project, when the registration names one.
200    pub label: Option<String>,
201}
202
203/// Internal structure for reading config files (uses String keys for short daemon names)
204#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
205struct PitchforkTomlRaw {
206    #[serde(skip_serializing_if = "Option::is_none", default)]
207    pub namespace: Option<String>,
208    /// Hostname label for this checkout when it is a linked git worktree.
209    #[serde(skip_serializing_if = "Option::is_none", default)]
210    pub worktree_label: Option<String>,
211    #[serde(default)]
212    pub daemons: IndexMap<String, PitchforkTomlDaemonRaw>,
213    /// Top-level environment variables applied to all daemons as defaults.
214    /// Per-daemon `env` overrides these. Values support Tera templates.
215    #[serde(skip_serializing_if = "Option::is_none", default)]
216    pub env: Option<IndexMap<String, String>>,
217    #[serde(default)]
218    pub settings: Option<SettingsPartial>,
219    /// Slug registry (only meaningful in global config).
220    /// Maps slug names to their configuration (dir + optional daemon name).
221    #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
222    pub slugs: IndexMap<String, SlugEntryRaw>,
223    /// Named groups of daemons for batch operations.
224    #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
225    pub groups: IndexMap<String, GroupEntryRaw>,
226    /// Namespace registry (only meaningful in global config).
227    /// Maps namespace names to their project directory.
228    #[serde(skip_serializing_if = "IndexMap::is_empty", default)]
229    pub namespaces: IndexMap<String, NamespaceEntryRaw>,
230}
231
232/// Per-daemon log configuration sub-table `[daemons.<name>.logs]`.
233///
234/// Fields here override the top-level daemon fields (`time_retention`,
235/// `line_retention`, `archive_hook`) and the global `[settings.logs]` defaults.
236#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
237pub struct PitchforkTomlDaemonLogs {
238    /// Log line format: `json`, `logfmt`, or `text`.
239    /// Defaults to `text` (no parsing).
240    #[serde(skip_serializing_if = "Option::is_none", default)]
241    pub log_format: Option<String>,
242    /// Maximum age of log entries to keep (e.g. "7d", "30d").
243    #[serde(skip_serializing_if = "Option::is_none", default)]
244    pub time_retention: Option<String>,
245    /// Maximum number of log entries to keep per daemon.
246    #[serde(skip_serializing_if = "Option::is_none", default)]
247    pub line_retention: Option<i64>,
248    /// Archive hook command invoked before retention prunes this daemon's logs.
249    #[serde(skip_serializing_if = "Option::is_none", default)]
250    pub archive_hook: Option<String>,
251}
252
253/// Internal daemon config for reading (uses String for depends).
254///
255/// Note: This struct mirrors `PitchforkTomlDaemon` but uses `Vec<String>` for `depends`
256/// (before namespace resolution) and has serde attributes for TOML serialization.
257/// When adding new fields, remember to update both structs and the conversion code
258/// in `read()` and `write()`.
259#[derive(Debug, serde::Serialize, serde::Deserialize)]
260struct PitchforkTomlDaemonRaw {
261    pub run: RunCommand,
262    #[serde(skip_serializing_if = "Vec::is_empty", default)]
263    pub auto: Vec<PitchforkTomlAuto>,
264    #[serde(skip_serializing_if = "Option::is_none", default)]
265    pub oneshot: Option<bool>,
266    #[serde(skip_serializing_if = "Option::is_none", default)]
267    pub cron: Option<PitchforkTomlCron>,
268    #[serde(default)]
269    pub retry: Retry,
270    #[serde(skip_serializing_if = "Option::is_none", default)]
271    pub ready_delay: Option<u64>,
272    #[serde(skip_serializing_if = "Option::is_none", default)]
273    pub ready_output: Option<ReadyOutput>,
274    #[serde(skip_serializing_if = "Option::is_none", default)]
275    pub ready_http: Option<ReadyHttp>,
276    #[serde(skip_serializing_if = "Option::is_none", default)]
277    pub ready_port: Option<ReadyPort>,
278    #[serde(skip_serializing_if = "Option::is_none", default)]
279    pub ready_cmd: Option<ReadyCmd>,
280    #[serde(skip_serializing_if = "Option::is_none", default)]
281    pub health_cmd: Option<HealthCmd>,
282    #[serde(skip_serializing_if = "Option::is_none", default)]
283    pub health_http: Option<HealthHttp>,
284    #[serde(skip_serializing_if = "Option::is_none", default)]
285    pub health_port: Option<HealthPort>,
286    /// New port configuration (preferred)
287    #[serde(skip_serializing_if = "Option::is_none", default)]
288    pub port: Option<PortConfig>,
289    /// Proxy routing: `false` opts out, a string overrides the daemon's hostname label.
290    #[serde(skip_serializing_if = "Option::is_none", default)]
291    pub proxy: Option<ProxyConfig>,
292    /// Deprecated: use `port` instead
293    #[serde(skip_serializing_if = "Vec::is_empty", default)]
294    pub expected_port: Vec<u16>,
295    /// Deprecated: use `port.bump` instead
296    #[serde(skip_serializing_if = "Option::is_none", default)]
297    pub auto_bump_port: Option<bool>,
298    /// Deprecated: use `port.bump` instead
299    #[serde(skip_serializing_if = "Option::is_none", default)]
300    pub port_bump_attempts: Option<u32>,
301    /// TLS handling for this daemon's proxy hostname: `terminate` or `passthrough`.
302    #[serde(skip_serializing_if = "Option::is_none", default)]
303    pub proxy_tls: Option<ProxyTlsMode>,
304    /// Which of the daemon's ports the proxy hostname maps to.
305    #[serde(skip_serializing_if = "Option::is_none", default)]
306    pub proxy_tls_port: Option<u16>,
307    /// Shorter spelling of `proxy_tls_port`. Never written back out, so a
308    /// config that used it keeps one spelling rather than gaining both.
309    #[serde(skip_serializing_if = "Option::is_none", default)]
310    pub proxy_port: Option<u16>,
311    /// Stop after this long without proxy activity when the proxy started it.
312    #[serde(skip_serializing_if = "Option::is_none", default)]
313    pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
314    #[serde(skip_serializing_if = "Option::is_none", default)]
315    pub boot_start: Option<bool>,
316    #[serde(skip_serializing_if = "Vec::is_empty", default)]
317    pub depends: Vec<String>,
318    #[serde(skip_serializing_if = "Vec::is_empty", default)]
319    pub watch: Vec<String>,
320    #[serde(skip_serializing_if = "Option::is_none", default)]
321    pub watch_mode: Option<WatchMode>,
322    #[serde(skip_serializing_if = "Option::is_none", default)]
323    pub dir: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none", default)]
325    pub env: Option<IndexMap<String, String>>,
326    #[serde(skip_serializing_if = "Vec::is_empty", default)]
327    pub env_file: Vec<String>,
328    #[serde(skip_serializing_if = "Option::is_none", default)]
329    pub hooks: Option<PitchforkTomlHooks>,
330    #[serde(skip_serializing_if = "Option::is_none", default)]
331    pub mise: Option<bool>,
332    /// Unix user to run this daemon as.
333    #[serde(skip_serializing_if = "Option::is_none", default)]
334    pub user: Option<String>,
335    /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
336    #[serde(skip_serializing_if = "Option::is_none", default)]
337    pub memory_limit: Option<MemoryLimit>,
338    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
339    #[serde(skip_serializing_if = "Option::is_none", default)]
340    pub cpu_limit: Option<CpuLimit>,
341    /// Unix signal to send for graceful shutdown (default: SIGTERM)
342    #[serde(skip_serializing_if = "Option::is_none", default)]
343    pub stop_signal: Option<StopConfig>,
344    /// Allocate a pseudo-terminal for the daemon process.
345    #[serde(skip_serializing_if = "Option::is_none", default)]
346    pub pty: Option<bool>,
347    /// Maximum age of log entries to keep (e.g. "7d", "30d").
348    /// Overrides the global `settings.logs.time_retention` when set.
349    #[serde(skip_serializing_if = "Option::is_none", default)]
350    pub time_retention: Option<String>,
351    /// Maximum number of log entries to keep per daemon.
352    /// Overrides the global `settings.logs.line_retention` when set.
353    #[serde(skip_serializing_if = "Option::is_none", default)]
354    pub line_retention: Option<i64>,
355    /// Archive hook command invoked before retention prunes this daemon's logs.
356    /// Overrides the global `settings.logs.archive_hook.command` when set.
357    #[serde(skip_serializing_if = "Option::is_none", default)]
358    pub archive_hook: Option<String>,
359    /// Per-daemon log configuration sub-table.
360    #[serde(skip_serializing_if = "Option::is_none", default)]
361    pub logs: Option<PitchforkTomlDaemonLogs>,
362}
363
364/// Configuration schema for pitchfork.toml daemon supervisor configuration files.
365///
366/// Note: When read from a file, daemon keys are short names (e.g., "api").
367/// After merging, keys become qualified DaemonIds (e.g., "project/api").
368#[derive(Debug, Clone, Default, JsonSchema)]
369#[schemars(title = "Pitchfork Configuration")]
370pub struct PitchforkToml {
371    /// Map of daemon IDs to their configurations
372    #[serde(default)]
373    pub daemons: IndexMap<DaemonId, PitchforkTomlDaemon>,
374    /// Top-level environment variables applied to all daemons as defaults.
375    /// Per-daemon `env` overrides these on key conflicts. Values support Tera
376    /// templates (e.g. `{{ daemons.api.port }}`, `{{ settings.proxy.tld }}`).
377    #[serde(skip_serializing_if = "Option::is_none", default)]
378    pub env: Option<IndexMap<String, String>>,
379    /// Optional explicit namespace declared in this file.
380    ///
381    /// This applies to per-file read/write flows. Merged configs may contain
382    /// daemons from multiple namespaces and leave this as `None`.
383    pub namespace: Option<String>,
384    /// Hostname label for this checkout when it is a linked git worktree.
385    ///
386    /// Overrides the worktree directory's name in proxy hostnames.
387    #[schemars(default, with = "Option<String>")]
388    pub worktree_label: Option<String>,
389    /// Settings configuration (merged from all config files).
390    ///
391    /// **Note:** This field exists for serialization round-trips and for
392    /// `PitchforkToml::merge()` to collect per-file overrides.  It is **not**
393    /// consumed by the global `settings()` singleton, which is populated
394    /// independently by `Settings::load()` to avoid a circular dependency
395    /// between `PitchforkToml` and `Settings`.  Do not rely on mutations to
396    /// this field being reflected in `settings()`.
397    #[serde(default)]
398    pub(crate) settings: SettingsPartial,
399    /// Slug registry (merged from global config files).
400    /// Maps slug names to their project directory and optional daemon name.
401    /// Only populated from global config files (`~/.config/pitchfork/config.toml`
402    /// or `/etc/pitchfork/config.toml`).
403    #[schemars(default, with = "IndexMap<String, SlugEntryRaw>")]
404    pub slugs: IndexMap<String, SlugEntry>,
405    /// Named groups of daemons for batch operations.
406    #[schemars(default, with = "IndexMap<String, GroupEntryRaw>")]
407    pub groups: IndexMap<String, GroupEntry>,
408    /// Namespace registry (merged from global config files).
409    /// Maps namespace names to their project directory.
410    #[schemars(default, with = "IndexMap<String, NamespaceEntryRaw>")]
411    pub namespaces: IndexMap<String, NamespaceEntry>,
412    #[schemars(skip)]
413    pub path: Option<PathBuf>,
414    /// Top-level `[env]` of the registered project each daemon merged in from
415    /// another namespace came from, keyed by that daemon.
416    ///
417    /// Such a daemon renders against its own project's defaults, not those of
418    /// the checkout that requested it. Daemons from the requesting checkout's
419    /// own config chain are absent and use [`Self::env`], which carries every
420    /// override along that chain.
421    #[schemars(skip)]
422    pub(crate) foreign_env: IndexMap<DaemonId, Option<IndexMap<String, String>>>,
423}
424
425impl PitchforkToml {
426    /// The top-level `[env]` defaults `id` renders against: its own project's
427    /// when it came from another registered project, otherwise this config's.
428    pub(crate) fn env_for(&self, id: &DaemonId) -> Option<&IndexMap<String, String>> {
429        self.foreign_env
430            .get(id)
431            .map_or(self.env.as_ref(), |env| env.as_ref())
432    }
433}
434
435pub fn is_global_config(path: &Path) -> bool {
436    path == *env::PITCHFORK_GLOBAL_CONFIG_USER || path == *env::PITCHFORK_GLOBAL_CONFIG_SYSTEM
437}
438
439pub(crate) fn is_dot_config_pitchfork(path: &Path) -> bool {
440    path.ends_with(".config/pitchfork.toml") || path.ends_with(".config/pitchfork.local.toml")
441}
442
443fn parse_namespace_override_from_content(path: &Path, content: &str) -> Result<Option<String>> {
444    use toml::Value;
445
446    let doc: Value = toml::from_str(content)
447        .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
448    let Some(value) = doc.get("namespace") else {
449        return Ok(None);
450    };
451
452    match value {
453        Value::String(s) => Ok(Some(s.clone())),
454        _ => Err(ConfigParseError::InvalidNamespace {
455            path: path.to_path_buf(),
456            namespace: value.to_string(),
457            reason: "top-level 'namespace' must be a string".to_string(),
458        }
459        .into()),
460    }
461}
462
463fn read_namespace_override_from_file(path: &Path) -> Result<Option<String>> {
464    if !path.exists() {
465        return Ok(None);
466    }
467    let content = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
468        path: path.to_path_buf(),
469        source: e,
470    })?;
471    parse_namespace_override_from_content(path, &content)
472}
473
474pub fn project_dir_for_config(path: &Path) -> Option<PathBuf> {
475    crate::extra_configs::project_dir(path).or_else(|| {
476        if is_dot_config_pitchfork(path) {
477            path.parent().and_then(Path::parent).map(Path::to_path_buf)
478        } else {
479            path.parent().map(Path::to_path_buf)
480        }
481    })
482}
483
484fn project_config_family(path: &Path) -> Vec<PathBuf> {
485    let Some(dir) = project_dir_for_config(path) else {
486        return vec![path.to_path_buf()];
487    };
488    vec![
489        dir.join(".config/pitchfork.toml"),
490        dir.join(".config/pitchfork.local.toml"),
491        dir.join("pitchfork.toml"),
492        dir.join("pitchfork.local.toml"),
493    ]
494}
495
496/// Resolve one namespace override shared by all project config files in a
497/// directory. `content_override` represents unsaved content for `path`.
498fn directory_namespace_override(
499    path: &Path,
500    content_override: Option<&str>,
501) -> Result<Option<String>> {
502    if is_global_config(path) {
503        return match content_override {
504            Some(content) => parse_namespace_override_from_content(path, content),
505            None => read_namespace_override_from_file(path),
506        };
507    }
508
509    let mut selected: Option<(String, PathBuf)> = None;
510    for candidate in project_config_family(path) {
511        let explicit = if candidate == path {
512            match content_override {
513                Some(content) => parse_namespace_override_from_content(&candidate, content)?,
514                None => read_namespace_override_from_file(&candidate)?,
515            }
516        } else {
517            read_namespace_override_from_file(&candidate)?
518        };
519        let Some(namespace) = explicit else { continue };
520        if let Some((selected_namespace, selected_path)) = &selected
521            && selected_namespace != &namespace
522        {
523            return Err(ConfigParseError::InvalidNamespace {
524                path: candidate,
525                namespace,
526                reason: format!(
527                    "namespace does not match directory-level namespace '{}' declared in {}",
528                    selected_namespace,
529                    selected_path.display()
530                ),
531            }
532            .into());
533        }
534        selected = Some((namespace, candidate));
535    }
536    Ok(selected.map(|(namespace, _)| namespace))
537}
538
539fn validate_namespace(path: &Path, namespace: &str) -> Result<String> {
540    if let Err(e) = DaemonId::try_new(namespace, "probe") {
541        return Err(ConfigParseError::InvalidNamespace {
542            path: path.to_path_buf(),
543            namespace: namespace.to_string(),
544            reason: e.to_string(),
545        }
546        .into());
547    }
548    Ok(namespace.to_string())
549}
550
551fn derive_namespace_from_dir(path: &Path) -> Result<String> {
552    let dir_for_namespace = project_dir_for_config(path);
553    if let Some(namespace) = dir_for_namespace
554        .as_deref()
555        .and_then(crate::extra_configs::namespace_for_dir)
556    {
557        return validate_namespace(path, &namespace);
558    }
559    let raw_namespace = dir_for_namespace
560        .as_deref()
561        .and_then(|p| p.file_name())
562        .and_then(|n| n.to_str())
563        .ok_or_else(|| miette::miette!("cannot derive namespace from path '{}'", path.display()))?
564        .to_string();
565
566    validate_namespace(path, &raw_namespace).map_err(|e| {
567        ConfigParseError::InvalidNamespace {
568            path: path.to_path_buf(),
569            namespace: raw_namespace,
570            reason: format!(
571                "{e}. Set a valid top-level namespace, e.g. namespace = \"my-project\""
572            ),
573        }
574        .into()
575    })
576}
577
578fn namespace_from_path_with_override(path: &Path, explicit: Option<&str>) -> Result<String> {
579    if is_global_config(path) {
580        if let Some(ns) = explicit
581            && ns != "global"
582        {
583            return Err(ConfigParseError::InvalidNamespace {
584                path: path.to_path_buf(),
585                namespace: ns.to_string(),
586                reason: "global config files must use namespace 'global'".to_string(),
587            }
588            .into());
589        }
590        return Ok("global".to_string());
591    }
592
593    if let Some(ns) = explicit {
594        return validate_namespace(path, ns);
595    }
596
597    derive_namespace_from_dir(path)
598}
599
600fn namespace_from_file(path: &Path) -> Result<String> {
601    let explicit = directory_namespace_override(path, None)?;
602    namespace_from_path_with_override(path, explicit.as_deref())
603}
604
605/// Extracts a namespace from a config file path.
606///
607/// - For user global config (`~/.config/pitchfork/config.toml`): returns "global"
608/// - For system global config (`/etc/pitchfork/config.toml`): returns "global"
609/// - For project configs: uses top-level `namespace` if present, otherwise parent directory name
610///
611/// Examples:
612/// - `~/.config/pitchfork/config.toml` → `"global"`
613/// - `/etc/pitchfork/config.toml` → `"global"`
614/// - `/home/user/project-a/pitchfork.toml` → `"project-a"`
615/// - `/home/user/project-b/sub/pitchfork.toml` → `"sub"`
616/// - `/home/user/中文目录/pitchfork.toml` → error unless `namespace = "..."` is set
617pub fn namespace_from_path(path: &Path) -> Result<String> {
618    namespace_from_file(path)
619}
620
621/// Find the nearest ancestor directory of `dir` that contains a `.git` or
622/// `.jj` marker (the project root of a git worktree / jj workspace).
623///
624/// Returns `None` when `dir` is not inside any git/jj project, so callers can
625/// fall back to non-worktree behavior. In a linked git worktree, `.git` is a
626/// *file* (not a directory) pointing at the common gitdir, so we check
627/// existence rather than `is_dir()`.
628fn find_project_root(dir: &Path) -> Option<PathBuf> {
629    // Canonicalize the start dir so a symlinked path resolves into the
630    // repository hierarchy before traversing parents; otherwise `parent()`
631    // walks outside the repo and misses `.git`/`.jj`.
632    let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
633    let mut current = canonical_dir.as_path();
634    loop {
635        if current.join(".git").exists() || current.join(".jj").exists() {
636            return Some(current.to_path_buf());
637        }
638        current = current.parent()?;
639    }
640}
641
642/// Cached result of `all_merged_from`, keyed by the cwd used to discover config paths.
643///
644/// The cache key is the canonical cwd `PathBuf`. The entry stores the merged
645/// [`PitchforkToml`] plus a snapshot of every source file's (mtime, size) at
646/// cache time. A cache hit requires the same set of paths with identical
647/// mtimes **and** sizes.
648///
649/// Tracking size in addition to mtime catches timestamp-preserving content
650/// changes (e.g. `cp --preserve=timestamps`, same-second edits on filesystems
651/// with coarse mtime granularity like NFS) that mtime alone would miss.
652///
653/// **Known limitation**: an equal-size content replacement that also preserves
654/// mtime will not be detected. This is an accepted trade-off — fully closing
655/// this gap would require hashing file contents on every cache hit, negating
656/// the I/O savings the cache exists to provide. In practice, editors and `git
657/// pull` always change mtime, and `cp --preserve=timestamps` almost always
658/// changes size. The `ReloadConfig` IPC handler (`settings reload`) serves as
659/// an explicit escape hatch to force a full re-read when needed.
660struct ConfigCacheEntry {
661    config: PitchforkToml,
662    /// (path, (mtime, size)) snapshot — order matches `list_paths_from` at cache time.
663    source_meta: Vec<(PathBuf, Option<(SystemTime, u64)>)>,
664}
665
666/// Global config parse cache, keyed by canonical cwd.
667///
668/// Uses a `std::sync::Mutex` (not tokio) because config parsing is CPU-bound
669/// and callers like `spawn_blocking(PitchforkToml::all_merged)` run outside
670/// the async runtime. Contention is minimal: the mutex is held only for the
671/// metadata comparison and the occasional re-read, not across I/O.
672static CONFIG_CACHE: Lazy<StdMutex<HashMap<PathBuf, ConfigCacheEntry>>> =
673    Lazy::new(|| StdMutex::new(HashMap::new()));
674
675/// Compare a list of paths' current (mtime, size) against a cached snapshot.
676///
677/// Returns `true` if every path exists (or not) and has the same mtime and
678/// size as when the snapshot was taken. Any difference — a new file, a deleted
679/// file, a changed mtime, or a changed size — invalidates the cache.
680fn meta_matches(paths: &[PathBuf], snapshot: &[(PathBuf, Option<(SystemTime, u64)>)]) -> bool {
681    if paths.len() != snapshot.len() {
682        return false;
683    }
684    paths
685        .iter()
686        .zip(snapshot.iter())
687        .all(|(p, (snap_p, snap_meta))| p == snap_p && current_meta(p) == *snap_meta)
688}
689
690/// Best-effort (mtime, size) — `None` if the path doesn't exist or metadata fails.
691pub(crate) fn current_meta(path: &Path) -> Option<(SystemTime, u64)> {
692    let md = std::fs::metadata(path).ok()?;
693    Some((md.modified().ok()?, md.len()))
694}
695
696/// Snapshot all (path, (mtime, size)) pairs for a list of paths.
697fn snapshot_meta(paths: &[PathBuf]) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> {
698    paths.iter().map(|p| (p.clone(), current_meta(p))).collect()
699}
700
701/// Invalidate the entire config parse cache.
702///
703/// Called after any config file write (`write()`, `write_unlocked()`,
704/// `add_slug_with_namespace()`, `remove_slug()`, `register_namespace()`,
705/// `remove_namespace()`) so that subsequent `all_merged_from` calls re-read
706/// from disk.
707///
708/// Also called by `settings reload` (via IPC `ReloadConfig`) so that external
709/// edits to config files are picked up.
710///
711/// **Cross-process note**: CLI and supervisor are separate processes with
712/// independent caches. When the CLI calls this after `write_unlocked()`, it
713/// only clears the CLI's own cache (which is about to exit anyway). The
714/// supervisor relies on mtime change detection to pick up CLI-written changes.
715/// The `ReloadConfig` IPC handler is the one that matters — it clears the
716/// supervisor's cache.
717pub fn invalidate_config_cache() {
718    crate::extra_configs::invalidate();
719    // The web project pages keep their own parsed-group cache, validated the
720    // same way, so it must be dropped here too.
721    crate::web::routes::api::projects::invalidate_group_cache();
722    if let Ok(mut cache) = CONFIG_CACHE.lock() {
723        cache.clear();
724    }
725}
726
727impl PitchforkToml {
728    /// Resolves a user-provided daemon ID to qualified DaemonIds.
729    ///
730    /// If the ID is already qualified (contains '/'), parses and returns it.
731    /// Otherwise, looks up the short ID in the config and returns
732    /// matching qualified IDs.
733    ///
734    /// # Arguments
735    /// * `user_id` - The daemon ID provided by the user
736    ///
737    /// # Returns
738    /// A Result containing a vector of matching DaemonIds (usually one, but could be multiple
739    /// if the same short ID exists in multiple namespaces), or an error if the ID is invalid.
740    pub fn resolve_daemon_id(&self, user_id: &str) -> Result<Vec<DaemonId>> {
741        // If already qualified, parse and return
742        if user_id.contains('/') {
743            return match DaemonId::parse(user_id) {
744                Ok(id) => Ok(vec![id]),
745                Err(e) => Err(e), // Invalid format - propagate error
746            };
747        }
748
749        // Check for slug match in global slugs registry
750        let global_slugs = Self::read_global_slugs();
751        if let Some(entry) = global_slugs.get(user_id) {
752            // Load the project's config from the slug's dir to find the daemon ID
753            let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
754            if let Some(dir) = entry.resolve_dir()
755                && let Ok(project_config) = Self::all_merged_from(&dir)
756            {
757                // Find daemon by short name in that project
758                let matches: Vec<DaemonId> = project_config
759                    .daemons
760                    .keys()
761                    .filter(|id| id.name() == daemon_name)
762                    .cloned()
763                    .collect();
764                match matches.as_slice() {
765                    [] => {}
766                    [id] => return Ok(vec![id.clone()]),
767                    _ => {
768                        let mut candidates: Vec<String> =
769                            matches.iter().map(|id| id.qualified()).collect();
770                        candidates.sort();
771                        return Err(miette::miette!(
772                            "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
773                            user_id,
774                            daemon_name,
775                            candidates.join(", ")
776                        ));
777                    }
778                }
779            }
780        }
781
782        // Look for matching qualified IDs in the config
783        let matches: Vec<DaemonId> = self
784            .daemons
785            .keys()
786            .filter(|id| id.name() == user_id)
787            .cloned()
788            .collect();
789
790        if matches.is_empty() {
791            // No config matches. Search state file for any daemon with matching short name.
792            let state_matches = Self::find_in_state_file(user_id);
793            match state_matches.as_slice() {
794                [] => {}
795                [id] => return Ok(vec![id.clone()]),
796                _ => {
797                    let mut candidates: Vec<String> =
798                        state_matches.iter().map(|id| id.qualified()).collect();
799                    candidates.sort();
800                    return Err(miette::miette!(
801                        "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
802                        user_id,
803                        candidates.join(", ")
804                    ));
805                }
806            }
807            // No config or state matches. Validate short ID format and return no matches.
808            let _ = DaemonId::try_new("global", user_id)?;
809        }
810        Ok(matches)
811    }
812
813    /// Finds all daemons in the persisted state file whose short name matches `short_name`.
814    ///
815    /// Logs a warning if the state file exists but cannot be read or parsed.
816    ///
817    /// Returns the matching `DaemonId`s. The caller must handle zero / one / many cases.
818    fn find_in_state_file(short_name: &str) -> Vec<DaemonId> {
819        match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
820            Ok(state) => state
821                .daemons
822                .keys()
823                .filter(|id| id.name() == short_name)
824                .cloned()
825                .collect(),
826            Err(e) => {
827                warn!("cannot read state file: {e}");
828                Vec::new()
829            }
830        }
831    }
832
833    /// Resolves a user-provided daemon ID to a qualified DaemonId, preferring the current directory's namespace.
834    ///
835    /// If the ID is already qualified (contains '/'), parses and returns it.
836    /// Otherwise, tries to find a daemon in the current directory's namespace first.
837    /// Falls back to any matching daemon if not found in current namespace.
838    ///
839    /// # Arguments
840    /// * `user_id` - The daemon ID provided by the user
841    /// * `current_dir` - The current working directory (used to determine namespace preference)
842    ///
843    /// # Returns
844    /// The resolved DaemonId, or an error if the ID format is invalid
845    ///
846    /// # Errors
847    /// Returns an error if `user_id` contains '/' but is not a valid qualified ID
848    /// (e.g., "foo/bar/baz" with multiple slashes), or if `user_id` contains invalid characters.
849    ///
850    /// # Warnings
851    /// If multiple daemons match the short name and none is in the current namespace,
852    /// a warning is logged to stderr indicating the ambiguity.
853    #[allow(dead_code)]
854    pub fn resolve_daemon_id_prefer_local(
855        &self,
856        user_id: &str,
857        current_dir: &Path,
858    ) -> Result<DaemonId> {
859        // If already qualified, parse and return (or error if invalid)
860        if user_id.contains('/') {
861            return DaemonId::parse(user_id);
862        }
863
864        // Determine the current directory's namespace by finding the nearest
865        // pitchfork.toml. Cache the namespace in the caller when resolving
866        // multiple IDs to avoid repeated filesystem traversal.
867        let current_namespace = Self::namespace_for_dir(current_dir)?;
868
869        self.resolve_daemon_id_with_namespace(user_id, &current_namespace)
870    }
871
872    /// Like `resolve_daemon_id_prefer_local` but accepts a pre-computed namespace,
873    /// avoiding redundant filesystem traversal when resolving multiple IDs.
874    fn resolve_daemon_id_with_namespace(
875        &self,
876        user_id: &str,
877        current_namespace: &str,
878    ) -> Result<DaemonId> {
879        // A daemon in the current namespace comes first, so another project's
880        // slug of the same name does not take over a local daemon. A slug can
881        // be a name no daemon can have (e.g. `my--alias`), so an invalid name
882        // only skips this check and is rejected after the slug lookup.
883        if let Ok(preferred_id) = DaemonId::try_new(current_namespace, user_id)
884            && self.daemons.contains_key(&preferred_id)
885        {
886            return Ok(preferred_id);
887        }
888
889        // Check for slug match in global slugs registry
890        let global_slugs = Self::read_global_slugs();
891        if let Some(entry) = global_slugs.get(user_id) {
892            let daemon_name = entry.daemon.as_deref().unwrap_or(user_id);
893            if let Some(dir) = entry.resolve_dir()
894                && let Ok(project_config) = Self::all_merged_from(&dir)
895            {
896                let matches: Vec<DaemonId> = project_config
897                    .daemons
898                    .keys()
899                    .filter(|id| id.name() == daemon_name)
900                    .cloned()
901                    .collect();
902                match matches.as_slice() {
903                    [] => {}
904                    [id] => return Ok(id.clone()),
905                    _ => {
906                        let mut candidates: Vec<String> =
907                            matches.iter().map(|id| id.qualified()).collect();
908                        candidates.sort();
909                        return Err(miette::miette!(
910                            "slug '{}' maps to daemon '{}' which matches multiple daemons: {}",
911                            user_id,
912                            daemon_name,
913                            candidates.join(", ")
914                        ));
915                    }
916                }
917            }
918        }
919
920        // Use try_new to validate user input
921        DaemonId::try_new(current_namespace, user_id)?;
922
923        // Fall back to any matching daemon
924        let matches = self.resolve_daemon_id(user_id)?;
925
926        // Error on ambiguity instead of implicitly preferring global.
927        if matches.len() > 1 {
928            let mut candidates: Vec<String> = matches.iter().map(|id| id.qualified()).collect();
929            candidates.sort();
930            return Err(miette::miette!(
931                "daemon '{}' is ambiguous; matches: {}. Use a qualified daemon ID (namespace/name)",
932                user_id,
933                candidates.join(", ")
934            ));
935        }
936
937        if let Some(id) = matches.into_iter().next() {
938            return Ok(id);
939        }
940
941        // If not found in current namespace or merged config matches, only fall back
942        // to global when it is explicitly configured.
943        let global_id = DaemonId::try_new("global", user_id)?;
944        if self.daemons.contains_key(&global_id) {
945            return Ok(global_id);
946        }
947
948        let suggestion = find_similar_daemon(user_id, self.daemons.keys().map(|id| id.name()));
949        Err(DependencyError::DaemonNotFound {
950            name: user_id.to_string(),
951            suggestion,
952        }
953        .into())
954    }
955
956    /// Resolve a project's namespace even when it has no ordinary config file.
957    pub fn namespace_for_project_dir(dir: &Path) -> Result<String> {
958        namespace_from_path(&dir.join("pitchfork.toml"))
959    }
960
961    /// Return the `worktree_label` declared for this project, ignoring configs
962    /// inherited from parent directories.
963    ///
964    /// This covers the project's own four config files and any external file
965    /// registered to this directory with `pitchfork config add --dir`, which is
966    /// where a generator such as mise writes its configuration. Later files win,
967    /// matching the ordinary configuration precedence.
968    pub fn project_worktree_label(dir: &Path) -> Option<String> {
969        let mut label = None;
970        let candidates = project_config_family(&dir.join("pitchfork.toml"))
971            .into_iter()
972            .chain(crate::extra_configs::configs_for_dir(dir));
973        for candidate in candidates {
974            if !candidate.exists() {
975                continue;
976            }
977            if let Ok(pt) = Self::read(&candidate)
978                && let Some(found) = pt.worktree_label
979            {
980                label = Some(found);
981            }
982        }
983        label
984    }
985
986    /// Return the explicit namespace shared by this project's own configuration files.
987    pub fn project_namespace_override(dir: &Path) -> Result<Option<String>> {
988        directory_namespace_override(&dir.join("pitchfork.toml"), None)
989    }
990
991    /// Find the effective namespace from the nearest configuration file.
992    pub fn namespace_for_dir(dir: &Path) -> Result<String> {
993        Ok(Self::list_paths_from(dir)
994            .iter()
995            .filter(|p| p.exists())
996            .max_by_key(|p| {
997                if is_global_config(p) {
998                    0
999                } else {
1000                    project_dir_for_config(p).map_or(0, |dir| dir.components().count())
1001                }
1002            })
1003            .map(|p| namespace_from_path(p))
1004            .transpose()?
1005            .unwrap_or_else(|| "global".to_string()))
1006    }
1007
1008    /// Convenience method: resolves a single user ID using the merged config and current directory.
1009    ///
1010    /// Equivalent to:
1011    /// ```ignore
1012    /// PitchforkToml::all_merged().resolve_daemon_id_prefer_local(user_id, &env::CWD)
1013    /// ```
1014    ///
1015    /// # Errors
1016    /// Returns an error if `user_id` contains '/' but is not a valid qualified ID
1017    pub fn resolve_id(user_id: &str) -> Result<DaemonId> {
1018        if user_id.contains('/') {
1019            return DaemonId::parse(user_id);
1020        }
1021
1022        // Compute the namespace once and reuse it — avoids a second traversal
1023        // inside resolve_daemon_id_prefer_local.
1024        let config = Self::all_merged()?;
1025        let ns = Self::namespace_for_dir(&env::CWD)?;
1026        config.resolve_daemon_id_with_namespace(user_id, &ns)
1027    }
1028
1029    /// Like `resolve_id`, but allows ad-hoc short IDs in the current directory's
1030    /// derived namespace.
1031    ///
1032    /// This is intended for commands such as `pitchfork run` that create
1033    /// managed daemons without requiring prior config entries.
1034    pub fn resolve_id_allow_adhoc(user_id: &str) -> Result<DaemonId> {
1035        Self::resolve_id_allow_adhoc_from(user_id, &env::CWD)
1036    }
1037
1038    fn resolve_id_allow_adhoc_from(user_id: &str, dir: &Path) -> Result<DaemonId> {
1039        if user_id.contains('/') {
1040            return DaemonId::parse(user_id);
1041        }
1042
1043        let ns = Self::namespace_for_dir(dir)?;
1044        DaemonId::try_new(ns, user_id)
1045    }
1046
1047    /// Convenience method: resolves multiple user IDs using the merged config and current directory.
1048    ///
1049    /// Equivalent to:
1050    /// ```ignore
1051    /// let config = PitchforkToml::all_merged();
1052    /// ids.iter().map(|s| config.resolve_daemon_id_prefer_local(s, &env::CWD)).collect()
1053    /// ```
1054    ///
1055    /// # Errors
1056    /// Returns an error if any ID is malformed
1057    pub fn resolve_ids<S: AsRef<str>>(user_ids: &[S]) -> Result<Vec<DaemonId>> {
1058        // Fast path: all IDs are already qualified and can be parsed directly.
1059        if user_ids.iter().all(|s| s.as_ref().contains('/')) {
1060            return user_ids
1061                .iter()
1062                .map(|s| DaemonId::parse(s.as_ref()))
1063                .collect();
1064        }
1065
1066        let config = Self::all_merged()?;
1067        // Compute namespace once for all IDs
1068        let ns = Self::namespace_for_dir(&env::CWD)?;
1069        user_ids
1070            .iter()
1071            .map(|s| {
1072                let id = s.as_ref();
1073                if id.contains('/') {
1074                    DaemonId::parse(id)
1075                } else {
1076                    config.resolve_daemon_id_with_namespace(id, &ns)
1077                }
1078            })
1079            .collect()
1080    }
1081
1082    /// Resolve explicit daemon IDs and/or a group name into a deduplicated list of DaemonIds.
1083    ///
1084    /// This is more efficient than calling `resolve_ids` and `resolve_group` separately
1085    /// because it reads the merged config only once.
1086    pub fn resolve_ids_and_group<S: AsRef<str>>(
1087        user_ids: &[S],
1088        group_name: Option<&str>,
1089    ) -> Result<Vec<DaemonId>> {
1090        let config = Self::all_merged()?;
1091        let ns = Self::namespace_for_dir(&env::CWD)?;
1092        let mut ids = Vec::new();
1093        let mut seen = std::collections::HashSet::new();
1094
1095        for id in user_ids {
1096            let id_str = id.as_ref();
1097            let daemon_id = if id_str.contains('/') {
1098                DaemonId::parse(id_str)?
1099            } else {
1100                config.resolve_daemon_id_with_namespace(id_str, &ns)?
1101            };
1102            if seen.insert(daemon_id.clone()) {
1103                ids.push(daemon_id);
1104            }
1105        }
1106
1107        if let Some(name) = group_name {
1108            let members = expand_group(&config.groups, name)?;
1109            let missing: Vec<String> = members
1110                .iter()
1111                .filter(|id| !config.daemons.contains_key(*id))
1112                .map(|id| id.qualified())
1113                .collect();
1114            if !missing.is_empty() {
1115                return Err(miette::miette!(
1116                    "group '{}' references undefined daemon{}: {}",
1117                    name,
1118                    if missing.len() > 1 { "s" } else { "" },
1119                    missing.join(", ")
1120                ));
1121            }
1122            for daemon_id in members {
1123                if seen.insert(daemon_id.clone()) {
1124                    ids.push(daemon_id);
1125                }
1126            }
1127        }
1128
1129        Ok(ids)
1130    }
1131
1132    /// List all configuration file paths from the current working directory.
1133    /// See `list_paths_from` for details on the search order.
1134    pub fn list_paths() -> Vec<PathBuf> {
1135        Self::list_paths_from(&env::CWD)
1136    }
1137
1138    /// List all configuration file paths starting from a given directory.
1139    ///
1140    /// Returns paths in order of precedence (lowest to highest):
1141    /// 1. System-level: /etc/pitchfork/config.toml
1142    /// 2. User-level: ~/.config/pitchfork/config.toml
1143    /// 3. Project-level: .config/pitchfork.toml, .config/pitchfork.local.toml, pitchfork.toml and pitchfork.local.toml files
1144    ///    from filesystem root to the given directory
1145    ///
1146    /// Within each directory, .config/ comes before pitchfork.toml,
1147    /// which comes before pitchfork.local.toml, so local.toml values override base config.
1148    pub fn list_paths_from(cwd: &Path) -> Vec<PathBuf> {
1149        let mut paths = Vec::new();
1150        paths.push(env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone());
1151        paths.push(env::PITCHFORK_GLOBAL_CONFIG_USER.clone());
1152
1153        // Find all project config files. Order is reversed so after .reverse():
1154        // - each directory has: .config/pitchfork.toml < .config/pitchfork.local.toml < pitchfork.toml < pitchfork.local.toml
1155        // - directories go from root to cwd (later configs override earlier)
1156        let mut project_paths = xx::file::find_up_all(
1157            cwd,
1158            &[
1159                "pitchfork.local.toml",
1160                "pitchfork.toml",
1161                ".config/pitchfork.local.toml",
1162                ".config/pitchfork.toml",
1163            ],
1164        );
1165        project_paths.reverse();
1166        paths.extend(project_paths);
1167        paths.extend(crate::extra_configs::paths_for(cwd));
1168
1169        paths
1170    }
1171
1172    /// Merge all configuration files from the current working directory.
1173    /// See `all_merged_from` for details.
1174    pub fn all_merged() -> Result<PitchforkToml> {
1175        Self::all_merged_from(&env::CWD)
1176    }
1177    /// Load all merged config including daemons from ALL registered namespaces.
1178    ///
1179    /// Unlike `all_merged_from` which only merges configs from the cwd chain,
1180    /// this also iterates all `[namespaces]` entries and loads their daemon configs.
1181    /// Use this when you need a complete view (e.g. `start` for a daemon from
1182    /// another namespace).
1183    ///
1184    /// The read walks the filesystem and can start `git` or `jj` to find
1185    /// worktrees, so it runs on a blocking worker: being `async` is what keeps
1186    /// an async caller from running it on the runtime by accident. Code already
1187    /// on a blocking thread uses [`Self::all_merged_all_namespaces_blocking`].
1188    pub async fn all_merged_all_namespaces() -> Result<Self> {
1189        Self::all_merged_all_namespaces_from(&env::CWD).await
1190    }
1191
1192    /// [`Self::all_merged_all_namespaces`] starting from `start_dir`.
1193    pub(crate) async fn all_merged_all_namespaces_from(start_dir: &Path) -> Result<Self> {
1194        let start_dir = start_dir.to_path_buf();
1195        match tokio::task::spawn_blocking(move || {
1196            Self::all_merged_all_namespaces_from_blocking(&start_dir)
1197        })
1198        .await
1199        {
1200            Ok(result) => result,
1201            Err(e) => Err(miette::miette!("reading the merged config panicked: {e}")),
1202        }
1203    }
1204
1205    /// [`Self::all_merged_all_namespaces`] on the calling thread, for code that
1206    /// is already on a blocking thread and must not wait on the runtime.
1207    pub fn all_merged_all_namespaces_blocking() -> Result<Self> {
1208        Self::all_merged_all_namespaces_from_blocking(&env::CWD)
1209    }
1210
1211    /// Core of [`Self::all_merged_all_namespaces`], parameterized by the
1212    /// starting directory so it is testable without touching the global `CWD`.
1213    pub(crate) fn all_merged_all_namespaces_from_blocking(start_dir: &Path) -> Result<Self> {
1214        let mut pt = Self::all_merged_from(start_dir)?;
1215
1216        let namespaces = Self::read_global_namespaces();
1217        for (ns_name, entry) in namespaces {
1218            match Self::all_merged_from(&entry.dir) {
1219                Ok(ns_config) => {
1220                    for (daemon_id, daemon_config) in ns_config.daemons {
1221                        if !pt.daemons.contains_key(&daemon_id) {
1222                            pt.foreign_env
1223                                .insert(daemon_id.clone(), ns_config.env.clone());
1224                            pt.daemons.insert(daemon_id, daemon_config);
1225                        }
1226                    }
1227                    // Merge namespace-level settings so daemon-local
1228                    // overrides (e.g. hooks, env defaults) are available.
1229                    pt.settings.merge_from(&ns_config.settings);
1230                }
1231                Err(e) => {
1232                    log::warn!(
1233                        "Failed to load namespace '{ns_name}' from {}: {e}",
1234                        entry.dir.display()
1235                    );
1236                }
1237            }
1238        }
1239
1240        // Auto-discover git worktrees / jj workspaces under the current
1241        // project, so daemons defined in a worktree are visible to supervisor
1242        // background tasks (cron registration, boot_start, file watch) even
1243        // when that worktree's namespace was never registered in
1244        // `[namespaces]`. Discovery spawns a `git`/`jj` subprocess (<1ms) and
1245        // the per-worktree config reads are cached by `CONFIG_CACHE`, so no
1246        // extra caching layer is needed here.
1247        // Controlled by the global setting so disabling worktrees affects both config
1248        // discovery and proxy slug routing.
1249        if crate::settings::settings().general.worktree
1250            && let Some(project_root) = find_project_root(start_dir)
1251        {
1252            let worktrees = crate::proxy::worktree::discover_worktrees(&project_root);
1253            for wt in &worktrees {
1254                match Self::all_merged_from(&wt.path) {
1255                    Ok(wt_config) => {
1256                        for (daemon_id, daemon_config) in wt_config.daemons {
1257                            if !pt.daemons.contains_key(&daemon_id) {
1258                                pt.daemons.insert(daemon_id, daemon_config);
1259                            }
1260                        }
1261                        pt.settings.merge_from(&wt_config.settings);
1262                    }
1263                    Err(e) => {
1264                        log::warn!(
1265                            "Failed to load worktree '{}' config from {}: {e}",
1266                            wt.branch,
1267                            wt.path.display()
1268                        );
1269                    }
1270                }
1271            }
1272        }
1273
1274        Ok(pt)
1275    }
1276
1277    /// Merge all configuration files starting from a given directory.
1278    ///
1279    /// Reads and merges configuration files in precedence order.
1280    /// Each daemon ID is qualified with a namespace based on its config file location:
1281    /// - Global configs (`~/.config/pitchfork/config.toml`) use namespace "global"
1282    /// - Project configs use the parent directory name as namespace
1283    ///
1284    /// This prevents ID conflicts when multiple projects define daemons with the same name.
1285    ///
1286    /// Results are cached by cwd and invalidated when any source file's mtime
1287    /// changes or when [`invalidate_config_cache`] is called (e.g. after a
1288    /// config write via `write()` / `write_unlocked()`).
1289    ///
1290    /// # Errors
1291    /// Returns an error if any config file fails to parse. Aborts with an error
1292    /// if two *different* project config files produce the same namespace (e.g. two
1293    /// `pitchfork.toml` files in separate directories that share the same directory name).
1294    pub fn all_merged_from(cwd: &Path) -> Result<PitchforkToml> {
1295        let paths = Self::list_paths_from(cwd);
1296
1297        // Fast path: check the cache under a short-lived lock.
1298        // We canonicalize cwd for a stable key. If canonicalization fails
1299        // (e.g. the directory was just deleted), fall back to the raw path.
1300        let cache_key = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1301
1302        {
1303            let cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1304            if let Some(entry) = cache.get(&cache_key)
1305                && meta_matches(&paths, &entry.source_meta)
1306            {
1307                return Ok(entry.config.clone());
1308            }
1309        }
1310
1311        // Cache miss: snapshot (mtime, size) BEFORE reading.
1312        // If a file changes during the read, the snapshot (old mtime/size) won't
1313        // match the current values on the next call, forcing a re-read.
1314        // Taking the snapshot after the read would store old content with new
1315        // metadata, serving stale data indefinitely.
1316        let snapshot = snapshot_meta(&paths);
1317        let pt = Self::all_merged_from_uncached(&paths)?;
1318
1319        // Store in cache.
1320        let mut cache = CONFIG_CACHE.lock().unwrap_or_else(|e| e.into_inner());
1321        cache.insert(
1322            cache_key,
1323            ConfigCacheEntry {
1324                config: pt.clone(),
1325                source_meta: snapshot,
1326            },
1327        );
1328
1329        Ok(pt)
1330    }
1331
1332    /// Uncached merge of all configuration files from a list of paths.
1333    ///
1334    /// This is the original merge logic extracted from `all_merged_from` so that
1335    /// the cache layer can wrap it without duplicating the algorithm.
1336    fn all_merged_from_uncached(paths: &[PathBuf]) -> Result<PitchforkToml> {
1337        use std::collections::HashMap as StdHashMap;
1338
1339        let mut ns_to_origin: StdHashMap<String, (PathBuf, PathBuf)> = StdHashMap::new();
1340
1341        let mut pt = Self::default();
1342        for p in paths {
1343            match Self::read(p) {
1344                Ok(pt2) => {
1345                    // Detect collisions for all existing project configs, including
1346                    // pitchfork.local.toml. Allow sibling base/local files in the same
1347                    // directory to share a namespace, including siblings via .config subfolder
1348                    if p.exists() && !is_global_config(p) {
1349                        let ns = namespace_from_path(p)?;
1350                        let origin_dir = project_dir_for_config(p)
1351                            .map(|dir| dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()))
1352                            .unwrap_or_else(|| p.clone());
1353
1354                        if let Some((other_path, other_dir)) = ns_to_origin.get(ns.as_str())
1355                            && *other_dir != origin_dir
1356                        {
1357                            return Err(crate::error::ConfigParseError::NamespaceCollision {
1358                                path_a: other_path.clone(),
1359                                path_b: p.clone(),
1360                                ns,
1361                            }
1362                            .into());
1363                        }
1364                        ns_to_origin.insert(ns, (p.clone(), origin_dir));
1365                    }
1366
1367                    pt.merge(pt2)
1368                }
1369                Err(e) => return Err(e.wrap_err(format!("error reading {}", p.display()))),
1370            }
1371        }
1372        Ok(pt)
1373    }
1374}
1375
1376impl PitchforkToml {
1377    pub fn new(path: PathBuf) -> Self {
1378        Self {
1379            daemons: Default::default(),
1380            env: None,
1381            namespace: None,
1382            worktree_label: None,
1383            settings: SettingsPartial::default(),
1384            slugs: IndexMap::new(),
1385            groups: IndexMap::new(),
1386            namespaces: IndexMap::new(),
1387            path: Some(path),
1388            foreign_env: IndexMap::new(),
1389        }
1390    }
1391
1392    /// Parse TOML content as a [`PitchforkToml`] without touching the filesystem.
1393    ///
1394    /// Applies the same namespace derivation and daemon validation as [`read()`] but
1395    /// uses the provided `content` directly instead of reading from disk.  `path` is
1396    /// used only for namespace derivation and error messages.
1397    ///
1398    /// This is useful for validating user-edited content before saving it.
1399    pub fn parse_str(content: &str, path: &Path) -> Result<Self> {
1400        let mut raw_config: PitchforkTomlRaw = toml::from_str(content)
1401            .map_err(|e| ConfigParseError::from_toml_error(path, content.to_string(), e))?;
1402        if let Some(settings) = &mut raw_config.settings {
1403            settings.canonicalize_aliases();
1404        }
1405
1406        let explicit = directory_namespace_override(path, Some(content))?;
1407        let namespace = namespace_from_path_with_override(path, explicit.as_deref())?;
1408        let mut pt = Self::new(path.to_path_buf());
1409        pt.namespace = raw_config.namespace.clone();
1410        pt.worktree_label = raw_config.worktree_label.clone();
1411
1412        for (short_name, raw_daemon) in raw_config.daemons {
1413            let id = match DaemonId::try_new(&namespace, &short_name) {
1414                Ok(id) => id,
1415                Err(e) => {
1416                    return Err(ConfigParseError::InvalidDaemonName {
1417                        name: short_name,
1418                        path: path.to_path_buf(),
1419                        reason: e.to_string(),
1420                    }
1421                    .into());
1422                }
1423            };
1424
1425            let mut depends = Vec::new();
1426            for dep in raw_daemon.depends {
1427                let dep_id = if dep.contains('/') {
1428                    match DaemonId::parse(&dep) {
1429                        Ok(id) => id,
1430                        Err(e) => {
1431                            return Err(ConfigParseError::InvalidDependency {
1432                                daemon: short_name.clone(),
1433                                dependency: dep,
1434                                path: path.to_path_buf(),
1435                                reason: e.to_string(),
1436                            }
1437                            .into());
1438                        }
1439                    }
1440                } else {
1441                    match DaemonId::try_new(&namespace, &dep) {
1442                        Ok(id) => id,
1443                        Err(e) => {
1444                            return Err(ConfigParseError::InvalidDependency {
1445                                daemon: short_name.clone(),
1446                                dependency: dep,
1447                                path: path.to_path_buf(),
1448                                reason: e.to_string(),
1449                            }
1450                            .into());
1451                        }
1452                    }
1453                };
1454                depends.push(dep_id);
1455            }
1456
1457            // Resolve port config: prefer new `port` field, fall back to deprecated fields
1458            let has_deprecated = !raw_daemon.expected_port.is_empty()
1459                || raw_daemon.auto_bump_port.is_some()
1460                || raw_daemon.port_bump_attempts.is_some();
1461            let deprecated_port = has_deprecated.then(|| {
1462                let bump = if raw_daemon.auto_bump_port.unwrap_or(false) {
1463                    PortBump(
1464                        raw_daemon
1465                            .port_bump_attempts
1466                            .unwrap_or_else(|| settings().default_port_bump_attempts()),
1467                    )
1468                } else {
1469                    PortBump(0)
1470                };
1471                PortConfig {
1472                    expect: raw_daemon.expected_port,
1473                    bump,
1474                }
1475            });
1476            let port = match (raw_daemon.port, deprecated_port) {
1477                (Some(port), Some(deprecated)) => {
1478                    // Writing the config adds the deprecated fields beside
1479                    // `port` for older versions; only a disagreement is news.
1480                    if deprecated != port {
1481                        warn!(
1482                            "daemon {short_name}: both `port` and deprecated expected_port/auto_bump_port/port_bump_attempts are set; ignoring deprecated fields"
1483                        );
1484                    }
1485                    Some(port)
1486                }
1487                (Some(port), None) => Some(port),
1488                (None, Some(deprecated)) => {
1489                    deprecated_at!(
1490                        "2026-10-03",
1491                        "2027-10-03",
1492                        format!("port-fields:{id}"),
1493                        "daemon {short_name}: expected_port/auto_bump_port/port_bump_attempts are deprecated, use [daemons.{short_name}.port] instead."
1494                    );
1495                    Some(deprecated)
1496                }
1497                (None, None) => None,
1498            };
1499
1500            // `proxy_port` is the shorter spelling of the same setting.
1501            if let (Some(explicit), Some(short)) =
1502                (raw_daemon.proxy_tls_port, raw_daemon.proxy_port)
1503                && explicit != short
1504            {
1505                warn!(
1506                    "daemon {short_name}: proxy_tls_port ({explicit}) and proxy_port ({short}) \
1507                     disagree; using proxy_tls_port"
1508                );
1509            }
1510            let proxy_tls_port = raw_daemon.proxy_tls_port.or(raw_daemon.proxy_port);
1511
1512            // Port 0 asks the operating system to choose, so it names no port
1513            // a hostname can be sent to.
1514            for (key, value) in [
1515                ("proxy_tls_port", raw_daemon.proxy_tls_port),
1516                ("proxy_port", raw_daemon.proxy_port),
1517            ] {
1518                if value == Some(0) {
1519                    return Err(ConfigParseError::ProxyPortZero {
1520                        daemon: short_name.clone(),
1521                        key,
1522                        path: path.to_path_buf(),
1523                    }
1524                    .into());
1525                }
1526            }
1527
1528            // The hostname maps to one of the daemon's own ports, so a port it
1529            // never declares cannot be routed to.
1530            if let Some(port_want) = proxy_tls_port {
1531                let declared = port.as_ref().map(|p| p.expect.clone()).unwrap_or_default();
1532                if !declared.contains(&port_want) {
1533                    return Err(ConfigParseError::ProxyPortNotDeclared {
1534                        daemon: short_name.clone(),
1535                        port: port_want,
1536                        declared,
1537                        path: path.to_path_buf(),
1538                    }
1539                    .into());
1540                }
1541            }
1542
1543            // TLS passthrough splices the raw stream to 127.0.0.1:<port>, so a
1544            // daemon without a known port has nothing to splice to.
1545            if raw_daemon
1546                .proxy_tls
1547                .is_some_and(ProxyTlsMode::is_passthrough)
1548                && port
1549                    .as_ref()
1550                    .is_none_or(|p| p.expect.iter().all(|&port| port == 0))
1551            {
1552                return Err(ConfigParseError::PassthroughWithoutPort {
1553                    daemon: short_name.clone(),
1554                    path: path.to_path_buf(),
1555                }
1556                .into());
1557            }
1558
1559            if let RunCommand::Argv(argv) = &raw_daemon.run {
1560                match argv.first().map(String::as_str) {
1561                    None => {
1562                        return Err(ConfigParseError::EmptyRunArgv {
1563                            daemon: short_name.clone(),
1564                            path: path.to_path_buf(),
1565                        }
1566                        .into());
1567                    }
1568                    // Almost certainly a shell command line carried over: in
1569                    // an array it would look for a program called `exec`.
1570                    Some("exec") => {
1571                        return Err(ConfigParseError::ExecInRunArgv {
1572                            daemon: short_name.clone(),
1573                            path: path.to_path_buf(),
1574                        }
1575                        .into());
1576                    }
1577                    Some(_) => {}
1578                }
1579            }
1580
1581            if let Some(RunCommand::Argv(argv)) = raw_daemon.ready_cmd.as_ref().map(|c| &c.run) {
1582                match argv.first().map(String::as_str) {
1583                    // An empty program would fail every probe, and with no
1584                    // timeout the start would wait for readiness forever.
1585                    None | Some("") => {
1586                        return Err(ConfigParseError::EmptyReadyCmdArgv {
1587                            daemon: short_name.clone(),
1588                            path: path.to_path_buf(),
1589                        }
1590                        .into());
1591                    }
1592                    Some("exec") => {
1593                        return Err(ConfigParseError::ExecInReadyCmdArgv {
1594                            daemon: short_name.clone(),
1595                            path: path.to_path_buf(),
1596                        }
1597                        .into());
1598                    }
1599                    Some(_) => {}
1600                }
1601            }
1602
1603            if let Some(RunCommand::Argv(argv)) = raw_daemon.health_cmd.as_ref().map(|c| &c.run) {
1604                match argv.first().map(String::as_str) {
1605                    // An empty program would fail every probe, and the daemon
1606                    // would be killed once its health check retries ran out.
1607                    None | Some("") => {
1608                        return Err(ConfigParseError::EmptyHealthCmdArgv {
1609                            daemon: short_name.clone(),
1610                            path: path.to_path_buf(),
1611                        }
1612                        .into());
1613                    }
1614                    Some("exec") => {
1615                        return Err(ConfigParseError::ExecInHealthCmdArgv {
1616                            daemon: short_name.clone(),
1617                            path: path.to_path_buf(),
1618                        }
1619                        .into());
1620                    }
1621                    Some(_) => {}
1622                }
1623            }
1624
1625            let daemon = PitchforkTomlDaemon {
1626                run: raw_daemon.run,
1627                auto: raw_daemon.auto,
1628                oneshot: raw_daemon.oneshot,
1629                cron: raw_daemon.cron,
1630                retry: raw_daemon.retry,
1631                ready_delay: raw_daemon.ready_delay,
1632                ready_output: raw_daemon.ready_output,
1633                ready_http: raw_daemon.ready_http,
1634                ready_port: raw_daemon.ready_port,
1635                ready_cmd: raw_daemon.ready_cmd,
1636                health_cmd: raw_daemon.health_cmd,
1637                health_http: raw_daemon.health_http,
1638                health_port: raw_daemon.health_port,
1639                port,
1640                proxy: raw_daemon.proxy,
1641                proxy_tls: raw_daemon.proxy_tls,
1642                proxy_tls_port: raw_daemon.proxy_tls_port,
1643                proxy_port: raw_daemon.proxy_port,
1644                proxy_idle_timeout: raw_daemon.proxy_idle_timeout,
1645                boot_start: raw_daemon.boot_start,
1646                depends,
1647                watch: raw_daemon.watch,
1648                watch_mode: raw_daemon.watch_mode.unwrap_or_default(),
1649                dir: raw_daemon.dir,
1650                env: raw_daemon.env,
1651                env_file: raw_daemon.env_file,
1652                hooks: raw_daemon.hooks,
1653                mise: raw_daemon.mise,
1654                deferred_template_context: None,
1655                user: raw_daemon.user,
1656                memory_limit: raw_daemon.memory_limit,
1657                cpu_limit: raw_daemon.cpu_limit,
1658                stop_signal: raw_daemon.stop_signal,
1659                pty: raw_daemon.pty,
1660                time_retention: raw_daemon.time_retention,
1661                line_retention: raw_daemon.line_retention,
1662                archive_hook: raw_daemon.archive_hook,
1663                logs: raw_daemon.logs,
1664                path: Some(path.to_path_buf()),
1665            };
1666            if daemon.is_oneshot() {
1667                let conflicts = daemon.oneshot_conflicts();
1668                if !conflicts.is_empty() {
1669                    return Err(ConfigParseError::OneshotConflict {
1670                        daemon: short_name.clone(),
1671                        path: path.to_path_buf(),
1672                        conflicts: conflicts.into_iter().map(str::to_string).collect(),
1673                    }
1674                    .into());
1675                }
1676            }
1677            pt.daemons.insert(id, daemon);
1678        }
1679
1680        // Copy settings if present
1681        if let Some(settings) = raw_config.settings {
1682            pt.settings = settings;
1683        }
1684
1685        // Copy top-level env
1686        pt.env = raw_config.env;
1687
1688        // Copy slugs registry (only meaningful in global config files)
1689        for (slug, entry) in raw_config.slugs {
1690            pt.slugs.insert(
1691                slug,
1692                SlugEntry {
1693                    dir: entry.dir.map(env::expand_tilde),
1694                    namespace: entry.namespace,
1695                    daemon: entry.daemon,
1696                },
1697            );
1698        }
1699
1700        // Copy namespaces registry (only meaningful in global config files)
1701        for (name, entry) in raw_config.namespaces {
1702            pt.namespaces.insert(
1703                name,
1704                NamespaceEntry {
1705                    config: entry
1706                        .config
1707                        .iter()
1708                        .map(|p| {
1709                            crate::extra_configs::resolve_path(&env::expand_tilde(&entry.dir), p)
1710                        })
1711                        .collect(),
1712                    dir: env::expand_tilde(entry.dir),
1713                    label: entry.label,
1714                },
1715            );
1716        }
1717
1718        // Resolve group entries: convert short daemon names to qualified DaemonIds
1719        for (group_name, raw_group) in raw_config.groups {
1720            let mut daemons = Vec::new();
1721            for daemon_name in &raw_group.daemons {
1722                let id = if daemon_name.contains('/') {
1723                    DaemonId::parse(daemon_name).map_err(|e| {
1724                        ConfigParseError::InvalidDependency {
1725                            daemon: group_name.clone(),
1726                            dependency: daemon_name.clone(),
1727                            path: path.to_path_buf(),
1728                            reason: e.to_string(),
1729                        }
1730                    })?
1731                } else {
1732                    DaemonId::try_new(&namespace, daemon_name).map_err(|e| {
1733                        ConfigParseError::InvalidDaemonName {
1734                            name: daemon_name.clone(),
1735                            path: path.to_path_buf(),
1736                            reason: e.to_string(),
1737                        }
1738                    })?
1739                };
1740                daemons.push(id);
1741            }
1742            pt.groups.insert(
1743                group_name,
1744                GroupEntry {
1745                    daemons,
1746                    groups: raw_group.groups,
1747                },
1748            );
1749        }
1750
1751        Ok(pt)
1752    }
1753
1754    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
1755        let path = path.as_ref();
1756        if !path.exists() {
1757            return Ok(Self::new(path.to_path_buf()));
1758        }
1759        let _lock = xx::fslock::get(path, false)
1760            .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1761        let raw = std::fs::read_to_string(path).map_err(|e| FileError::ReadError {
1762            path: path.to_path_buf(),
1763            source: e,
1764        })?;
1765        Self::parse_str(&raw, path)
1766    }
1767
1768    pub fn write(&self) -> Result<()> {
1769        if let Some(path) = &self.path {
1770            let _lock = xx::fslock::get(path, false)
1771                .wrap_err_with(|| format!("failed to acquire lock on {}", path.display()))?;
1772            self.write_unlocked()
1773        } else {
1774            Err(FileError::NoPath.into())
1775        }
1776    }
1777
1778    /// Write the config file without acquiring a file lock.
1779    ///
1780    /// The caller MUST hold the file lock (via `xx::fslock::get`) before
1781    /// calling this method. This is used by `register_slug` which needs to
1782    /// hold a single lock across a read-modify-write cycle.
1783    pub(crate) fn write_unlocked(&self) -> Result<()> {
1784        if let Some(path) = &self.path {
1785            // Determine the namespace for this config file
1786            // A new file without its own `namespace` shares the one its
1787            // directory's other config files declare, as it will once read.
1788            let config_namespace = if path.exists() || self.namespace.is_none() {
1789                namespace_from_path(path)?
1790            } else {
1791                namespace_from_path_with_override(path, self.namespace.as_deref())?
1792            };
1793
1794            // Convert back to raw format for writing (use short names as keys)
1795            // Preserve settings so read-modify-write (e.g. `settings set`, `proxy add`)
1796            // doesn't drop `[settings.*]`. Gate on is_empty to avoid a bare `[settings]`.
1797            let mut raw = PitchforkTomlRaw {
1798                namespace: self.namespace.clone(),
1799                worktree_label: self.worktree_label.clone(),
1800                env: self.env.clone(),
1801                settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1802                ..PitchforkTomlRaw::default()
1803            };
1804            for (id, daemon) in &self.daemons {
1805                if id.namespace() != config_namespace {
1806                    return Err(miette::miette!(
1807                        "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1808                        id,
1809                        path.display(),
1810                        id.namespace(),
1811                        config_namespace
1812                    ));
1813                }
1814                let port = daemon.port.as_ref();
1815                let raw_daemon = PitchforkTomlDaemonRaw {
1816                    run: daemon.run.clone(),
1817                    auto: daemon.auto.clone(),
1818                    oneshot: daemon.oneshot,
1819                    cron: daemon.cron.clone(),
1820                    retry: daemon.retry,
1821                    ready_delay: daemon.ready_delay,
1822                    ready_output: daemon.ready_output.clone(),
1823                    ready_http: daemon.ready_http.clone(),
1824                    ready_port: daemon.ready_port.clone(),
1825                    ready_cmd: daemon.ready_cmd.clone(),
1826                    health_cmd: daemon.health_cmd.clone(),
1827                    health_http: daemon.health_http.clone(),
1828                    health_port: daemon.health_port.clone(),
1829                    port: port.cloned(),
1830                    proxy: daemon.proxy.clone(),
1831                    proxy_tls: daemon.proxy_tls,
1832                    proxy_tls_port: daemon.proxy_tls_port,
1833                    proxy_port: daemon.proxy_port,
1834                    proxy_idle_timeout: daemon.proxy_idle_timeout,
1835                    // Deprecated fields: written for backward compatibility with older pitchfork versions
1836                    expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1837                    auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1838                    port_bump_attempts: port
1839                        .filter(|p| p.auto_bump())
1840                        .map(|p| p.max_bump_attempts()),
1841                    boot_start: daemon.boot_start,
1842                    // Preserve cross-namespace dependencies: use qualified ID if namespace differs,
1843                    // otherwise use short name
1844                    depends: daemon
1845                        .depends
1846                        .iter()
1847                        .map(|d| {
1848                            if d.namespace() == config_namespace {
1849                                d.name().to_string()
1850                            } else {
1851                                d.qualified()
1852                            }
1853                        })
1854                        .collect(),
1855                    watch: daemon.watch.clone(),
1856                    watch_mode: match daemon.watch_mode {
1857                        WatchMode::Native => None,
1858                        mode => Some(mode),
1859                    },
1860                    dir: daemon.dir.clone(),
1861                    env: daemon.env.clone(),
1862                    env_file: daemon.env_file.clone(),
1863                    hooks: daemon.hooks.clone(),
1864                    mise: daemon.mise,
1865                    user: daemon.user.clone(),
1866                    memory_limit: daemon.memory_limit,
1867                    cpu_limit: daemon.cpu_limit,
1868                    stop_signal: daemon.stop_signal,
1869                    pty: daemon.pty,
1870                    time_retention: daemon.time_retention.clone(),
1871                    line_retention: daemon.line_retention,
1872                    archive_hook: daemon.archive_hook.clone(),
1873                    logs: daemon.logs.clone(),
1874                };
1875                raw.daemons.insert(id.name().to_string(), raw_daemon);
1876            }
1877
1878            // Copy slugs registry to raw format
1879            for (slug, entry) in &self.slugs {
1880                raw.slugs.insert(
1881                    slug.clone(),
1882                    SlugEntryRaw {
1883                        dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1884                        namespace: entry.namespace.clone(),
1885                        daemon: entry.daemon.clone(),
1886                    },
1887                );
1888            }
1889
1890            // Serialize groups back to raw format (preserve cross-namespace refs as qualified IDs)
1891            for (name, group) in &self.groups {
1892                let raw_daemons: Vec<String> = group
1893                    .daemons
1894                    .iter()
1895                    .map(|id| {
1896                        if id.namespace() == config_namespace {
1897                            id.name().to_string()
1898                        } else {
1899                            id.qualified()
1900                        }
1901                    })
1902                    .collect();
1903                raw.groups.insert(
1904                    name.clone(),
1905                    GroupEntryRaw {
1906                        daemons: raw_daemons,
1907                        groups: group.groups.clone(),
1908                    },
1909                );
1910            }
1911
1912            // Copy namespaces registry to raw format
1913            for (name, entry) in &self.namespaces {
1914                raw.namespaces.insert(
1915                    name.clone(),
1916                    NamespaceEntryRaw {
1917                        dir: entry.dir.to_string_lossy().to_string(),
1918                        config: entry
1919                            .config
1920                            .iter()
1921                            .map(|p| p.to_string_lossy().into_owned())
1922                            .collect(),
1923                        label: entry.label.clone(),
1924                    },
1925                );
1926            }
1927
1928            let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1929                path: path.clone(),
1930                source: e,
1931            })?;
1932            xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1933                path: path.clone(),
1934                details: Some(e.to_string()),
1935            })?;
1936            invalidate_config_cache();
1937            Ok(())
1938        } else {
1939            Err(FileError::NoPath.into())
1940        }
1941    }
1942
1943    /// Simple merge without namespace re-qualification.
1944    /// Used primarily for testing or when merging configs from the same namespace.
1945    /// Since read() already qualifies daemon IDs with namespace, this just inserts them.
1946    /// Settings are also merged - later values override earlier ones.
1947    pub fn merge(&mut self, pt: Self) {
1948        if pt.worktree_label.is_some() {
1949            self.worktree_label = pt.worktree_label.clone();
1950        }
1951        for (id, d) in pt.daemons {
1952            self.daemons.insert(id, d);
1953        }
1954        // Merge top-level env - pt's values override self's values
1955        if let Some(env) = pt.env {
1956            let merged = self.env.get_or_insert_with(IndexMap::new);
1957            for (k, v) in env {
1958                merged.insert(k, v);
1959            }
1960        }
1961        // Merge slugs - pt's values override self's values
1962        for (slug, entry) in pt.slugs {
1963            self.slugs.insert(slug, entry);
1964        }
1965        // Merge groups - pt's values override self's values
1966        for (name, group) in pt.groups {
1967            self.groups.insert(name, group);
1968        }
1969        // Merge namespaces - pt's values override self's values
1970        for (name, entry) in pt.namespaces {
1971            self.namespaces.insert(name, entry);
1972        }
1973        // Merge settings - pt's values override self's values
1974        self.settings.merge_from(&pt.settings);
1975    }
1976
1977    /// Read the global slug registry from the user-level global config.
1978    ///
1979    /// Returns a map of slug → SlugEntry from `[slugs]` in
1980    /// `~/.config/pitchfork/config.toml`.
1981    pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1982        match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1983            Ok(pt) => pt.slugs,
1984            Err(_) => IndexMap::new(),
1985        }
1986    }
1987
1988    /// Whether more than one registry key folds to `slug`'s ASCII-lowercased form.
1989    ///
1990    /// Host names are case-insensitive (RFC 4343), so the proxy refuses to route
1991    /// such a slug at all rather than pick one of the spellings.  Callers that
1992    /// turn a slug into a URL must not advertise an address the proxy will
1993    /// reject, so they check this first.
1994    pub fn slug_is_ambiguous(slug: &str, global_slugs: &IndexMap<String, SlugEntry>) -> bool {
1995        global_slugs
1996            .keys()
1997            .filter(|k| k.eq_ignore_ascii_case(slug))
1998            .count()
1999            > 1
2000    }
2001
2002    /// Find the registered slug for a daemon using a pre-loaded slug registry.
2003    ///
2004    /// Returns `None` for a slug the proxy will not route — see
2005    /// [`Self::slug_is_ambiguous`].
2006    pub fn find_slug_for_daemon_in_registry(
2007        daemon_id: &DaemonId,
2008        global_slugs: &IndexMap<String, SlugEntry>,
2009    ) -> Option<String> {
2010        global_slugs
2011            .iter()
2012            .find(|(slug, entry)| {
2013                let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
2014                if daemon_id.name() != daemon_name {
2015                    return false;
2016                }
2017
2018                // Skipped rather than returned and discarded: another alias for
2019                // the same daemon may still be routable.
2020                if Self::slug_is_ambiguous(slug, global_slugs) {
2021                    return false;
2022                }
2023
2024                match entry.resolve_namespace() {
2025                    Some(namespace) => daemon_id.namespace() == namespace,
2026                    None => false,
2027                }
2028            })
2029            .map(|(slug, _)| slug.clone())
2030    }
2031
2032    /// Check if a slug is registered in the global config's `[slugs]` section.
2033    #[allow(dead_code)]
2034    pub fn is_slug_registered(slug: &str) -> bool {
2035        Self::read_global_slugs().contains_key(slug)
2036    }
2037
2038    /// Add a slug entry to the global config's `[slugs]` section using namespace instead of dir.
2039    ///
2040    /// Reads the global config, adds/updates the slug entry, and writes it back.
2041    /// If `namespace` is provided but not yet registered in `[namespaces]`,
2042    /// also registers it at `dir` (acquired via `resolve_dir()` on the slug entry).
2043    pub fn add_slug_with_namespace(
2044        slug: &str,
2045        namespace: Option<&str>,
2046        daemon: Option<&str>,
2047    ) -> Result<()> {
2048        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2049
2050        // Ensure the config directory exists
2051        if let Some(parent) = global_path.parent() {
2052            std::fs::create_dir_all(parent).map_err(|e| {
2053                miette::miette!(
2054                    "Failed to create config directory {}: {e}",
2055                    parent.display()
2056                )
2057            })?;
2058        }
2059
2060        let _lock = xx::fslock::get(global_path, false)
2061            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2062
2063        let mut pt = if global_path.exists() {
2064            let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2065                path: global_path.to_path_buf(),
2066                source: e,
2067            })?;
2068            Self::parse_str(&raw, global_path)?
2069        } else {
2070            Self::new(global_path.to_path_buf())
2071        };
2072
2073        // If caller provided a namespace that isn't yet registered,
2074        // auto-register it at the directory we can resolve.
2075        // Falls back to CWD if the slug dir cannot be resolved.
2076        if let Some(ns) = namespace
2077            && !pt.namespaces.contains_key(ns)
2078        {
2079            // Resolve against the already-parsed `pt` instead of
2080            // SlugEntry::resolve_dir(): that re-reads the global config via
2081            // read(), which would re-acquire the lock held above (flock is per
2082            // open file description, so the same process deadlocks on itself).
2083            let dir = pt
2084                .slugs
2085                .get(slug)
2086                .and_then(|e| {
2087                    e.dir.clone().or_else(|| {
2088                        e.namespace
2089                            .as_ref()
2090                            .and_then(|ns| pt.namespaces.get(ns).map(|entry| entry.dir.clone()))
2091                    })
2092                })
2093                .or_else(|| env::CWD.as_path().canonicalize().ok());
2094            if let Some(ref d) = dir {
2095                pt.namespaces.insert(
2096                    ns.to_string(),
2097                    NamespaceEntry {
2098                        dir: d.clone(),
2099                        config: Vec::new(),
2100                        label: None,
2101                    },
2102                );
2103            }
2104        }
2105
2106        pt.slugs.insert(
2107            slug.to_string(),
2108            SlugEntry {
2109                dir: None,
2110                namespace: namespace.map(str::to_string),
2111                daemon: daemon.map(str::to_string),
2112            },
2113        );
2114        pt.write_unlocked()?;
2115        // Sync hosts from the in-memory slug set, not sync_hosts_from_settings():
2116        // that re-reads the global config via read(), which acquires the lock held
2117        // above — flock is per open file description, so re-acquiring in the same
2118        // process deadlocks against our own lock. Staying under the lock also keeps
2119        // hosts writes ordered with config mutations across concurrent commands.
2120        let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2121        crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2122        Ok(())
2123    }
2124
2125    /// Remove a slug from the global config's `[slugs]` section.
2126    pub fn remove_slug(slug: &str) -> Result<bool> {
2127        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2128        if !global_path.exists() {
2129            return Ok(false);
2130        }
2131
2132        let _lock = xx::fslock::get(global_path, false)
2133            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2134
2135        let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2136            path: global_path.to_path_buf(),
2137            source: e,
2138        })?;
2139        let mut pt = Self::parse_str(&raw, global_path)?;
2140
2141        let removed = pt.slugs.shift_remove(slug).is_some();
2142        if removed {
2143            pt.write_unlocked()?;
2144            // Sync hosts from the in-memory slug set, not sync_hosts_from_settings():
2145            // that re-reads the global config via read(), which acquires the lock held
2146            // above — flock is per open file description, so re-acquiring in the same
2147            // process deadlocks against our own lock. Staying under the lock also keeps
2148            // hosts writes ordered with config mutations across concurrent commands.
2149            let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2150            crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2151        }
2152        Ok(removed)
2153    }
2154    /// Returns a map of namespace → NamespaceEntry from `[namespaces]` in
2155    /// `~/.config/pitchfork/config.toml`.
2156    pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
2157        match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
2158            Ok(pt) => pt.namespaces,
2159            Err(_) => IndexMap::new(),
2160        }
2161    }
2162
2163    /// Add a namespace entry to the global config's `[namespaces]` section.
2164    ///
2165    /// Reads the global config, adds/updates the namespace entry, and writes it back.
2166    pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
2167        let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2168
2169        // Ensure the config directory exists
2170        if let Some(parent) = global_path.parent() {
2171            std::fs::create_dir_all(parent).map_err(|e| {
2172                miette::miette!(
2173                    "Failed to create config directory {}: {e}",
2174                    parent.display()
2175                )
2176            })?;
2177        }
2178
2179        let _lock = xx::fslock::get(global_path, false)
2180            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2181
2182        let mut pt = if global_path.exists() {
2183            let raw = std::fs::read_to_string(global_path).map_err(|e| {
2184                crate::error::FileError::ReadError {
2185                    path: global_path.to_path_buf(),
2186                    source: e,
2187                }
2188            })?;
2189            Self::parse_str(&raw, global_path)?
2190        } else {
2191            Self::new(global_path.to_path_buf())
2192        };
2193
2194        let dir = env::expand_tilde(dir);
2195        if let Some(entry) = pt.namespaces.get_mut(name) {
2196            if crate::extra_configs::path_is_gone(&entry.dir)
2197                && crate::extra_configs::normalize(&entry.dir)
2198                    != crate::extra_configs::normalize(&dir)
2199            {
2200                if crate::extra_configs::namespace_has_running_daemon(name) {
2201                    // Retargeting would show the old project's running daemons
2202                    // to the new one.
2203                    miette::bail!(
2204                        "namespace '{name}' has running daemons from {}, which no longer exists; stop them first",
2205                        entry.dir.display()
2206                    );
2207                }
2208                // The old project is gone: its attachments and label go with it.
2209                entry.config.clear();
2210                entry.label = None;
2211            }
2212            if !entry.config.is_empty()
2213                && crate::extra_configs::normalize(&entry.dir)
2214                    != crate::extra_configs::normalize(&dir)
2215            {
2216                miette::bail!(
2217                    "namespace '{name}' has external configuration attached to another directory"
2218                );
2219            }
2220            entry.dir = dir;
2221        } else {
2222            pt.namespaces.insert(
2223                name.to_string(),
2224                NamespaceEntry {
2225                    dir,
2226                    config: Vec::new(),
2227                    label: None,
2228                },
2229            );
2230        }
2231        pt.write_unlocked()?;
2232        Ok(())
2233    }
2234
2235    /// Remove a namespace from the global config's `[namespaces]` section.
2236    pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2237        let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2238        if !global_path.exists() {
2239            return Ok(false);
2240        }
2241
2242        let _lock = xx::fslock::get(global_path, false)
2243            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2244
2245        let raw = std::fs::read_to_string(global_path).map_err(|e| {
2246            crate::error::FileError::ReadError {
2247                path: global_path.to_path_buf(),
2248                source: e,
2249            }
2250        })?;
2251        let mut pt = Self::parse_str(&raw, global_path)?;
2252
2253        let removed = pt.namespaces.shift_remove(name).is_some();
2254        if removed {
2255            pt.write_unlocked()?;
2256        }
2257        Ok(removed)
2258    }
2259}
2260
2261/// What [`PitchforkToml::prune_stale_registrations`] removed.
2262#[derive(Debug, Default, Clone, PartialEq, Eq)]
2263pub struct PruneReport {
2264    /// `[namespaces]` entries whose project directory no longer exists.
2265    pub namespaces: Vec<String>,
2266    /// `[slugs]` entries that pointed at a removed or missing directory.
2267    pub slugs: Vec<String>,
2268    /// Attached external configuration files that no longer exist.
2269    pub config_files: usize,
2270}
2271
2272impl PruneReport {
2273    pub fn is_empty(&self) -> bool {
2274        self.namespaces.is_empty() && self.slugs.is_empty() && self.config_files == 0
2275    }
2276}
2277
2278impl PitchforkToml {
2279    /// Drop `[namespaces]`, `[slugs]` and attached external config files in the
2280    /// user-level global config that point at paths that no longer exist.
2281    ///
2282    /// Tools and scratch projects register namespaces and slugs and are rarely
2283    /// able to unregister them when their directory is deleted, so the registry
2284    /// otherwise only ever grows.
2285    ///
2286    /// * `keep` names namespaces that must be left alone, typically those with
2287    ///   a running daemon.
2288    /// * `only` limits the pass to the listed namespaces; empty means all.
2289    ///
2290    /// A path is only treated as gone when the filesystem answers that it does
2291    /// not exist: an I/O error (permissions, a flaky mount) keeps the entry.
2292    pub fn prune_stale_registrations(
2293        keep: &std::collections::HashSet<String>,
2294        only: &[String],
2295    ) -> Result<PruneReport> {
2296        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
2297        let mut report = PruneReport::default();
2298        if !global_path.exists() {
2299            return Ok(report);
2300        }
2301        let _lock = xx::fslock::get(global_path, false)
2302            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2303        let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
2304            path: global_path.to_path_buf(),
2305            source: e,
2306        })?;
2307        let mut pt = Self::parse_str(&raw, global_path)?;
2308        let in_scope = |ns: &str| only.is_empty() || only.iter().any(|o| o == ns);
2309
2310        for (name, entry) in pt.namespaces.iter_mut() {
2311            if keep.contains(name) || !in_scope(name) {
2312                continue;
2313            }
2314            if crate::extra_configs::path_is_gone(&entry.dir) {
2315                report.namespaces.push(name.clone());
2316                continue;
2317            }
2318            let before = entry.config.len();
2319            entry
2320                .config
2321                .retain(|file| !crate::extra_configs::path_is_gone(file));
2322            let dropped = before - entry.config.len();
2323            if dropped > 0 {
2324                report.config_files += dropped;
2325                if entry.config.is_empty() {
2326                    // Same as detaching the last file: the label goes with it.
2327                    entry.label = None;
2328                }
2329            }
2330        }
2331        for name in &report.namespaces {
2332            pt.namespaces.shift_remove(name);
2333        }
2334
2335        if only.is_empty() || !report.namespaces.is_empty() {
2336            for (slug, entry) in &pt.slugs {
2337                // A slug of a namespace with a running daemon stays, even if
2338                // its own directory moved.
2339                if entry.namespace.as_ref().is_some_and(|ns| keep.contains(ns)) {
2340                    continue;
2341                }
2342                let ns_pruned = entry
2343                    .namespace
2344                    .as_ref()
2345                    .is_some_and(|ns| report.namespaces.contains(ns));
2346                let dir_gone = entry
2347                    .dir
2348                    .as_ref()
2349                    .is_some_and(|d| crate::extra_configs::path_is_gone(d));
2350                // A slug that names a directory directly is only in scope for
2351                // an unscoped pass, since it belongs to no namespace.
2352                let scoped_ok = only.is_empty() || ns_pruned;
2353                if scoped_ok && (ns_pruned || dir_gone) {
2354                    report.slugs.push(slug.clone());
2355                }
2356            }
2357            for slug in &report.slugs {
2358                pt.slugs.shift_remove(slug);
2359            }
2360        }
2361
2362        if !report.is_empty() {
2363            pt.write_unlocked()?;
2364            if !report.slugs.is_empty() {
2365                let slug_names: Vec<String> = pt.slugs.keys().cloned().collect();
2366                crate::proxy::hosts::sync_hosts_from_settings_with_slugs(&slug_names);
2367            }
2368        }
2369        Ok(report)
2370    }
2371}
2372
2373/// Configuration for a single daemon (internal representation with DaemonId)
2374#[derive(Debug, Clone, JsonSchema, Default)]
2375pub struct PitchforkTomlDaemon {
2376    /// The command to run: a command line for the shell, or an array of a
2377    /// program and its arguments to start without a shell. In the string
2378    /// form, prepend 'exec' to avoid shell process overhead.
2379    #[schemars(example = example_run_command())]
2380    pub run: RunCommand,
2381    /// Automatic start/stop behavior based on shell hooks
2382    #[schemars(default)]
2383    pub auto: Vec<PitchforkTomlAuto>,
2384    /// Run this daemon as a task that must finish rather than a long-running
2385    /// service. Readiness means the process exited with code 0, the daemon
2386    /// then reports the `completed` status, and daemons that `depends` on it
2387    /// wait for that completion. Cannot be combined with any `ready_*` or
2388    /// `health_*` field.
2389    pub oneshot: Option<bool>,
2390    /// Cron scheduling configuration for periodic execution
2391    pub cron: Option<PitchforkTomlCron>,
2392    /// Number of times to retry if the daemon fails.
2393    /// Can be a number (e.g., `3`) or `true` for infinite retries.
2394    #[schemars(default)]
2395    pub retry: Retry,
2396    /// Delay in seconds before considering the daemon ready
2397    pub ready_delay: Option<u64>,
2398    /// Regex pattern to match in ANSI-stripped stdout/stderr to determine readiness
2399    pub ready_output: Option<ReadyOutput>,
2400    /// HTTP URL to poll for readiness. Accepts any 2xx response by default, or configured statuses.
2401    pub ready_http: Option<ReadyHttp>,
2402    /// TCP port to check for readiness (connection success = ready).
2403    /// Accepts a port number, a Tera template string that renders to one, or an
2404    /// object with an optional overall polling timeout.
2405    pub ready_port: Option<ReadyPort>,
2406    /// Shell command to poll for readiness (exit code 0 = ready)
2407    pub ready_cmd: Option<ReadyCmd>,
2408    /// Shell command to poll for health (exit code 0 = healthy)
2409    pub health_cmd: Option<HealthCmd>,
2410    /// HTTP endpoint URL to poll for health
2411    pub health_http: Option<HealthHttp>,
2412    /// TCP port to probe for health (connection success = healthy).
2413    /// Accepts a port number, a Tera template string that renders to one, or an
2414    /// object with optional per-check `interval`, `retries`, and `timeout`.
2415    pub health_port: Option<HealthPort>,
2416    /// Port configuration: expected ports and auto-bump settings
2417    pub port: Option<PortConfig>,
2418    /// Proxy routing: `false` opts out of the automatic hostname, a string
2419    /// overrides the daemon label used in it.
2420    #[serde(skip_serializing_if = "Option::is_none", default)]
2421    pub proxy: Option<ProxyConfig>,
2422    /// TLS handling for this daemon's proxy hostname.
2423    ///
2424    /// - `terminate` (default): the proxy terminates TLS with its own
2425    ///   certificate and forwards plain HTTP to the daemon.
2426    /// - `passthrough`: the proxy reads the SNI hostname from the TLS
2427    ///   ClientHello and splices the raw TCP stream to the daemon, which
2428    ///   presents its own certificate and can require client certificates.
2429    ///   Requires `port`.
2430    pub proxy_tls: Option<ProxyTlsMode>,
2431    /// Which of the daemon's ports the proxy hostname maps to.
2432    ///
2433    /// Must name one of the ports in `port`. Defaults to the daemon's first
2434    /// port. `proxy_port` is the shorter spelling of the same setting.
2435    #[schemars(range(min = 1))]
2436    pub proxy_tls_port: Option<u16>,
2437    /// Shorter spelling of `proxy_tls_port`. Set one or the other, not both.
2438    ///
2439    /// Kept separate rather than folded so that a config keeps the spelling
2440    /// its author chose when pitchfork rewrites it. Read
2441    /// [`Self::effective_proxy_tls_port`] rather than either field.
2442    #[schemars(range(min = 1))]
2443    pub proxy_port: Option<u16>,
2444    /// Idle timeout when the proxy auto-starts this daemon, for example `"15m"`.
2445    /// Accepts a duration string or `false`; `"0"` also disables idle shutdown.
2446    ///
2447    /// When omitted, a daemon started through its URL uses
2448    /// `settings.proxy.idle_timeout`; a dependency inherits the requested
2449    /// daemon's timeout. An explicit `false` exempts this daemon in either case.
2450    ///
2451    /// The timeout is recorded at startup. Explicitly started daemons are
2452    /// exempt; live dependents, active proxy connections, and tracked shell
2453    /// sessions prevent idle shutdown.
2454    pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2455    /// Whether to start this daemon automatically on system boot
2456    pub boot_start: Option<bool>,
2457    /// List of daemon IDs that must be started before this one
2458    #[schemars(default)]
2459    pub depends: Vec<DaemonId>,
2460    /// File patterns to watch for changes
2461    #[schemars(default)]
2462    pub watch: Vec<String>,
2463    /// File watching backend mode.
2464    ///
2465    /// - `native`: use platform-native notifications (default)
2466    /// - `poll`: use polling-based watcher
2467    /// - `auto`: prefer native, fall back to polling if native watch fails
2468    #[schemars(default)]
2469    pub watch_mode: WatchMode,
2470    /// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
2471    pub dir: Option<String>,
2472    /// Environment variables to set for the daemon process
2473    pub env: Option<IndexMap<String, String>>,
2474    /// Dotenv files (e.g. `.env.local`) whose variables are added to the daemon's
2475    /// environment. Relative paths resolve from the project directory (the same base as
2476    /// `dir`); missing files are skipped. Values from `env` win over the files, and later files win
2477    /// over earlier ones. Values support Tera templates.
2478    #[serde(default)]
2479    pub env_file: Vec<String>,
2480    /// Lifecycle hooks (on_ready, on_fail, on_retry)
2481    pub hooks: Option<PitchforkTomlHooks>,
2482    /// Wrap this daemon's command with `mise x --` for tool/env setup.
2483    /// Overrides the global `settings.general.mise` when set.
2484    pub mise: Option<bool>,
2485    /// Pitchfork's template context as JSON, set while rendering when `run` was left
2486    /// unrendered for `mise x` to finish. Never read from or written to a file.
2487    #[schemars(skip)]
2488    pub deferred_template_context: Option<String>,
2489    /// Unix user to run this daemon as. Overrides `settings.supervisor.user` when set.
2490    pub user: Option<String>,
2491    /// Memory limit for the daemon process (e.g. "50MB", "1GiB").
2492    /// The supervisor periodically monitors RSS and kills the process if it exceeds the limit.
2493    pub memory_limit: Option<MemoryLimit>,
2494    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores).
2495    /// The supervisor periodically monitors CPU usage and kills the process if it exceeds the limit.
2496    pub cpu_limit: Option<CpuLimit>,
2497    /// Stop signal and optional per-daemon timeout. Accepts a signal name string
2498    /// or `{ signal = "...", timeout = "..." }` object.
2499    pub stop_signal: Option<StopConfig>,
2500    /// Allocate a pseudo-terminal for the daemon process.
2501    pub pty: Option<bool>,
2502    /// Maximum age of log entries to keep (e.g. "7d", "30d").
2503    /// Overrides the global `settings.logs.time_retention` when set.
2504    pub time_retention: Option<String>,
2505    /// Maximum number of log entries to keep per daemon.
2506    /// Overrides the global `settings.logs.line_retention` when set.
2507    pub line_retention: Option<i64>,
2508    /// Archive hook command invoked before retention prunes this daemon's logs.
2509    /// Overrides the global `settings.logs.archive_hook.command` when set.
2510    pub archive_hook: Option<String>,
2511    /// Per-daemon log configuration sub-table.
2512    pub logs: Option<PitchforkTomlDaemonLogs>,
2513    #[schemars(skip)]
2514    pub path: Option<PathBuf>,
2515}
2516
2517impl PitchforkTomlDaemon {
2518    /// Which of the daemon's ports its proxy hostname maps to, from either
2519    /// spelling of the setting.
2520    ///
2521    /// `proxy_tls_port` wins when both are set; parsing has already warned
2522    /// about that combination.
2523    pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2524        self.proxy_tls_port.or(self.proxy_port)
2525    }
2526
2527    /// Whether this daemon is a oneshot task (`oneshot = true`).
2528    pub fn is_oneshot(&self) -> bool {
2529        self.oneshot.unwrap_or(false)
2530    }
2531
2532    /// Names of the readiness and health fields that are set on this daemon
2533    /// and cannot be combined with `oneshot`. Empty when there is no conflict.
2534    pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2535        [
2536            ("ready_delay", self.ready_delay.is_some()),
2537            ("ready_output", self.ready_output.is_some()),
2538            ("ready_http", self.ready_http.is_some()),
2539            ("ready_port", self.ready_port.is_some()),
2540            ("ready_cmd", self.ready_cmd.is_some()),
2541            ("health_cmd", self.health_cmd.is_some()),
2542            ("health_http", self.health_http.is_some()),
2543            ("health_port", self.health_port.is_some()),
2544        ]
2545        .into_iter()
2546        .filter(|(_, set)| *set)
2547        .map(|(name, _)| name)
2548        .collect()
2549    }
2550
2551    /// Effective user for this daemon: per-daemon `user` overrides `settings.supervisor.user`.
2552    ///
2553    /// Returns `None` when neither is set (inherit the supervisor's user).
2554    pub fn effective_user(&self) -> Option<String> {
2555        let daemon_user = self
2556            .user
2557            .as_deref()
2558            .map(str::trim)
2559            .filter(|u| !u.is_empty());
2560        daemon_user.map(str::to_owned).or_else(|| {
2561            let s = crate::settings::settings();
2562            let su = s.supervisor.user.trim();
2563            (!su.is_empty()).then(|| su.to_owned())
2564        })
2565    }
2566
2567    /// Build RunOptions from this daemon configuration.
2568    ///
2569    /// Carries over all config fields and resolves the working directory.
2570    /// Callers can override specific fields on the returned value.
2571    pub fn to_run_options(
2572        &self,
2573        id: &crate::daemon_id::DaemonId,
2574        cmd: Vec<String>,
2575    ) -> crate::daemon::RunOptions {
2576        use crate::daemon::RunOptions;
2577
2578        let effective_user = self.effective_user();
2579        let dir = crate::ipc::batch::resolve_daemon_dir(
2580            self.dir.as_deref(),
2581            self.path.as_deref(),
2582            effective_user.as_deref(),
2583        );
2584        // The same lookup the proxy and the CLI use, so a slug the proxy
2585        // refuses to route as ambiguous is not handed to the daemon either.
2586        let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2587            id,
2588            &PitchforkToml::read_global_slugs(),
2589        );
2590
2591        RunOptions {
2592            id: id.clone(),
2593            cmd,
2594            run: self.run.shell_script().map(str::to_string),
2595            no_shell: self.run.is_argv(),
2596            replaces_ready_checks: false,
2597            // Set by the IPC handler for a client's own request.
2598            requested_by_client: false,
2599            force: false,
2600            shell_pid: None,
2601            dir: Dir(dir),
2602            autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2603            oneshot: self.is_oneshot(),
2604            // Set only by the cron watcher's own call; a start built from
2605            // config is not the schedule asking for a run.
2606            cron_started: false,
2607            // Filled in by `build_run_options`, which resolves it against the
2608            // daemon's own project rather than whatever directory this process
2609            // happens to be in.
2610            oneshot_wait: None,
2611            on_directory_enter: false,
2612            cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2613            cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2614            cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2615            retry: self.retry,
2616            retry_count: 0,
2617            ready_delay: self.ready_delay,
2618            ready_output: self.ready_output.clone(),
2619            ready_http: self.ready_http.clone(),
2620            ready_port: self.ready_port.clone(),
2621            ready_cmd: self.ready_cmd.clone(),
2622            health_cmd: self.health_cmd.clone(),
2623            health_http: self.health_http.clone(),
2624            health_port: self.health_port.clone(),
2625            port: self.port.clone(),
2626            wait_ready: false,
2627            depends: self.depends.clone(),
2628            env: self.env.clone(),
2629            watch: self.watch.clone(),
2630            watch_mode: self.watch_mode,
2631            watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2632                self.path.as_deref(),
2633            )),
2634            mise: self.mise,
2635            deferred_template_context: self.deferred_template_context.clone(),
2636            // Resolved from the project settings by `build_run_options`.
2637            mise_bin: None,
2638            slug,
2639            proxy: None,
2640            user: self.user.clone(),
2641            memory_limit: self.memory_limit,
2642            cpu_limit: self.cpu_limit,
2643            stop_signal: self.stop_signal,
2644            archive_hook: self
2645                .logs
2646                .as_ref()
2647                .and_then(|l| l.archive_hook.clone())
2648                .or_else(|| self.archive_hook.clone()),
2649            log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2650            on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2651            pty: self.pty,
2652            // Explicit unless the proxy's start marks it otherwise.
2653            proxy_idle_timeout_ms: None,
2654            replaces_saved_record: true,
2655        }
2656    }
2657}
2658fn example_run_command() -> &'static str {
2659    "exec node server.js"
2660}
2661
2662/// A daemon's `run`: a command line for the shell, or a program and its
2663/// arguments to start without one.
2664#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2665#[serde(untagged)]
2666pub enum RunCommand {
2667    /// A command line, passed verbatim to the shell (`general.shell`, or
2668    /// `general.windows_shell` on Windows).
2669    Shell(String),
2670    /// A program and its arguments, started directly. No shell interprets
2671    /// them, so each argument reaches the program exactly as written, on
2672    /// every platform.
2673    Argv(#[schemars(length(min = 1))] Vec<String>),
2674}
2675
2676impl Default for RunCommand {
2677    fn default() -> Self {
2678        Self::Shell(String::new())
2679    }
2680}
2681
2682impl From<String> for RunCommand {
2683    fn from(run: String) -> Self {
2684        Self::Shell(run)
2685    }
2686}
2687
2688impl From<&str> for RunCommand {
2689    fn from(run: &str) -> Self {
2690        Self::Shell(run.to_string())
2691    }
2692}
2693
2694impl From<Vec<String>> for RunCommand {
2695    fn from(argv: Vec<String>) -> Self {
2696        Self::Argv(argv)
2697    }
2698}
2699
2700impl PartialEq<str> for RunCommand {
2701    fn eq(&self, other: &str) -> bool {
2702        matches!(self, Self::Shell(run) if run == other)
2703    }
2704}
2705
2706impl PartialEq<&str> for RunCommand {
2707    fn eq(&self, other: &&str) -> bool {
2708        self == *other
2709    }
2710}
2711
2712/// The command as a user would read it: the shell form as written, the argv
2713/// form quoted for a POSIX shell.
2714impl std::fmt::Display for RunCommand {
2715    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2716        match self {
2717            Self::Shell(run) => f.write_str(run),
2718            Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2719        }
2720    }
2721}
2722
2723impl RunCommand {
2724    /// The command as separate words: the argv form as it is, the shell form
2725    /// split the way a POSIX shell would.
2726    pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2727        match self {
2728            Self::Shell(run) => shell_words::split(run),
2729            Self::Argv(argv) => Ok(argv.clone()),
2730        }
2731    }
2732
2733    /// The command line for the shell, or `None` for the argv form.
2734    pub fn shell_script(&self) -> Option<&str> {
2735        match self {
2736            Self::Shell(run) => Some(run),
2737            Self::Argv(_) => None,
2738        }
2739    }
2740
2741    pub fn is_argv(&self) -> bool {
2742        matches!(self, Self::Argv(_))
2743    }
2744
2745    /// Apply `f` to the command line, or to each argument of the argv form.
2746    pub fn try_map<E>(
2747        &self,
2748        mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2749    ) -> std::result::Result<Self, E> {
2750        Ok(match self {
2751            Self::Shell(run) => Self::Shell(f(run)?),
2752            Self::Argv(argv) => Self::Argv(
2753                argv.iter()
2754                    .map(|arg| f(arg))
2755                    .collect::<std::result::Result<_, _>>()?,
2756            ),
2757        })
2758    }
2759}
2760
2761#[cfg(test)]
2762mod tests {
2763    use super::*;
2764    use std::path::Path;
2765
2766    fn parse_groups(toml: &str) -> IndexMap<String, GroupEntry> {
2767        PitchforkToml::parse_str(toml, Path::new("/tmp/my-project/pitchfork.toml"))
2768            .unwrap()
2769            .groups
2770    }
2771
2772    fn names(ids: &[DaemonId]) -> Vec<&str> {
2773        ids.iter().map(|id| id.name()).collect()
2774    }
2775
2776    #[test]
2777    fn test_group_includes_expand_before_own_daemons_and_dedupe() {
2778        let groups = parse_groups(
2779            r#"
2780[groups.core]
2781daemons = ["db", "cache"]
2782
2783[groups.extras]
2784daemons = ["mailer", "cache"]
2785
2786[groups.all]
2787groups = ["core", "extras"]
2788daemons = ["api", "db"]
2789"#,
2790        );
2791        let all = expand_group(&groups, "all").unwrap();
2792        assert_eq!(names(&all), ["db", "cache", "mailer", "api"]);
2793    }
2794
2795    #[test]
2796    fn test_group_includes_nest_and_allow_diamonds() {
2797        let groups = parse_groups(
2798            r#"
2799[groups.base]
2800daemons = ["db"]
2801
2802[groups.left]
2803groups = ["base"]
2804daemons = ["l"]
2805
2806[groups.right]
2807groups = ["base"]
2808daemons = ["r"]
2809
2810[groups.top]
2811groups = ["left", "right"]
2812"#,
2813        );
2814        let top = expand_group(&groups, "top").unwrap();
2815        assert_eq!(names(&top), ["db", "l", "r"]);
2816    }
2817
2818    #[test]
2819    fn test_group_includes_shared_by_many_paths_are_expanded_once() {
2820        // Each level includes the next twice, so walking every path would
2821        // take 2^60 steps.
2822        let mut toml = String::from("[groups.g0]\ngroups = [\"g1\", \"g1\"]\n");
2823        for i in 1..60 {
2824            toml.push_str(&format!(
2825                "\n[groups.g{i}]\ngroups = [\"g{n}\", \"g{n}\"]\n",
2826                n = i + 1
2827            ));
2828        }
2829        toml.push_str("\n[groups.g60]\ndaemons = [\"db\"]\n");
2830        let groups = parse_groups(&toml);
2831        assert_eq!(names(&expand_group(&groups, "g0").unwrap()), ["db"]);
2832    }
2833
2834    #[test]
2835    fn test_group_include_cycle_is_an_error() {
2836        let groups = parse_groups(
2837            r#"
2838[groups.a]
2839groups = ["b"]
2840
2841[groups.b]
2842groups = ["c"]
2843
2844[groups.c]
2845groups = ["a"]
2846"#,
2847        );
2848        let err = expand_group(&groups, "a").unwrap_err().to_string();
2849        assert!(err.contains("a -> b -> c -> a"), "{err}");
2850        let err = expand_group(&parse_groups("[groups.x]\ngroups = [\"x\"]\n"), "x")
2851            .unwrap_err()
2852            .to_string();
2853        assert!(err.contains("x -> x"), "{err}");
2854    }
2855
2856    #[test]
2857    fn test_group_include_of_undefined_group_is_an_error() {
2858        let groups = parse_groups("[groups.all]\ngroups = [\"cor\"]\n\n[groups.core]\n");
2859        let err = expand_group(&groups, "all").unwrap_err().to_string();
2860        assert!(
2861            err.contains("group 'all' includes undefined group 'cor'"),
2862            "{err}"
2863        );
2864    }
2865
2866    #[test]
2867    fn test_group_without_daemons_key_parses() {
2868        let groups = parse_groups("[groups.empty]\n");
2869        assert!(expand_group(&groups, "empty").unwrap().is_empty());
2870    }
2871
2872    #[test]
2873    fn test_daemon_user_parses_and_flows_to_run_options() {
2874        let pt = PitchforkToml::parse_str(
2875            r#"
2876[daemons.api]
2877run = "node server.js"
2878user = "postgres"
2879"#,
2880            Path::new("/tmp/my-project/pitchfork.toml"),
2881        )
2882        .unwrap();
2883
2884        let id = DaemonId::new("my-project", "api");
2885        let daemon = pt.daemons.get(&id).unwrap();
2886        assert_eq!(daemon.user.as_deref(), Some("postgres"));
2887
2888        let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2889        assert_eq!(opts.user.as_deref(), Some("postgres"));
2890        // A start from config describes the daemon in full.
2891        assert!(opts.replaces_saved_record);
2892    }
2893
2894    #[test]
2895    fn test_new_local_file_writes_daemons_of_the_directory_namespace() {
2896        let temp = tempfile::tempdir().unwrap();
2897        std::fs::write(
2898            temp.path().join("pitchfork.toml"),
2899            "namespace = \"myns\"\n\n[daemons.web]\nrun = \"sleep 60\"\n",
2900        )
2901        .unwrap();
2902        let path = temp.path().join("pitchfork.local.toml");
2903        let mut pt = PitchforkToml::new(path.clone());
2904        let id = DaemonId::new(namespace_from_path(&path).unwrap(), "api");
2905        assert_eq!(id.namespace(), "myns");
2906        pt.daemons.insert(
2907            id.clone(),
2908            PitchforkTomlDaemon {
2909                run: "sleep 60".into(),
2910                ..PitchforkTomlDaemon::default()
2911            },
2912        );
2913
2914        pt.write().unwrap();
2915
2916        let parsed = PitchforkToml::read(&path).unwrap();
2917        assert!(parsed.daemons.contains_key(&id));
2918    }
2919
2920    #[test]
2921    fn test_daemon_user_write_roundtrip() {
2922        let temp = tempfile::tempdir().unwrap();
2923        let path = temp.path().join("pitchfork.toml");
2924        let mut pt = PitchforkToml::new(path.clone());
2925        pt.namespace = Some("test-project".to_string());
2926        pt.daemons.insert(
2927            DaemonId::new("test-project", "api"),
2928            PitchforkTomlDaemon {
2929                run: "node server.js".into(),
2930                user: Some("postgres".to_string()),
2931                ..PitchforkTomlDaemon::default()
2932            },
2933        );
2934
2935        pt.write().unwrap();
2936
2937        let raw = std::fs::read_to_string(&path).unwrap();
2938        assert!(raw.contains("user = \"postgres\""));
2939
2940        let parsed = PitchforkToml::read(&path).unwrap();
2941        let daemon = parsed
2942            .daemons
2943            .get(&DaemonId::new("test-project", "api"))
2944            .unwrap();
2945        assert_eq!(daemon.user.as_deref(), Some("postgres"));
2946    }
2947
2948    /// Arguments a shell would reinterpret, which the argv form must not.
2949    fn awkward_argv() -> Vec<String> {
2950        [
2951            "node",
2952            "my server.js",
2953            "--name=\"a b\"",
2954            "it's",
2955            "a&b",
2956            "%PATH%",
2957            "$HOME",
2958        ]
2959        .map(str::to_string)
2960        .to_vec()
2961    }
2962
2963    #[test]
2964    fn test_run_array_parses_and_starts_without_a_shell() {
2965        let pt = PitchforkToml::parse_str(
2966            r#"
2967[daemons.api]
2968run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2969"#,
2970            Path::new("/tmp/my-project/pitchfork.toml"),
2971        )
2972        .unwrap();
2973
2974        let id = DaemonId::new("my-project", "api");
2975        let daemon = pt.daemons.get(&id).unwrap();
2976        assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2977
2978        let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2979        assert_eq!(opts.cmd, awkward_argv());
2980        assert!(opts.no_shell);
2981        assert_eq!(opts.run, None);
2982    }
2983
2984    #[test]
2985    fn test_run_string_still_goes_through_the_shell() {
2986        let pt = PitchforkToml::parse_str(
2987            "[daemons.api]\nrun = \"exec node server.js\"\n",
2988            Path::new("/tmp/my-project/pitchfork.toml"),
2989        )
2990        .unwrap();
2991
2992        let id = DaemonId::new("my-project", "api");
2993        let daemon = pt.daemons.get(&id).unwrap();
2994        let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2995        assert!(!opts.no_shell);
2996        assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2997    }
2998
2999    #[test]
3000    fn test_run_array_write_roundtrip() {
3001        let temp = tempfile::tempdir().unwrap();
3002        let path = temp.path().join("pitchfork.toml");
3003        let mut pt = PitchforkToml::new(path.clone());
3004        pt.namespace = Some("test-project".to_string());
3005        let id = DaemonId::new("test-project", "api");
3006        pt.daemons.insert(
3007            id.clone(),
3008            PitchforkTomlDaemon {
3009                run: RunCommand::Argv(awkward_argv()),
3010                ..PitchforkTomlDaemon::default()
3011            },
3012        );
3013        pt.daemons.insert(
3014            DaemonId::new("test-project", "worker"),
3015            PitchforkTomlDaemon {
3016                run: "exec ./worker --queue \"a b\"".into(),
3017                ..PitchforkTomlDaemon::default()
3018            },
3019        );
3020
3021        pt.write().unwrap();
3022
3023        let parsed = PitchforkToml::read(&path).unwrap();
3024        assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
3025        assert_eq!(
3026            parsed.daemons[&DaemonId::new("test-project", "worker")].run,
3027            "exec ./worker --queue \"a b\""
3028        );
3029    }
3030
3031    #[test]
3032    fn test_run_array_must_name_a_program() {
3033        let err = PitchforkToml::parse_str(
3034            "[daemons.api]\nrun = []\n",
3035            Path::new("/tmp/my-project/pitchfork.toml"),
3036        )
3037        .unwrap_err();
3038        assert!(
3039            err.chain()
3040                .any(|cause| cause.to_string().contains("empty run array")),
3041            "unexpected error: {err:?}"
3042        );
3043    }
3044
3045    #[test]
3046    fn test_run_array_rejects_exec() {
3047        let err = PitchforkToml::parse_str(
3048            "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
3049            Path::new("/tmp/my-project/pitchfork.toml"),
3050        )
3051        .unwrap_err();
3052        assert!(
3053            err.chain().any(|cause| cause
3054                .to_string()
3055                .contains("starts its run array with \"exec\"")),
3056            "unexpected error: {err:?}"
3057        );
3058    }
3059
3060    #[test]
3061    fn test_ready_cmd_array_parses_in_both_forms() {
3062        let pt = PitchforkToml::parse_str(
3063            r#"
3064[daemons.api]
3065run = "node server.js"
3066ready_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
3067
3068[daemons.db]
3069run = "postgres"
3070ready_cmd = { run = ["pg_isready", "-h", "localhost"], timeout = "30s" }
3071
3072[daemons.cache]
3073run = "redis-server"
3074ready_cmd = "redis-cli ping"
3075"#,
3076            Path::new("/tmp/my-project/pitchfork.toml"),
3077        )
3078        .unwrap();
3079
3080        let ready = |name: &str| {
3081            pt.daemons[&DaemonId::new("my-project", name)]
3082                .ready_cmd
3083                .clone()
3084                .unwrap()
3085        };
3086        assert_eq!(ready("api").run, RunCommand::Argv(awkward_argv()));
3087        assert_eq!(ready("api").timeout, None);
3088        assert_eq!(
3089            ready("db").run,
3090            RunCommand::Argv(
3091                ["pg_isready", "-h", "localhost"]
3092                    .map(str::to_string)
3093                    .to_vec()
3094            )
3095        );
3096        assert_eq!(
3097            ready("db").timeout,
3098            Some(std::time::Duration::from_secs(30))
3099        );
3100        assert_eq!(ready("cache").run, "redis-cli ping");
3101    }
3102
3103    #[test]
3104    fn test_ready_cmd_array_write_roundtrip() {
3105        let temp = tempfile::tempdir().unwrap();
3106        let path = temp.path().join("pitchfork.toml");
3107        let mut pt = PitchforkToml::new(path.clone());
3108        pt.namespace = Some("test-project".to_string());
3109        let short = ReadyCmd::new(awkward_argv());
3110        let full = ReadyCmd {
3111            run: RunCommand::Argv(
3112                ["pg_isready", "-h", "localhost"]
3113                    .map(str::to_string)
3114                    .to_vec(),
3115            ),
3116            timeout: Some(std::time::Duration::from_secs(30)),
3117        };
3118        for (name, ready_cmd) in [("api", &short), ("db", &full)] {
3119            pt.daemons.insert(
3120                DaemonId::new("test-project", name),
3121                PitchforkTomlDaemon {
3122                    run: "true".into(),
3123                    ready_cmd: Some(ready_cmd.clone()),
3124                    ..PitchforkTomlDaemon::default()
3125                },
3126            );
3127        }
3128
3129        pt.write().unwrap();
3130
3131        let parsed = PitchforkToml::read(&path).unwrap();
3132        let ready = |name: &str| {
3133            parsed.daemons[&DaemonId::new("test-project", name)]
3134                .ready_cmd
3135                .clone()
3136        };
3137        assert_eq!(ready("api"), Some(short));
3138        assert_eq!(ready("db"), Some(full));
3139    }
3140
3141    #[test]
3142    fn test_ready_cmd_array_must_name_a_program() {
3143        for ready_cmd in ["[]", "[\"\"]", "{ run = [\"\", \"-h\"] }"] {
3144            let err = PitchforkToml::parse_str(
3145                &format!("[daemons.api]\nrun = \"true\"\nready_cmd = {ready_cmd}\n"),
3146                Path::new("/tmp/my-project/pitchfork.toml"),
3147            )
3148            .unwrap_err();
3149            assert!(
3150                err.chain().any(|cause| cause
3151                    .to_string()
3152                    .contains("has no program in its ready_cmd array")),
3153                "unexpected error for {ready_cmd}: {err:?}"
3154            );
3155        }
3156    }
3157
3158    #[test]
3159    fn test_ready_cmd_array_rejects_exec() {
3160        let err = PitchforkToml::parse_str(
3161            "[daemons.api]\nrun = \"true\"\nready_cmd = { run = [\"exec\", \"pg_isready\"] }\n",
3162            Path::new("/tmp/my-project/pitchfork.toml"),
3163        )
3164        .unwrap_err();
3165        assert!(
3166            err.chain().any(|cause| cause
3167                .to_string()
3168                .contains("starts its ready_cmd array with \"exec\"")),
3169            "unexpected error: {err:?}"
3170        );
3171    }
3172
3173    #[test]
3174    fn test_health_cmd_array_parses_and_round_trips() {
3175        let pt = PitchforkToml::parse_str(
3176            r#"
3177[daemons.api]
3178run = "node server.js"
3179health_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
3180
3181[daemons.db]
3182run = "postgres"
3183health_cmd = { run = ["pg_isready", "-h", "localhost"], interval = "5s", retries = 2 }
3184
3185[daemons.cache]
3186run = "redis-server"
3187health_cmd = "redis-cli ping"
3188"#,
3189            Path::new("/tmp/my-project/pitchfork.toml"),
3190        )
3191        .unwrap();
3192        let health = |pt: &PitchforkToml, ns: &str, name: &str| {
3193            pt.daemons[&DaemonId::new(ns, name)]
3194                .health_cmd
3195                .clone()
3196                .unwrap()
3197        };
3198        assert_eq!(
3199            health(&pt, "my-project", "api").run,
3200            RunCommand::Argv(awkward_argv())
3201        );
3202        let db = health(&pt, "my-project", "db");
3203        assert_eq!(
3204            db.run,
3205            RunCommand::Argv(
3206                ["pg_isready", "-h", "localhost"]
3207                    .map(str::to_string)
3208                    .to_vec()
3209            )
3210        );
3211        assert_eq!(db.interval, Some(std::time::Duration::from_secs(5)));
3212        assert_eq!(db.retries, Some(2));
3213        assert_eq!(health(&pt, "my-project", "cache").run, "redis-cli ping");
3214
3215        let temp = tempfile::tempdir().unwrap();
3216        let path = temp.path().join("pitchfork.toml");
3217        let mut written = PitchforkToml::new(path.clone());
3218        written.namespace = Some("test-project".to_string());
3219        for name in ["api", "db"] {
3220            written.daemons.insert(
3221                DaemonId::new("test-project", name),
3222                PitchforkTomlDaemon {
3223                    run: "true".into(),
3224                    health_cmd: Some(health(&pt, "my-project", name)),
3225                    ..PitchforkTomlDaemon::default()
3226                },
3227            );
3228        }
3229        written.write().unwrap();
3230        let parsed = PitchforkToml::read(&path).unwrap();
3231        for name in ["api", "db"] {
3232            assert_eq!(
3233                health(&parsed, "test-project", name),
3234                health(&pt, "my-project", name)
3235            );
3236        }
3237    }
3238
3239    #[test]
3240    fn test_health_cmd_array_must_name_a_program_and_not_exec() {
3241        for (health_cmd, expected) in [
3242            ("[]", "has no program in its health_cmd array"),
3243            ("[\"\"]", "has no program in its health_cmd array"),
3244            (
3245                "{ run = [\"exec\", \"pg_isready\"] }",
3246                "starts its health_cmd array with \"exec\"",
3247            ),
3248        ] {
3249            let err = PitchforkToml::parse_str(
3250                &format!("[daemons.api]\nrun = \"true\"\nhealth_cmd = {health_cmd}\n"),
3251                Path::new("/tmp/my-project/pitchfork.toml"),
3252            )
3253            .unwrap_err();
3254            assert!(
3255                err.chain()
3256                    .any(|cause| cause.to_string().contains(expected)),
3257                "unexpected error for {health_cmd}: {err:?}"
3258            );
3259        }
3260    }
3261
3262    #[test]
3263    fn test_registry_dirs_expand_tilde() {
3264        let pt = PitchforkToml::parse_str(
3265            r#"
3266[slugs.api]
3267dir = "~/projects/api"
3268
3269[namespaces.web]
3270dir = "~/projects/web"
3271"#,
3272            Path::new("/tmp/config.toml"),
3273        )
3274        .unwrap();
3275
3276        assert_eq!(
3277            pt.slugs["api"].dir,
3278            Some(crate::env::HOME_DIR.join("projects/api"))
3279        );
3280        assert_eq!(
3281            pt.namespaces["web"].dir,
3282            crate::env::HOME_DIR.join("projects/web")
3283        );
3284    }
3285
3286    #[test]
3287    fn test_settings_write_roundtrip() {
3288        let temp = tempfile::tempdir().unwrap();
3289        let path = temp.path().join("pitchfork.toml");
3290        let mut pt = PitchforkToml::new(path.clone());
3291        pt.namespace = Some("test-project".to_string());
3292        pt.settings.web.auto_start = Some(true);
3293        pt.settings.general.log_level = Some("debug".to_string());
3294
3295        pt.write().unwrap();
3296
3297        let raw = std::fs::read_to_string(&path).unwrap();
3298        assert!(
3299            raw.contains("[settings.web]"),
3300            "settings.web section should be written, got:\n{raw}"
3301        );
3302        assert!(raw.contains("auto_start = true"));
3303        assert!(raw.contains("log_level = \"debug\""));
3304
3305        let parsed = PitchforkToml::read(&path).unwrap();
3306        assert_eq!(parsed.settings.web.auto_start, Some(true));
3307        assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
3308    }
3309
3310    fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
3311        SlugEntry {
3312            dir: None,
3313            namespace: Some(namespace.to_string()),
3314            daemon: daemon.map(str::to_string),
3315        }
3316    }
3317
3318    #[test]
3319    fn test_slug_is_ambiguous() {
3320        let mut slugs = IndexMap::new();
3321        slugs.insert("api".to_string(), slug_entry("my-project", None));
3322        assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
3323
3324        slugs.insert("API".to_string(), slug_entry("other-project", None));
3325        // Host names are case-insensitive, so both spellings are ambiguous.
3326        assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
3327        assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
3328    }
3329
3330    #[test]
3331    fn test_find_slug_for_daemon_skips_case_collisions() {
3332        let id = DaemonId::new("my-project", "api");
3333        let mut slugs = IndexMap::new();
3334        slugs.insert("api".to_string(), slug_entry("my-project", None));
3335        assert_eq!(
3336            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3337            Some("api".to_string())
3338        );
3339
3340        // The proxy refuses to route either spelling, so no URL may be
3341        // advertised for this daemon.
3342        slugs.insert("API".to_string(), slug_entry("other-project", None));
3343        assert_eq!(
3344            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3345            None
3346        );
3347    }
3348
3349    #[test]
3350    fn test_find_slug_for_daemon_prefers_a_routable_alias() {
3351        let id = DaemonId::new("my-project", "api");
3352        let mut slugs = IndexMap::new();
3353        // A colliding pair comes first in config order, then a routable alias
3354        // for the same daemon.
3355        slugs.insert("api".to_string(), slug_entry("my-project", None));
3356        slugs.insert("API".to_string(), slug_entry("my-project", None));
3357        slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
3358
3359        assert_eq!(
3360            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3361            Some("my-api".to_string())
3362        );
3363    }
3364
3365    #[test]
3366    fn test_settings_preserved_on_unrelated_write() {
3367        // Regression test for https://github.com/jdx/pitchfork/discussions/574
3368        // A read-modify-write of slugs/namespaces must not drop existing [settings].
3369        let temp = tempfile::tempdir().unwrap();
3370        let path = temp.path().join("pitchfork.toml");
3371        std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
3372
3373        let mut pt = PitchforkToml::read(&path).unwrap();
3374        pt.slugs.insert(
3375            "api".to_string(),
3376            SlugEntry {
3377                dir: None,
3378                namespace: Some("myproject".to_string()),
3379                daemon: None,
3380            },
3381        );
3382        pt.namespaces.insert(
3383            "myproject".to_string(),
3384            NamespaceEntry {
3385                dir: PathBuf::from("/tmp/myproject"),
3386                config: Vec::new(),
3387                label: None,
3388            },
3389        );
3390        pt.write().unwrap();
3391
3392        let raw = std::fs::read_to_string(&path).unwrap();
3393        assert!(
3394            raw.contains("[settings.web]"),
3395            "existing settings must be preserved, got:\n{raw}"
3396        );
3397        assert!(raw.contains("auto_start = true"));
3398        assert!(raw.contains("[slugs.api]"));
3399
3400        let parsed = PitchforkToml::read(&path).unwrap();
3401        assert_eq!(parsed.settings.web.auto_start, Some(true));
3402        assert!(parsed.slugs.contains_key("api"));
3403    }
3404
3405    #[tokio::test]
3406    async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
3407        let temp = tempfile::tempdir().unwrap();
3408        let path = temp.path().join("pitchfork.toml");
3409        tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
3410            .await
3411            .unwrap();
3412
3413        let read_path = path.clone();
3414        let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
3415            .await
3416            .unwrap()
3417            .unwrap();
3418        assert_eq!(pt.settings.general.worktree, Some(false));
3419        assert_eq!(pt.settings.proxy.worktree, None);
3420        tokio::task::spawn_blocking(move || pt.write())
3421            .await
3422            .unwrap()
3423            .unwrap();
3424
3425        let raw = tokio::fs::read_to_string(&path).await.unwrap();
3426        assert!(raw.contains("[settings.general]"), "{raw}");
3427        assert!(raw.contains("worktree = false"), "{raw}");
3428        assert!(!raw.contains("[settings.proxy]"), "{raw}");
3429
3430        let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
3431            .await
3432            .unwrap()
3433            .unwrap();
3434        assert_eq!(parsed.settings.general.worktree, Some(false));
3435    }
3436
3437    #[test]
3438    fn test_config_cache_hit_and_invalidation() {
3439        let temp = tempfile::tempdir().unwrap();
3440        let dir = temp.path();
3441        let config_path = dir.join("pitchfork.toml");
3442        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3443
3444        // Clear any pre-existing cache entries for this directory.
3445        super::invalidate_config_cache();
3446
3447        // First call: cache miss, reads from disk.
3448        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3449        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3450        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3451
3452        // Second call: should be a cache hit (same mtime).
3453        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3454        assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
3455
3456        // Modify the config file — mtime changes, cache should miss.
3457        // Sleep briefly to ensure mtime resolution differs.
3458        std::thread::sleep(std::time::Duration::from_millis(50));
3459        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
3460
3461        let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
3462        assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
3463
3464        // Explicit invalidation should also force a re-read.
3465        super::invalidate_config_cache();
3466        let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
3467        assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
3468
3469        // Clean up.
3470        super::invalidate_config_cache();
3471    }
3472
3473    #[test]
3474    fn test_config_cache_invalidation_on_write() {
3475        let temp = tempfile::tempdir().unwrap();
3476        let dir = temp.path();
3477        let config_path = dir.join("pitchfork.toml");
3478        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3479
3480        super::invalidate_config_cache();
3481
3482        // Populate cache.
3483        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3484        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3485        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3486
3487        // Write via PitchforkToml::write() — should invalidate cache.
3488        let mut pt = PitchforkToml::read(&config_path).unwrap();
3489        pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
3490        // write() needs the path set and namespace match
3491        let _ = pt.write();
3492
3493        // Next read should see the updated value, not the cached one.
3494        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3495        assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
3496
3497        super::invalidate_config_cache();
3498    }
3499
3500    #[test]
3501    fn test_config_cache_size_invalidation() {
3502        let temp = tempfile::tempdir().unwrap();
3503        let dir = temp.path();
3504        let config_path = dir.join("pitchfork.toml");
3505        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3506
3507        super::invalidate_config_cache();
3508
3509        // Populate cache.
3510        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3511        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3512        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3513
3514        // Capture the original mtime, then write different-size content and
3515        // restore the *same* mtime — simulating `cp --preserve=timestamps`
3516        // or a same-second edit on a coarse-grained filesystem.
3517        let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
3518        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
3519        // On Windows, set_times requires the file handle to be opened with
3520        // write access; File::open is read-only.
3521        let file = std::fs::OpenOptions::new()
3522            .write(true)
3523            .open(&config_path)
3524            .unwrap();
3525        let times = std::fs::FileTimes::new().set_modified(original_mtime);
3526        file.set_times(times).unwrap();
3527
3528        // Size changed (shorter run string), so cache should miss even though
3529        // mtime is identical.
3530        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3531        assert_eq!(
3532            pt2.daemons[&daemon_id].run, "echo different",
3533            "cache should invalidate on size change even with identical mtime"
3534        );
3535
3536        super::invalidate_config_cache();
3537    }
3538
3539    #[test]
3540    fn test_find_project_root_in_plain_dir_returns_none() {
3541        let temp = tempfile::tempdir().unwrap();
3542        assert_eq!(find_project_root(temp.path()), None);
3543    }
3544
3545    #[test]
3546    fn test_find_project_root_finds_git_marker() {
3547        let temp = tempfile::tempdir().unwrap();
3548        let repo = temp.path().join("my-repo");
3549        std::fs::create_dir(&repo).unwrap();
3550        std::fs::create_dir(repo.join(".git")).unwrap();
3551
3552        let sub = repo.join("sub/dir");
3553        std::fs::create_dir_all(&sub).unwrap();
3554
3555        // `find_project_root` canonicalizes, so compare against the canonical
3556        // path (on Windows this differs by the `\\?\` verbatim prefix).
3557        assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
3558    }
3559
3560    #[test]
3561    fn test_find_project_root_accepts_git_file_marker() {
3562        // Linked git worktrees store `.git` as a *file* pointing at the
3563        // common gitdir, so `exists()` (not `is_dir()`) is the right check.
3564        let temp = tempfile::tempdir().unwrap();
3565        let wt = temp.path().join("my-worktree");
3566        std::fs::create_dir(&wt).unwrap();
3567        std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
3568
3569        assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
3570    }
3571
3572    /// A symlinked start dir must resolve into the repository hierarchy so
3573    /// `parent()` traversal does not walk out of the repo.
3574    #[cfg(unix)]
3575    #[test]
3576    fn test_find_project_root_resolves_symlinked_start_dir() {
3577        use std::os::unix::fs::symlink;
3578
3579        let temp = tempfile::tempdir().unwrap();
3580        let repo = temp.path().join("real-repo");
3581        std::fs::create_dir(&repo).unwrap();
3582        std::fs::create_dir(repo.join(".git")).unwrap();
3583
3584        let sub = repo.join("sub/dir");
3585        std::fs::create_dir_all(&sub).unwrap();
3586        let link = temp.path().join("link-to-sub");
3587        symlink(&sub, &link).unwrap();
3588
3589        assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
3590    }
3591
3592    /// Build a real git repository with a linked worktree and assert that
3593    /// `all_merged_all_namespaces_from` picks up daemons from both.
3594    #[test]
3595    fn test_all_merged_all_namespaces_discovers_worktrees() {
3596        let temp = tempfile::tempdir().unwrap();
3597        let repo = temp.path().join("my-repo");
3598        std::fs::create_dir(&repo).unwrap();
3599
3600        // git worktree add requires at least one commit.
3601        let git_init = std::process::Command::new("git")
3602            .args(["init", "-b", "main"])
3603            .current_dir(&repo)
3604            .output()
3605            .expect("git init");
3606        assert!(git_init.status.success(), "git init failed: {:?}", git_init);
3607
3608        std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
3609
3610        let git_commit = std::process::Command::new("git")
3611            .args([
3612                "-c",
3613                "user.name=pitchfork-test",
3614                "-c",
3615                "user.email=pitchfork-test@example.com",
3616                "add",
3617                "-A",
3618            ])
3619            .current_dir(&repo)
3620            .output()
3621            .expect("git add");
3622        assert!(git_commit.status.success());
3623
3624        let git_commit = std::process::Command::new("git")
3625            .args([
3626                "-c",
3627                "user.name=pitchfork-test",
3628                "-c",
3629                "user.email=pitchfork-test@example.com",
3630                "commit",
3631                "-m",
3632                "init",
3633            ])
3634            .current_dir(&repo)
3635            .output()
3636            .expect("git commit");
3637        assert!(
3638            git_commit.status.success(),
3639            "git commit failed: {:?}",
3640            git_commit
3641        );
3642
3643        let wt = temp.path().join("my-repo-feature");
3644        let git_wt = std::process::Command::new("git")
3645            .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3646            .current_dir(&repo)
3647            .output()
3648            .expect("git worktree add");
3649        assert!(
3650            git_wt.status.success(),
3651            "git worktree add failed: {:?}",
3652            git_wt
3653        );
3654
3655        // Config in the main checkout.
3656        std::fs::write(
3657            repo.join("pitchfork.toml"),
3658            "[daemons.api]\nrun = \"echo main\"\n",
3659        )
3660        .unwrap();
3661        // Config in the linked worktree (different namespace: dir name).
3662        std::fs::write(
3663            wt.join("pitchfork.toml"),
3664            "[daemons.worker]\nrun = \"echo wt\"\n",
3665        )
3666        .unwrap();
3667
3668        super::invalidate_config_cache();
3669
3670        // Resolve from inside the worktree: both namespaces must be visible.
3671        let pt = PitchforkToml::all_merged_all_namespaces_from_blocking(&wt).unwrap();
3672
3673        let main_id = DaemonId::new("my-repo", "api");
3674        let wt_id = DaemonId::new("my-repo-feature", "worker");
3675        assert!(
3676            pt.daemons.contains_key(&main_id),
3677            "main checkout daemon missing"
3678        );
3679        assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3680
3681        // Resolving from the main checkout must also see the worktree daemon.
3682        let pt_from_main = PitchforkToml::all_merged_all_namespaces_from_blocking(&repo).unwrap();
3683        assert!(pt_from_main.daemons.contains_key(&wt_id));
3684
3685        // Clean up.
3686        let _ = std::process::Command::new("git")
3687            .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3688            .current_dir(&repo)
3689            .output();
3690        super::invalidate_config_cache();
3691    }
3692
3693    #[test]
3694    fn test_adhoc_id_uses_invocation_directory_namespace() {
3695        let temp = tempfile::tempdir().unwrap();
3696        let project = temp.path().join("feature-tree");
3697        std::fs::create_dir(&project).unwrap();
3698        std::fs::write(
3699            project.join("pitchfork.toml"),
3700            "[daemons.other]\nrun = \"true\"\n",
3701        )
3702        .unwrap();
3703
3704        let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3705        assert_eq!(id, DaemonId::new("feature-tree", "api"));
3706        let qualified =
3707            PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3708        assert_eq!(qualified, DaemonId::new("explicit", "api"));
3709    }
3710
3711    #[test]
3712    fn test_adhoc_id_falls_back_to_global_without_project_config() {
3713        let temp = tempfile::tempdir().unwrap();
3714        let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3715        assert_eq!(id, DaemonId::new("global", "api"));
3716    }
3717}