nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **Team roster + role↔task-kind assignment** — the real-person half of the
//! funnel-prompt-planner keystone (spec §3).
//!
//! Deliberately impure/nornir-side (file I/O over `.nornir/team.toml`), unlike
//! the pure `Card`/`Lane`/`Event` vocabulary that lives in `funnel-core`: a
//! roster is workspace config, not a DAG primitive. `Role` here is a plain
//! nornir enum (NOT re-exported from `funnel-core`) so `Event::CardAssigned`'s
//! `role` field stays a free-form `String` and funnel-core never needs to know
//! nornir's role vocabulary — one fewer cross-repo coupling point.

use std::collections::BTreeMap;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// A team member's function. Drives `--assign auto`'s role↔task-kind mapping
/// (spec §3): `code:write`/`code:fix` → Coder, `test:write` → Tester,
/// architecture/decomposition parents (the mother idea) → Architect.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
    Architect,
    Coder,
    Reviewer,
    Tester,
}

impl Role {
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Role::Architect => "architect",
            Role::Coder => "coder",
            Role::Reviewer => "reviewer",
            Role::Tester => "tester",
        }
    }

    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "architect" => Some(Role::Architect),
            "coder" | "developer" | "dev" => Some(Role::Coder),
            "reviewer" => Some(Role::Reviewer),
            "tester" | "qa" => Some(Role::Tester),
            _ => None,
        }
    }

    /// The role `--assign auto` picks for a decomposed **task node**, from its
    /// free-verb `node_kind` (spec §3): `test:*` → Tester, everything else
    /// (`code:write`/`code:fix` and any other verb) → Coder. The DECOMPOSITION
    /// PARENT (the mother idea) is handled separately — see [`Role::for_parent`]
    /// — it is always Architect, regardless of node_kind.
    #[must_use]
    pub fn for_node_kind(kind: &str) -> Self {
        if kind.starts_with("test:") {
            Role::Tester
        } else {
            Role::Coder
        }
    }

    /// The role a decomposition's PARENT/mother node is assigned under
    /// (spec §3: "architecture/decomposition parents → an Architect").
    #[must_use]
    pub fn for_parent() -> Self {
        Role::Architect
    }
}

/// One team member: name, the role(s) they can be allocated for, and how many
/// concurrent cards they can carry (`--assign auto`'s capacity cap).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
    pub name: String,
    pub roles: Vec<Role>,
    #[serde(default = "default_capacity")]
    pub capacity: u32,
}

fn default_capacity() -> u32 {
    3
}

impl Member {
    #[must_use]
    pub fn can(&self, role: Role) -> bool {
        self.roles.contains(&role)
    }
}

/// The roster. Source: `.nornir/team.toml` (deterministic, testable, v1 per
/// spec §3) — `members = [[…]]` TOML tables, e.g.:
///
/// ```toml
/// [[members]]
/// name = "ada"
/// roles = ["architect", "coder"]
/// capacity = 4
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Team {
    pub members: Vec<Member>,
}

