quvyta-cli 0.1.0-alpha.5

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
//! The provider form: the four settings a provider needs, checked before they are written.

use qframe::prelude::*;
use qframe::widgets::{Field as FormFieldWidget, Form, FormErrors, Modal, TextInput};

use super::Msg;
use crate::config::Provider;
use crate::provider::{Key, KeyError};

/// The id of the form's first field, which has focus when the form opens.
pub const FIRST: &str = "provider-address";

/// The fields of the form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
    /// The address.
    Address,
    /// The model.
    Model,
    /// The key file's path.
    KeyFile,
    /// The header the key goes in.
    KeyHeader,
}

impl Field {
    const ALL: [Self; 4] = [Self::Address, Self::Model, Self::KeyFile, Self::KeyHeader];

    fn id(self) -> &'static str {
        match self {
            Self::Address => FIRST,
            Self::Model => "provider-model",
            Self::KeyFile => "provider-key-file",
            Self::KeyHeader => "provider-key-header",
        }
    }

    fn label(self) -> String {
        match self {
            Self::Address => t!("provider.address"),
            Self::Model => t!("provider.model"),
            Self::KeyFile => t!("provider.key-file"),
            Self::KeyHeader => t!("provider.key-header"),
        }
    }

    fn hint(self) -> String {
        match self {
            Self::Address => t!("provider.address-hint"),
            Self::Model => t!("provider.model-hint"),
            Self::KeyFile => t!("provider.key-file-hint"),
            Self::KeyHeader => t!("provider.key-header-hint"),
        }
    }

    /// Whether the provider cannot be asked without this field.
    fn needed(self) -> bool {
        !matches!(self, Self::KeyHeader)
    }
}

/// The form's values while it is open, with what the last save found wrong in the framework's own
/// `FormErrors`, named after the controls the problems belong to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderForm {
    provider: Provider,
    errors: FormErrors,
}

impl ProviderForm {
    /// The form filled with `provider`.
    #[must_use]
    pub fn from_provider(provider: &Provider) -> Self {
        Self { provider: provider.clone(), errors: FormErrors::new() }
    }

    /// Changes one field.
    pub fn set(&mut self, field: Field, value: String) {
        match field {
            Field::Address => self.provider.address = value,
            Field::Model => self.provider.model = value,
            Field::KeyFile => self.provider.key_file = value,
            Field::KeyHeader => self.provider.key_header = value,
        }
    }

    fn value(&self, field: Field) -> &str {
        match field {
            Field::Address => &self.provider.address,
            Field::Model => &self.provider.model,
            Field::KeyFile => &self.provider.key_file,
            Field::KeyHeader => &self.provider.key_header,
        }
    }

    /// The provider when every field holds what it must, the key file included; otherwise the
    /// problems are kept for the form to show and there is nothing to save.
    pub fn check(&mut self) -> Option<Provider> {
        let provider = Provider {
            address: self.provider.address.trim().to_owned(),
            model: self.provider.model.trim().to_owned(),
            key_file: self.provider.key_file.trim().to_owned(),
            key_header: self.provider.key_header.trim().to_owned(),
            source: crate::config::Source::Settings,
        };
        let mut errors = FormErrors::new();
        for field in Field::ALL.into_iter().filter(|field| field.needed()) {
            let value = match field {
                Field::Address => &provider.address,
                Field::Model => &provider.model,
                _ => &provider.key_file,
            };
            errors.check(field.id(), !value.is_empty(), t!("provider.empty"));
        }
        if !provider.key_file.is_empty()
            && let Err(error) = Key::from_file(&provider.key_path())
        {
            errors.set(Field::KeyFile.id(), refusal(&error));
        }
        self.errors = errors;
        self.errors.is_empty().then_some(provider)
    }

    /// What the last save found wrong, for the fields to show and for focus to follow.
    #[must_use]
    pub fn errors(&self) -> &FormErrors {
        &self.errors
    }

