shep 0.24.2

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
use super::super::*;

impl App {
    /// The settings editor's own text keymap, in force while a
    /// [`Pending::Typing`] owns [`InputMode::Text`]. The buffer is never
    /// trimmed.
    ///
    /// `TextApply` arms rather than writes: an empty buffer becomes
    /// [`SettingEdit::Unset`], anything else [`SettingEdit::Set`], and the
    /// next `Enter` sends it. `TextAbandon` leaves the screen open.
    pub(in crate::lookout::app) fn on_settings_text_key(&mut self, key: KeyPress) -> Effect {
        if self
            .settings()
            .is_some_and(|settings| settings.env_typing().is_some())
        {
            return self.on_settings_env_key(key);
        }
        let now = self.now;
        let Some(settings) = self.settings_mut() else {
            return Effect::None;
        };
        match key {
            KeyPress::Quit => return Effect::Quit,
            KeyPress::TextChar(typed) => {
                if let Some(Pending::Typing { buffer, .. }) = settings.pending.as_mut() {
                    buffer.insert(typed);
                }
            }
            KeyPress::TextBackspace => {
                if let Some(Pending::Typing { buffer, .. }) = settings.pending.as_mut() {
                    buffer.backspace();
                }
            }
            KeyPress::TextEdit(edit) => {
                if let Some(Pending::Typing { buffer, .. }) = settings.pending.as_mut() {
                    buffer.edit(edit);
                }
            }
            KeyPress::TextApply => {
                if let Some(Pending::Typing { target, buffer }) = settings.pending.take() {
                    match target.edit(settings, buffer.as_str()) {
                        Ok(TargetEdit::Setting(edit)) => {
                            let text = confirm_text(&edit, settings.snapshot.style.1);
                            settings.pending = Some(Pending::Armed {
                                edit,
                                text,
                                at: now,
                            });
                        }
                        // A dog's own field leaves as its whole section, which
                        // is the request a dog pane's close sends. The prompt
                        // says which file it lands in and that the operator's
                        // own comments survive it.
                        Ok(TargetEdit::DogSection { name, toml }) => {
                            let text = format!(
                                "write {name}'s section? it lands in dogs.toml, {name} is told, and \
                                 its comments and key order stay"
                            );
                            settings.pending = Some(Pending::DogSectionArmed {
                                name,
                                toml,
                                text,
                                at: now,
                            });
                        }
                        // Stays in the editor, with the text, until it says
                        // something that stands for an edit.
                        Err(why) => {
                            settings.pending = Some(Pending::Typing { target, buffer });
                            self.notice = Some(Notice::grave(why));
                            return Effect::None;
                        }
                    }
                }
                self.mode = InputMode::Normal;
            }
            KeyPress::TextAbandon => {
                settings.pending = None;
                self.mode = InputMode::Normal;
            }
            _ => {}
        }
        Effect::None
    }
}

#[cfg(test)]
mod tests {

    use super::*;

    use crate::lookout::view::fixtures;

    #[test]
    fn enter_on_a_text_row_opens_the_editor_seeded_with_the_current_value() {
        let mut app = fixtures::app_in_settings_on("daemon.max_cron_sleep");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let (label, buffer) = app.settings().unwrap().typing().expect("the editor opens");
        assert_eq!(label, "daemon.max_cron_sleep");
        assert_eq!(buffer.as_str(), "30s");
    }

    #[test]
    fn typing_then_enter_arms_rather_than_writing() {
        let mut app = fixtures::app_in_settings_on("daemon.max_cron_sleep");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        for _ in 0..3 {
            let _ = app.update(Msg::Key(KeyPress::TextBackspace));
        }
        for c in "45s".chars() {
            let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
        }
        assert_eq!(app.update(Msg::Key(KeyPress::TextApply)), Effect::None);
        let prompt = app.settings().unwrap().pending().unwrap();
        assert!(
            !prompt.sent,
            "the editor arms; a second Enter is what sends"
        );
        assert!(prompt.text.contains("45s"), "got: {}", prompt.text);
        assert!(
            prompt.text.contains("SHEP_MAX_CRON_SLEEP"),
            "got: {}",
            prompt.text
        );
    }

