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            // Preserve settings so read-modify-write (e.g. `settings set`, `proxy add`)
1184            // doesn't drop `[settings.*]`. Gate on is_empty to avoid a bare `[settings]`.
1185            let mut raw = PitchforkTomlRaw {
1186                namespace: self.namespace.clone(),
1187                settings: (!self.settings.is_empty()).then(|| self.settings.clone()),
1188                ..PitchforkTomlRaw::default()
1189            };
1190            for (id, daemon) in &self.daemons {
1191                if id.namespace() != config_namespace {
1192                    return Err(miette::miette!(
1193                        "cannot write daemon '{}' to {}: daemon belongs to namespace '{}' but file namespace is '{}'",
1194                        id,
1195                        path.display(),
1196                        id.namespace(),
1197                        config_namespace
1198                    ));
1199                }
1200                let port = daemon.port.as_ref();
1201                let raw_daemon = PitchforkTomlDaemonRaw {
1202                    run: daemon.run.clone(),
1203                    auto: daemon.auto.clone(),
1204                    cron: daemon.cron.clone(),
1205                    retry: daemon.retry,
1206                    ready_delay: daemon.ready_delay,
1207                    ready_output: daemon.ready_output.clone(),
1208                    ready_http: daemon.ready_http.clone(),
1209                    ready_port: daemon.ready_port,
1210                    ready_cmd: daemon.ready_cmd.clone(),
1211                    port: port.cloned(),
1212                    // Deprecated fields: written for backward compatibility with older pitchfork versions
1213                    expected_port: port.map(|p| p.expect.clone()).unwrap_or_default(),
1214                    auto_bump_port: port.filter(|p| p.auto_bump()).map(|_| true),
1215                    port_bump_attempts: port
1216                        .filter(|p| p.auto_bump())
1217                        .map(|p| p.max_bump_attempts()),
1218                    boot_start: daemon.boot_start,
1219                    // Preserve cross-namespace dependencies: use qualified ID if namespace differs,
1220                    // otherwise use short name
1221                    depends: daemon
1222                        .depends
1223                        .iter()
1224                        .map(|d| {
1225                            if d.namespace() == config_namespace {
1226                                d.name().to_string()
1227                            } else {
1228                                d.qualified()
1229                            }
1230                        })
1231                        .collect(),
1232                    watch: daemon.watch.clone(),
1233                    watch_mode: match daemon.watch_mode {
1234                        WatchMode::Native => None,
1235                        mode => Some(mode),
1236                    },
1237                    dir: daemon.dir.clone(),
1238                    env: daemon.env.clone(),
1239                    hooks: daemon.hooks.clone(),
1240                    mise: daemon.mise,
1241                    user: daemon.user.clone(),
1242                    memory_limit: daemon.memory_limit,
1243                    cpu_limit: daemon.cpu_limit,
1244                    stop_signal: daemon.stop_signal,
1245                    pty: daemon.pty,
1246                    time_retention: daemon.time_retention.clone(),
1247                    line_retention: daemon.line_retention,
1248                    archive_hook: daemon.archive_hook.clone(),
1249                };
1250                raw.daemons.insert(id.name().to_string(), raw_daemon);
1251            }
1252
1253            // Copy slugs registry to raw format
1254            for (slug, entry) in &self.slugs {
1255                raw.slugs.insert(
1256                    slug.clone(),
1257                    SlugEntryRaw {
1258                        dir: entry.dir.as_ref().map(|d| d.to_string_lossy().to_string()),
1259                        namespace: entry.namespace.clone(),
1260                        daemon: entry.daemon.clone(),
1261                    },
1262                );
1263            }
1264
1265            // Serialize groups back to raw format (preserve cross-namespace refs as qualified IDs)
1266            for (name, group) in &self.groups {
1267                let raw_daemons: Vec<String> = group
1268                    .daemons
1269                    .iter()
1270                    .map(|id| {
1271                        if id.namespace() == config_namespace {
1272                            id.name().to_string()
1273                        } else {
1274                            id.qualified()
1275                        }
1276                    })
1277                    .collect();
1278                raw.groups.insert(
1279                    name.clone(),
1280                    GroupEntryRaw {
1281                        daemons: raw_daemons,
1282                    },
1283                );
1284            }
1285
1286            // Copy namespaces registry to raw format
1287            for (name, entry) in &self.namespaces {
1288                raw.namespaces.insert(
1289                    name.clone(),
1290                    NamespaceEntryRaw {
1291                        dir: entry.dir.to_string_lossy().to_string(),
1292                    },
1293                );
1294            }
1295
1296            let raw_str = toml::to_string(&raw).map_err(|e| FileError::SerializeError {
1297                path: path.clone(),
1298                source: e,
1299            })?;
1300            xx::file::write(path, &raw_str).map_err(|e| FileError::WriteError {
1301                path: path.clone(),
1302                details: Some(e.to_string()),
1303            })?;
1304            Ok(())
1305        } else {
1306            Err(FileError::NoPath.into())
1307        }
1308    }
1309
1310    /// Simple merge without namespace re-qualification.
1311    /// Used primarily for testing or when merging configs from the same namespace.
1312    /// Since read() already qualifies daemon IDs with namespace, this just inserts them.
1313    /// Settings are also merged - later values override earlier ones.
1314    pub fn merge(&mut self, pt: Self) {
1315        for (id, d) in pt.daemons {
1316            self.daemons.insert(id, d);
1317        }
1318        // Merge slugs - pt's values override self's values
1319        for (slug, entry) in pt.slugs {
1320            self.slugs.insert(slug, entry);
1321        }
1322        // Merge groups - pt's values override self's values
1323        for (name, group) in pt.groups {
1324            self.groups.insert(name, group);
1325        }
1326        // Merge namespaces - pt's values override self's values
1327        for (name, entry) in pt.namespaces {
1328            self.namespaces.insert(name, entry);
1329        }
1330        // Merge settings - pt's values override self's values
1331        self.settings.merge_from(&pt.settings);
1332    }
1333
1334    /// Read the global slug registry from the user-level global config.
1335    ///
1336    /// Returns a map of slug → SlugEntry from `[slugs]` in
1337    /// `~/.config/pitchfork/config.toml`.
1338    pub fn read_global_slugs() -> IndexMap<String, SlugEntry> {
1339        match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1340            Ok(pt) => pt.slugs,
1341            Err(_) => IndexMap::new(),
1342        }
1343    }
1344
1345    /// Find the registered slug for a daemon using a pre-loaded slug registry.
1346    pub fn find_slug_for_daemon_in_registry(
1347        daemon_id: &DaemonId,
1348        global_slugs: &IndexMap<String, SlugEntry>,
1349    ) -> Option<String> {
1350        global_slugs
1351            .iter()
1352            .find(|(slug, entry)| {
1353                let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1354                if daemon_id.name() != daemon_name {
1355                    return false;
1356                }
1357
1358                match entry.resolve_namespace() {
1359                    Some(namespace) => daemon_id.namespace() == namespace,
1360                    None => false,
1361                }
1362            })
1363            .map(|(slug, _)| slug.clone())
1364    }
1365
1366    /// Check if a slug is registered in the global config's `[slugs]` section.
1367    #[allow(dead_code)]
1368    pub fn is_slug_registered(slug: &str) -> bool {
1369        Self::read_global_slugs().contains_key(slug)
1370    }
1371
1372    /// Add a slug entry to the global config's `[slugs]` section using namespace instead of dir.
1373    ///
1374    /// Reads the global config, adds/updates the slug entry, and writes it back.
1375    /// If `namespace` is provided but not yet registered in `[namespaces]`,
1376    /// also registers it at `dir` (acquired via `resolve_dir()` on the slug entry).
1377    pub fn add_slug_with_namespace(
1378        slug: &str,
1379        namespace: Option<&str>,
1380        daemon: Option<&str>,
1381    ) -> Result<()> {
1382        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1383
1384        // Ensure the config directory exists
1385        if let Some(parent) = global_path.parent() {
1386            std::fs::create_dir_all(parent).map_err(|e| {
1387                miette::miette!(
1388                    "Failed to create config directory {}: {e}",
1389                    parent.display()
1390                )
1391            })?;
1392        }
1393
1394        let _lock = xx::fslock::get(global_path, false)
1395            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1396
1397        let mut pt = if global_path.exists() {
1398            let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1399                path: global_path.to_path_buf(),
1400                source: e,
1401            })?;
1402            Self::parse_str(&raw, global_path)?
1403        } else {
1404            Self::new(global_path.to_path_buf())
1405        };
1406
1407        // If caller provided a namespace that isn't yet registered,
1408        // auto-register it at the directory we can resolve.
1409        // Falls back to CWD if the slug dir cannot be resolved.
1410        if let Some(ns) = namespace {
1411            if !pt.namespaces.contains_key(ns) {
1412                let dir = pt
1413                    .slugs
1414                    .get(slug)
1415                    .and_then(|e| e.resolve_dir())
1416                    .or_else(|| namespace.and_then(|_| env::CWD.as_path().canonicalize().ok()));
1417                if let Some(ref d) = dir {
1418                    pt.namespaces
1419                        .insert(ns.to_string(), NamespaceEntry { dir: d.clone() });
1420                }
1421            }
1422        }
1423
1424        pt.slugs.insert(
1425            slug.to_string(),
1426            SlugEntry {
1427                dir: None,
1428                namespace: namespace.map(str::to_string),
1429                daemon: daemon.map(str::to_string),
1430            },
1431        );
1432        pt.write_unlocked()?;
1433        crate::proxy::hosts::sync_hosts_from_settings();
1434        Ok(())
1435    }
1436
1437    /// Remove a slug from the global config's `[slugs]` section.
1438    pub fn remove_slug(slug: &str) -> Result<bool> {
1439        let global_path = &*env::PITCHFORK_GLOBAL_CONFIG_USER;
1440        if !global_path.exists() {
1441            return Ok(false);
1442        }
1443
1444        let _lock = xx::fslock::get(global_path, false)
1445            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1446
1447        let raw = std::fs::read_to_string(global_path).map_err(|e| FileError::ReadError {
1448            path: global_path.to_path_buf(),
1449            source: e,
1450        })?;
1451        let mut pt = Self::parse_str(&raw, global_path)?;
1452
1453        let removed = pt.slugs.shift_remove(slug).is_some();
1454        if removed {
1455            pt.write_unlocked()?;
1456            crate::proxy::hosts::sync_hosts_from_settings();
1457        }
1458        Ok(removed)
1459    }
1460    /// Returns a map of namespace → NamespaceEntry from `[namespaces]` in
1461    /// `~/.config/pitchfork/config.toml`.
1462    pub fn read_global_namespaces() -> IndexMap<String, NamespaceEntry> {
1463        match Self::read(&*env::PITCHFORK_GLOBAL_CONFIG_USER) {
1464            Ok(pt) => pt.namespaces,
1465            Err(_) => IndexMap::new(),
1466        }
1467    }
1468
1469    /// Add a namespace entry to the global config's `[namespaces]` section.
1470    ///
1471    /// Reads the global config, adds/updates the namespace entry, and writes it back.
1472    pub fn register_namespace(name: &str, dir: &str) -> crate::Result<()> {
1473        let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1474
1475        // Ensure the config directory exists
1476        if let Some(parent) = global_path.parent() {
1477            std::fs::create_dir_all(parent).map_err(|e| {
1478                miette::miette!(
1479                    "Failed to create config directory {}: {e}",
1480                    parent.display()
1481                )
1482            })?;
1483        }
1484
1485        let _lock = xx::fslock::get(global_path, false)
1486            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1487
1488        let mut pt = if global_path.exists() {
1489            let raw = std::fs::read_to_string(global_path).map_err(|e| {
1490                crate::error::FileError::ReadError {
1491                    path: global_path.to_path_buf(),
1492                    source: e,
1493                }
1494            })?;
1495            Self::parse_str(&raw, global_path)?
1496        } else {
1497            Self::new(global_path.to_path_buf())
1498        };
1499
1500        pt.namespaces.insert(
1501            name.to_string(),
1502            NamespaceEntry {
1503                dir: PathBuf::from(dir),
1504            },
1505        );
1506        pt.write_unlocked()?;
1507        Ok(())
1508    }
1509
1510    /// Remove a namespace from the global config's `[namespaces]` section.
1511    pub fn remove_namespace(name: &str) -> crate::Result<bool> {
1512        let global_path = &*crate::env::PITCHFORK_GLOBAL_CONFIG_USER;
1513        if !global_path.exists() {
1514            return Ok(false);
1515        }
1516
1517        let _lock = xx::fslock::get(global_path, false)
1518            .wrap_err_with(|| format!("failed to acquire lock on {}", global_path.display()))?;
1519
1520        let raw = std::fs::read_to_string(global_path).map_err(|e| {
1521            crate::error::FileError::ReadError {
1522                path: global_path.to_path_buf(),
1523                source: e,
1524            }
1525        })?;
1526        let mut pt = Self::parse_str(&raw, global_path)?;
1527
1528        let removed = pt.namespaces.shift_remove(name).is_some();
1529        if removed {
1530            pt.write_unlocked()?;
1531        }
1532        Ok(removed)
1533    }
1534}
1535
1536/// Configuration for a single daemon (internal representation with DaemonId)
1537#[derive(Debug, Clone, JsonSchema, Default)]
1538pub struct PitchforkTomlDaemon {
1539    /// The command to run. Prepend with 'exec' to avoid shell process overhead.
1540    #[schemars(example = example_run_command())]
1541    pub run: String,
1542    /// Automatic start/stop behavior based on shell hooks
1543    #[schemars(default)]
1544    pub auto: Vec<PitchforkTomlAuto>,
1545    /// Cron scheduling configuration for periodic execution
1546    pub cron: Option<PitchforkTomlCron>,
1547    /// Number of times to retry if the daemon fails.
1548    /// Can be a number (e.g., `3`) or `true` for infinite retries.
1549    #[schemars(default)]
1550    pub retry: Retry,
1551    /// Delay in seconds before considering the daemon ready
1552    pub ready_delay: Option<u64>,
1553    /// Regex pattern to match in ANSI-stripped stdout/stderr to determine readiness
1554    pub ready_output: Option<String>,
1555    /// HTTP URL to poll for readiness. Accepts any 2xx response by default, or configured statuses.
1556    pub ready_http: Option<ReadyHttp>,
1557    /// TCP port to check for readiness (connection success = ready)
1558    #[schemars(range(min = 1, max = 65535))]
1559    pub ready_port: Option<u16>,
1560    /// Shell command to poll for readiness (exit code 0 = ready)
1561    pub ready_cmd: Option<String>,
1562    /// Port configuration: expected ports and auto-bump settings
1563    pub port: Option<PortConfig>,
1564    /// Whether to start this daemon automatically on system boot
1565    pub boot_start: Option<bool>,
1566    /// List of daemon IDs that must be started before this one
1567    #[schemars(default)]
1568    pub depends: Vec<DaemonId>,
1569    /// File patterns to watch for changes
1570    #[schemars(default)]
1571    pub watch: Vec<String>,
1572    /// File watching backend mode.
1573    ///
1574    /// - `native`: use platform-native notifications (default)
1575    /// - `poll`: use polling-based watcher
1576    /// - `auto`: prefer native, fall back to polling if native watch fails
1577    #[schemars(default)]
1578    pub watch_mode: WatchMode,
1579    /// Working directory for the daemon. Relative paths are resolved from the pitchfork.toml location.
1580    pub dir: Option<String>,
1581    /// Environment variables to set for the daemon process
1582    pub env: Option<IndexMap<String, String>>,
1583    /// Lifecycle hooks (on_ready, on_fail, on_retry)
1584    pub hooks: Option<PitchforkTomlHooks>,
1585    /// Wrap this daemon's command with `mise x --` for tool/env setup.
1586    /// Overrides the global `settings.general.mise` when set.
1587    pub mise: Option<bool>,
1588    /// Unix user to run this daemon as. Overrides `settings.supervisor.user` when set.
1589    pub user: Option<String>,
1590    /// Memory limit for the daemon process (e.g. "50MB", "1GiB").
1591    /// The supervisor periodically monitors RSS and kills the process if it exceeds the limit.
1592    pub memory_limit: Option<MemoryLimit>,
1593    /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores).
1594    /// The supervisor periodically monitors CPU usage and kills the process if it exceeds the limit.
1595    pub cpu_limit: Option<CpuLimit>,
1596    /// Stop signal and optional per-daemon timeout. Accepts a signal name string
1597    /// or `{ signal = "...", timeout = "..." }` object.
1598    pub stop_signal: Option<StopConfig>,
1599    /// Allocate a pseudo-terminal for the daemon process.
1600    pub pty: Option<bool>,
1601    /// Maximum age of log entries to keep (e.g. "7d", "30d").
1602    /// Overrides the global `settings.logs.time_retention` when set.
1603    pub time_retention: Option<String>,
1604    /// Maximum number of log entries to keep per daemon.
1605    /// Overrides the global `settings.logs.line_retention` when set.
1606    pub line_retention: Option<i64>,
1607    /// Archive hook command invoked before retention prunes this daemon's logs.
1608    /// Overrides the global `settings.logs.archive_hook.command` when set.
1609    pub archive_hook: Option<String>,
1610    #[schemars(skip)]
1611    pub path: Option<PathBuf>,
1612}
1613
1614impl PitchforkTomlDaemon {
1615    /// Build RunOptions from this daemon configuration.
1616    ///
1617    /// Carries over all config fields and resolves the working directory.
1618    /// Callers can override specific fields on the returned value.
1619    pub fn to_run_options(
1620        &self,
1621        id: &crate::daemon_id::DaemonId,
1622        cmd: Vec<String>,
1623    ) -> crate::daemon::RunOptions {
1624        use crate::daemon::RunOptions;
1625
1626        let dir = crate::ipc::batch::resolve_daemon_dir(self.dir.as_deref(), self.path.as_deref());
1627        let slug = crate::pitchfork_toml::PitchforkToml::read_global_slugs()
1628            .into_iter()
1629            .find(|(slug, entry)| {
1630                let daemon_name = entry.daemon.as_deref().unwrap_or(slug);
1631                if daemon_name != id.name() {
1632                    return false;
1633                }
1634
1635                match entry.resolve_namespace() {
1636                    Some(namespace) => namespace == id.namespace(),
1637                    None => false,
1638                }
1639            })
1640            .map(|(slug, _)| slug);
1641
1642        RunOptions {
1643            id: id.clone(),
1644            cmd,
1645            run: Some(self.run.clone()),
1646            force: false,
1647            shell_pid: None,
1648            dir: Dir(dir),
1649            autostop: self.auto.contains(&PitchforkTomlAuto::Stop),
1650            cron_schedule: self.cron.as_ref().map(|c| c.schedule.clone()),
1651            cron_retrigger: self.cron.as_ref().map(|c| c.retrigger),
1652            cron_immediate: self.cron.as_ref().map(|c| c.immediate),
1653            retry: self.retry,
1654            retry_count: 0,
1655            ready_delay: self.ready_delay,
1656            ready_output: self.ready_output.clone(),
1657            ready_http: self.ready_http.clone(),
1658            ready_port: self.ready_port,
1659            ready_cmd: self.ready_cmd.clone(),
1660            port: self.port.clone(),
1661            wait_ready: false,
1662            depends: self.depends.clone(),
1663            env: self.env.clone(),
1664            watch: self.watch.clone(),
1665            watch_mode: self.watch_mode,
1666            watch_base_dir: Some(crate::ipc::batch::resolve_config_base_dir(
1667                self.path.as_deref(),
1668            )),
1669            mise: self.mise,
1670            slug,
1671            proxy: None,
1672            user: self.user.clone(),
1673            memory_limit: self.memory_limit,
1674            cpu_limit: self.cpu_limit,
1675            stop_signal: self.stop_signal,
1676            archive_hook: self.archive_hook.clone(),
1677            on_output_hook: self.hooks.as_ref().and_then(|h| h.on_output.clone()),
1678            pty: self.pty,
1679        }
1680    }
1681}
1682fn example_run_command() -> &'static str {
1683    "exec node server.js"
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689    use std::path::Path;
1690
1691    #[test]
1692    fn test_daemon_user_parses_and_flows_to_run_options() {
1693        let pt = PitchforkToml::parse_str(
1694            r#"
1695[daemons.api]
1696run = "node server.js"
1697user = "postgres"
1698"#,
1699            Path::new("/tmp/my-project/pitchfork.toml"),
1700        )
1701        .unwrap();
1702
1703        let id = DaemonId::new("my-project", "api");
1704        let daemon = pt.daemons.get(&id).unwrap();
1705        assert_eq!(daemon.user.as_deref(), Some("postgres"));
1706
1707        let opts = daemon.to_run_options(&id, vec!["node".to_string(), "server.js".to_string()]);
1708        assert_eq!(opts.user.as_deref(), Some("postgres"));
1709    }
1710
1711    #[test]
1712    fn test_daemon_user_write_roundtrip() {
1713        let temp = tempfile::tempdir().unwrap();
1714        let path = temp.path().join("pitchfork.toml");
1715        let mut pt = PitchforkToml::new(path.clone());
1716        pt.namespace = Some("test-project".to_string());
1717        pt.daemons.insert(
1718            DaemonId::new("test-project", "api"),
1719            PitchforkTomlDaemon {
1720                run: "node server.js".to_string(),
1721                user: Some("postgres".to_string()),
1722                ..PitchforkTomlDaemon::default()
1723            },
1724        );
1725
1726        pt.write().unwrap();
1727
1728        let raw = std::fs::read_to_string(&path).unwrap();
1729        assert!(raw.contains("user = \"postgres\""));
1730
1731        let parsed = PitchforkToml::read(&path).unwrap();
1732        let daemon = parsed
1733            .daemons
1734            .get(&DaemonId::new("test-project", "api"))
1735            .unwrap();
1736        assert_eq!(daemon.user.as_deref(), Some("postgres"));
1737    }
1738
1739    #[test]
1740    fn test_settings_write_roundtrip() {
1741        let temp = tempfile::tempdir().unwrap();
1742        let path = temp.path().join("pitchfork.toml");
1743        let mut pt = PitchforkToml::new(path.clone());
1744        pt.namespace = Some("test-project".to_string());
1745        pt.settings.web.auto_start = Some(true);
1746        pt.settings.general.log_level = Some("debug".to_string());
1747
1748        pt.write().unwrap();
1749
1750        let raw = std::fs::read_to_string(&path).unwrap();
1751        assert!(
1752            raw.contains("[settings.web]"),
1753            "settings.web section should be written, got:\n{raw}"
1754        );
1755        assert!(raw.contains("auto_start = true"));
1756        assert!(raw.contains("log_level = \"debug\""));
1757
1758        let parsed = PitchforkToml::read(&path).unwrap();
1759        assert_eq!(parsed.settings.web.auto_start, Some(true));
1760        assert_eq!(parsed.settings.general.log_level.as_deref(), Some("debug"));
1761    }
1762
1763    #[test]
1764    fn test_settings_preserved_on_unrelated_write() {
1765        // Regression test for https://github.com/jdx/pitchfork/discussions/574
1766        // A read-modify-write of slugs/namespaces must not drop existing [settings].
1767        let temp = tempfile::tempdir().unwrap();
1768        let path = temp.path().join("pitchfork.toml");
1769        std::fs::write(&path, "[settings.web]\nauto_start = true\n").unwrap();
1770
1771        let mut pt = PitchforkToml::read(&path).unwrap();
1772        pt.slugs.insert(
1773            "api".to_string(),
1774            SlugEntry {
1775                dir: None,
1776                namespace: Some("myproject".to_string()),
1777                daemon: None,
1778            },
1779        );
1780        pt.namespaces.insert(
1781            "myproject".to_string(),
1782            NamespaceEntry {
1783                dir: PathBuf::from("/tmp/myproject"),
1784            },
1785        );
1786        pt.write().unwrap();
1787
1788        let raw = std::fs::read_to_string(&path).unwrap();
1789        assert!(
1790            raw.contains("[settings.web]"),
1791            "existing settings must be preserved, got:\n{raw}"
1792        );
1793        assert!(raw.contains("auto_start = true"));
1794        assert!(raw.contains("[slugs.api]"));
1795
1796        let parsed = PitchforkToml::read(&path).unwrap();
1797        assert_eq!(parsed.settings.web.auto_start, Some(true));
1798        assert!(parsed.slugs.contains_key("api"));
1799    }
1800}