qqq-cli 0.8.0

Local-first task queue for coding agent sessions
use super::{
    render::{PopupKind, PopupRow},
    tag_input,
};
use crate::{list_filter::ListStatus, selection::Readiness, views::SavedView};
use anyhow::{Context, Result, bail};
use clap::ValueEnum;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

const FIELDS: [(&str, &str); 8] = [
    ("Name", "Exact name; spaces and Unicode allowed"),
    ("Tags", "One per line or comma-separated; blank means any"),
    ("Luau filter", "Expression; blank means any"),
    ("Query", "Description text; blank means any"),
    (
        "Statuses",
        "new,in_progress,completed,error; blank means any",
    ),
    ("Readiness", "ready or blocked; blank means any"),
    ("Archived", "hide or show"),
    ("Completed limit", "all, 0 to hide, or positive count"),
];

pub(super) enum Action {
    Save(SavedView),
    Cancel,
}

pub(super) struct Form {
    values: [String; 8],
    cursors: [tag_input::Cursor; 8],
    selected: usize,
    error: String,
}

impl Form {
    pub fn new() -> Self {
        let values = ["", "", "", "", "", "", "hide", "all"].map(str::to_owned);
        let cursors = std::array::from_fn(|index| tag_input::Cursor::at_end(&values[index]));
        Self {
            values,
            cursors,
            selected: 0,
            error: String::new(),
        }
    }

    pub fn set_error(&mut self, error: String) {
        self.error = error;
    }

    pub fn paste(&mut self, text: &str) {
        self.cursors[self.selected]
            .insert(&mut self.values[self.selected], &text.replace("\r\n", "\n"));
        self.error.clear();
    }

    fn definition(&self) -> Result<SavedView> {
        let statuses = if self.values[4].trim().is_empty() {
            Vec::new()
        } else {
            self.values[4]
                .split(',')
                .map(|value| {
                    ListStatus::from_str(value.trim(), false)
                        .map_err(anyhow::Error::msg)
                        .context("Statuses must be new,in_progress,completed,error")
                })
                .collect::<Result<Vec<_>>>()?
        };
        let readiness = match self.values[5].trim() {
            "" => None,
            value => Some(
                Readiness::from_str(value, false)
                    .map_err(anyhow::Error::msg)
                    .context("Readiness must be ready or blocked, or blank")?,
            ),
        };
        let include_archived = match self.values[6].trim() {
            "hide" => false,
            "show" => true,
            _ => bail!("Archived must be hide or show"),
        };
        let max_completed = match self.values[7].trim() {
            "all" | "" => None,
            value => Some(
                value
                    .parse::<i64>()
                    .context("Completed limit must be all or nonnegative count")?,
            ),
        };
        let mut view = SavedView {
            name: self.values[0].clone(),
            criteria: crate::selection::Selectors {
                tags: crate::tags::parse_lines(&self.values[1])?,
                filter: (!self.values[2].is_empty()).then(|| self.values[2].clone()),
                query: (!self.values[3].is_empty()).then(|| self.values[3].clone()),
                statuses,
                readiness,
            },
            include_archived,
            max_completed,
        };
        crate::views::validate(&mut view)?;
        Ok(view)
    }