impl Team {
    /// Load from an explicit TOML file path.
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("read team roster {}", path.display()))?;
        toml::from_str(&text).with_context(|| format!("parse team roster {}", path.display()))
    }

    /// Load `<workspace_root>/.nornir/team.toml`, falling back to
    /// [`Team::sample`] when the file doesn't exist (so `--assign auto` always
    /// has *someone* to allocate to, even in a repo that never ran the sample
    /// setup). A present-but-unparsable file is a hard error (never a silent
    /// fallback — a typo'd roster should be loud, not invisible).
    pub fn load_default(workspace_root: &Path) -> Result<Self> {
        let path = workspace_root.join(".nornir").join("team.toml");
        if path.exists() {
            Self::load(&path)
        } else {
            Ok(Self::sample())
        }
    }

    /// The default sample roster (also shipped on disk as `.nornir/team.toml`):
    /// one Architect, two Coders (uneven capacity so round-robin has something
    /// to prove), one Tester, one Reviewer.
    #[must_use]
    pub fn sample() -> Self {
        Team {
            members: vec![
                Member { name: "ada".into(), roles: vec![Role::Architect, Role::Coder], capacity: 4 },
                Member { name: "grace".into(), roles: vec![Role::Coder], capacity: 3 },
                Member { name: "linus".into(), roles: vec![Role::Coder], capacity: 2 },
                Member { name: "margaret".into(), roles: vec![Role::Tester], capacity: 3 },
                Member { name: "alan".into(), roles: vec![Role::Reviewer, Role::Architect], capacity: 3 },
            ],
        }
    }

    /// Deterministically allocate ONE member for `role`, given the current
    /// in-flight `load` (card count already assigned to each member THIS run —
    /// the caller owns and mutates this across a whole `prompt --assign auto`
    /// batch so round-robin spreads across subtasks). Prefers a member who
    /// both (a) has `role` and (b) is under `capacity`; ties break on the
    /// LOWEST current load, then lexicographically on `name` (spec §3:
    /// "deterministic tie-break"). Falls back to ignoring the capacity cap
    /// (still role-filtered + deterministically tie-broken) when every
    /// eligible member is already at capacity, so allocation never fails as
    /// long as at least one member can fill the role.
    #[must_use]
    pub fn allocate(&self, role: Role, load: &mut BTreeMap<String, u32>) -> Option<String> {
        let eligible: Vec<&Member> = self.members.iter().filter(|m| m.can(role)).collect();
        if eligible.is_empty() {
            return None;
        }
        let under_cap: Vec<&Member> = eligible
            .iter()
            .filter(|m| load.get(m.name.as_str()).copied().unwrap_or(0) < m.capacity)
            .copied()
            .collect();
        let pool = if under_cap.is_empty() { &eligible } else { &under_cap };
        let picked = pool
            .iter()
            .min_by_key(|m| (load.get(m.name.as_str()).copied().unwrap_or(0), m.name.clone()))
            .copied()?;
        *load.entry(picked.name.clone()).or_insert(0) += 1;
        Some(picked.name.clone())
    }
}

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

    #[test]
    fn role_round_trips_str_and_kind_mapping() {
        for r in [Role::Architect, Role::Coder, Role::Reviewer, Role::Tester] {
            assert_eq!(Role::parse(r.as_str()), Some(r));
        }
        assert_eq!(Role::for_node_kind("code:write"), Role::Coder);
        assert_eq!(Role::for_node_kind("code:fix"), Role::Coder);
        assert_eq!(Role::for_node_kind("test:write"), Role::Tester);
        assert_eq!(Role::for_parent(), Role::Architect);
    }

    #[test]
    fn sample_roster_parses_and_has_every_role() {
        let team = Team::sample();
        for r in [Role::Architect, Role::Coder, Role::Reviewer, Role::Tester] {
            assert!(team.members.iter().any(|m| m.can(r)), "sample roster covers {r:?}");
        }
    }

    /// Round-robin within a role: 5 Coder-role allocations spread across the
    /// two Coder-capable members roughly by capacity, deterministically — a
    /// SECOND run with a fresh `load` map produces the IDENTICAL sequence.
    #[test]
    fn allocate_round_robins_deterministically_within_role() {
        let team = Team::sample();
        let run = || {
            let mut load = BTreeMap::new();
            (0..5).map(|_| team.allocate(Role::Coder, &mut load).unwrap()).collect::<Vec<_>>()
        };
        let a = run();
        let b = run();
        assert_eq!(a, b, "same inputs -> byte-identical allocation sequence");
        // ada (cap 4) and grace (cap 3) and linus (cap 2) are the 3 Coders;
        // lowest-load-then-name tie-break means the very first pick is
        // whichever name sorts first among the (load=0) trio: "ada".
        assert_eq!(a[0], "ada");
    }

    /// Capacity is respected while headroom exists: a member never receives a
    /// second card while a same-role peer is still under capacity.
    #[test]
    fn allocate_respects_capacity_before_overflowing() {
        let team = Team { members: vec![
            Member { name: "low".into(), roles: vec![Role::Coder], capacity: 1 },
            Member { name: "high".into(), roles: vec![Role::Coder], capacity: 5 },
        ] };
        let mut load = BTreeMap::new();
        let picks: Vec<String> = (0..3).map(|_| team.allocate(Role::Coder, &mut load).unwrap()).collect();
        // The capacity invariant, asserted order-independently: "low" (cap 1)
        // may be filled AT MOST once, and every remaining card overflows to
        // "high" (cap 5). (Exact sequence here is [high, low, high] — "high"
        // sorts BEFORE "low", so the first zero-load tie-break picks it — but
        // that's incidental; what matters is nobody exceeds capacity while a
        // peer has headroom.)
        assert_eq!(picks.iter().filter(|p| p.as_str() == "low").count(), 1, "low (cap 1) filled exactly once");
        assert_eq!(picks.iter().filter(|p| p.as_str() == "high").count(), 2, "high (cap 5) absorbs the overflow");
    }

    /// When every eligible member is at capacity, allocation still succeeds
    /// (overflow, not a hard failure) and stays deterministic.
    #[test]
    fn allocate_overflows_deterministically_when_all_at_capacity() {
        let team = Team { members: vec![
            Member { name: "solo".into(), roles: vec![Role::Tester], capacity: 1 },
        ] };
        let mut load = BTreeMap::new();
        assert_eq!(team.allocate(Role::Tester, &mut load), Some("solo".to_string()));
        // Over capacity now, but still the only Tester -> still returns it.
        assert_eq!(team.allocate(Role::Tester, &mut load), Some("solo".to_string()));
    }

    /// No member has the role -> None (never a wrong-role assignment).
    #[test]
    fn allocate_returns_none_for_uncovered_role() {
        let team = Team { members: vec![
            Member { name: "solo".into(), roles: vec![Role::Coder], capacity: 3 },
        ] };
        let mut load = BTreeMap::new();
        assert_eq!(team.allocate(Role::Tester, &mut load), None);
    }

    #[test]
    fn load_default_falls_back_to_sample_when_no_file() {
        let dir = tempfile::tempdir().unwrap();
        let team = Team::load_default(dir.path()).unwrap();
        assert_eq!(team.members.len(), Team::sample().members.len());
    }

    #[test]
    fn load_default_reads_the_toml_file_when_present() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".nornir")).unwrap();
        std::fs::write(
            dir.path().join(".nornir").join("team.toml"),
            "[[members]]\nname = \"solo\"\nroles = [\"coder\"]\ncapacity = 9\n",
        )
        .unwrap();
        let team = Team::load_default(dir.path()).unwrap();
        assert_eq!(team.members.len(), 1);
        assert_eq!(team.members[0].name, "solo");
        assert_eq!(team.members[0].capacity, 9);
    }
}