1use serde::{Deserialize, Serialize};
4
5pub const CONCIERGE_AGENT: &str = "mur";
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct Fleet {
9 pub name: String,
10 #[serde(default)]
11 pub display_name: String,
12 #[serde(default)]
13 pub goal: String,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub router: Option<String>,
16 #[serde(default)]
17 pub members: Vec<String>,
18 pub channel_id: String,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub rules: Vec<String>,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
22 pub skills: Vec<String>,
23 #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
24 pub loop_cfg: Option<FleetLoop>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct FleetLoop {
29 #[serde(default = "default_trigger")]
30 pub trigger: String,
31 #[serde(default)]
34 pub max_iterations: u32,
35 #[serde(default)]
36 pub budget_usd: f64,
37 #[serde(default)]
38 pub deadline: String,
39 #[serde(default)]
40 pub done_when: String,
41}
42
43fn default_trigger() -> String {
44 "manual".to_string()
45}
46
47impl Fleet {
48 pub fn router_or_concierge(&self) -> &str {
49 self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
50 }
51}
52
53pub fn valid_fleet_name(name: &str) -> bool {
56 !name.is_empty()
57 && name.len() <= 64
58 && name
59 .chars()
60 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
61}
62
63pub const CHANNEL_PREFIX: &str = "fleet-";
65
66pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
73 let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
74 valid_fleet_name(name).then_some(name)
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn valid_fleet_name_accepts_and_rejects() {
83 assert!(valid_fleet_name("dev"));
85 assert!(valid_fleet_name("dev-team"));
86 assert!(valid_fleet_name("dev_1"));
87 assert!(valid_fleet_name("ab12"));
88 assert!(!valid_fleet_name("")); assert!(!valid_fleet_name("../x")); assert!(!valid_fleet_name("a/b")); assert!(!valid_fleet_name("a\\b")); assert!(!valid_fleet_name("Dev")); assert!(!valid_fleet_name("a b")); assert!(!valid_fleet_name(".hidden")); }
97
98 #[test]
99 fn fleet_name_from_channel_id_extracts_and_validates() {
100 assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
102 assert_eq!(
103 fleet_name_from_channel_id("fleet-my-squad"),
104 Some("my-squad")
105 );
106 assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
107 assert_eq!(fleet_name_from_channel_id("dev"), None);
109 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
110 assert_eq!(fleet_name_from_channel_id("fleet-"), None); assert_eq!(fleet_name_from_channel_id("fleet-../etc"), None); assert_eq!(fleet_name_from_channel_id("fleet-a/b"), None); assert_eq!(fleet_name_from_channel_id("fleet-Dev"), None); }
116
117 #[test]
118 fn fleet_minimal_yaml_deserializes_with_defaults() {
119 let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
120 assert_eq!(f.name, "dev");
121 assert_eq!(f.channel_id, "fleet-dev");
122 assert!(f.members.is_empty());
123 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
124 assert!(f.loop_cfg.is_none());
125 }
126
127 #[test]
128 fn fleet_yaml_roundtrip_and_router_default() {
129 let f = Fleet {
130 name: "dev".into(),
131 display_name: "Dev Team".into(),
132 goal: "ship it".into(),
133 router: None,
134 members: vec!["pm".into(), "qa".into()],
135 channel_id: "fleet-dev".into(),
136 rules: vec![],
137 skills: vec![],
138 loop_cfg: None,
139 };
140 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
141 let yaml = serde_yaml::to_string(&f).unwrap();
142 let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
143 assert_eq!(back, f);
144 let with_loop: Fleet = serde_yaml::from_str(
146 "name: dev\ndisplay_name: Dev\ngoal: test\nchannel_id: fleet-dev\nrules: []\nskills: []\nmembers: []\nloop:\n trigger: manual\n max_iterations: 3\n budget_usd: 1.0\n deadline: '2026-12-31'\n done_when: 'all_tasks_done'\n",
147 ).unwrap();
148 assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
149 }
150
151 #[test]
152 fn minimal_loop_block_deserializes_with_defaults() {
153 let f: Fleet = serde_yaml::from_str(
156 "name: dev\nchannel_id: fleet-dev\nloop:\n trigger: \"interval:1h\"\n",
157 )
158 .unwrap();
159 let l = f.loop_cfg.unwrap();
160 assert_eq!(l.trigger, "interval:1h");
161 assert_eq!(l.max_iterations, 0);
162 assert_eq!(l.budget_usd, 0.0);
163 }
164}