Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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