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