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