Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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