Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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