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