use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[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,
}
}
#[must_use]
pub fn for_node_kind(kind: &str) -> Self {
if kind.starts_with("test:") {
Role::Tester
} else {
Role::Coder
}
}
#[must_use]
pub fn for_parent() -> Self {
Role::Architect
}
}
#[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)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Team {
pub members: Vec<Member>,
}
impl Team {
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()))
}
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())
}
}
#[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 },
],
}
}
#[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:?}");
}
}
#[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");
assert_eq!(a[0], "ada");
}
#[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();
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");
}
#[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()));
assert_eq!(team.allocate(Role::Tester, &mut load), Some("solo".to_string()));
}
#[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);
}
}