Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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