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