    #[test]
    fn an_empty_editor_arms_an_unset() {
        let mut app = fixtures::app_in_settings_on("daemon.max_cron_sleep");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        for _ in 0..8 {
            let _ = app.update(Msg::Key(KeyPress::TextBackspace));
        }
        let _ = app.update(Msg::Key(KeyPress::TextApply));
        let text = app.settings().unwrap().pending().unwrap().text.to_string();
        assert!(
            text.starts_with("unset daemon.max_cron_sleep?"),
            "got: {text}"
        );
    }

    #[test]
    fn the_socket_confirm_rules_out_the_reload_it_would_otherwise_imply() {
        let mut app = fixtures::app_in_settings_on("daemon.socket");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let _ = app.update(Msg::Key(KeyPress::TextApply));
        let text = app.settings().unwrap().pending().unwrap().text.to_string();
        assert!(text.contains("stopped and started"), "got: {text}");
        assert!(text.contains("a reload will not move it"), "got: {text}");
    }

    /// A refusal is discovered under the lock, so it lands after the confirm,
    /// and the typed text has to survive it.
    #[test]
    fn a_refused_write_reopens_the_editor_with_the_text_intact() {
        let mut app = fixtures::app_in_settings_on("daemon.max_cron_sleep");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        for _ in 0..3 {
            let _ = app.update(Msg::Key(KeyPress::TextBackspace));
        }
        for c in "500ms".chars() {
            let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
        }
        let _ = app.update(Msg::Key(KeyPress::TextApply));
        let Effect::WriteSetting { edit, ticket, .. } = app.update(Msg::Key(KeyPress::Confirm))
        else {
            panic!("Enter must send");
        };
        let _ = app.update(Msg::SettingWritten {
            edit,
            ticket,
            result: Err("max_cron_sleep is 500ms, below the 1s floor".into()),
        });

        let (_, buffer) = app
            .settings()
            .unwrap()
            .typing()
            .expect("the editor reopens");
        assert_eq!(buffer.as_str(), "500ms");
        assert_eq!(
            app.mode(),
            InputMode::Text,
            "a reopened editor owns the keyboard, or the text is unreachable"
        );
        assert!(
            app.notice()
                .unwrap()
                .to_string()
                .contains("below the 1s floor")
        );
    }

    #[test]
    fn escape_abandons_the_editor_and_keeps_the_screen_open() {
        let mut app = fixtures::app_in_settings_on("daemon.socket");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let _ = app.update(Msg::Key(KeyPress::TextAbandon));
        assert!(app.settings().unwrap().typing().is_none());
        assert!(app.settings().is_some());
    }

    #[test]
    fn a_closed_scalar_has_no_editor() {
        let mut app = fixtures::app_in_settings_on("daemon.log_level");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        assert!(
            app.settings().unwrap().typing().is_none(),
            "log_level is a cycle, not a text field"
        );
    }

    #[test]
    fn a_list_opens_seeded_with_its_items_comma_separated() {
        let mut app = fixtures::app_in_settings_on("hosts");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let (label, buffer) = app.settings().unwrap().typing().expect("the editor opens");
        assert_eq!(label, "hosts");
        assert_eq!(buffer.as_str(), "ada@box");
    }

    #[test]
    fn enter_on_a_map_entry_edits_that_entry() {
        let mut app = fixtures::app_in_settings_on("interpreters.py");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let (label, buffer) = app.settings().unwrap().typing().expect("the editor opens");
        assert_eq!(label, "interpreters.py");
        assert_eq!(buffer.as_str(), "python3");
    }

    #[test]
    fn a_new_entry_needs_a_name_and_a_value() {
        let mut app = fixtures::app_in_settings_on("interpreters+");
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        for c in "rb".chars() {
            let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
        }
        let _ = app.update(Msg::Key(KeyPress::TextApply));
        assert!(
            app.settings().unwrap().typing().is_some(),
            "a bare name keeps the editor open"
        );
        assert!(app.settings().unwrap().pending().is_none());

        for c in "=ruby".chars() {
            let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
        }
        let _ = app.update(Msg::Key(KeyPress::TextApply));
        let text = app.settings().unwrap().pending().unwrap().text.to_string();
        assert!(
            text.starts_with("set interpreters.rb to ruby?"),
            "got: {text}"
        );
    }
}