Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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