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 !entry.config.is_empty()
2120                && crate::extra_configs::normalize(&entry.dir)
2121                    != crate::extra_configs::normalize(&dir)
2122            {
2123                miette::bail!(
2124                    "namespace '{name}' has external configuration attached to another directory"
2125                );
2126            }
2127            entry.dir = dir;
2128        } else {
2129            pt.namespaces.insert(
2130                name.to_string(),
2131                NamespaceEntry {
2132                    dir,
2133                    config: Vec::new(),
2134                    label: None,
2135                },
2136            );
2137        }
2138        pt.write_unlocked()?;
2139        Ok(())
2140    }
2141
2142    /// Remove a namespace from the global config's `[namespaces]` section.
2143    pub fn remove_namespace(name: &str) -> crate::Result<bool> {
2144        let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
2145        if !global_path.exists() {
2146            return Ok(false);
2147        }
2148
2149        let _lock = xx::fslock::get(global_path, false)
2150            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
2151
2152        let raw = std::fs::read_to_string(global_path).map_err(|e| {
2153            crate::error::FileError::ReadError {
2154                path: global_path.to_path_buf(),
2155                source: e,
2156            }
2157        })?;
2158        let mut pt = Self::parse_str(&raw, global_path)?;
2159
2160        let removed = pt.namespaces.shift_remove(name).is_some();
2161        if removed {
2162            pt.write_unlocked()?;
2163        }
2164        Ok(removed)
2165    }
2166}
2167
2168/// Configuration for a single daemon (internal representation with DaemonId)
2169#[derive(Debug, Clone, JsonSchema, Default)]
2170pub struct PitchforkTomlDaemon {
2171    /// The command to run: a command line for the shell, or an array of a
2172    /// program and its arguments to start without a shell. In the string
2173    /// form, prepend 'exec' to avoid shell process overhead.
2174    #[schemars(example = example_run_command())]
2175    pub run: RunCommand,
2176    /// Automatic start/stop behavior based on shell hooks
2177    #[schemars(default)]
2178    pub auto: Vec<PitchforkTomlAuto>,
2179    /// Run this daemon as a task that must finish rather than a long-running
2180    /// service. Readiness means the process exited with code 0, the daemon
2181    /// then reports the `completed` status, and daemons that `depends` on it
2182    /// wait for that completion. Cannot be combined with any `ready_*` or
2183    /// `health_*` field.
2184    pub oneshot: Option<bool>,
2185    /// Cron scheduling configuration for periodic execution
2186    pub cron: Option<PitchforkTomlCron>,
2187    /// Number of times to retry if the daemon fails.
2188    /// Can be a number (e.g., `3`) or `true` for infinite retries.
2189    #[schemars(default)]
2190    pub retry: Retry,
2191    /// Delay in seconds before considering the daemon ready
2192    pub ready_delay: Option<u64>,
2193    /// Regex pattern to match in ANSI-stripped stdout/stderr to determine readiness
2194    pub ready_output: Option<ReadyOutput>,
2195    /// HTTP URL to poll for readiness. Accepts any 2xx response by default, or configured statuses.
2196    pub ready_http: Option<ReadyHttp>,
2197    /// TCP port to check for readiness (connection success = ready).
2198    /// Accepts a port number, a Tera template string that renders to one, or an
2199    /// object with an optional overall polling timeout.
2200    pub ready_port: Option<ReadyPort>,
2201    /// Shell command to poll for readiness (exit code 0 = ready)
2202    pub ready_cmd: Option<ReadyCmd>,
2203    /// Shell command to poll for health (exit code 0 = healthy)
2204    pub health_cmd: Option<HealthCmd>,
2205    /// HTTP endpoint URL to poll for health
2206    pub health_http: Option<HealthHttp>,
2207    /// TCP port to probe for health (connection success = healthy).
2208    /// Accepts a port number, a Tera template string that renders to one, or an
2209    /// object with optional per-check `interval`, `retries`, and `timeout`.
2210    pub health_port: Option<HealthPort>,
2211    /// Port configuration: expected ports and auto-bump settings
2212    pub port: Option<PortConfig>,
2213    /// Proxy routing: `false` opts out of the automatic hostname, a string
2214    /// overrides the daemon label used in it.
2215    #[serde(skip_serializing_if = "Option::is_none", default)]
2216    pub proxy: Option<ProxyConfig>,
2217    /// TLS handling for this daemon's proxy hostname.
2218    ///
2219    /// - `terminate` (default): the proxy terminates TLS with its own
2220    ///   certificate and forwards plain HTTP to the daemon.
2221    /// - `passthrough`: the proxy reads the SNI hostname from the TLS
2222    ///   ClientHello and splices the raw TCP stream to the daemon, which
2223    ///   presents its own certificate and can require client certificates.
2224    ///   Requires `port`.
2225    pub proxy_tls: Option<ProxyTlsMode>,
2226    /// Which of the daemon's ports the proxy hostname maps to.
2227    ///
2228    /// Must name one of the ports in `port`. Defaults to the daemon's first
2229    /// port. `proxy_port` is the shorter spelling of the same setting.
2230    #[schemars(range(min = 1))]
2231    pub proxy_tls_port: Option<u16>,
2232    /// Shorter spelling of `proxy_tls_port`. Set one or the other, not both.
2233    ///
2234    /// Kept separate rather than folded so that a config keeps the spelling
2235    /// its author chose when pitchfork rewrites it. Read
2236    /// [`Self::effective_proxy_tls_port`] rather than either field.
2237    #[schemars(range(min = 1))]
2238    pub proxy_port: Option<u16>,
2239    /// Idle timeout when the proxy auto-starts this daemon, for example `"15m"`.
2240    /// Accepts a duration string or `false`; `"0"` also disables idle shutdown.
2241    ///
2242    /// When omitted, a daemon started through its URL uses
2243    /// `settings.proxy.idle_timeout`; a dependency inherits the requested
2244    /// daemon's timeout. An explicit `false` exempts this daemon in either case.
2245    ///
2246    /// The timeout is recorded at startup. Explicitly started daemons are
2247    /// exempt; live dependents, active proxy connections, and tracked shell
2248    /// sessions prevent idle shutdown.
2249    pub proxy_idle_timeout: Option<ProxyIdleTimeout>,
2250    /// Whether to start this daemon automatically on system boot
2251    pub boot_start: Option<bool>,
2252    /// List of daemon IDs that must be started before this one
2253    #[schemars(default)]
2254    pub depends: Vec<DaemonId>,
2255    /// File patterns to watch for changes
2256    #[schemars(default)]
2257    pub watch: Vec<String>,
2258    /// File watching backend mode.
2259    ///
2260    /// - `native`: use platform-native notifications (default)
2261    /// - `poll`: use polling-based watcher
2262    /// - `auto`: prefer native, fall back to polling if native watch fails
2263    #[schemars(default)]
2264    pub watch_mode: WatchMode,
2265    /// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
2266    pub dir: Option<String>,
2267    /// Environment variables to set for the daemon process
2268    pub env: Option<IndexMap<String, String>>,
2269    /// Lifecycle hooks (on_ready, on_fail, on_retry)
2270    pub hooks: Option<PitchforkTomlHooks>,
2271    /// Wrap this daemon's command with `mise x --` for tool/env setup.
2272    /// Overrides the global `settings.general.mise` when set.
2273    pub mise: Option<bool>,
2274    /// Pitchfork's template context as JSON, set while rendering when `run` was left
2275    /// unrendered for `mise x` to finish. Never read from or written to a file.
2276    #[schemars(skip)]
2277    pub deferred_template_context: Option<String>,
2278    /// Unix user to run this daemon as. Overrides `settings.supervisor.user` when set.
2279    pub user: Option<String>,
2280    /// Memory limit for the daemon process (e.g. "50MB", "1GiB").
2281    /// The supervisor periodically monitors RSS and kills the process if it exceeds the limit.
2282    pub memory_limit: Option<MemoryLimit>,
2283    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores).
2284    /// The supervisor periodically monitors CPU usage and kills the process if it exceeds the limit.
2285    pub cpu_limit: Option<CpuLimit>,
2286    /// Stop signal and optional per-daemon timeout. Accepts a signal name string
2287    /// or `{ signal = "...", timeout = "..." }` object.
2288    pub stop_signal: Option<StopConfig>,
2289    /// Allocate a pseudo-terminal for the daemon process.
2290    pub pty: Option<bool>,
2291    /// Maximum age of log entries to keep (e.g. "7d", "30d").
2292    /// Overrides the global `settings.logs.time_retention` when set.
2293    pub time_retention: Option<String>,
2294    /// Maximum number of log entries to keep per daemon.
2295    /// Overrides the global `settings.logs.line_retention` when set.
2296    pub line_retention: Option<i64>,
2297    /// Archive hook command invoked before retention prunes this daemon's logs.
2298    /// Overrides the global `settings.logs.archive_hook.command` when set.
2299    pub archive_hook: Option<String>,
2300    /// Per-daemon log configuration sub-table.
2301    pub logs: Option<PitchforkTomlDaemonLogs>,
2302    #[schemars(skip)]
2303    pub path: Option<PathBuf>,
2304}
2305
2306impl PitchforkTomlDaemon {
2307    /// Which of the daemon's ports its proxy hostname maps to, from either
2308    /// spelling of the setting.
2309    ///
2310    /// `proxy_tls_port` wins when both are set; parsing has already warned
2311    /// about that combination.
2312    pub fn effective_proxy_tls_port(&self) -> Option<u16> {
2313        self.proxy_tls_port.or(self.proxy_port)
2314    }
2315
2316    /// Whether this daemon is a oneshot task (`oneshot = true`).
2317    pub fn is_oneshot(&self) -> bool {
2318        self.oneshot.unwrap_or(false)
2319    }
2320
2321    /// Names of the readiness and health fields that are set on this daemon
2322    /// and cannot be combined with `oneshot`. Empty when there is no conflict.
2323    pub(crate) fn oneshot_conflicts(&self) -> Vec<&'static str> {
2324        [
2325            ("ready_delay", self.ready_delay.is_some()),
2326            ("ready_output", self.ready_output.is_some()),
2327            ("ready_http", self.ready_http.is_some()),
2328            ("ready_port", self.ready_port.is_some()),
2329            ("ready_cmd", self.ready_cmd.is_some()),
2330            ("health_cmd", self.health_cmd.is_some()),
2331            ("health_http", self.health_http.is_some()),
2332            ("health_port", self.health_port.is_some()),
2333        ]
2334        .into_iter()
2335        .filter(|(_, set)| *set)
2336        .map(|(name, _)| name)
2337        .collect()
2338    }
2339
2340    /// Effective user for this daemon: per-daemon `user` overrides `settings.supervisor.user`.
2341    ///
2342    /// Returns `None` when neither is set (inherit the supervisor's user).
2343    pub fn effective_user(&self) -> Option<String> {
2344        let daemon_user = self
2345            .user
2346            .as_deref()
2347            .map(str::trim)
2348            .filter(|u| !u.is_empty());
2349        daemon_user.map(str::to_owned).or_else(|| {
2350            let s = crate::settings::settings();
2351            let su = s.supervisor.user.trim();
2352            (!su.is_empty()).then(|| su.to_owned())
2353        })
2354    }
2355
2356    /// Build RunOptions from this daemon configuration.
2357    ///
2358    /// Carries over all config fields and resolves the working directory.
2359    /// Callers can override specific fields on the returned value.
2360    pub fn to_run_options(
2361        &self,
2362        id: &crate::daemon_id::DaemonId,
2363        cmd: Vec<String>,
2364    ) -> crate::daemon::RunOptions {
2365        use crate::daemon::RunOptions;
2366
2367        let effective_user = self.effective_user();
2368        let dir = crate::ipc::batch::resolve_daemon_dir(
2369            self.dir.as_deref(),
2370            self.path.as_deref(),
2371            effective_user.as_deref(),
2372        );
2373        // The same lookup the proxy and the CLI use, so a slug the proxy
2374        // refuses to route as ambiguous is not handed to the daemon either.
2375        let slug = PitchforkToml::find_slug_for_daemon_in_registry(
2376            id,
2377            &PitchforkToml::read_global_slugs(),
2378        );
2379
2380        RunOptions {
2381            id: id.clone(),
2382            cmd,
2383            run: self.run.shell_script().map(str::to_string),
2384            no_shell: self.run.is_argv(),
2385            replaces_ready_checks: false,
2386            // Set by the IPC handler for a client's own request.
2387            requested_by_client: false,
2388            force: false,
2389            shell_pid: None,
2390            dir: Dir(dir),
2391            autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
2392            oneshot: self.is_oneshot(),
2393            // Set only by the cron watcher's own call; a start built from
2394            // config is not the schedule asking for a run.
2395            cron_started: false,
2396            // Filled in by `build_run_options`, which resolves it against the
2397            // daemon's own project rather than whatever directory this process
2398            // happens to be in.
2399            oneshot_wait: None,
2400            on_directory_enter: false,
2401            cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
2402            cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
2403            cron_immediate: self.cron.as_ref().map(|c| c.immediate),
2404            retry: self.retry,
2405            retry_count: 0,
2406            ready_delay: self.ready_delay,
2407            ready_output: self.ready_output.clone(),
2408            ready_http: self.ready_http.clone(),
2409            ready_port: self.ready_port.clone(),
2410            ready_cmd: self.ready_cmd.clone(),
2411            health_cmd: self.health_cmd.clone(),
2412            health_http: self.health_http.clone(),
2413            health_port: self.health_port.clone(),
2414            port: self.port.clone(),
2415            wait_ready: false,
2416            depends: self.depends.clone(),
2417            env: self.env.clone(),
2418            watch: self.watch.clone(),
2419            watch_mode: self.watch_mode,
2420            watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
2421                self.path.as_deref(),
2422            )),
2423            mise: self.mise,
2424            deferred_template_context: self.deferred_template_context.clone(),
2425            // Resolved from the project settings by `build_run_options`.
2426            mise_bin: None,
2427            slug,
2428            proxy: None,
2429            user: self.user.clone(),
2430            memory_limit: self.memory_limit,
2431            cpu_limit: self.cpu_limit,
2432            stop_signal: self.stop_signal,
2433            archive_hook: self
2434                .logs
2435                .as_ref()
2436                .and_then(|l| l.archive_hook.clone())
2437                .or_else(|| self.archive_hook.clone()),
2438            log_format: self.logs.as_ref().and_then(|l| l.log_format.clone()),
2439            on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
2440            pty: self.pty,
2441            // Explicit unless the proxy's start marks it otherwise.
2442            proxy_idle_timeout_ms: None,
2443            replaces_saved_record: true,
2444        }
2445    }
2446}
2447fn example_run_command() -> &'static str {
2448    "exec node server.js"
2449}
2450
2451/// A daemon's `run`: a command line for the shell, or a program and its
2452/// arguments to start without one.
2453#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema)]
2454#[serde(untagged)]
2455pub enum RunCommand {
2456    /// A command line, passed verbatim to the shell (`general.shell`, or
2457    /// `general.windows_shell` on Windows).
2458    Shell(String),
2459    /// A program and its arguments, started directly. No shell interprets
2460    /// them, so each argument reaches the program exactly as written, on
2461    /// every platform.
2462    Argv(#[schemars(length(min = 1))] Vec<String>),
2463}
2464
2465impl Default for RunCommand {
2466    fn default() -> Self {
2467        Self::Shell(String::new())
2468    }
2469}
2470
2471impl From<String> for RunCommand {
2472    fn from(run: String) -> Self {
2473        Self::Shell(run)
2474    }
2475}
2476
2477impl From<&str> for RunCommand {
2478    fn from(run: &str) -> Self {
2479        Self::Shell(run.to_string())
2480    }
2481}
2482
2483impl From<Vec<String>> for RunCommand {
2484    fn from(argv: Vec<String>) -> Self {
2485        Self::Argv(argv)
2486    }
2487}
2488
2489impl PartialEq<str> for RunCommand {
2490    fn eq(&self, other: &str) -> bool {
2491        matches!(self, Self::Shell(run) if run == other)
2492    }
2493}
2494
2495impl PartialEq<&str> for RunCommand {
2496    fn eq(&self, other: &&str) -> bool {
2497        self == *other
2498    }
2499}
2500
2501/// The command as a user would read it: the shell form as written, the argv
2502/// form quoted for a POSIX shell.
2503impl std::fmt::Display for RunCommand {
2504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2505        match self {
2506            Self::Shell(run) => f.write_str(run),
2507            Self::Argv(argv) => f.write_str(&shell_words::join(argv)),
2508        }
2509    }
2510}
2511
2512impl RunCommand {
2513    /// The command as separate words: the argv form as it is, the shell form
2514    /// split the way a POSIX shell would.
2515    pub fn argv(&self) -> std::result::Result<Vec<String>, shell_words::ParseError> {
2516        match self {
2517            Self::Shell(run) => shell_words::split(run),
2518            Self::Argv(argv) => Ok(argv.clone()),
2519        }
2520    }
2521
2522    /// The command line for the shell, or `None` for the argv form.
2523    pub fn shell_script(&self) -> Option<&str> {
2524        match self {
2525            Self::Shell(run) => Some(run),
2526            Self::Argv(_) => None,
2527        }
2528    }
2529
2530    pub fn is_argv(&self) -> bool {
2531        matches!(self, Self::Argv(_))
2532    }
2533
2534    /// Apply `f` to the command line, or to each argument of the argv form.
2535    pub fn try_map<E>(
2536        &self,
2537        mut f: impl FnMut(&str) -> std::result::Result<String, E>,
2538    ) -> std::result::Result<Self, E> {
2539        Ok(match self {
2540            Self::Shell(run) => Self::Shell(f(run)?),
2541            Self::Argv(argv) => Self::Argv(
2542                argv.iter()
2543                    .map(|arg| f(arg))
2544                    .collect::<std::result::Result<_, _>>()?,
2545            ),
2546        })
2547    }
2548}
2549
2550#[cfg(test)]
2551mod tests {
2552    use super::*;
2553    use std::path::Path;
2554
2555    #[test]
2556    fn test_daemon_user_parses_and_flows_to_run_options() {
2557        let pt = PitchforkToml::parse_str(
2558            r#"
2559[daemons.api]
2560run = "node server.js"
2561user = "postgres"
2562"#,
2563            Path::new("/tmp/my-project/pitchfork.toml"),
2564        )
2565        .unwrap();
2566
2567        let id = DaemonId::new("my-project", "api");
2568        let daemon = pt.daemons.get(&id).unwrap();
2569        assert_eq!(daemon.user.as_deref(), Some("postgres"));
2570
2571        let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
2572        assert_eq!(opts.user.as_deref(), Some("postgres"));
2573        // A start from config describes the daemon in full.
2574        assert!(opts.replaces_saved_record);
2575    }
2576
2577    #[test]
2578    fn test_new_local_file_writes_daemons_of_the_directory_namespace() {
2579        let temp = tempfile::tempdir().unwrap();
2580        std::fs::write(
2581            temp.path().join("pitchfork.toml"),
2582            "namespace = \"myns\"\n\n[daemons.web]\nrun = \"sleep 60\"\n",
2583        )
2584        .unwrap();
2585        let path = temp.path().join("pitchfork.local.toml");
2586        let mut pt = PitchforkToml::new(path.clone());
2587        let id = DaemonId::new(namespace_from_path(&path).unwrap(), "api");
2588        assert_eq!(id.namespace(), "myns");
2589        pt.daemons.insert(
2590            id.clone(),
2591            PitchforkTomlDaemon {
2592                run: "sleep 60".into(),
2593                ..PitchforkTomlDaemon::default()
2594            },
2595        );
2596
2597        pt.write().unwrap();
2598
2599        let parsed = PitchforkToml::read(&path).unwrap();
2600        assert!(parsed.daemons.contains_key(&id));
2601    }
2602
2603    #[test]
2604    fn test_daemon_user_write_roundtrip() {
2605        let temp = tempfile::tempdir().unwrap();
2606        let path = temp.path().join("pitchfork.toml");
2607        let mut pt = PitchforkToml::new(path.clone());
2608        pt.namespace = Some("test-project".to_string());
2609        pt.daemons.insert(
2610            DaemonId::new("test-project", "api"),
2611            PitchforkTomlDaemon {
2612                run: "node server.js".into(),
2613                user: Some("postgres".to_string()),
2614                ..PitchforkTomlDaemon::default()
2615            },
2616        );
2617
2618        pt.write().unwrap();
2619
2620        let raw = std::fs::read_to_string(&path).unwrap();
2621        assert!(raw.contains("user = \"postgres\""));
2622
2623        let parsed = PitchforkToml::read(&path).unwrap();
2624        let daemon = parsed
2625            .daemons
2626            .get(&DaemonId::new("test-project", "api"))
2627            .unwrap();
2628        assert_eq!(daemon.user.as_deref(), Some("postgres"));
2629    }
2630
2631    /// Arguments a shell would reinterpret, which the argv form must not.
2632    fn awkward_argv() -> Vec<String> {
2633        [
2634            "node",
2635            "my server.js",
2636            "--name=\"a b\"",
2637            "it's",
2638            "a&b",
2639            "%PATH%",
2640            "$HOME",
2641        ]
2642        .map(str::to_string)
2643        .to_vec()
2644    }
2645
2646    #[test]
2647    fn test_run_array_parses_and_starts_without_a_shell() {
2648        let pt = PitchforkToml::parse_str(
2649            r#"
2650[daemons.api]
2651run = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2652"#,
2653            Path::new("/tmp/my-project/pitchfork.toml"),
2654        )
2655        .unwrap();
2656
2657        let id = DaemonId::new("my-project", "api");
2658        let daemon = pt.daemons.get(&id).unwrap();
2659        assert_eq!(daemon.run, RunCommand::Argv(awkward_argv()));
2660
2661        let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2662        assert_eq!(opts.cmd, awkward_argv());
2663        assert!(opts.no_shell);
2664        assert_eq!(opts.run, None);
2665    }
2666
2667    #[test]
2668    fn test_run_string_still_goes_through_the_shell() {
2669        let pt = PitchforkToml::parse_str(
2670            "[daemons.api]\nrun = \"exec node server.js\"\n",
2671            Path::new("/tmp/my-project/pitchfork.toml"),
2672        )
2673        .unwrap();
2674
2675        let id = DaemonId::new("my-project", "api");
2676        let daemon = pt.daemons.get(&id).unwrap();
2677        let opts = daemon.to_run_options(&id, daemon.run.argv().unwrap());
2678        assert!(!opts.no_shell);
2679        assert_eq!(opts.run.as_deref(), Some("exec node server.js"));
2680    }
2681
2682    #[test]
2683    fn test_run_array_write_roundtrip() {
2684        let temp = tempfile::tempdir().unwrap();
2685        let path = temp.path().join("pitchfork.toml");
2686        let mut pt = PitchforkToml::new(path.clone());
2687        pt.namespace = Some("test-project".to_string());
2688        let id = DaemonId::new("test-project", "api");
2689        pt.daemons.insert(
2690            id.clone(),
2691            PitchforkTomlDaemon {
2692                run: RunCommand::Argv(awkward_argv()),
2693                ..PitchforkTomlDaemon::default()
2694            },
2695        );
2696        pt.daemons.insert(
2697            DaemonId::new("test-project", "worker"),
2698            PitchforkTomlDaemon {
2699                run: "exec ./worker --queue \"a b\"".into(),
2700                ..PitchforkTomlDaemon::default()
2701            },
2702        );
2703
2704        pt.write().unwrap();
2705
2706        let parsed = PitchforkToml::read(&path).unwrap();
2707        assert_eq!(parsed.daemons[&id].run, RunCommand::Argv(awkward_argv()));
2708        assert_eq!(
2709            parsed.daemons[&DaemonId::new("test-project", "worker")].run,
2710            "exec ./worker --queue \"a b\""
2711        );
2712    }
2713
2714    #[test]
2715    fn test_run_array_must_name_a_program() {
2716        let err = PitchforkToml::parse_str(
2717            "[daemons.api]\nrun = []\n",
2718            Path::new("/tmp/my-project/pitchfork.toml"),
2719        )
2720        .unwrap_err();
2721        assert!(
2722            err.chain()
2723                .any(|cause| cause.to_string().contains("empty run array")),
2724            "unexpected error: {err:?}"
2725        );
2726    }
2727
2728    #[test]
2729    fn test_run_array_rejects_exec() {
2730        let err = PitchforkToml::parse_str(
2731            "[daemons.api]\nrun = [\"exec\", \"node\", \"server.js\"]\n",
2732            Path::new("/tmp/my-project/pitchfork.toml"),
2733        )
2734        .unwrap_err();
2735        assert!(
2736            err.chain().any(|cause| cause
2737                .to_string()
2738                .contains("starts its run array with \"exec\"")),
2739            "unexpected error: {err:?}"
2740        );
2741    }
2742
2743    #[test]
2744    fn test_ready_cmd_array_parses_in_both_forms() {
2745        let pt = PitchforkToml::parse_str(
2746            r#"
2747[daemons.api]
2748run = "node server.js"
2749ready_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2750
2751[daemons.db]
2752run = "postgres"
2753ready_cmd = { run = ["pg_isready", "-h", "localhost"], timeout = "30s" }
2754
2755[daemons.cache]
2756run = "redis-server"
2757ready_cmd = "redis-cli ping"
2758"#,
2759            Path::new("/tmp/my-project/pitchfork.toml"),
2760        )
2761        .unwrap();
2762
2763        let ready = |name: &str| {
2764            pt.daemons[&DaemonId::new("my-project", name)]
2765                .ready_cmd
2766                .clone()
2767                .unwrap()
2768        };
2769        assert_eq!(ready("api").run, RunCommand::Argv(awkward_argv()));
2770        assert_eq!(ready("api").timeout, None);
2771        assert_eq!(
2772            ready("db").run,
2773            RunCommand::Argv(
2774                ["pg_isready", "-h", "localhost"]
2775                    .map(str::to_string)
2776                    .to_vec()
2777            )
2778        );
2779        assert_eq!(
2780            ready("db").timeout,
2781            Some(std::time::Duration::from_secs(30))
2782        );
2783        assert_eq!(ready("cache").run, "redis-cli ping");
2784    }
2785
2786    #[test]
2787    fn test_ready_cmd_array_write_roundtrip() {
2788        let temp = tempfile::tempdir().unwrap();
2789        let path = temp.path().join("pitchfork.toml");
2790        let mut pt = PitchforkToml::new(path.clone());
2791        pt.namespace = Some("test-project".to_string());
2792        let short = ReadyCmd::new(awkward_argv());
2793        let full = ReadyCmd {
2794            run: RunCommand::Argv(
2795                ["pg_isready", "-h", "localhost"]
2796                    .map(str::to_string)
2797                    .to_vec(),
2798            ),
2799            timeout: Some(std::time::Duration::from_secs(30)),
2800        };
2801        for (name, ready_cmd) in [("api", &short), ("db", &full)] {
2802            pt.daemons.insert(
2803                DaemonId::new("test-project", name),
2804                PitchforkTomlDaemon {
2805                    run: "true".into(),
2806                    ready_cmd: Some(ready_cmd.clone()),
2807                    ..PitchforkTomlDaemon::default()
2808                },
2809            );
2810        }
2811
2812        pt.write().unwrap();
2813
2814        let parsed = PitchforkToml::read(&path).unwrap();
2815        let ready = |name: &str| {
2816            parsed.daemons[&DaemonId::new("test-project", name)]
2817                .ready_cmd
2818                .clone()
2819        };
2820        assert_eq!(ready("api"), Some(short));
2821        assert_eq!(ready("db"), Some(full));
2822    }
2823
2824    #[test]
2825    fn test_ready_cmd_array_must_name_a_program() {
2826        for ready_cmd in ["[]", "[\"\"]", "{ run = [\"\", \"-h\"] }"] {
2827            let err = PitchforkToml::parse_str(
2828                &format!("[daemons.api]\nrun = \"true\"\nready_cmd = {ready_cmd}\n"),
2829                Path::new("/tmp/my-project/pitchfork.toml"),
2830            )
2831            .unwrap_err();
2832            assert!(
2833                err.chain().any(|cause| cause
2834                    .to_string()
2835                    .contains("has no program in its ready_cmd array")),
2836                "unexpected error for {ready_cmd}: {err:?}"
2837            );
2838        }
2839    }
2840
2841    #[test]
2842    fn test_ready_cmd_array_rejects_exec() {
2843        let err = PitchforkToml::parse_str(
2844            "[daemons.api]\nrun = \"true\"\nready_cmd = { run = [\"exec\", \"pg_isready\"] }\n",
2845            Path::new("/tmp/my-project/pitchfork.toml"),
2846        )
2847        .unwrap_err();
2848        assert!(
2849            err.chain().any(|cause| cause
2850                .to_string()
2851                .contains("starts its ready_cmd array with \"exec\"")),
2852            "unexpected error: {err:?}"
2853        );
2854    }
2855
2856    #[test]
2857    fn test_health_cmd_array_parses_and_round_trips() {
2858        let pt = PitchforkToml::parse_str(
2859            r#"
2860[daemons.api]
2861run = "node server.js"
2862health_cmd = ["node", "my server.js", "--name=\"a b\"", "it's", "a&b", "%PATH%", "$HOME"]
2863
2864[daemons.db]
2865run = "postgres"
2866health_cmd = { run = ["pg_isready", "-h", "localhost"], interval = "5s", retries = 2 }
2867
2868[daemons.cache]
2869run = "redis-server"
2870health_cmd = "redis-cli ping"
2871"#,
2872            Path::new("/tmp/my-project/pitchfork.toml"),
2873        )
2874        .unwrap();
2875        let health = |pt: &PitchforkToml, ns: &str, name: &str| {
2876            pt.daemons[&DaemonId::new(ns, name)]
2877                .health_cmd
2878                .clone()
2879                .unwrap()
2880        };
2881        assert_eq!(
2882            health(&pt, "my-project", "api").run,
2883            RunCommand::Argv(awkward_argv())
2884        );
2885        let db = health(&pt, "my-project", "db");
2886        assert_eq!(
2887            db.run,
2888            RunCommand::Argv(
2889                ["pg_isready", "-h", "localhost"]
2890                    .map(str::to_string)
2891                    .to_vec()
2892            )
2893        );
2894        assert_eq!(db.interval, Some(std::time::Duration::from_secs(5)));
2895        assert_eq!(db.retries, Some(2));
2896        assert_eq!(health(&pt, "my-project", "cache").run, "redis-cli ping");
2897
2898        let temp = tempfile::tempdir().unwrap();
2899        let path = temp.path().join("pitchfork.toml");
2900        let mut written = PitchforkToml::new(path.clone());
2901        written.namespace = Some("test-project".to_string());
2902        for name in ["api", "db"] {
2903            written.daemons.insert(
2904                DaemonId::new("test-project", name),
2905                PitchforkTomlDaemon {
2906                    run: "true".into(),
2907                    health_cmd: Some(health(&pt, "my-project", name)),
2908                    ..PitchforkTomlDaemon::default()
2909                },
2910            );
2911        }
2912        written.write().unwrap();
2913        let parsed = PitchforkToml::read(&path).unwrap();
2914        for name in ["api", "db"] {
2915            assert_eq!(
2916                health(&parsed, "test-project", name),
2917                health(&pt, "my-project", name)
2918            );
2919        }
2920    }
2921
2922    #[test]
2923    fn test_health_cmd_array_must_name_a_program_and_not_exec() {
2924        for (health_cmd, expected) in [
2925            ("[]", "has no program in its health_cmd array"),
2926            ("[\"\"]", "has no program in its health_cmd array"),
2927            (
2928                "{ run = [\"exec\", \"pg_isready\"] }",
2929                "starts its health_cmd array with \"exec\"",
2930            ),
2931        ] {
2932            let err = PitchforkToml::parse_str(
2933                &format!("[daemons.api]\nrun = \"true\"\nhealth_cmd = {health_cmd}\n"),
2934                Path::new("/tmp/my-project/pitchfork.toml"),
2935            )
2936            .unwrap_err();
2937            assert!(
2938                err.chain()
2939                    .any(|cause| cause.to_string().contains(expected)),
2940                "unexpected error for {health_cmd}: {err:?}"
2941            );
2942        }
2943    }
2944
2945    #[test]
2946    fn test_registry_dirs_expand_tilde() {
2947        let pt = PitchforkToml::parse_str(
2948            r#"
2949[slugs.api]
2950dir = "~/projects/api"
2951
2952[namespaces.web]
2953dir = "~/projects/web"
2954"#,
2955            Path::new("/tmp/config.toml"),
2956        )
2957        .unwrap();
2958
2959        assert_eq!(
2960            pt.slugs["api"].dir,
2961            Some(crate::env::HOME_DIR.join("projects/api"))
2962        );
2963        assert_eq!(
2964            pt.namespaces["web"].dir,
2965            crate::env::HOME_DIR.join("projects/web")
2966        );
2967    }
2968
2969    #[test]
2970    fn test_settings_write_roundtrip() {
2971        let temp = tempfile::tempdir().unwrap();
2972        let path = temp.path().join("pitchfork.toml");
2973        let mut pt = PitchforkToml::new(path.clone());
2974        pt.namespace = Some("test-project".to_string());
2975        pt.settings.web.auto_start = Some(true);
2976        pt.settings.general.log_level = Some("debug".to_string());
2977
2978        pt.write().unwrap();
2979
2980        let raw = std::fs::read_to_string(&path).unwrap();
2981        assert!(
2982            raw.contains("[settings.web]"),
2983            "settings.web section should be written, got:\n{raw}"
2984        );
2985        assert!(raw.contains("auto_start = true"));
2986        assert!(raw.contains("log_level = \"debug\""));
2987
2988        let parsed = PitchforkToml::read(&path).unwrap();
2989        assert_eq!(parsed.settings.web.auto_start, Some(true));
2990        assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
2991    }
2992
2993    fn slug_entry(namespace: &str, daemon: Option<&str>) -> SlugEntry {
2994        SlugEntry {
2995            dir: None,
2996            namespace: Some(namespace.to_string()),
2997            daemon: daemon.map(str::to_string),
2998        }
2999    }
3000
3001    #[test]
3002    fn test_slug_is_ambiguous() {
3003        let mut slugs = IndexMap::new();
3004        slugs.insert("api".to_string(), slug_entry("my-project", None));
3005        assert!(!PitchforkToml::slug_is_ambiguous("api", &slugs));
3006
3007        slugs.insert("API".to_string(), slug_entry("other-project", None));
3008        // Host names are case-insensitive, so both spellings are ambiguous.
3009        assert!(PitchforkToml::slug_is_ambiguous("api", &slugs));
3010        assert!(PitchforkToml::slug_is_ambiguous("API", &slugs));
3011    }
3012
3013    #[test]
3014    fn test_find_slug_for_daemon_skips_case_collisions() {
3015        let id = DaemonId::new("my-project", "api");
3016        let mut slugs = IndexMap::new();
3017        slugs.insert("api".to_string(), slug_entry("my-project", None));
3018        assert_eq!(
3019            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3020            Some("api".to_string())
3021        );
3022
3023        // The proxy refuses to route either spelling, so no URL may be
3024        // advertised for this daemon.
3025        slugs.insert("API".to_string(), slug_entry("other-project", None));
3026        assert_eq!(
3027            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3028            None
3029        );
3030    }
3031
3032    #[test]
3033    fn test_find_slug_for_daemon_prefers_a_routable_alias() {
3034        let id = DaemonId::new("my-project", "api");
3035        let mut slugs = IndexMap::new();
3036        // A colliding pair comes first in config order, then a routable alias
3037        // for the same daemon.
3038        slugs.insert("api".to_string(), slug_entry("my-project", None));
3039        slugs.insert("API".to_string(), slug_entry("my-project", None));
3040        slugs.insert("my-api".to_string(), slug_entry("my-project", Some("api")));
3041
3042        assert_eq!(
3043            PitchforkToml::find_slug_for_daemon_in_registry(&id, &slugs),
3044            Some("my-api".to_string())
3045        );
3046    }
3047
3048    #[test]
3049    fn test_settings_preserved_on_unrelated_write() {
3050        // Regression test for https://github.com/jdx/pitchfork/discussions/574
3051        // A read-modify-write of slugs/namespaces must not drop existing [settings].
3052        let temp = tempfile::tempdir().unwrap();
3053        let path = temp.path().join("pitchfork.toml");
3054        std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
3055
3056        let mut pt = PitchforkToml::read(&path).unwrap();
3057        pt.slugs.insert(
3058            "api".to_string(),
3059            SlugEntry {
3060                dir: None,
3061                namespace: Some("myproject".to_string()),
3062                daemon: None,
3063            },
3064        );
3065        pt.namespaces.insert(
3066            "myproject".to_string(),
3067            NamespaceEntry {
3068                dir: PathBuf::from("/tmp/myproject"),
3069                config: Vec::new(),
3070                label: None,
3071            },
3072        );
3073        pt.write().unwrap();
3074
3075        let raw = std::fs::read_to_string(&path).unwrap();
3076        assert!(
3077            raw.contains("[settings.web]"),
3078            "existing settings must be preserved, got:\n{raw}"
3079        );
3080        assert!(raw.contains("auto_start = true"));
3081        assert!(raw.contains("[slugs.api]"));
3082
3083        let parsed = PitchforkToml::read(&path).unwrap();
3084        assert_eq!(parsed.settings.web.auto_start, Some(true));
3085        assert!(parsed.slugs.contains_key("api"));
3086    }
3087
3088    #[tokio::test]
3089    async fn test_proxy_worktree_alias_is_canonicalized_on_rewrite() {
3090        let temp = tempfile::tempdir().unwrap();
3091        let path = temp.path().join("pitchfork.toml");
3092        tokio::fs::write(&path, "[settings.proxy]\nworktree = false\n")
3093            .await
3094            .unwrap();
3095
3096        let read_path = path.clone();
3097        let pt = tokio::task::spawn_blocking(move || PitchforkToml::read(&read_path))
3098            .await
3099            .unwrap()
3100            .unwrap();
3101        assert_eq!(pt.settings.general.worktree, Some(false));
3102        assert_eq!(pt.settings.proxy.worktree, None);
3103        tokio::task::spawn_blocking(move || pt.write())
3104            .await
3105            .unwrap()
3106            .unwrap();
3107
3108        let raw = tokio::fs::read_to_string(&path).await.unwrap();
3109        assert!(raw.contains("[settings.general]"), "{raw}");
3110        assert!(raw.contains("worktree = false"), "{raw}");
3111        assert!(!raw.contains("[settings.proxy]"), "{raw}");
3112
3113        let parsed = tokio::task::spawn_blocking(move || PitchforkToml::read(&path))
3114            .await
3115            .unwrap()
3116            .unwrap();
3117        assert_eq!(parsed.settings.general.worktree, Some(false));
3118    }
3119
3120    #[test]
3121    fn test_config_cache_hit_and_invalidation() {
3122        let temp = tempfile::tempdir().unwrap();
3123        let dir = temp.path();
3124        let config_path = dir.join("pitchfork.toml");
3125        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3126
3127        // Clear any pre-existing cache entries for this directory.
3128        super::invalidate_config_cache();
3129
3130        // First call: cache miss, reads from disk.
3131        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3132        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3133        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3134
3135        // Second call: should be a cache hit (same mtime).
3136        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3137        assert_eq!(pt2.daemons[&daemon_id].run, "echo v1");
3138
3139        // Modify the config file — mtime changes, cache should miss.
3140        // Sleep briefly to ensure mtime resolution differs.
3141        std::thread::sleep(std::time::Duration::from_millis(50));
3142        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v2\"\n").unwrap();
3143
3144        let pt3 = PitchforkToml::all_merged_from(dir).unwrap();
3145        assert_eq!(pt3.daemons[&daemon_id].run, "echo v2");
3146
3147        // Explicit invalidation should also force a re-read.
3148        super::invalidate_config_cache();
3149        let pt4 = PitchforkToml::all_merged_from(dir).unwrap();
3150        assert_eq!(pt4.daemons[&daemon_id].run, "echo v2");
3151
3152        // Clean up.
3153        super::invalidate_config_cache();
3154    }
3155
3156    #[test]
3157    fn test_config_cache_invalidation_on_write() {
3158        let temp = tempfile::tempdir().unwrap();
3159        let dir = temp.path();
3160        let config_path = dir.join("pitchfork.toml");
3161        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3162
3163        super::invalidate_config_cache();
3164
3165        // Populate cache.
3166        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3167        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3168        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3169
3170        // Write via PitchforkToml::write() — should invalidate cache.
3171        let mut pt = PitchforkToml::read(&config_path).unwrap();
3172        pt.daemons.get_mut(&daemon_id).unwrap().run = "echo v3".into();
3173        // write() needs the path set and namespace match
3174        let _ = pt.write();
3175
3176        // Next read should see the updated value, not the cached one.
3177        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3178        assert_eq!(pt2.daemons[&daemon_id].run, "echo v3");
3179
3180        super::invalidate_config_cache();
3181    }
3182
3183    #[test]
3184    fn test_config_cache_size_invalidation() {
3185        let temp = tempfile::tempdir().unwrap();
3186        let dir = temp.path();
3187        let config_path = dir.join("pitchfork.toml");
3188        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo v1\"\n").unwrap();
3189
3190        super::invalidate_config_cache();
3191
3192        // Populate cache.
3193        let pt1 = PitchforkToml::all_merged_from(dir).unwrap();
3194        let daemon_id = DaemonId::new(namespace_from_path(&config_path).unwrap(), "api");
3195        assert_eq!(pt1.daemons[&daemon_id].run, "echo v1");
3196
3197        // Capture the original mtime, then write different-size content and
3198        // restore the *same* mtime — simulating `cp --preserve=timestamps`
3199        // or a same-second edit on a coarse-grained filesystem.
3200        let original_mtime = std::fs::metadata(&config_path).unwrap().modified().unwrap();
3201        std::fs::write(&config_path, "[daemons.api]\nrun = \"echo different\"\n").unwrap();
3202        // On Windows, set_times requires the file handle to be opened with
3203        // write access; File::open is read-only.
3204        let file = std::fs::OpenOptions::new()
3205            .write(true)
3206            .open(&config_path)
3207            .unwrap();
3208        let times = std::fs::FileTimes::new().set_modified(original_mtime);
3209        file.set_times(times).unwrap();
3210
3211        // Size changed (shorter run string), so cache should miss even though
3212        // mtime is identical.
3213        let pt2 = PitchforkToml::all_merged_from(dir).unwrap();
3214        assert_eq!(
3215            pt2.daemons[&daemon_id].run, "echo different",
3216            "cache should invalidate on size change even with identical mtime"
3217        );
3218
3219        super::invalidate_config_cache();
3220    }
3221
3222    #[test]
3223    fn test_find_project_root_in_plain_dir_returns_none() {
3224        let temp = tempfile::tempdir().unwrap();
3225        assert_eq!(find_project_root(temp.path()), None);
3226    }
3227
3228    #[test]
3229    fn test_find_project_root_finds_git_marker() {
3230        let temp = tempfile::tempdir().unwrap();
3231        let repo = temp.path().join("my-repo");
3232        std::fs::create_dir(&repo).unwrap();
3233        std::fs::create_dir(repo.join(".git")).unwrap();
3234
3235        let sub = repo.join("sub/dir");
3236        std::fs::create_dir_all(&sub).unwrap();
3237
3238        // `find_project_root` canonicalizes, so compare against the canonical
3239        // path (on Windows this differs by the `\\?\` verbatim prefix).
3240        assert_eq!(find_project_root(&sub), Some(repo.canonicalize().unwrap()));
3241    }
3242
3243    #[test]
3244    fn test_find_project_root_accepts_git_file_marker() {
3245        // Linked git worktrees store `.git` as a *file* pointing at the
3246        // common gitdir, so `exists()` (not `is_dir()`) is the right check.
3247        let temp = tempfile::tempdir().unwrap();
3248        let wt = temp.path().join("my-worktree");
3249        std::fs::create_dir(&wt).unwrap();
3250        std::fs::write(wt.join(".git"), "gitdir: /tmp/some-common-gitdir\n").unwrap();
3251
3252        assert_eq!(find_project_root(&wt), Some(wt.canonicalize().unwrap()));
3253    }
3254
3255    /// A symlinked start dir must resolve into the repository hierarchy so
3256    /// `parent()` traversal does not walk out of the repo.
3257    #[cfg(unix)]
3258    #[test]
3259    fn test_find_project_root_resolves_symlinked_start_dir() {
3260        use std::os::unix::fs::symlink;
3261
3262        let temp = tempfile::tempdir().unwrap();
3263        let repo = temp.path().join("real-repo");
3264        std::fs::create_dir(&repo).unwrap();
3265        std::fs::create_dir(repo.join(".git")).unwrap();
3266
3267        let sub = repo.join("sub/dir");
3268        std::fs::create_dir_all(&sub).unwrap();
3269        let link = temp.path().join("link-to-sub");
3270        symlink(&sub, &link).unwrap();
3271
3272        assert_eq!(find_project_root(&link), Some(repo.canonicalize().unwrap()));
3273    }
3274
3275    /// Build a real git repository with a linked worktree and assert that
3276    /// `all_merged_all_namespaces_from` picks up daemons from both.
3277    #[test]
3278    fn test_all_merged_all_namespaces_discovers_worktrees() {
3279        let temp = tempfile::tempdir().unwrap();
3280        let repo = temp.path().join("my-repo");
3281        std::fs::create_dir(&repo).unwrap();
3282
3283        // git worktree add requires at least one commit.
3284        let git_init = std::process::Command::new("git")
3285            .args(["init", "-b", "main"])
3286            .current_dir(&repo)
3287            .output()
3288            .expect("git init");
3289        assert!(git_init.status.success(), "git init failed: {:?}", git_init);
3290
3291        std::fs::write(repo.join("main.toml"), "hello\n").unwrap();
3292
3293        let git_commit = std::process::Command::new("git")
3294            .args([
3295                "-c",
3296                "user.name=pitchfork-test",
3297                "-c",
3298                "user.email=pitchfork-test@example.com",
3299                "add",
3300                "-A",
3301            ])
3302            .current_dir(&repo)
3303            .output()
3304            .expect("git add");
3305        assert!(git_commit.status.success());
3306
3307        let git_commit = std::process::Command::new("git")
3308            .args([
3309                "-c",
3310                "user.name=pitchfork-test",
3311                "-c",
3312                "user.email=pitchfork-test@example.com",
3313                "commit",
3314                "-m",
3315                "init",
3316            ])
3317            .current_dir(&repo)
3318            .output()
3319            .expect("git commit");
3320        assert!(
3321            git_commit.status.success(),
3322            "git commit failed: {:?}",
3323            git_commit
3324        );
3325
3326        let wt = temp.path().join("my-repo-feature");
3327        let git_wt = std::process::Command::new("git")
3328            .args(["worktree", "add", "-b", "feature-x", wt.to_str().unwrap()])
3329            .current_dir(&repo)
3330            .output()
3331            .expect("git worktree add");
3332        assert!(
3333            git_wt.status.success(),
3334            "git worktree add failed: {:?}",
3335            git_wt
3336        );
3337
3338        // Config in the main checkout.
3339        std::fs::write(
3340            repo.join("pitchfork.toml"),
3341            "[daemons.api]\nrun = \"echo main\"\n",
3342        )
3343        .unwrap();
3344        // Config in the linked worktree (different namespace: dir name).
3345        std::fs::write(
3346            wt.join("pitchfork.toml"),
3347            "[daemons.worker]\nrun = \"echo wt\"\n",
3348        )
3349        .unwrap();
3350
3351        super::invalidate_config_cache();
3352
3353        // Resolve from inside the worktree: both namespaces must be visible.
3354        let pt = PitchforkToml::all_merged_all_namespaces_from_blocking(&wt).unwrap();
3355
3356        let main_id = DaemonId::new("my-repo", "api");
3357        let wt_id = DaemonId::new("my-repo-feature", "worker");
3358        assert!(
3359            pt.daemons.contains_key(&main_id),
3360            "main checkout daemon missing"
3361        );
3362        assert!(pt.daemons.contains_key(&wt_id), "worktree daemon missing");
3363
3364        // Resolving from the main checkout must also see the worktree daemon.
3365        let pt_from_main = PitchforkToml::all_merged_all_namespaces_from_blocking(&repo).unwrap();
3366        assert!(pt_from_main.daemons.contains_key(&wt_id));
3367
3368        // Clean up.
3369        let _ = std::process::Command::new("git")
3370            .args(["worktree", "remove", "--force", wt.to_str().unwrap()])
3371            .current_dir(&repo)
3372            .output();
3373        super::invalidate_config_cache();
3374    }
3375
3376    #[test]
3377    fn test_adhoc_id_uses_invocation_directory_namespace() {
3378        let temp = tempfile::tempdir().unwrap();
3379        let project = temp.path().join("feature-tree");
3380        std::fs::create_dir(&project).unwrap();
3381        std::fs::write(
3382            project.join("pitchfork.toml"),
3383            "[daemons.other]\nrun = \"true\"\n",
3384        )
3385        .unwrap();
3386
3387        let id = PitchforkToml::resolve_id_allow_adhoc_from("api", &project).unwrap();
3388        assert_eq!(id, DaemonId::new("feature-tree", "api"));
3389        let qualified =
3390            PitchforkToml::resolve_id_allow_adhoc_from("explicit/api", &project).unwrap();
3391        assert_eq!(qualified, DaemonId::new("explicit", "api"));
3392    }
3393
3394    #[test]
3395    fn test_adhoc_id_falls_back_to_global_without_project_config() {
3396        let temp = tempfile::tempdir().unwrap();
3397        let id = PitchforkToml::resolve_id_allow_adhoc_from("api", temp.path()).unwrap();
3398        assert_eq!(id, DaemonId::new("global", "api"));
3399    }
3400}