Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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