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