    pub fn key(&mut self, key: KeyEvent) -> Option<Action> {
        let control = key.modifiers.contains(KeyModifiers::CONTROL);
        if key.code == KeyCode::Esc || (control && key.code == KeyCode::Char('c')) {
            return Some(Action::Cancel);
        }
        if key.modifiers.contains(KeyModifiers::SUPER) {
            return None;
        }
        if control && !key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Char('s') {
            match self.definition() {
                Ok(view) => return Some(Action::Save(view)),
                Err(error) => self.error = format!("{error:#}"),
            }
        } else if !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
            && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
        {
            let previous =
                key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT);
            self.selected =
                (self.selected + if previous { FIELDS.len() - 1 } else { 1 }) % FIELDS.len();
            self.error.clear();
        } else if key.code == KeyCode::Enter
            && !control
            && !key.modifiers.contains(KeyModifiers::ALT)
            && !matches!(self.selected, 1..=3)
        {
            self.selected = (self.selected + 1) % FIELDS.len();
            self.error.clear();
        } else {
            self.cursors[self.selected].edit(&mut self.values[self.selected], &mut self.error, key);
        }
        None
    }

    pub fn rows(&self, width: usize, height: usize) -> Vec<PopupRow> {
        let mut rows = tag_input::rows(
            None,
            &self.values[self.selected],
            &self.cursors[self.selected],
            &self.error,
            width,
            height,
        );
        let last = rows.len().saturating_sub(1);
        let guidance_index = rows.iter().enumerate().find_map(|(index, row)| {
            (row.kind == PopupKind::Hint && index != last).then_some(index)
        });
        for (index, row) in rows.iter_mut().enumerate() {
            match row.kind {
                PopupKind::Heading => row.text = "Create view".into(),
                PopupKind::Hint if index == last => {
                    row.text = if width >= 38 {
                        "Tab next Shift-Tab back Ctrl-S save Esc".into()
                    } else if width >= 24 {
                        "Tab/Shift-Tab ^S save Esc".into()
                    } else {
                        "Tab ^S Esc".into()
                    };
                }
                _ => (),
            }
        }
        if let Some(index) = guidance_index {
            let (name, guidance) = FIELDS[self.selected];
            let label = if width >= 24 {
                format!("{name} ({}/8): {guidance}", self.selected + 1)
            } else {
                format!("{name} ({}/8)", self.selected + 1)
            };
            let available = 1 + height.saturating_sub(rows.len());
            let guidance = super::wrap_modal(&label, width)
                .into_iter()
                .take(available)
                .map(|line| PopupRow::new(line, PopupKind::Hint));
            rows.splice(index..=index, guidance);
        }
        rows
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }
    fn save(form: &mut Form) -> SavedView {
        match form.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)) {
            Some(Action::Save(view)) => view,
            _ => panic!("Expected valid view, got {}", form.error),
        }
    }

    #[test]
    fn blank_optional_fields_and_unicode_names_use_shared_validation() {
        let mut form = Form::new();
        form.paste("界 e\u{301}X");
        form.key(key(KeyCode::Backspace));
        let view = save(&mut form);
        assert_eq!(view.name, "界 e\u{301}");
        assert!(view.criteria.tags.is_empty());
        assert!(view.criteria.statuses.is_empty());
        assert!(!view.include_archived && view.max_completed.is_none());
    }

    #[test]
    fn invalid_fields_keep_values_until_corrected() {
        let mut form = Form::new();
        form.values[0] = "Ready".into();
        for (index, value) in [
            (0, " bad "),
            (1, "[tag]"),
            (2, "unknown == 1"),
            (4, "new,bad"),
            (5, "bad"),
            (6, "bad"),
            (7, "-1"),
        ] {
            let original = form.values[index].clone();
            form.values[index] = value.into();
            assert!(form.definition().is_err(), "{index}: {value}");
            assert_eq!(form.values[index], value);
            form.values[index] = original;
        }
        assert_eq!(save(&mut form).name, "Ready");
    }

    #[test]
    fn field_navigation_and_paste_preserve_each_caret() {
        let mut form = Form::new();
        form.paste("界e\u{301}");
        form.key(key(KeyCode::Left));
        form.key(key(KeyCode::Tab));
        form.paste("backend\r\n界 面\nbackend");
        form.key(key(KeyCode::BackTab));
        form.paste("X");
        let view = save(&mut form);
        assert_eq!(view.name, "界Xe\u{301}");
        assert_eq!(view.criteria.tags, vec!["backend", "界 面"]);
        assert!(matches!(
            form.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
            Some(Action::Cancel)
        ));
    }

    #[test]
    fn normal_popup_width_explains_status_values_before_input() {
        let mut form = Form::new();
        form.selected = 4;
        let rows = form.rows(44, 12);
        assert!(
            rows.iter()
                .any(|row| row.kind == PopupKind::Hint && row.text.contains("error"))
        );
        let hints = rows
            .iter()
            .filter(|row| row.kind == PopupKind::Hint)
            .map(|row| row.text.as_str())
            .collect::<Vec<_>>()
            .join(" ");
        assert!(hints.contains("blank means any"));
    }

    #[test]
    fn narrow_form_keeps_input_caret_and_bounded_rows() {
        let mut form = Form::new();
        form.paste(&"界e\u{301}".repeat(40));
        for height in 1..=12 {
            let rows = form.rows(12, height);
            assert!(rows.len() <= height);
            assert!(
                rows.iter()
                    .any(|row| row.cursor.is_some_and(|column| column < 12))
            );
        }
    }
}