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, skip_serializing_if = "Option::is_none")]
20 pub team_id: Option<String>,
21 #[serde(default)]
22 pub members: Vec<String>,
23 pub channel_id: String,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub rules: Vec<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub skills: Vec<String>,
28 #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
29 pub loop_cfg: Option<FleetLoop>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct FleetLoop {
34 #[serde(default = "default_trigger")]
35 pub trigger: String,
36 #[serde(default)]
39 pub max_iterations: u32,
40 #[serde(default)]
41 pub budget_usd: f64,
42 #[serde(default)]
43 pub deadline: String,
44 #[serde(default)]
45 pub done_when: String,
46}
47
48fn default_trigger() -> String {
49 "manual".to_string()
50}
51
52impl Fleet {
53 pub fn router_or_concierge(&self) -> &str {
54 self.router.as_deref().unwrap_or(CONCIERGE_AGENT)
55 }
56}
57
58pub fn valid_fleet_name(name: &str) -> bool {
61 !name.is_empty()
62 && name.len() <= 64
63 && name
64 .chars()
65 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
66}
67
68pub const CHANNEL_PREFIX: &str = "fleet-";
70
71pub fn fleet_name_from_channel_id(channel_id: &str) -> Option<&str> {
78 let name = channel_id.strip_prefix(CHANNEL_PREFIX)?;
79 valid_fleet_name(name).then_some(name)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn valid_fleet_name_accepts_and_rejects() {
88 assert!(valid_fleet_name("dev"));
90 assert!(valid_fleet_name("dev-team"));
91 assert!(valid_fleet_name("dev_1"));
92 assert!(valid_fleet_name("ab12"));
93 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")); }
102
103 #[test]
104 fn fleet_name_from_channel_id_extracts_and_validates() {
105 assert_eq!(fleet_name_from_channel_id("fleet-dev"), Some("dev"));
107 assert_eq!(
108 fleet_name_from_channel_id("fleet-my-squad"),
109 Some("my-squad")
110 );
111 assert_eq!(fleet_name_from_channel_id("fleet-ab12"), Some("ab12"));
112 assert_eq!(fleet_name_from_channel_id("dev"), None);
114 assert_eq!(fleet_name_from_channel_id("agent:foo:uuid"), None);
115 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); }
121
122 #[test]
123 fn fleet_minimal_yaml_deserializes_with_defaults() {
124 let f: Fleet = serde_yaml::from_str("name: dev\nchannel_id: fleet-dev\n").unwrap();
125 assert_eq!(f.name, "dev");
126 assert_eq!(f.channel_id, "fleet-dev");
127 assert!(f.members.is_empty());
128 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
129 assert!(f.loop_cfg.is_none());
130 }
131
132 #[test]
133 fn fleet_yaml_roundtrip_and_router_default() {
134 let f = Fleet {
135 name: "dev".into(),
136 display_name: "Dev Team".into(),
137 goal: "ship it".into(),
138 router: None,
139 team_id: None,
140 members: vec!["pm".into(), "qa".into()],
141 channel_id: "fleet-dev".into(),
142 rules: vec![],
143 skills: vec![],
144 loop_cfg: None,
145 };
146 assert_eq!(f.router_or_concierge(), CONCIERGE_AGENT);
147 let yaml = serde_yaml::to_string(&f).unwrap();
148 let back: Fleet = serde_yaml::from_str(&yaml).unwrap();
149 assert_eq!(back, f);
150 let with_loop: Fleet = serde_yaml::from_str(
152 "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",
153 ).unwrap();
154 assert_eq!(with_loop.loop_cfg.unwrap().max_iterations, 3);
155 }
156
157 #[test]
158 fn minimal_loop_block_deserializes_with_defaults() {
159 let f: Fleet = serde_yaml::from_str(
162 "name: dev\nchannel_id: fleet-dev\nloop:\n trigger: \"interval:1h\"\n",
163 )
164 .unwrap();
165 let l = f.loop_cfg.unwrap();
166 assert_eq!(l.trigger, "interval:1h");
167 assert_eq!(l.max_iterations, 0);
168 assert_eq!(l.budget_usd, 0.0);
169 }
170}