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