Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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