Skip to main content

pitchfork_cli/tui/
app.rs

1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::daemon_list::{DaemonListEntry, NamespaceFilter};
5use crate::ipc::client::IpcClient;
6use crate::log_store::LogStore;
7use crate::log_store::sqlite::LOG_STORE;
8use crate::pitchfork_toml::{
9    CronRetrigger, HealthCmd, HealthHttp, HealthPort, PitchforkToml, PitchforkTomlAuto,
10    PitchforkTomlCron, PitchforkTomlDaemon, ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry,
11    RunCommand, namespace_from_path,
12};
13use crate::procs::{PROCS, ProcessStats};
14use crate::settings::settings;
15use fuzzy_matcher::FuzzyMatcher;
16use fuzzy_matcher::skim::SkimMatcherV2;
17use listeners::Listener;
18use std::collections::{HashMap, HashSet, VecDeque};
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Instant;
22
23/// Convert character index to byte index for UTF-8 strings
24fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
25    s.char_indices()
26        .nth(char_idx)
27        .map(|(i, _)| i)
28        .unwrap_or(s.len())
29}
30
31/// A snapshot of stats at a point in time
32#[derive(Debug, Clone, Copy)]
33pub struct StatsSnapshot {
34    pub cpu_percent: f32,
35    pub memory_bytes: u64,
36    pub disk_read_bytes: u64,
37    pub disk_write_bytes: u64,
38}
39
40impl From<&ProcessStats> for StatsSnapshot {
41    fn from(stats: &ProcessStats) -> Self {
42        Self {
43            cpu_percent: stats.cpu_percent,
44            memory_bytes: stats.memory_bytes,
45            disk_read_bytes: stats.disk_read_bytes,
46            disk_write_bytes: stats.disk_write_bytes,
47        }
48    }
49}
50
51/// Historical stats for a daemon
52#[derive(Debug, Clone, Default)]
53pub struct StatsHistory {
54    pub samples: VecDeque<StatsSnapshot>,
55}
56
57impl StatsHistory {
58    pub fn push(&mut self, snapshot: StatsSnapshot) {
59        self.samples.push_back(snapshot);
60        let max_history = settings().tui.stat_history.max(1) as usize;
61        while self.samples.len() > max_history {
62            self.samples.pop_front();
63        }
64    }
65
66    pub fn cpu_values(&self) -> Vec<f32> {
67        self.samples.iter().map(|s| s.cpu_percent).collect()
68    }
69
70    pub fn memory_values(&self) -> Vec<u64> {
71        self.samples.iter().map(|s| s.memory_bytes).collect()
72    }
73
74    pub fn disk_read_values(&self) -> Vec<u64> {
75        self.samples.iter().map(|s| s.disk_read_bytes).collect()
76    }
77
78    pub fn disk_write_values(&self) -> Vec<u64> {
79        self.samples.iter().map(|s| s.disk_write_bytes).collect()
80    }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum View {
85    Dashboard,
86    Logs,
87    Network,
88    Help,
89    Confirm,
90    Details,
91    ConfigEditor,
92    ConfigFileSelect,
93}
94
95/// Edit mode for the config editor
96#[derive(Debug, Clone, PartialEq)]
97pub enum EditMode {
98    Create,
99    Edit { original_id: String },
100}
101
102/// Form field value types
103#[derive(Debug, Clone)]
104pub enum FormFieldValue {
105    Text(String),
106    OptionalText(Option<String>),
107    Number(u32),
108    OptionalNumber(Option<u64>),
109    OptionalPort(Option<ReadyPort>),
110    #[allow(dead_code)]
111    Boolean(bool),
112    OptionalBoolean(Option<bool>),
113    AutoBehavior(Vec<PitchforkTomlAuto>),
114    Retrigger(CronRetrigger),
115    StringList(Vec<String>),
116}
117
118/// A form field with metadata
119#[derive(Debug, Clone)]
120pub struct FormField {
121    pub name: &'static str,
122    pub label: &'static str,
123    pub value: FormFieldValue,
124    pub required: bool,
125    #[allow(dead_code)]
126    pub help_text: &'static str,
127    pub error: Option<String>,
128    pub editing: bool,
129    pub cursor: usize,
130}
131
132impl FormField {
133    fn text(name: &'static str, label: &'static str, help: &'static str, required: bool) -> Self {
134        Self {
135            name,
136            label,
137            value: FormFieldValue::Text(String::new()),
138            required,
139            help_text: help,
140            error: None,
141            editing: false,
142            cursor: 0,
143        }
144    }
145
146    fn optional_text(name: &'static str, label: &'static str, help: &'static str) -> Self {
147        Self {
148            name,
149            label,
150            value: FormFieldValue::OptionalText(None),
151            required: false,
152            help_text: help,
153            error: None,
154            editing: false,
155            cursor: 0,
156        }
157    }
158
159    fn number(name: &'static str, label: &'static str, help: &'static str, default: u32) -> Self {
160        Self {
161            name,
162            label,
163            value: FormFieldValue::Number(default),
164            required: false,
165            help_text: help,
166            error: None,
167            editing: false,
168            cursor: 0,
169        }
170    }
171
172    fn optional_number(name: &'static str, label: &'static str, help: &'static str) -> Self {
173        Self {
174            name,
175            label,
176            value: FormFieldValue::OptionalNumber(None),
177            required: false,
178            help_text: help,
179            error: None,
180            editing: false,
181            cursor: 0,
182        }
183    }
184
185    fn optional_port(name: &'static str, label: &'static str, help: &'static str) -> Self {
186        Self {
187            name,
188            label,
189            value: FormFieldValue::OptionalPort(None),
190            required: false,
191            help_text: help,
192            error: None,
193            editing: false,
194            cursor: 0,
195        }
196    }
197
198    fn optional_bool(name: &'static str, label: &'static str, help: &'static str) -> Self {
199        Self {
200            name,
201            label,
202            value: FormFieldValue::OptionalBoolean(None),
203            required: false,
204            help_text: help,
205            error: None,
206            editing: false,
207            cursor: 0,
208        }
209    }
210
211    fn auto_behavior(name: &'static str, label: &'static str, help: &'static str) -> Self {
212        Self {
213            name,
214            label,
215            value: FormFieldValue::AutoBehavior(vec![]),
216            required: false,
217            help_text: help,
218            error: None,
219            editing: false,
220            cursor: 0,
221        }
222    }
223
224    fn retrigger(name: &'static str, label: &'static str, help: &'static str) -> Self {
225        Self {
226            name,
227            label,
228            value: FormFieldValue::Retrigger(CronRetrigger::Finish),
229            required: false,
230            help_text: help,
231            error: None,
232            editing: false,
233            cursor: 0,
234        }
235    }
236
237    fn string_list(name: &'static str, label: &'static str, help: &'static str) -> Self {
238        Self {
239            name,
240            label,
241            value: FormFieldValue::StringList(vec![]),
242            required: false,
243            help_text: help,
244            error: None,
245            editing: false,
246            cursor: 0,
247        }
248    }
249
250    pub fn get_text(&self) -> String {
251        match &self.value {
252            FormFieldValue::Text(s) => s.clone(),
253            FormFieldValue::OptionalText(Some(s)) => s.clone(),
254            FormFieldValue::OptionalText(None) => String::new(),
255            FormFieldValue::Number(n) => n.to_string(),
256            FormFieldValue::OptionalNumber(Some(n)) => n.to_string(),
257            FormFieldValue::OptionalNumber(None) => String::new(),
258            FormFieldValue::OptionalPort(Some(p)) => p.to_string(),
259            FormFieldValue::OptionalPort(None) => String::new(),
260            FormFieldValue::StringList(v) => v.join(", "),
261            _ => String::new(),
262        }
263    }
264
265    pub fn set_text(&mut self, text: String) {
266        // Any edit invalidates a previous validation error; arms below
267        // re-set it for input that fails to parse.
268        self.error = None;
269        match &mut self.value {
270            FormFieldValue::Text(s) => *s = text,
271            FormFieldValue::OptionalText(opt) => {
272                *opt = if text.is_empty() { None } else { Some(text) };
273            }
274            FormFieldValue::Number(n) => {
275                let trimmed = text.trim();
276                if trimmed.is_empty() {
277                    *n = 0;
278                    self.error = None;
279                } else {
280                    match trimmed.parse() {
281                        Ok(value) => {
282                            *n = value;
283                            self.error = None;
284                        }
285                        Err(_) => {
286                            *n = 0;
287                            self.error = Some("Invalid number".to_string());
288                        }
289                    }
290                }
291            }
292            FormFieldValue::OptionalNumber(opt) => {
293                *opt = text.parse().ok();
294            }
295            FormFieldValue::OptionalPort(opt) => {
296                let trimmed = text.trim();
297                if trimmed.is_empty() {
298                    *opt = None;
299                    self.error = None;
300                } else {
301                    match trimmed.parse() {
302                        Ok(value) => {
303                            *opt = Some(value);
304                            self.error = None;
305                        }
306                        Err(e) => {
307                            *opt = None;
308                            self.error = Some(e);
309                        }
310                    }
311                }
312            }
313            FormFieldValue::StringList(v) => {
314                *v = text
315                    .split(',')
316                    .map(|s| s.trim().to_string())
317                    .filter(|s| !s.is_empty())
318                    .collect();
319            }
320            _ => {}
321        }
322    }
323
324    pub fn is_text_editable(&self) -> bool {
325        matches!(
326            self.value,
327            FormFieldValue::Text(_)
328                | FormFieldValue::OptionalText(_)
329                | FormFieldValue::Number(_)
330                | FormFieldValue::OptionalNumber(_)
331                | FormFieldValue::OptionalPort(_)
332                | FormFieldValue::StringList(_)
333        )
334    }
335}
336
337/// An argv `run` as text for the single-line editor field, read back by
338/// [`edit_text_to_argv`].
339///
340/// POSIX shell quoting, except on Windows: there a backslash separates path
341/// components, so it is never an escape, and quoting uses single or double
342/// quotes only.
343fn argv_to_edit_text(argv: &[String]) -> String {
344    if cfg!(windows) {
345        argv.iter()
346            .map(|arg| windows_edit_quote(arg))
347            .collect::<Vec<_>>()
348            .join(" ")
349    } else {
350        shell_words::join(argv)
351    }
352}
353
354/// Why the editor's text cannot be saved as an argv command, if it cannot.
355fn argv_edit_error(text: &str) -> Option<String> {
356    match edit_text_to_argv(text).as_deref() {
357        Ok([]) => Some("Required".to_string()),
358        Ok([program, ..]) if program.is_empty() => Some("The program name is empty".to_string()),
359        Ok([program, ..]) if program == "exec" => {
360            Some("Remove exec: this command starts the program without a shell".to_string())
361        }
362        Ok(_) => None,
363        Err(_) => Some("Unbalanced quotes".to_string()),
364    }
365}
366
367/// Split the editor's text back into arguments; `Err` for an unclosed quote.
368fn edit_text_to_argv(text: &str) -> Result<Vec<String>, String> {
369    if cfg!(windows) {
370        windows_edit_split(text)
371    } else {
372        shell_words::split(text).map_err(|e| e.to_string())
373    }
374}
375
376/// Quote `arg` so [`windows_edit_split`] reads it back unchanged.
377fn windows_edit_quote(arg: &str) -> String {
378    if !arg.is_empty() && !arg.contains(|c: char| c.is_whitespace() || c == '\'' || c == '"') {
379        return arg.to_string();
380    }
381    // Quoted runs join into one word, so a single quote can sit in a
382    // double-quoted run next to single-quoted text: it's -> 'it'"'"'s'.
383    let mut quoted = String::from("'");
384    for c in arg.chars() {
385        if c == '\'' {
386            quoted.push_str("'\"'\"'");
387        } else {
388            quoted.push(c);
389        }
390    }
391    quoted.push('\'');
392    quoted
393}
394
395/// Split on whitespace, with `'...'` and `"..."` taken literally, and
396/// backslashes kept as they are.
397fn windows_edit_split(text: &str) -> Result<Vec<String>, String> {
398    let mut words = Vec::new();
399    let mut word = String::new();
400    // Whether a word has begun: `''` is an empty argument, not nothing.
401    let mut in_word = false;
402    let mut chars = text.chars();
403    while let Some(c) = chars.next() {
404        match c {
405            '\'' | '"' => {
406                in_word = true;
407                loop {
408                    match chars.next() {
409                        Some(q) if q == c => break,
410                        Some(other) => word.push(other),
411                        None => return Err("unclosed quote".to_string()),
412                    }
413                }
414            }
415            c if c.is_whitespace() => {
416                if in_word {
417                    words.push(std::mem::take(&mut word));
418                    in_word = false;
419                }
420            }
421            c => {
422                in_word = true;
423                word.push(c);
424            }
425        }
426    }
427    if in_word {
428        words.push(word);
429    }
430    Ok(words)
431}
432
433/// State for the daemon config editor
434#[derive(Debug, Clone)]
435pub struct EditorState {
436    pub mode: EditMode,
437    pub daemon_id: String,
438    pub daemon_id_editing: bool,
439    pub daemon_id_cursor: usize,
440    pub daemon_id_error: Option<String>,
441    pub fields: Vec<FormField>,
442    pub focused_field: usize,
443    pub config_path: PathBuf,
444    pub unsaved_changes: bool,
445    #[allow(dead_code)]
446    pub scroll_offset: usize,
447    /// Preserved config field for ready_cmd (no form UI yet)
448    preserved_ready_cmd: Option<ReadyCmd>,
449    /// Preserved `oneshot` flag (no form UI yet). Without this, saving a
450    /// oneshot daemon through the editor would silently turn it back into a
451    /// long-running service.
452    preserved_oneshot: Option<bool>,
453    /// The argv form of `run`, which the single-line field cannot hold. Saving
454    /// with the field unchanged keeps it; editing the text makes it a shell
455    /// command line.
456    preserved_run_argv: Option<RunCommand>,
457    /// Preserved ready_http statuses (no form UI yet)
458    preserved_ready_http_status: Option<Vec<u16>>,
459    /// Preserved ready_http timeout (no form UI yet)
460    preserved_ready_http_timeout: Option<std::time::Duration>,
461    /// Preserved ready_output timeout (no form UI yet)
462    preserved_ready_output_timeout: Option<std::time::Duration>,
463    /// Preserved health_cmd parameters (only `run` has form UI)
464    preserved_health_cmd: Option<HealthCmd>,
465    /// Preserved health_http parameters (only `url` has form UI)
466    preserved_health_http: Option<HealthHttp>,
467    /// Preserved health_port parameters (only the port has form UI)
468    preserved_health_port: Option<HealthPort>,
469}
470
471impl EditorState {
472    pub fn new_create(config_path: PathBuf) -> Self {
473        Self {
474            mode: EditMode::Create,
475            daemon_id: String::new(),
476            daemon_id_editing: true,
477            daemon_id_cursor: 0,
478            daemon_id_error: None,
479            fields: Self::default_fields(),
480            focused_field: 0,
481            config_path,
482            unsaved_changes: false,
483            scroll_offset: 0,
484            preserved_ready_cmd: None,
485            preserved_oneshot: None,
486            preserved_run_argv: None,
487            preserved_ready_http_status: None,
488            preserved_ready_http_timeout: None,
489            preserved_ready_output_timeout: None,
490            preserved_health_cmd: None,
491            preserved_health_http: None,
492            preserved_health_port: None,
493        }
494    }
495
496    pub fn new_edit(daemon_id: String, config: &PitchforkTomlDaemon, config_path: PathBuf) -> Self {
497        Self {
498            mode: EditMode::Edit {
499                original_id: daemon_id.clone(),
500            },
501            daemon_id,
502            daemon_id_editing: false,
503            daemon_id_cursor: 0,
504            daemon_id_error: None,
505            fields: Self::fields_from_config(config),
506            focused_field: 0,
507            config_path,
508            unsaved_changes: false,
509            scroll_offset: 0,
510            preserved_ready_cmd: config.ready_cmd.clone(),
511            preserved_oneshot: config.oneshot,
512            preserved_run_argv: config.run.is_argv().then(|| config.run.clone()),
513            preserved_ready_http_status: config
514                .ready_http
515                .as_ref()
516                .and_then(|h| (!h.status.is_empty()).then(|| h.status.clone())),
517            preserved_ready_http_timeout: config.ready_http.as_ref().and_then(|h| h.timeout),
518            preserved_ready_output_timeout: config.ready_output.as_ref().and_then(|o| o.timeout),
519            preserved_health_cmd: config.health_cmd.clone(),
520            preserved_health_http: config.health_http.clone(),
521            preserved_health_port: config.health_port.clone(),
522        }
523    }
524
525    fn default_fields() -> Vec<FormField> {
526        vec![
527            FormField::text(
528                "run",
529                "Run Command",
530                "Command to execute. Prepend 'exec' to avoid shell overhead.",
531                true,
532            ),
533            FormField::optional_text(
534                "dir",
535                "Working Directory",
536                "Working directory for the daemon. Relative to pitchfork.toml location.",
537            ),
538            FormField::string_list(
539                "env",
540                "Environment Variables",
541                "Comma-separated KEY=VALUE pairs (e.g., NODE_ENV=dev, PORT=3000).",
542            ),
543            FormField::auto_behavior(
544                "auto",
545                "Auto Behavior",
546                "Auto start/stop based on directory hooks.",
547            ),
548            FormField::number(
549                "retry",
550                "Retry Count",
551                "Number of retry attempts on failure (0 = no retries).",
552                0,
553            ),
554            FormField::optional_number(
555                "ready_delay",
556                "Ready Delay (ms)",
557                "Milliseconds to wait before considering daemon ready.",
558            ),
559            FormField::optional_text(
560                "ready_output",
561                "Ready Output Pattern",
562                "Regex pattern in ANSI-stripped stdout/stderr indicating readiness.",
563            ),
564            FormField::optional_text(
565                "ready_http",
566                "Ready HTTP URL",
567                "HTTP URL to poll for readiness (expects 2xx).",
568            ),
569            FormField::optional_port(
570                "ready_port",
571                "Ready Port",
572                "TCP port to check for readiness (1-65535).",
573            ),
574            FormField::optional_text(
575                "health_cmd",
576                "Health Command",
577                "Shell command that must exit 0 for the daemon to stay healthy.",
578            ),
579            FormField::optional_text(
580                "health_http",
581                "Health HTTP URL",
582                "HTTP URL to poll for health (expects 2xx).",
583            ),
584            FormField::optional_port(
585                "health_port",
586                "Health Port",
587                "TCP port that must accept connections for the daemon to stay healthy (1-65535).",
588            ),
589            FormField::optional_bool(
590                "boot_start",
591                "Start on Boot",
592                "Automatically start this daemon on system boot.",
593            ),
594            FormField::string_list(
595                "depends",
596                "Dependencies",
597                "Comma-separated daemon names that must start first.",
598            ),
599            FormField::string_list(
600                "watch",
601                "Watch Files",
602                "Comma-separated glob patterns to watch for auto-restart.",
603            ),
604            FormField::optional_text(
605                "cron_schedule",
606                "Cron Schedule",
607                "Cron expression (e.g., '*/5 * * * *' for every 5 minutes).",
608            ),
609            FormField::retrigger(
610                "cron_retrigger",
611                "Cron Retrigger",
612                "Behavior when cron triggers while previous run is active.",
613            ),
614            FormField::optional_bool(
615                "cron_immediate",
616                "Cron Immediate",
617                "Trigger immediately on first check (default: false).",
618            ),
619        ]
620    }
621
622    fn fields_from_config(config: &PitchforkTomlDaemon) -> Vec<FormField> {
623        let mut fields = Self::default_fields();
624
625        for field in &mut fields {
626            match field.name {
627                "run" => {
628                    field.value = FormFieldValue::Text(match &config.run {
629                        RunCommand::Argv(argv) => argv_to_edit_text(argv),
630                        RunCommand::Shell(run) => run.clone(),
631                    })
632                }
633                "dir" => field.value = FormFieldValue::OptionalText(config.dir.clone()),
634                "env" => {
635                    field.value = FormFieldValue::StringList(
636                        config
637                            .env
638                            .as_ref()
639                            .map(|m| m.iter().map(|(k, v)| format!("{k}={v}")).collect())
640                            .unwrap_or_default(),
641                    );
642                }
643                "auto" => field.value = FormFieldValue::AutoBehavior(config.auto.clone()),
644                "retry" => field.value = FormFieldValue::Number(config.retry.count()),
645                "ready_delay" => field.value = FormFieldValue::OptionalNumber(config.ready_delay),
646                "ready_output" => {
647                    field.value = FormFieldValue::OptionalText(
648                        config.ready_output.as_ref().map(|o| o.pattern.clone()),
649                    )
650                }
651                "ready_http" => {
652                    field.value = FormFieldValue::OptionalText(
653                        config.ready_http.as_ref().map(|h| h.url.clone()),
654                    )
655                }
656                "ready_port" => {
657                    field.value = FormFieldValue::OptionalPort(config.ready_port.clone())
658                }
659                "health_cmd" => {
660                    field.value = FormFieldValue::OptionalText(config.health_cmd.as_ref().map(
661                        |c| match &c.run {
662                            RunCommand::Argv(argv) => argv_to_edit_text(argv),
663                            RunCommand::Shell(run) => run.clone(),
664                        },
665                    ))
666                }
667                "health_http" => {
668                    field.value = FormFieldValue::OptionalText(
669                        config.health_http.as_ref().map(|h| h.url.clone()),
670                    )
671                }
672                "health_port" => {
673                    field.value =
674                        FormFieldValue::OptionalPort(config.health_port.as_ref().map(|hp| {
675                            ReadyPort {
676                                port: hp.port,
677                                template: hp.template.clone(),
678                                timeout: None,
679                            }
680                        }))
681                }
682                "boot_start" => field.value = FormFieldValue::OptionalBoolean(config.boot_start),
683                "depends" => {
684                    field.value = FormFieldValue::StringList(
685                        config
686                            .depends
687                            .iter()
688                            .map(|d: &DaemonId| d.qualified())
689                            .collect(),
690                    )
691                }
692                "watch" => field.value = FormFieldValue::StringList(config.watch.clone()),
693                "cron_schedule" => {
694                    field.value = FormFieldValue::OptionalText(
695                        config.cron.as_ref().map(|c| c.schedule.clone()),
696                    );
697                }
698                "cron_retrigger" => {
699                    field.value = FormFieldValue::Retrigger(
700                        config
701                            .cron
702                            .as_ref()
703                            .map(|c| c.retrigger)
704                            .unwrap_or(CronRetrigger::Finish),
705                    );
706                }
707                "cron_immediate" => {
708                    field.value =
709                        FormFieldValue::OptionalBoolean(config.cron.as_ref().map(|c| c.immediate));
710                }
711                _ => {}
712            }
713        }
714
715        fields
716    }
717
718    pub fn to_daemon_config(&self) -> PitchforkTomlDaemon {
719        let mut config = PitchforkTomlDaemon {
720            ready_cmd: self.preserved_ready_cmd.clone(),
721            oneshot: self.preserved_oneshot,
722            path: Some(self.config_path.clone()),
723            ..PitchforkTomlDaemon::default()
724        };
725
726        let mut cron_schedule: Option<String> = None;
727        let mut cron_retrigger = CronRetrigger::Finish;
728        let mut cron_immediate = false;
729
730        for field in &self.fields {
731            match (field.name, &field.value) {
732                ("run", FormFieldValue::Text(s)) => {
733                    config.run = match &self.preserved_run_argv {
734                        Some(RunCommand::Argv(argv)) if argv_to_edit_text(argv) == *s => {
735                            RunCommand::Argv(argv.clone())
736                        }
737                        // An edit is split back the way the field quoted it, and
738                        // stays an array. `validate` has already refused text
739                        // that cannot be.
740                        Some(_) => match edit_text_to_argv(s) {
741                            Ok(words) if !words.is_empty() => words.into(),
742                            _ => s.clone().into(),
743                        },
744                        None => s.clone().into(),
745                    };
746                }
747                ("dir", FormFieldValue::OptionalText(s)) => config.dir = s.clone(),
748                ("env", FormFieldValue::StringList(v)) => {
749                    if v.is_empty() {
750                        config.env = None;
751                    } else {
752                        let mut map = indexmap::IndexMap::new();
753                        for entry in v {
754                            if let Some((k, val)) = entry.split_once('=') {
755                                map.insert(k.trim().to_string(), val.trim().to_string());
756                            }
757                        }
758                        config.env = if map.is_empty() { None } else { Some(map) };
759                    }
760                }
761                ("auto", FormFieldValue::AutoBehavior(v)) => config.auto = v.clone(),
762                ("retry", FormFieldValue::Number(n)) => config.retry = Retry(*n),
763                ("ready_delay", FormFieldValue::OptionalNumber(n)) => config.ready_delay = *n,
764                ("ready_output", FormFieldValue::OptionalText(s)) => {
765                    config.ready_output = s.clone().map(|pattern| ReadyOutput {
766                        pattern,
767                        timeout: self.preserved_ready_output_timeout,
768                    })
769                }
770                ("ready_http", FormFieldValue::OptionalText(s)) => {
771                    config.ready_http = s.clone().map(|url| ReadyHttp {
772                        url,
773                        status: self.preserved_ready_http_status.clone().unwrap_or_default(),
774                        timeout: self.preserved_ready_http_timeout,
775                    })
776                }
777                ("ready_port", FormFieldValue::OptionalPort(p)) => config.ready_port = p.clone(),
778                ("health_cmd", FormFieldValue::OptionalText(s)) => {
779                    config.health_cmd = s.clone().map(|text| {
780                        let mut cmd = self.preserved_health_cmd.clone().unwrap_or_default();
781                        // An array stays an array, edited or not, as for `run`.
782                        cmd.run = match &cmd.run {
783                            RunCommand::Argv(argv) if argv_to_edit_text(argv) == text => {
784                                RunCommand::Argv(argv.clone())
785                            }
786                            RunCommand::Argv(_) => match edit_text_to_argv(&text) {
787                                Ok(words) if !words.is_empty() => words.into(),
788                                _ => text.into(),
789                            },
790                            RunCommand::Shell(_) => text.into(),
791                        };
792                        cmd
793                    })
794                }
795                ("health_http", FormFieldValue::OptionalText(s)) => {
796                    config.health_http = s.clone().map(|url| {
797                        let mut http = self.preserved_health_http.clone().unwrap_or_default();
798                        http.url = url;
799                        http
800                    })
801                }
802                ("health_port", FormFieldValue::OptionalPort(p)) => {
803                    config.health_port = p.clone().map(|port_value| {
804                        let mut hp = self.preserved_health_port.clone().unwrap_or_default();
805                        hp.port = port_value.port;
806                        hp.template = port_value.template;
807                        hp
808                    })
809                }
810                ("boot_start", FormFieldValue::OptionalBoolean(b)) => config.boot_start = *b,
811                ("depends", FormFieldValue::StringList(v)) => {
812                    config.depends = v.iter().filter_map(|s| DaemonId::parse(s).ok()).collect()
813                }
814                ("watch", FormFieldValue::StringList(v)) => config.watch = v.clone(),
815                ("cron_schedule", FormFieldValue::OptionalText(s)) => cron_schedule = s.clone(),
816                ("cron_retrigger", FormFieldValue::Retrigger(r)) => cron_retrigger = *r,
817                ("cron_immediate", FormFieldValue::OptionalBoolean(b)) => {
818                    cron_immediate = b.unwrap_or(false);
819                }
820                _ => {}
821            }
822        }
823
824        if let Some(schedule) = cron_schedule {
825            config.cron = Some(PitchforkTomlCron {
826                schedule,
827                retrigger: cron_retrigger,
828                immediate: cron_immediate,
829            });
830        }
831
832        config
833    }
834
835    pub fn next_field(&mut self) {
836        // Stop editing current field if text editing
837        if let Some(field) = self.fields.get_mut(self.focused_field) {
838            field.editing = false;
839        }
840
841        // When leaving daemon_id editing, don't increment - just move to first form field
842        if self.daemon_id_editing {
843            self.daemon_id_editing = false;
844            return;
845        }
846
847        if self.focused_field < self.fields.len() - 1 {
848            self.focused_field += 1;
849        }
850    }
851
852    pub fn prev_field(&mut self) {
853        // Stop editing current field if text editing
854        if let Some(field) = self.fields.get_mut(self.focused_field) {
855            field.editing = false;
856        }
857        self.daemon_id_editing = false;
858
859        if self.focused_field > 0 {
860            self.focused_field -= 1;
861        }
862    }
863
864    pub fn toggle_current_field(&mut self) {
865        if let Some(field) = self.fields.get_mut(self.focused_field) {
866            let toggled = match &mut field.value {
867                FormFieldValue::Boolean(b) => {
868                    *b = !*b;
869                    true
870                }
871                FormFieldValue::OptionalBoolean(opt) => {
872                    *opt = match opt {
873                        None => Some(true),
874                        Some(true) => Some(false),
875                        Some(false) => None,
876                    };
877                    true
878                }
879                FormFieldValue::AutoBehavior(v) => {
880                    // Cycle through: [] -> [Start] -> [Stop] -> [Start, Stop] -> []
881                    let has_start = v.contains(&PitchforkTomlAuto::Start);
882                    let has_stop = v.contains(&PitchforkTomlAuto::Stop);
883                    *v = match (has_start, has_stop) {
884                        (false, false) => vec![PitchforkTomlAuto::Start],
885                        (true, false) => vec![PitchforkTomlAuto::Stop],
886                        (false, true) => vec![PitchforkTomlAuto::Start, PitchforkTomlAuto::Stop],
887                        (true, true) => vec![],
888                    };
889                    true
890                }
891                FormFieldValue::Retrigger(r) => {
892                    *r = match r {
893                        CronRetrigger::Finish => CronRetrigger::Always,
894                        CronRetrigger::Always => CronRetrigger::Success,
895                        CronRetrigger::Success => CronRetrigger::Fail,
896                        CronRetrigger::Fail => CronRetrigger::Finish,
897                    };
898                    true
899                }
900                _ => false,
901            };
902            if toggled {
903                self.unsaved_changes = true;
904            }
905        }
906    }
907
908    pub fn start_editing(&mut self) {
909        if let Some(field) = self.fields.get_mut(self.focused_field) {
910            if field.is_text_editable() {
911                field.editing = true;
912                field.cursor = field.get_text().chars().count();
913            } else {
914                // For non-text fields, toggle them
915                self.toggle_current_field();
916            }
917        }
918    }
919
920    pub fn stop_editing(&mut self) {
921        if let Some(field) = self.fields.get_mut(self.focused_field) {
922            field.editing = false;
923        }
924        self.daemon_id_editing = false;
925    }
926
927    pub fn is_editing(&self) -> bool {
928        self.daemon_id_editing
929            || self
930                .fields
931                .get(self.focused_field)
932                .map(|f| f.editing)
933                .unwrap_or(false)
934    }
935
936    pub fn text_push(&mut self, c: char) {
937        if self.daemon_id_editing {
938            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
939            self.daemon_id.insert(byte_idx, c);
940            self.daemon_id_cursor += 1;
941            self.unsaved_changes = true;
942        } else if let Some(field) = self.fields.get_mut(self.focused_field)
943            && field.editing
944        {
945            let mut text = field.get_text();
946            let byte_idx = char_to_byte_index(&text, field.cursor);
947            text.insert(byte_idx, c);
948            field.cursor += 1;
949            field.set_text(text);
950            self.unsaved_changes = true;
951        }
952    }
953
954    pub fn text_pop(&mut self) {
955        if self.daemon_id_editing && self.daemon_id_cursor > 0 {
956            self.daemon_id_cursor -= 1;
957            let byte_idx = char_to_byte_index(&self.daemon_id, self.daemon_id_cursor);
958            self.daemon_id.remove(byte_idx);
959            self.unsaved_changes = true;
960        } else if let Some(field) = self.fields.get_mut(self.focused_field)
961            && field.editing
962            && field.cursor > 0
963        {
964            let mut text = field.get_text();
965            // Value-backed fields (OptionalNumber/OptionalPort) drop invalid
966            // input in set_text, so the cursor can be past the derived text.
967            field.cursor = field.cursor.min(text.chars().count());
968            if field.cursor == 0 {
969                return;
970            }
971            field.cursor -= 1;
972            let byte_idx = char_to_byte_index(&text, field.cursor);
973            text.remove(byte_idx);
974            field.set_text(text);
975            // For Number fields, sync cursor to end if value defaulted to "0"
976            // This handles the case where backspacing to empty makes value 0
977            if matches!(field.value, FormFieldValue::Number(_)) {
978                field.cursor = field.get_text().chars().count();
979            }
980            self.unsaved_changes = true;
981        }
982    }
983
984    pub fn validate(&mut self) -> bool {
985        let mut valid = true;
986
987        // Validate daemon ID
988        self.daemon_id_error = None;
989        if self.daemon_id.is_empty() {
990            self.daemon_id_error = Some("Name is required".to_string());
991            valid = false;
992        } else if !self
993            .daemon_id
994            .chars()
995            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
996        {
997            self.daemon_id_error =
998                Some("Only letters, digits, hyphens, and underscores allowed".to_string());
999            valid = false;
1000        }
1001
1002        // Validate fields
1003        let run_is_argv = self.preserved_run_argv.is_some();
1004        let health_cmd_is_argv = self
1005            .preserved_health_cmd
1006            .as_ref()
1007            .is_some_and(|c| c.run.is_argv());
1008        for field in &mut self.fields {
1009            // Keep parse errors from set_text (e.g. out-of-range ready_port):
1010            // the typed value was already dropped, so saving now would
1011            // silently lose the field.
1012            if field.error.is_some() {
1013                valid = false;
1014                continue;
1015            }
1016
1017            match (field.name, &field.value) {
1018                ("run", FormFieldValue::Text(s)) if s.is_empty() => {
1019                    field.error = Some("Required".to_string());
1020                    valid = false;
1021                }
1022                ("run", FormFieldValue::Text(s)) if run_is_argv => {
1023                    if let Some(error) = argv_edit_error(s) {
1024                        field.error = Some(error);
1025                        valid = false;
1026                    }
1027                }
1028                ("health_cmd", FormFieldValue::OptionalText(Some(s))) if health_cmd_is_argv => {
1029                    if let Some(error) = argv_edit_error(s) {
1030                        field.error = Some(error);
1031                        valid = false;
1032                    }
1033                }
1034                ("ready_http", FormFieldValue::OptionalText(Some(url)))
1035                    if !(url.starts_with("http://") || url.starts_with("https://")) =>
1036                {
1037                    field.error = Some("Must start with http:// or https://".to_string());
1038                    valid = false;
1039                }
1040                ("health_http", FormFieldValue::OptionalText(Some(url)))
1041                    if !(url.starts_with("http://") || url.starts_with("https://")) =>
1042                {
1043                    field.error = Some("Must start with http:// or https://".to_string());
1044                    valid = false;
1045                }
1046                _ => {}
1047            }
1048        }
1049
1050        valid
1051    }
1052}
1053
1054/// State for config file selection
1055#[derive(Debug, Clone)]
1056pub struct ConfigFileSelector {
1057    pub files: Vec<PathBuf>,
1058    pub selected: usize,
1059}
1060
1061#[derive(Debug, Clone)]
1062pub enum PendingAction {
1063    Stop(DaemonId),
1064    Restart(DaemonId),
1065    Disable(DaemonId),
1066    // Batch operations
1067    BatchStop(Vec<DaemonId>),
1068    BatchRestart(Vec<DaemonId>),
1069    BatchDisable(Vec<DaemonId>),
1070    Clean(Vec<DaemonId>),
1071    // Config editor actions
1072    DeleteDaemon { id: String, config_path: PathBuf },
1073    DiscardEditorChanges,
1074}
1075
1076#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1077pub enum SortColumn {
1078    #[default]
1079    Name,
1080    Status,
1081    Cpu,
1082    Memory,
1083    Uptime,
1084}
1085
1086impl SortColumn {
1087    pub fn next(self) -> Self {
1088        match self {
1089            Self::Name => Self::Status,
1090            Self::Status => Self::Cpu,
1091            Self::Cpu => Self::Memory,
1092            Self::Memory => Self::Uptime,
1093            Self::Uptime => Self::Name,
1094        }
1095    }
1096}
1097
1098#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1099pub enum SortOrder {
1100    #[default]
1101    Ascending,
1102    Descending,
1103}
1104
1105impl SortOrder {
1106    pub fn toggle(self) -> Self {
1107        match self {
1108            Self::Ascending => Self::Descending,
1109            Self::Descending => Self::Ascending,
1110        }
1111    }
1112
1113    pub fn indicator(self) -> &'static str {
1114        match self {
1115            Self::Ascending => "↑",
1116            Self::Descending => "↓",
1117        }
1118    }
1119}
1120
1121pub struct App {
1122    pub daemons: Vec<Daemon>,
1123    pub disabled: Vec<DaemonId>,
1124    pub selected: usize,
1125    pub view: View,
1126    pub prev_view: View,
1127    pub log_content: Vec<String>,
1128    pub log_daemon_id: Option<DaemonId>,
1129    pub log_scroll: usize,
1130    pub log_follow: bool, // Auto-scroll to bottom as new lines appear
1131    pub message: Option<String>,
1132    pub message_time: Option<Instant>,
1133    pub process_stats: HashMap<u32, ProcessStats>, // PID -> stats
1134    pub stats_history: HashMap<DaemonId, StatsHistory>, // daemon_id -> history
1135    pub pending_action: Option<PendingAction>,
1136    pub loading_text: Option<String>,
1137    pub search_query: String,
1138    pub search_active: bool,
1139    // Sorting
1140    pub sort_column: SortColumn,
1141    pub sort_order: SortOrder,
1142    // Log search
1143    pub log_search_query: String,
1144    pub log_search_active: bool,
1145    pub log_search_matches: Vec<usize>, // Line indices that match
1146    pub log_search_current: usize,      // Current match index
1147    // Details view daemon (now used for full-page details view from 'l' key)
1148    pub details_daemon_id: Option<DaemonId>,
1149    // Whether logs are expanded to fill the screen (hides charts)
1150    pub logs_expanded: bool,
1151    // Multi-select state
1152    pub multi_select: HashSet<DaemonId>,
1153    // Config-only daemons (defined in pitchfork.toml but not currently active)
1154    pub config_daemon_ids: HashSet<DaemonId>,
1155    // Whether to show config-only daemons in the list
1156    pub show_available: bool,
1157    // Config editor state
1158    pub editor_state: Option<EditorState>,
1159    // Config file selector state
1160    pub file_selector: Option<ConfigFileSelector>,
1161    // Network view state
1162    pub network_listeners: Vec<Listener>,
1163    pub network_search_query: String,
1164    pub network_search_active: bool,
1165    pub network_selected: usize,
1166    pub network_scroll_offset: usize,
1167    pub network_selected_pid: Option<u32>, // Store PID to ensure correct targeting
1168    pub network_visible_rows: usize,       // Cached visible row count for scroll calculations
1169    // Namespace scope from `--namespace`/`--project`; applied when fetching
1170    // the daemon list, so search/sort/selection all operate on the scoped set
1171    pub namespace_filter: NamespaceFilter,
1172}
1173
1174impl App {
1175    pub fn new(namespace_filter: NamespaceFilter) -> Self {
1176        Self {
1177            namespace_filter,
1178            daemons: Vec::new(),
1179            disabled: Vec::new(),
1180            selected: 0,
1181            view: View::Dashboard,
1182            prev_view: View::Dashboard,
1183            log_content: Vec::new(),
1184            log_daemon_id: None,
1185            log_scroll: 1,
1186            log_follow: true,
1187            message: None,
1188            message_time: None,
1189            process_stats: HashMap::new(),
1190            stats_history: HashMap::new(),
1191            pending_action: None,
1192            loading_text: None,
1193            search_query: String::new(),
1194            search_active: false,
1195            sort_column: SortColumn::default(),
1196            sort_order: SortOrder::default(),
1197            log_search_query: String::new(),
1198            log_search_active: false,
1199            log_search_matches: Vec::new(),
1200            log_search_current: 0,
1201            details_daemon_id: None,
1202            logs_expanded: false,
1203            multi_select: HashSet::new(),
1204            config_daemon_ids: HashSet::new(),
1205            show_available: true, // Show available daemons by default
1206            editor_state: None,
1207            file_selector: None,
1208            network_listeners: Vec::new(),
1209            network_search_query: String::new(),
1210            network_search_active: false,
1211            network_selected: 0,
1212            network_scroll_offset: 0,
1213            network_selected_pid: None,
1214            network_visible_rows: 20, // Default value, updated during draw
1215        }
1216    }
1217
1218    pub fn confirm_action(&mut self, action: PendingAction) {
1219        self.pending_action = Some(action);
1220        self.prev_view = self.view;
1221        self.view = View::Confirm;
1222    }
1223
1224    pub fn cancel_confirm(&mut self) {
1225        self.pending_action = None;
1226        self.view = self.prev_view;
1227    }
1228
1229    pub fn take_pending_action(&mut self) -> Option<PendingAction> {
1230        self.view = self.prev_view;
1231        self.pending_action.take()
1232    }
1233
1234    pub fn start_loading(&mut self, text: impl Into<String>) {
1235        self.loading_text = Some(text.into());
1236    }
1237
1238    pub fn stop_loading(&mut self) {
1239        self.loading_text = None;
1240    }
1241
1242    // Search functionality
1243    pub fn start_search(&mut self) {
1244        self.search_active = true;
1245    }
1246
1247    pub fn end_search(&mut self) {
1248        self.search_active = false;
1249    }
1250
1251    pub fn clear_search(&mut self) {
1252        self.search_query.clear();
1253        self.search_active = false;
1254        self.selected = 0;
1255    }
1256
1257    pub fn search_push(&mut self, c: char) {
1258        self.search_query.push(c);
1259        // Reset selection when search changes
1260        self.selected = 0;
1261    }
1262
1263    pub fn search_pop(&mut self) {
1264        self.search_query.pop();
1265        self.selected = 0;
1266    }
1267
1268    pub fn filtered_daemons(&self) -> Vec<&Daemon> {
1269        let mut filtered: Vec<&Daemon> = if self.search_query.is_empty() {
1270            self.daemons.iter().collect()
1271        } else {
1272            // Use fuzzy matching with SkimMatcherV2
1273            let matcher = SkimMatcherV2::default();
1274            let mut scored: Vec<_> = self
1275                .daemons
1276                .iter()
1277                .filter_map(|d| {
1278                    matcher
1279                        .fuzzy_match(&d.id.qualified(), &self.search_query)
1280                        .map(|score| (d, score))
1281                })
1282                .collect();
1283            // Sort by score descending (best matches first)
1284            scored.sort_by_key(|s| std::cmp::Reverse(s.1));
1285            scored.into_iter().map(|(d, _)| d).collect()
1286        };
1287
1288        // Sort the filtered list
1289        filtered.sort_by(|a, b| {
1290            let cmp = match self.sort_column {
1291                SortColumn::Name => {
1292                    a.id.to_string()
1293                        .to_lowercase()
1294                        .cmp(&b.id.to_string().to_lowercase())
1295                }
1296                SortColumn::Status => {
1297                    let status_order = |d: &Daemon| match &d.status {
1298                        crate::daemon_status::DaemonStatus::Running => 0,
1299                        crate::daemon_status::DaemonStatus::Waiting => 1,
1300                        crate::daemon_status::DaemonStatus::Stopping
1301                        | crate::daemon_status::DaemonStatus::Restarting => 2,
1302                        crate::daemon_status::DaemonStatus::Completed => 3,
1303                        crate::daemon_status::DaemonStatus::Stopped => 4,
1304                        crate::daemon_status::DaemonStatus::Errored(_) => 5,
1305                        crate::daemon_status::DaemonStatus::Failed(_) => 6,
1306                    };
1307                    status_order(a).cmp(&status_order(b))
1308                }
1309                SortColumn::Cpu => {
1310                    let cpu_a = a
1311                        .pid
1312                        .and_then(|p| self.get_stats(p))
1313                        .map(|s| s.cpu_percent)
1314                        .unwrap_or(0.0);
1315                    let cpu_b = b
1316                        .pid
1317                        .and_then(|p| self.get_stats(p))
1318                        .map(|s| s.cpu_percent)
1319                        .unwrap_or(0.0);
1320                    cpu_a
1321                        .partial_cmp(&cpu_b)
1322                        .unwrap_or(std::cmp::Ordering::Equal)
1323                }
1324                SortColumn::Memory => {
1325                    let mem_a = a
1326                        .pid
1327                        .and_then(|p| self.get_stats(p))
1328                        .map(|s| s.memory_bytes)
1329                        .unwrap_or(0);
1330                    let mem_b = b
1331                        .pid
1332                        .and_then(|p| self.get_stats(p))
1333                        .map(|s| s.memory_bytes)
1334                        .unwrap_or(0);
1335                    mem_a.cmp(&mem_b)
1336                }
1337                SortColumn::Uptime => {
1338                    let up_a = a
1339                        .pid
1340                        .and_then(|p| self.get_stats(p))
1341                        .map(|s| s.uptime_secs)
1342                        .unwrap_or(0);
1343                    let up_b = b
1344                        .pid
1345                        .and_then(|p| self.get_stats(p))
1346                        .map(|s| s.uptime_secs)
1347                        .unwrap_or(0);
1348                    up_a.cmp(&up_b)
1349                }
1350            };
1351            match self.sort_order {
1352                SortOrder::Ascending => cmp,
1353                SortOrder::Descending => cmp.reverse(),
1354            }
1355        });
1356
1357        filtered
1358    }
1359
1360    // Sorting
1361    pub fn cycle_sort(&mut self) {
1362        // If clicking the same column, toggle order; otherwise switch column
1363        self.sort_column = self.sort_column.next();
1364        self.selected = 0;
1365    }
1366
1367    pub fn toggle_sort_order(&mut self) {
1368        self.sort_order = self.sort_order.toggle();
1369        self.selected = 0;
1370    }
1371
1372    pub fn selected_daemon(&self) -> Option<&Daemon> {
1373        let filtered = self.filtered_daemons();
1374        filtered.get(self.selected).copied()
1375    }
1376
1377    pub fn select_next(&mut self) {
1378        let count = self.filtered_daemons().len();
1379        if count > 0 {
1380            self.selected = (self.selected + 1) % count;
1381        }
1382    }
1383
1384    pub fn select_prev(&mut self) {
1385        let count = self.filtered_daemons().len();
1386        if count > 0 {
1387            self.selected = self.selected.checked_sub(1).unwrap_or(count - 1);
1388        }
1389    }
1390
1391    // Log follow mode
1392    pub fn toggle_log_follow(&mut self) {
1393        self.log_follow = !self.log_follow;
1394        if self.log_follow && !self.log_content.is_empty() {
1395            // Jump to bottom when enabling follow
1396            self.log_scroll = self.log_content.len();
1397        }
1398    }
1399
1400    // Toggle logs expanded (hide/show charts)
1401    pub fn toggle_logs_expanded(&mut self) {
1402        self.logs_expanded = !self.logs_expanded;
1403    }
1404
1405    // Multi-select methods
1406    pub fn toggle_select(&mut self) {
1407        if let Some(daemon) = self.selected_daemon() {
1408            let id = daemon.id.clone();
1409            if self.multi_select.contains(&id) {
1410                self.multi_select.remove(&id);
1411            } else {
1412                self.multi_select.insert(id);
1413            }
1414        }
1415    }
1416
1417    pub fn select_all_visible(&mut self) {
1418        // Collect IDs first to avoid borrow conflict
1419        let ids: Vec<DaemonId> = self
1420            .filtered_daemons()
1421            .iter()
1422            .map(|d| d.id.clone())
1423            .collect();
1424        for id in ids {
1425            self.multi_select.insert(id);
1426        }
1427    }
1428
1429    pub fn clear_selection(&mut self) {
1430        self.multi_select.clear();
1431    }
1432
1433    pub fn is_selected(&self, daemon_id: &DaemonId) -> bool {
1434        self.multi_select.contains(daemon_id)
1435    }
1436
1437    pub fn has_selection(&self) -> bool {
1438        !self.multi_select.is_empty()
1439    }
1440
1441    pub fn selected_daemon_ids(&self) -> Vec<DaemonId> {
1442        self.multi_select.iter().cloned().collect()
1443    }
1444
1445    pub fn set_message(&mut self, msg: impl Into<String>) {
1446        self.message = Some(msg.into());
1447        self.message_time = Some(Instant::now());
1448    }
1449
1450    pub fn clear_stale_message(&mut self) {
1451        let duration = settings().tui_message_duration();
1452        if let Some(time) = self.message_time
1453            && time.elapsed() >= duration
1454        {
1455            self.message = None;
1456            self.message_time = None;
1457        }
1458    }
1459
1460    pub fn get_stats(&self, pid: u32) -> Option<&ProcessStats> {
1461        self.process_stats.get(&pid)
1462    }
1463
1464    fn refresh_process_stats(&mut self) {
1465        let pids: Vec<u32> = self.daemons.iter().filter_map(|d| d.pid).collect();
1466        if !pids.is_empty() {
1467            PROCS.refresh_if_stale();
1468        }
1469        self.process_stats.clear();
1470
1471        let stats_by_pid = PROCS.get_batch_tree_stats_map(&pids);
1472
1473        for daemon in &self.daemons {
1474            if let Some(pid) = daemon.pid
1475                && let Some(stats) = stats_by_pid.get(&pid).copied()
1476            {
1477                self.process_stats.insert(pid, stats);
1478                // Record history for this daemon
1479                let history = self.stats_history.entry(daemon.id.clone()).or_default();
1480                history.push(StatsSnapshot::from(&stats));
1481            }
1482        }
1483    }
1484
1485    /// Get stats history for a daemon
1486    pub fn get_stats_history(&self, daemon_id: &DaemonId) -> Option<&StatsHistory> {
1487        self.stats_history.get(daemon_id)
1488    }
1489
1490    /// Fetch fresh daemon data from IPC. Called from a background task.
1491    pub async fn fetch_daemon_data(
1492        client: &Arc<IpcClient>,
1493        namespace_filter: &NamespaceFilter,
1494    ) -> Result<Vec<DaemonListEntry>> {
1495        use crate::daemon_list::get_all_daemons;
1496        get_all_daemons(client, namespace_filter).await
1497    }
1498
1499    /// Apply previously fetched daemon data to update app state.
1500    pub fn apply_refresh(&mut self, all_entries: Vec<DaemonListEntry>) {
1501        // Clear current lists
1502        self.daemons.clear();
1503        self.disabled.clear();
1504        self.config_daemon_ids.clear();
1505
1506        for entry in all_entries {
1507            let daemon_id = entry.daemon.id.clone();
1508
1509            if entry.is_disabled {
1510                self.disabled.push(daemon_id.clone());
1511            }
1512
1513            if entry.is_available {
1514                self.config_daemon_ids.insert(daemon_id.clone());
1515            }
1516
1517            if !entry.is_available || self.show_available {
1518                self.daemons.push(entry.daemon);
1519            }
1520        }
1521
1522        self.refresh_process_stats();
1523        self.clear_stale_message();
1524
1525        let total_count = self.total_daemon_count();
1526        if total_count > 0 && self.selected >= total_count {
1527            self.selected = total_count - 1;
1528        }
1529
1530        if self.view == View::Logs
1531            && let Some(id) = self.log_daemon_id.clone()
1532        {
1533            self.load_logs(&id);
1534        }
1535    }
1536
1537    pub async fn refresh(&mut self, client: &Arc<IpcClient>) -> Result<()> {
1538        let entries = Self::fetch_daemon_data(client, &self.namespace_filter).await?;
1539        self.apply_refresh(entries);
1540        Ok(())
1541    }
1542
1543    /// Apply network listener data fetched by a background task.
1544    pub fn apply_network_refresh(&mut self, listeners: Vec<Listener>) {
1545        self.network_listeners = listeners;
1546
1547        let filtered_count = self.filtered_network_listeners().len();
1548
1549        if filtered_count > 0 && self.network_selected >= filtered_count {
1550            self.network_selected = filtered_count - 1;
1551        } else if filtered_count == 0 {
1552            self.network_selected = 0;
1553        }
1554
1555        let selected_pid = self
1556            .filtered_network_listeners()
1557            .get(self.network_selected)
1558            .map(|l| l.process.pid);
1559        self.network_selected_pid = selected_pid;
1560    }
1561
1562    /// Get filtered network listeners based on search query
1563    pub fn filtered_network_listeners(&self) -> Vec<&listeners::Listener> {
1564        if self.network_search_query.is_empty() {
1565            return self.network_listeners.iter().collect();
1566        }
1567
1568        let matcher = SkimMatcherV2::default();
1569        let query = &self.network_search_query;
1570
1571        self.network_listeners
1572            .iter()
1573            .filter(|listener| {
1574                // Search by process name, PID, or port
1575                let search_text = format!(
1576                    "{} {} {}",
1577                    listener.process.name,
1578                    listener.process.pid,
1579                    listener.socket.port()
1580                );
1581                matcher.fuzzy_match(&search_text, query).is_some()
1582            })
1583            .collect()
1584    }
1585
1586    /// Toggle network search active state
1587    pub fn toggle_network_search(&mut self) {
1588        self.network_search_active = !self.network_search_active;
1589        if !self.network_search_active {
1590            self.network_search_query.clear();
1591        }
1592        // Reset scroll and selection when toggling search
1593        self.network_selected = 0;
1594        self.network_scroll_offset = 0;
1595        // Update PID for new selection
1596        let filtered = self.filtered_network_listeners();
1597        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1598    }
1599
1600    /// Clear network search
1601    pub fn clear_network_search(&mut self) {
1602        self.network_search_query.clear();
1603        self.network_search_active = false;
1604        // Reset scroll and selection
1605        self.network_selected = 0;
1606        self.network_scroll_offset = 0;
1607        // Update PID for new selection
1608        let filtered = self.filtered_network_listeners();
1609        self.network_selected_pid = filtered.first().map(|l| l.process.pid);
1610    }
1611
1612    /// Check if a daemon is from config only (not currently active)
1613    pub fn is_config_only(&self, daemon_id: &DaemonId) -> bool {
1614        self.config_daemon_ids.contains(daemon_id)
1615    }
1616
1617    /// Toggle showing available daemons from config
1618    pub fn toggle_show_available(&mut self) {
1619        self.show_available = !self.show_available;
1620    }
1621
1622    /// Get total daemon count (for selection bounds)
1623    fn total_daemon_count(&self) -> usize {
1624        self.filtered_daemons().len()
1625    }
1626
1627    pub fn scroll_logs_down(&mut self) {
1628        let max_scroll = self.log_content.len();
1629        self.log_scroll = (self.log_scroll + 1).clamp(1, max_scroll);
1630    }
1631
1632    pub fn scroll_logs_up(&mut self) {
1633        self.log_scroll = self.log_scroll.saturating_sub(1).max(1);
1634    }
1635
1636    /// Scroll down by half page (Ctrl+D)
1637    pub fn scroll_logs_page_down(&mut self, visible_lines: usize) {
1638        let half_page = visible_lines / 2;
1639        let max_scroll = self.log_content.len();
1640        self.log_scroll = (self.log_scroll + half_page).clamp(1, max_scroll);
1641    }
1642
1643    /// Scroll up by half page (Ctrl+U)
1644    pub fn scroll_logs_page_up(&mut self, visible_lines: usize) {
1645        let half_page = visible_lines / 2;
1646        self.log_scroll = self.log_scroll.saturating_sub(half_page).max(1);
1647    }
1648
1649    // Log search
1650    pub fn start_log_search(&mut self) {
1651        self.log_search_active = true;
1652        self.log_search_query.clear();
1653        self.log_search_matches.clear();
1654        self.log_search_current = 0;
1655    }
1656
1657    pub fn end_log_search(&mut self) {
1658        self.log_search_active = false;
1659    }
1660
1661    pub fn clear_log_search(&mut self) {
1662        self.log_search_query.clear();
1663        self.log_search_active = false;
1664        self.log_search_matches.clear();
1665        self.log_search_current = 0;
1666    }
1667
1668    pub fn log_search_push(&mut self, c: char) {
1669        self.log_search_query.push(c);
1670        self.update_log_search_matches();
1671    }
1672
1673    pub fn log_search_pop(&mut self) {
1674        self.log_search_query.pop();
1675        self.update_log_search_matches();
1676    }
1677
1678    fn update_log_search_matches(&mut self) {
1679        self.log_search_matches.clear();
1680        if !self.log_search_query.is_empty() {
1681            let query = self.log_search_query.to_lowercase();
1682            for (i, line) in self.log_content.iter().enumerate() {
1683                if line.to_lowercase().contains(&query) {
1684                    self.log_search_matches.push(i);
1685                }
1686            }
1687            // Jump to first match if any
1688            if !self.log_search_matches.is_empty() {
1689                self.log_search_current = 0;
1690                self.jump_to_log_match();
1691            }
1692        }
1693    }
1694
1695    pub fn log_search_next(&mut self) {
1696        if !self.log_search_matches.is_empty() {
1697            self.log_search_current = (self.log_search_current + 1) % self.log_search_matches.len();
1698            self.jump_to_log_match();
1699        }
1700    }
1701
1702    pub fn log_search_prev(&mut self) {
1703        if !self.log_search_matches.is_empty() {
1704            self.log_search_current = self
1705                .log_search_current
1706                .checked_sub(1)
1707                .unwrap_or(self.log_search_matches.len() - 1);
1708            self.jump_to_log_match();
1709        }
1710    }
1711
1712    fn jump_to_log_match(&mut self) {
1713        if let Some(&line_idx) = self.log_search_matches.get(self.log_search_current) {
1714            // Scroll so the match is visible (center it if possible)
1715            let half_page = 10; // Assume ~20 visible lines
1716            self.log_scroll = line_idx.saturating_sub(half_page).max(1);
1717            self.log_follow = false;
1718        }
1719    }
1720
1721    // Details view
1722    pub fn show_details(&mut self, daemon_id: &DaemonId) {
1723        self.details_daemon_id = Some(daemon_id.clone());
1724        self.prev_view = self.view;
1725        self.view = View::Details;
1726    }
1727
1728    pub fn hide_details(&mut self) {
1729        self.details_daemon_id = None;
1730        self.view = View::Dashboard;
1731    }
1732
1733    /// View daemon details (charts + logs)
1734    pub fn view_daemon_details(&mut self, daemon_id: &DaemonId) {
1735        self.log_daemon_id = Some(daemon_id.clone());
1736        self.logs_expanded = false; // Start with charts visible
1737        self.load_logs(daemon_id);
1738        self.view = View::Logs; // Logs view is now the full daemon details view
1739    }
1740
1741    fn load_logs(&mut self, daemon_id: &DaemonId) {
1742        const TUI_LOG_LIMIT: usize = 5000;
1743        let prev_len = self.log_content.len();
1744
1745        self.log_content = match LOG_STORE.query(&crate::log_store::LogQuery {
1746            daemon_ids: vec![daemon_id.qualified()],
1747            from: None,
1748            to: None,
1749            limit: Some(TUI_LOG_LIMIT),
1750            order_desc: true,
1751            after_id: None,
1752            before_id: None,
1753            message_filters: Vec::new(),
1754            field_filters: Vec::new(),
1755            include_structured: false,
1756        }) {
1757            Ok(entries) if !entries.is_empty() => {
1758                let mut lines: Vec<String> = entries
1759                    .into_iter()
1760                    .map(|e| {
1761                        let ts = e.timestamp.format("%H:%M:%S").to_string();
1762                        format!("{} {}", ts, e.message)
1763                    })
1764                    .collect();
1765                lines.reverse();
1766                lines
1767            }
1768            _ => vec!["No logs available".to_string()],
1769        };
1770
1771        // Auto-scroll to bottom when in follow mode
1772        if self.log_follow {
1773            self.log_scroll = self.log_content.len().max(1);
1774        } else if prev_len == 0 {
1775            // First load - start at bottom
1776            self.log_scroll = self.log_content.len().max(1);
1777        }
1778        // If not following and not first load, keep scroll position
1779    }
1780
1781    pub fn show_help(&mut self) {
1782        self.view = View::Help;
1783    }
1784
1785    pub fn back_to_dashboard(&mut self) {
1786        self.view = View::Dashboard;
1787        self.log_daemon_id = None;
1788        self.log_content.clear();
1789        self.log_scroll = 1;
1790    }
1791
1792    /// Returns (total, running, stopped, errored, available)
1793    pub fn stats(&self) -> (usize, usize, usize, usize, usize) {
1794        let available = self.config_daemon_ids.len();
1795        let total = self.daemons.len();
1796        let running = self
1797            .daemons
1798            .iter()
1799            .filter(|d| d.status.is_running())
1800            .count();
1801        // Don't count config-only daemons as stopped
1802        let stopped = self
1803            .daemons
1804            .iter()
1805            .filter(|d| d.status.is_stopped() && !self.config_daemon_ids.contains(&d.id))
1806            .count();
1807        let errored = self
1808            .daemons
1809            .iter()
1810            .filter(|d| d.status.is_errored() || d.status.is_failed())
1811            .count();
1812        (total, running, stopped, errored, available)
1813    }
1814
1815    pub fn is_disabled(&self, daemon_id: &DaemonId) -> bool {
1816        self.disabled.contains(daemon_id)
1817    }
1818
1819    // Config editor methods
1820
1821    /// Get list of available config file paths
1822    pub fn get_config_files(&self) -> Vec<PathBuf> {
1823        let mut files: Vec<PathBuf> = PitchforkToml::list_paths()
1824            .into_iter()
1825            .filter(|p| p.exists())
1826            .collect();
1827
1828        // Add option to create in current directory if not present
1829        let cwd_config = crate::env::CWD.join("pitchfork.toml");
1830        if !files.contains(&cwd_config) {
1831            files.push(cwd_config);
1832        }
1833
1834        files
1835    }
1836
1837    /// Open file selector for creating a new daemon
1838    pub fn open_file_selector(&mut self) {
1839        let files = self.get_config_files();
1840        self.file_selector = Some(ConfigFileSelector { files, selected: 0 });
1841        self.view = View::ConfigFileSelect;
1842    }
1843
1844    /// Open editor for a new daemon with the selected config file
1845    pub fn open_editor_create(&mut self, config_path: PathBuf) {
1846        self.editor_state = Some(EditorState::new_create(config_path));
1847        self.file_selector = None;
1848        self.view = View::ConfigEditor;
1849    }
1850
1851    /// Open editor for an existing daemon
1852    pub fn open_editor_edit(&mut self, daemon_id: &DaemonId) {
1853        let config = match PitchforkToml::all_merged() {
1854            Ok(config) => config,
1855            Err(e) => {
1856                self.set_message(format!("Failed to load config: {e}"));
1857                return;
1858            }
1859        };
1860        if let Some(daemon_config) = config.daemons.get(daemon_id) {
1861            let config_path = daemon_config
1862                .path
1863                .clone()
1864                .unwrap_or_else(|| crate::env::CWD.join("pitchfork.toml"));
1865            self.editor_state = Some(EditorState::new_edit(
1866                daemon_id.to_string(),
1867                daemon_config,
1868                config_path,
1869            ));
1870            self.view = View::ConfigEditor;
1871        } else {
1872            self.set_message(format!("Daemon '{daemon_id}' not found in config"));
1873        }
1874    }
1875
1876    /// Close the editor and return to dashboard
1877    pub fn close_editor(&mut self) {
1878        self.editor_state = None;
1879        self.file_selector = None;
1880        self.view = View::Dashboard;
1881    }
1882
1883    /// Save the current editor state to config file.
1884    /// Returns Ok(true) if saved successfully, Ok(false) if validation/duplicate error (don't close editor).
1885    pub fn save_editor_config(&mut self) -> Result<bool> {
1886        let editor = self
1887            .editor_state
1888            .as_mut()
1889            .ok_or_else(|| miette::miette!("No editor state"))?;
1890
1891        // Validate
1892        if !editor.validate() {
1893            self.set_message("Please fix validation errors before saving");
1894            return Ok(false);
1895        }
1896
1897        // Build daemon config
1898        let daemon_config = editor.to_daemon_config();
1899
1900        // Parse daemon ID from string
1901        let daemon_id = DaemonId::parse(&editor.daemon_id)
1902            .map_err(|e| miette::miette!("Invalid daemon ID: {}", e))?;
1903
1904        // Read existing config (or create new)
1905        let mut config = PitchforkToml::read(&editor.config_path)?;
1906
1907        // Check for duplicate daemon ID
1908        let is_duplicate = match &editor.mode {
1909            EditMode::Create => config.daemons.contains_key(&daemon_id),
1910            EditMode::Edit { original_id } => {
1911                // Only a duplicate if ID changed AND new ID already exists
1912                let original_daemon_id = DaemonId::parse(original_id)
1913                    .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1914                original_daemon_id != daemon_id && config.daemons.contains_key(&daemon_id)
1915            }
1916        };
1917
1918        if is_duplicate {
1919            self.set_message(format!("A daemon named '{daemon_id}' already exists"));
1920            return Ok(false);
1921        }
1922
1923        // Handle rename case
1924        if let EditMode::Edit { original_id } = &editor.mode {
1925            let original_daemon_id = DaemonId::parse(original_id)
1926                .map_err(|e| miette::miette!("Invalid original daemon ID: {}", e))?;
1927            if original_daemon_id != daemon_id {
1928                config.daemons.shift_remove(&original_daemon_id);
1929            }
1930        }
1931
1932        // Insert/update daemon
1933        config.daemons.insert(daemon_id, daemon_config);
1934
1935        // Write back
1936        config.write()?;
1937
1938        editor.unsaved_changes = false;
1939        let daemon_id = editor.daemon_id.clone();
1940        self.set_message(format!("Saved daemon '{daemon_id}'"));
1941
1942        Ok(true)
1943    }
1944
1945    /// Delete a daemon from the config file. Returns Ok(true) if deleted, Ok(false) if not found.
1946    pub fn delete_daemon_from_config(
1947        &mut self,
1948        id: &str,
1949        config_path: &std::path::Path,
1950    ) -> Result<bool> {
1951        let mut config = PitchforkToml::read(config_path)?;
1952
1953        // For qualified IDs, parse directly; for bare names derive the namespace
1954        // from the config file path so project-level daemons are found correctly.
1955        let daemon_id = if id.contains('/') {
1956            DaemonId::parse(id)?
1957        } else {
1958            let ns = namespace_from_path(config_path)?;
1959            DaemonId::try_new(&ns, id)?
1960        };
1961
1962        if config.daemons.shift_remove(&daemon_id).is_some() {
1963            config.write()?;
1964            Ok(true)
1965        } else {
1966            Ok(false)
1967        }
1968    }
1969}
1970
1971impl Default for App {
1972    fn default() -> Self {
1973        Self::new(NamespaceFilter::default())
1974    }
1975}
1976
1977#[cfg(test)]
1978mod run_argv_editor_tests {
1979    use super::*;
1980
1981    fn argv(words: &[&str]) -> RunCommand {
1982        RunCommand::Argv(words.iter().map(|w| w.to_string()).collect())
1983    }
1984
1985    fn editor(run: RunCommand) -> EditorState {
1986        let config = PitchforkTomlDaemon {
1987            run,
1988            ..PitchforkTomlDaemon::default()
1989        };
1990        EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
1991    }
1992
1993    fn set_run(editor: &mut EditorState, text: &str) {
1994        let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
1995        field.set_text(text.to_string());
1996    }
1997
1998    #[test]
1999    fn an_untouched_array_is_saved_as_an_array() {
2000        let run = argv(&["node", "my server.js"]);
2001        let editor = editor(run.clone());
2002        assert_eq!(editor.to_daemon_config().run, run);
2003    }
2004
2005    #[test]
2006    fn an_edited_array_stays_an_array() {
2007        let mut editor = editor(argv(&["node", "my server.js"]));
2008        set_run(&mut editor, "node 'my server.js' --port 8080");
2009        assert!(editor.validate());
2010        assert_eq!(
2011            editor.to_daemon_config().run,
2012            argv(&["node", "my server.js", "--port", "8080"])
2013        );
2014    }
2015
2016    #[test]
2017    fn an_edited_array_must_still_parse() {
2018        let mut editor = editor(argv(&["node", "server.js"]));
2019        set_run(&mut editor, "node 'server.js");
2020        assert!(!editor.validate());
2021        set_run(&mut editor, "exec node server.js");
2022        assert!(!editor.validate());
2023    }
2024
2025    #[test]
2026    fn a_string_stays_a_string() {
2027        let mut editor = editor("exec node server.js".into());
2028        set_run(&mut editor, "exec node 'my server.js'");
2029        assert!(editor.validate());
2030        assert_eq!(editor.to_daemon_config().run, "exec node 'my server.js'");
2031    }
2032}
2033
2034#[cfg(test)]
2035mod run_argv_editor_review_tests {
2036    use super::*;
2037
2038    fn editor(run: RunCommand) -> EditorState {
2039        let config = PitchforkTomlDaemon {
2040            run,
2041            ..PitchforkTomlDaemon::default()
2042        };
2043        EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
2044    }
2045
2046    fn set_run(editor: &mut EditorState, text: &str) {
2047        let field = editor.fields.iter_mut().find(|f| f.name == "run").unwrap();
2048        field.set_text(text.to_string());
2049    }
2050
2051    #[test]
2052    fn windows_edit_text_round_trips_awkward_arguments() {
2053        let argv: Vec<String> = [
2054            r"C:\Program Files\node.exe",
2055            "",
2056            "it's",
2057            "say \"hi\"",
2058            "both ' and \"",
2059            r"trailing\",
2060            "a&b",
2061        ]
2062        .map(str::to_string)
2063        .to_vec();
2064        let text = argv
2065            .iter()
2066            .map(|a| windows_edit_quote(a))
2067            .collect::<Vec<_>>()
2068            .join(" ");
2069        assert_eq!(windows_edit_split(&text).unwrap(), argv, "text: {text}");
2070    }
2071
2072    #[test]
2073    fn windows_edit_split_keeps_backslashes_and_refuses_open_quotes() {
2074        assert_eq!(
2075            windows_edit_split(r#"C:\Tools\node.exe "my server.js"  x"#).unwrap(),
2076            vec![r"C:\Tools\node.exe", "my server.js", "x"]
2077        );
2078        assert!(windows_edit_split("node 'server.js").is_err());
2079    }
2080
2081    #[test]
2082    fn an_empty_program_is_rejected() {
2083        let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2084        set_run(&mut editor, "'' node");
2085        assert!(!editor.validate());
2086    }
2087
2088    #[cfg(windows)]
2089    #[test]
2090    fn a_windows_path_keeps_its_backslashes() {
2091        let mut editor = editor(RunCommand::Argv(vec!["node".into()]));
2092        set_run(&mut editor, r"C:\Tools\node.exe --port 8080");
2093        assert!(editor.validate());
2094        assert_eq!(
2095            editor.to_daemon_config().run,
2096            RunCommand::Argv(vec![
2097                r"C:\Tools\node.exe".into(),
2098                "--port".into(),
2099                "8080".into()
2100            ])
2101        );
2102    }
2103}
2104
2105#[cfg(test)]
2106mod health_cmd_argv_editor_tests {
2107    use super::*;
2108    use crate::config_types::HealthCmd;
2109
2110    fn argv(words: &[&str]) -> RunCommand {
2111        RunCommand::Argv(words.iter().map(|w| w.to_string()).collect())
2112    }
2113
2114    fn editor(health_cmd: HealthCmd) -> EditorState {
2115        let config = PitchforkTomlDaemon {
2116            run: "sleep 60".into(),
2117            health_cmd: Some(health_cmd),
2118            ..PitchforkTomlDaemon::default()
2119        };
2120        EditorState::new_edit("api".to_string(), &config, PathBuf::from("pitchfork.toml"))
2121    }
2122
2123    fn set_health_cmd(editor: &mut EditorState, text: &str) {
2124        let field = editor
2125            .fields
2126            .iter_mut()
2127            .find(|f| f.name == "health_cmd")
2128            .unwrap();
2129        field.set_text(text.to_string());
2130    }
2131
2132    #[test]
2133    fn an_untouched_array_is_saved_as_an_array_with_its_settings() {
2134        let health_cmd = HealthCmd {
2135            run: argv(&["pg_isready", "-h", "my host"]),
2136            interval: Some(std::time::Duration::from_secs(5)),
2137            ..HealthCmd::default()
2138        };
2139        let editor = editor(health_cmd.clone());
2140        assert_eq!(editor.to_daemon_config().health_cmd, Some(health_cmd));
2141    }
2142
2143    #[test]
2144    fn an_edited_array_stays_an_array() {
2145        let mut editor = editor(HealthCmd::new(argv(&["pg_isready"])));
2146        set_health_cmd(&mut editor, "pg_isready -h 'my host'");
2147        assert!(editor.validate());
2148        assert_eq!(
2149            editor.to_daemon_config().health_cmd.unwrap().run,
2150            argv(&["pg_isready", "-h", "my host"])
2151        );
2152    }
2153
2154    #[test]
2155    fn an_edited_array_must_still_parse() {
2156        let mut editor = editor(HealthCmd::new(argv(&["pg_isready"])));
2157        set_health_cmd(&mut editor, "pg_isready -h 'my host");
2158        assert!(!editor.validate());
2159        set_health_cmd(&mut editor, "exec pg_isready");
2160        assert!(!editor.validate());
2161    }
2162
2163    #[test]
2164    fn a_string_stays_a_string() {
2165        let mut editor = editor(HealthCmd::new("curl -f http://localhost"));
2166        set_health_cmd(&mut editor, "curl -f 'http://localhost/a b'");
2167        assert!(editor.validate());
2168        assert_eq!(
2169            editor.to_daemon_config().health_cmd.unwrap().run,
2170            "curl -f 'http://localhost/a b'"
2171        );
2172    }
2173}