Skip to main content

pitchfork_cli/
pitchfork_toml.rs

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