//! The funnel-prompt-planner **mode** vocabulary — a typed reading of the
//! free-form `mode` token an [`Event::IdeaModeSet`](crate::event::Event::IdeaModeSet)
//! records on a mother idea.
//!
//! ## Why the field stays a `String`
//!
//! [`Idea::mode`](crate::state::Idea::mode) is deliberately kept as an
//! `Option<String>` (a free-form wire token) so a brand-new mode never forces a
//! schema bump and every pre-keystone log row (no `IdeaModeSet`) replays as
//! `None`. That is the *storage* contract and it must not change. [`FunnelMode`]
//! is the *reading* contract layered on top: it turns the two modes the funnel
//! actually gates on — `auto` and `manual` — into a proper enum so callers
//! decide behaviour by pattern-match instead of string comparison, while an
//! unknown/future token degrades to `None` rather than an error.
//!
//! nornir's `funnel prompt` already stamps `IdeaModeSet { mode: "auto" | "manual" }`
//! and then reads it back; before this crate carried a mode field at all,
//! `ordning-funnel` failed to adopt a `funnel_core::Idea` field-for-field
//! (`crates/ordning-funnel/src/lib.rs`). The field closed that gap; [`FunnelMode`]
//! and the [`child_lane`](FunnelMode::child_lane) **mode gate** make the vocabulary
//! coherent and single-sourced instead of re-implemented at each call site.
use serde::{Deserialize, Serialize};
use crate::card::Lane;
/// How a mother idea's prompt-driven decomposition runs (the funnel-prompt-planner
/// keystone, §2). The typed reading of [`Idea::mode`](crate::state::Idea::mode).
///
/// - **`Auto`** — the decomposed child tasks join the live plan immediately
/// (their kanban lane is [`Lane::Planned`]).
/// - **`Manual`** — the child tasks are staged as drafts for a human accept/reject
/// decision first (their lane is [`Lane::Proposed`], the staging gate).
///
/// Serialised lower-case (`auto` / `manual`), matching the wire token
/// `IdeaModeSet` records. `Auto` is the default — an idea decomposed without an
/// explicit mode behaves as auto (tasks go straight to the plan).
///
/// **Open enum** (`#[non_exhaustive]`) — the mode is stored as a *free-form* token
/// on the wire precisely so a new mode never needs a schema bump, and
/// [`parse`](FunnelMode::parse) degrades an unknown token to `None` rather than
/// erroring. The typed reading must be able to grow to match. Use
/// [`child_lane`](FunnelMode::child_lane) / [`as_str`](FunnelMode::as_str) instead
/// of matching where you can. See the open-enum contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FunnelMode {
/// Child tasks join the live plan immediately (lane `Planned`).
#[default]
Auto,
/// Child tasks are staged as drafts for human review (lane `Proposed`).
Manual,
}
impl FunnelMode {
/// The lower-case wire/CLI token (`auto` | `manual`) — the exact string an
/// [`Event::IdeaModeSet`](crate::event::Event::IdeaModeSet) carries.
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
FunnelMode::Auto => "auto",
FunnelMode::Manual => "manual",
}
}
/// Parse a wire token (case-insensitive). Unknown / empty → `None` so an
/// unrecognised or future mode degrades gracefully rather than erroring — the
/// same forgiving contract [`Lane::parse`] and [`ItemKind::parse`] follow.
///
/// [`ItemKind::parse`]: crate::event::ItemKind::parse
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" | "automatic" => Some(FunnelMode::Auto),
"manual" | "draft" => Some(FunnelMode::Manual),
_ => None,
}
}
/// The **mode gate**: the kanban [`Lane`] a decomposed child task lands in
/// under this mode — `Auto` → [`Lane::Planned`] (straight into the live plan),
/// `Manual` → [`Lane::Proposed`] (staged for human accept/reject). This is the
/// single source of the rule nornir's `funnel prompt` applies when it stamps
/// each child's `CardStateChanged`.
#[must_use]
pub fn child_lane(&self) -> Lane {
match self {
FunnelMode::Auto => Lane::Planned,
FunnelMode::Manual => Lane::Proposed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_round_trip_and_are_stable() {
for m in [FunnelMode::Auto, FunnelMode::Manual] {
assert_eq!(
FunnelMode::parse(m.as_str()),
Some(m),
"as_str∘parse is identity"
);
}
// The stable wire tokens downstream keys on.
assert_eq!(FunnelMode::Auto.as_str(), "auto");
assert_eq!(FunnelMode::Manual.as_str(), "manual");
}
#[test]
fn parse_is_forgiving_and_case_insensitive() {
assert_eq!(FunnelMode::parse("AUTO"), Some(FunnelMode::Auto));
assert_eq!(FunnelMode::parse(" Manual "), Some(FunnelMode::Manual));
assert_eq!(FunnelMode::parse("automatic"), Some(FunnelMode::Auto));
assert_eq!(FunnelMode::parse("draft"), Some(FunnelMode::Manual));
// Unknown / empty degrade to None (never an error).
assert_eq!(FunnelMode::parse("turbo"), None);
assert_eq!(FunnelMode::parse(""), None);
}
#[test]
fn mode_gate_maps_to_the_right_lane() {
assert_eq!(FunnelMode::Auto.child_lane(), Lane::Planned);
assert_eq!(FunnelMode::Manual.child_lane(), Lane::Proposed);
// The gate agrees with the lane's openness model: an auto task is tracked
// (closed lane), a manual draft is still actionable (open lane).
assert!(!FunnelMode::Auto.child_lane().is_open());
assert!(FunnelMode::Manual.child_lane().is_open());
}
#[test]
fn default_is_auto() {
assert_eq!(FunnelMode::default(), FunnelMode::Auto);
}
}