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