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