    /// Says that the settings could not be written.
    pub fn failed(&mut self, error: &std::io::Error) {
        self.errors = FormErrors::new();
        self.errors.set(Field::Address.id(), t!("provider.unsaved", reason = error.to_string()));
    }

    /// What the field's value puts at risk, said under it in place of its hint. A warning never
    /// keeps the form from being saved: a server on another machine without TLS, or a key file
    /// others can read, can be what the person means, and they are told rather than stopped.
    fn warning(&self, field: Field) -> Option<String> {
        match field {
            Field::Address => plain_http(&self.provider.address).then(|| t!("provider.plain-http")),
            Field::KeyFile => {
                let path = self.provider.key_path();
                (!self.provider.key_file.trim().is_empty() && others_can_read(&path))
                    .then(|| t!("provider.key-file-open"))
            }
            _ => None,
        }
    }

    /// Makes the key file readable by its owner alone, which the person asked for with the button
    /// beside the warning; what went wrong, if anything, is said under the field.
    pub fn make_key_file_private(&mut self) {
        use std::os::unix::fs::PermissionsExt as _;
        let path = self.provider.key_path();
        if let Err(error) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
            self.errors.set(
                Field::KeyFile.id(),
                t!("provider.key-unreadable", path = path.display().to_string(), reason = error.to_string()),
            );
        }
    }

    /// Draws the form as a dialog over the chat.
    pub fn view(&self, ui: &mut View<'_, Msg>) {
        let dialog = Modal::new()
            .title(t!("provider.title"))
            .width(72)
            .on_close(Msg::CloseProvider)
            .action(Button::new(t!("provider.cancel")).on_press(Msg::CloseProvider))
            .action(Button::new(t!("provider.save")).variant("primary").on_press(Msg::SaveProvider));
        ui.add_with(dialog, |ui| {
            ui.add(Text::new(t!("provider.intro")).role("secondary"));
            Form::new().show(ui, |fields| {
                for field in Field::ALL {
                    let widget = FormFieldWidget::new(field.label())
                        .required(field.needed())
                        .hint(field.hint())
                        .warning(self.warning(field))
                        .error(self.errors.get(field.id()));
                    fields.field(widget, |ui| {
                        ui.add(
                            TextInput::new(self.value(field))
                                .invalid(self.errors.has(field.id()))
                                .on_change(move |value| Msg::Form(field, value))
                                .on_submit(|_| Msg::SaveProvider),
                        )
                        .id(field.id())
                        .fill_width();
                    });
                    // Done here, with the person's click, rather than a command for them to copy.
                    if field == Field::KeyFile && self.warning(field).is_some() {
                        fields
                            .ui()
                            .add(Button::new(t!("provider.make-private")).on_press(Msg::PrivateKeyFile))
                            .id("provider-make-private");
                    }
                }
            });
        });
    }
}

/// What a key file's refusal says, in the language on screen.
fn refusal(error: &KeyError) -> String {
    match error {
        KeyError::Unreadable { path, reason } => {
            t!("provider.key-unreadable", path = path.display().to_string(), reason = reason.clone())
        }
        KeyError::NotAKey { path } => t!("provider.not-a-key", path = path.display().to_string()),
    }
}

/// Whether `address` sends the key over plain HTTP to another machine. A server on this machine
/// (`localhost`, `127.x`, `::1`) never puts it on a network.
fn plain_http(address: &str) -> bool {
    let address = address.trim();
    let Some(rest) = address.get(..7).filter(|scheme| scheme.eq_ignore_ascii_case("http://")).map(|_| &address[7..])
    else {
        return false;
    };
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    !crate::config::loopback(crate::config::host(authority))
}

/// Whether anyone but the owner can read the file at `path`.
fn others_can_read(path: &std::path::Path) -> bool {
    use std::os::unix::fs::PermissionsExt as _;
    std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o044 != 0)
}