1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Deserializer, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(transparent)]
34pub struct SchemaVersion {
35 pub value: String,
36 #[serde(skip)]
37 pub from_legacy_int: bool,
38}
39
40impl SchemaVersion {
41 pub fn new(value: impl Into<String>) -> Self {
44 Self {
45 value: value.into(),
46 from_legacy_int: false,
47 }
48 }
49}
50
51impl<'de> Deserialize<'de> for SchemaVersion {
52 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
53 use serde::de::{self, Visitor};
54 struct V;
55 impl<'de> Visitor<'de> for V {
56 type Value = SchemaVersion;
57 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
58 f.write_str(
59 "a semver string like \"2.0.0\" (legacy integer `2` also accepted \
60 and auto-rewritten to \"2.0.0\" on next save)",
61 )
62 }
63 fn visit_str<E: de::Error>(self, s: &str) -> Result<SchemaVersion, E> {
64 Ok(SchemaVersion {
65 value: s.to_string(),
66 from_legacy_int: false,
67 })
68 }
69 fn visit_string<E: de::Error>(self, s: String) -> Result<SchemaVersion, E> {
70 Ok(SchemaVersion {
71 value: s,
72 from_legacy_int: false,
73 })
74 }
75 fn visit_u64<E: de::Error>(self, n: u64) -> Result<SchemaVersion, E> {
76 if n == 2 {
77 Ok(SchemaVersion {
78 value: "2.0.0".to_string(),
79 from_legacy_int: true,
80 })
81 } else {
82 Err(E::custom(format!(
83 "compose schema version must be a semver string like \"2.0.0\"; \
84 got integer {n} — only legacy `2` is auto-coerced"
85 )))
86 }
87 }
88 fn visit_i64<E: de::Error>(self, n: i64) -> Result<SchemaVersion, E> {
89 if n == 2 {
90 Ok(SchemaVersion {
91 value: "2.0.0".to_string(),
92 from_legacy_int: true,
93 })
94 } else {
95 Err(E::custom(format!(
96 "compose schema version must be a semver string like \"2.0.0\"; \
97 got integer {n} — only legacy `2` is auto-coerced"
98 )))
99 }
100 }
101 }
102 d.deserialize_any(V)
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Global {
109 pub version: SchemaVersion,
110
111 #[serde(default)]
112 pub broker: Broker,
113
114 #[serde(default)]
115 pub supervisor: SupervisorCfg,
116
117 #[serde(default)]
118 pub budget: Budget,
119
120 #[serde(default)]
121 pub hitl: Hitl,
122
123 #[serde(default)]
124 pub rate_limits: RateLimits,
125
126 #[serde(default)]
129 pub interfaces: Vec<Interface>,
130
131 #[serde(default)]
133 pub projects: Vec<ProjectRef>,
134
135 #[serde(default)]
139 pub attachments: Attachments,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct Attachments {
144 #[serde(default = "default_attachments_enabled")]
145 pub enabled: bool,
146 #[serde(default = "default_attachments_max_size_bytes")]
147 pub max_size_bytes: u64,
148 #[serde(default = "default_attachments_allowed_roots")]
153 pub allowed_roots: Vec<String>,
154 #[serde(default)]
155 pub scanner: Option<AttachmentScanner>,
156 #[serde(default)]
160 pub audit_log_path: Option<PathBuf>,
161 #[serde(default = "default_attachments_tempfile_ttl_seconds")]
169 pub tempfile_ttl_seconds: u64,
170}
171
172impl Default for Attachments {
173 fn default() -> Self {
174 Self {
175 enabled: default_attachments_enabled(),
176 max_size_bytes: default_attachments_max_size_bytes(),
177 allowed_roots: default_attachments_allowed_roots(),
178 scanner: None,
179 audit_log_path: None,
180 tempfile_ttl_seconds: default_attachments_tempfile_ttl_seconds(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct AttachmentScanner {
187 pub command: String,
190 #[serde(default = "default_scanner_timeout_seconds")]
191 pub timeout_seconds: u64,
192}
193
194fn default_attachments_enabled() -> bool {
195 true
196}
197
198fn default_attachments_max_size_bytes() -> u64 {
199 5 * 1024 * 1024
200}
201
202fn default_attachments_allowed_roots() -> Vec<String> {
203 vec!["$HOME".to_string()]
204}
205
206fn default_scanner_timeout_seconds() -> u64 {
207 30
208}
209
210fn default_attachments_tempfile_ttl_seconds() -> u64 {
211 6 * 60 * 60
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct Interface {
216 pub r#type: String,
218 pub name: String,
220 #[serde(default)]
222 pub config: serde_yaml::Value,
223}
224
225impl Interface {
226 pub fn is_telegram(&self) -> bool {
227 self.r#type == "telegram"
228 }
229
230 pub fn manager(&self) -> Option<String> {
232 self.config_str("manager")
233 }
234
235 pub fn bot_token_env(&self) -> Option<String> {
237 self.config_str("bot_token_env")
238 }
239
240 pub fn authorized_chat_ids_env(&self) -> Option<String> {
242 self.config_str("authorized_chat_ids_env")
243 }
244
245 fn config_str(&self, key: &str) -> Option<String> {
246 match &self.config {
247 serde_yaml::Value::Mapping(m) => m
248 .get(serde_yaml::Value::String(key.into()))
249 .and_then(|v| v.as_str())
250 .map(str::to_owned),
251 _ => None,
252 }
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, Default)]
257pub struct Budget {
258 #[serde(default)]
259 pub daily_usd_limit: Option<f64>,
260 #[serde(default)]
261 pub warn_threshold_pct: Option<u32>,
262 #[serde(default)]
263 pub message_ttl_hours: Option<u32>,
264 #[serde(default)]
265 pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
270pub struct RateLimits {
271 #[serde(default)]
273 pub default_on_hit: Vec<String>,
274
275 #[serde(default)]
277 pub hooks: Vec<RateLimitHook>,
278
279 #[serde(default = "default_fallback_wait")]
282 pub fallback_wait_seconds: u64,
283}
284
285fn default_fallback_wait() -> u64 {
286 30 * 60
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct RateLimitHook {
303 pub name: String,
304 pub action: String,
305 #[serde(default)]
306 pub to: Option<String>,
307 #[serde(default)]
308 pub template: Option<String>,
309 #[serde(default)]
310 pub url: Option<String>,
311 #[serde(default)]
312 pub url_env: Option<String>,
313 #[serde(default)]
314 pub method: Option<String>,
315 #[serde(default)]
316 pub command: Vec<String>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Hitl {
321 #[serde(default = "default_sensitive_actions")]
322 pub globally_sensitive_actions: Vec<String>,
323 #[serde(default)]
324 pub auto_approve_windows: Vec<AutoApprove>,
325}
326
327impl Default for Hitl {
328 fn default() -> Self {
329 Self {
330 globally_sensitive_actions: default_sensitive_actions(),
331 auto_approve_windows: Vec::new(),
332 }
333 }
334}
335
336fn default_sensitive_actions() -> Vec<String> {
337 vec![
338 "publish".into(),
339 "release".into(),
340 "payment".into(),
341 "external_email".into(),
342 "external_api_post".into(),
343 "merge_to_main".into(),
344 "dns_change".into(),
345 "deploy".into(),
346 ]
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct AutoApprove {
351 pub action: String,
352 #[serde(default)]
353 pub project: Option<String>,
354 #[serde(default)]
355 pub agent: Option<String>,
356 #[serde(default)]
357 pub scope: Option<String>,
358 pub until: String,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ProjectRef {
364 pub file: PathBuf,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368pub struct Broker {
369 #[serde(default = "default_broker_type")]
370 pub r#type: String,
371 #[serde(default = "default_mailbox_path")]
372 pub path: PathBuf,
373}
374
375impl Default for Broker {
376 fn default() -> Self {
377 Self {
378 r#type: default_broker_type(),
379 path: default_mailbox_path(),
380 }
381 }
382}
383
384fn default_broker_type() -> String {
385 "sqlite".into()
386}
387
388fn default_mailbox_path() -> PathBuf {
389 PathBuf::from("state/mailbox.db")
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
393pub struct SupervisorCfg {
394 #[serde(default = "default_supervisor_type")]
395 pub r#type: String,
396 #[serde(default = "default_tmux_prefix")]
397 pub tmux_prefix: String,
398 #[serde(default = "default_drain_timeout_secs")]
405 pub drain_timeout_secs: u64,
406}
407
408impl Default for SupervisorCfg {
409 fn default() -> Self {
410 Self {
411 r#type: default_supervisor_type(),
412 tmux_prefix: default_tmux_prefix(),
413 drain_timeout_secs: default_drain_timeout_secs(),
414 }
415 }
416}
417
418fn default_supervisor_type() -> String {
419 "tmux".into()
420}
421
422fn default_drain_timeout_secs() -> u64 {
423 10
424}
425
426fn default_tmux_prefix() -> String {
427 "a-".into()
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct Project {
433 pub version: u32,
434 pub project: ProjectMeta,
435
436 #[serde(default)]
437 pub channels: Vec<Channel>,
438
439 #[serde(default)]
440 pub managers: BTreeMap<String, Agent>,
441
442 #[serde(default)]
443 pub workers: BTreeMap<String, Agent>,
444
445 #[serde(default)]
456 pub interfaces: Option<ProjectInterfaces>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ProjectMeta {
461 pub id: String,
462 pub name: String,
463 pub cwd: PathBuf,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
467pub struct Channel {
468 pub name: String,
469 #[serde(default)]
471 pub members: ChannelMembers,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(untagged)]
476pub enum ChannelMembers {
477 All(String),
478 Explicit(Vec<String>),
479}
480
481impl Default for ChannelMembers {
482 fn default() -> Self {
483 Self::Explicit(Vec::new())
484 }
485}
486
487impl ChannelMembers {
488 pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
489 match self {
490 ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
491 ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
492 _ => false,
493 }
494 }
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(untagged)]
505pub enum RolePrompt {
506 Single(PathBuf),
507 Multiple(Vec<PathBuf>),
508}
509
510impl RolePrompt {
511 pub fn paths(&self) -> Vec<&Path> {
514 match self {
515 RolePrompt::Single(p) => vec![p.as_path()],
516 RolePrompt::Multiple(v) => v.iter().map(|p| p.as_path()).collect(),
517 }
518 }
519
520 pub fn is_blank(&self) -> bool {
525 match self {
526 RolePrompt::Single(p) => p.as_os_str().is_empty(),
527 RolePrompt::Multiple(v) => v.is_empty(),
528 }
529 }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct HookSpec {
541 pub event: String,
543
544 #[serde(default)]
548 pub matcher: Option<String>,
549
550 pub command: PathBuf,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct McpServer {
566 pub command: String,
569
570 #[serde(default)]
572 pub args: Vec<String>,
573
574 #[serde(default)]
578 pub env: BTreeMap<String, String>,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct Agent {
583 #[serde(default = "default_runtime")]
584 pub runtime: String,
585 pub model: Option<String>,
586 pub role_prompt: Option<RolePrompt>,
587 #[serde(default)]
588 pub permission_mode: Option<String>,
589 #[serde(default = "default_autonomy")]
590 pub autonomy: String,
591 #[serde(default)]
592 pub can_dm: Vec<String>,
593 #[serde(default)]
594 pub can_broadcast: Vec<String>,
595 #[serde(default)]
596 pub reports_to: Option<String>,
597
598 #[serde(default)]
600 pub on_rate_limit: Option<Vec<String>>,
601
602 #[serde(default)]
608 pub effort: Option<EffortLevel>,
609
610 #[serde(default)]
624 pub ultracode: bool,
625
626 #[serde(default)]
631 pub interfaces: Option<AgentInterfaces>,
632
633 #[serde(default)]
641 pub display_name: Option<String>,
642
643 #[serde(default)]
652 pub hooks: Vec<HookSpec>,
653
654 #[serde(default)]
662 pub mcps: BTreeMap<String, McpServer>,
663
664 #[serde(default)]
677 pub subagents: Vec<PathBuf>,
678
679 #[serde(default)]
692 pub skills: Vec<PathBuf>,
693}
694
695#[derive(Debug, Clone, Serialize, Deserialize, Default)]
698pub struct AgentInterfaces {
699 #[serde(default)]
704 pub telegram: Option<TelegramConfig>,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct TelegramConfig {
711 pub bot_token_env: String,
714 pub chat_ids_env: String,
717 #[serde(default)]
722 pub speech_to_text: Option<SttConfig>,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
729pub struct SttConfig {
730 pub provider: String,
732 pub api_key_env: String,
736 pub model: String,
738 #[serde(default)]
741 pub language: Option<String>,
742}
743
744impl Agent {
745 pub fn telegram(&self) -> Option<&TelegramConfig> {
749 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
750 }
751}
752
753#[derive(Debug, Clone, Serialize, Deserialize, Default)]
758pub struct ProjectInterfaces {
759 #[serde(default)]
766 pub telegram: Option<ProjectTelegramConfig>,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize, Default)]
775pub struct ProjectTelegramConfig {
776 #[serde(default)]
781 pub manager_bot: Option<ManagerBotConfig>,
782
783 #[serde(default)]
789 pub profile_picture: Option<ProfilePictureConfig>,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize)]
796pub struct ManagerBotConfig {
797 pub token_env: String,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, Default)]
810pub struct ProfilePictureConfig {
811 #[serde(default)]
817 pub image_model: Option<ImageModelConfig>,
818
819 #[serde(default)]
824 pub fallback: ProfilePictureFallback,
825}
826
827#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
832#[serde(rename_all = "lowercase")]
833pub enum ProfilePictureFallback {
834 #[default]
838 Initials,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct ImageModelConfig {
847 pub provider: String,
849 pub api_key_env: String,
854 pub model: String,
857}
858
859impl Project {
860 pub fn telegram(&self) -> Option<&ProjectTelegramConfig> {
864 self.interfaces.as_ref().and_then(|i| i.telegram.as_ref())
865 }
866}
867
868#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
872#[serde(rename_all = "lowercase")]
873pub enum EffortLevel {
874 Low,
875 Medium,
876 High,
877 Xhigh,
878 Max,
879}
880
881impl EffortLevel {
882 pub fn as_str(self) -> &'static str {
885 match self {
886 EffortLevel::Low => "low",
887 EffortLevel::Medium => "medium",
888 EffortLevel::High => "high",
889 EffortLevel::Xhigh => "xhigh",
890 EffortLevel::Max => "max",
891 }
892 }
893}
894
895fn default_runtime() -> String {
896 "claude-code".into()
897}
898
899fn default_autonomy() -> String {
900 "low_risk_only".into()
901}
902
903#[derive(Debug, Clone)]
905pub struct Compose {
906 pub root: PathBuf,
907 pub global: Global,
908 pub projects: Vec<Project>,
909}
910
911impl Compose {
912 pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
923 let start = start
924 .canonicalize()
925 .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
926 let mut cur: Option<&Path> = Some(&start);
927 while let Some(dir) = cur {
928 let candidate = dir.join(".team").join("team-compose.yaml");
929 if candidate.is_file() {
930 return Ok(dir.join(".team"));
931 }
932 cur = dir.parent();
933 }
934 Err(anyhow::anyhow!(
935 "no `.team/team-compose.yaml` found in {} or any parent",
936 start.display()
937 ))
938 }
939
940 pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
942 let root = root.as_ref().to_path_buf();
943 let global_path = root.join("team-compose.yaml");
944 let raw = std::fs::read_to_string(&global_path)
945 .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?;
946 let global: Global = serde_yaml::from_str(&raw)
947 .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
948
949 if global.version.from_legacy_int {
966 if let Err(e) = rewrite_legacy_version_in_file(&global_path, &raw) {
967 tracing::warn!(
968 target: "team-core::compose",
969 "could not rewrite legacy `version: 2` in {}: {e}; \
970 proceeding with in-memory `\"2.0.0\"`",
971 global_path.display()
972 );
973 }
974 }
975
976 let mut projects = Vec::with_capacity(global.projects.len());
977 for r in &global.projects {
978 let p = root.join(&r.file);
979 let parsed: Project = serde_yaml::from_str(
980 &std::fs::read_to_string(&p)
981 .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
982 )
983 .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
984 projects.push(parsed);
985 }
986
987 Ok(Self {
988 root,
989 global,
990 projects,
991 })
992 }
993
994 pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
996 self.projects.iter().flat_map(|p| {
997 p.managers
998 .iter()
999 .map(move |(id, a)| AgentHandle {
1000 project: &p.project.id,
1001 agent: id,
1002 spec: a,
1003 is_manager: true,
1004 })
1005 .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
1006 project: &p.project.id,
1007 agent: id,
1008 spec: a,
1009 is_manager: false,
1010 }))
1011 })
1012 }
1013}
1014
1015fn rewrite_legacy_version_in_file(path: &Path, raw: &str) -> anyhow::Result<()> {
1021 let updated = crate::yaml_edit::set_top_level_scalar(raw, "version", "\"2.0.0\"")?;
1022 std::fs::write(path, updated).map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
1023 Ok(())
1024}
1025
1026#[derive(Debug, Clone, Copy)]
1027pub struct AgentHandle<'a> {
1028 pub project: &'a str,
1029 pub agent: &'a str,
1030 pub spec: &'a Agent,
1031 pub is_manager: bool,
1032}
1033
1034impl AgentHandle<'_> {
1035 pub fn id(&self) -> String {
1037 format!("{}:{}", self.project, self.agent)
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044
1045 #[test]
1046 fn channel_members_all_expands() {
1047 let all = ChannelMembers::All("*".into());
1048 assert!(all.includes("dev1", &["dev1", "dev2"]));
1049 assert!(!all.includes("ghost", &["dev1", "dev2"]));
1050 }
1051
1052 #[test]
1053 fn channel_members_explicit_checks_list() {
1054 let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
1055 assert!(exp.includes("dev1", &[]));
1056 assert!(!exp.includes("dev2", &[]));
1057 }
1058
1059 #[test]
1060 fn agent_defaults_are_stable() {
1061 let a: Agent = serde_yaml::from_str("model: claude-opus-4-8\n").unwrap();
1062 assert_eq!(a.runtime, "claude-code");
1063 assert_eq!(a.autonomy, "low_risk_only");
1064 assert!(a.interfaces.is_none());
1065 assert!(a.telegram().is_none());
1066 assert!(a.effort.is_none());
1067 }
1068
1069 #[test]
1070 fn agent_telegram_block_parses_under_interfaces() {
1071 let yaml = "interfaces:\n telegram:\n bot_token_env: T\n chat_ids_env: C\n";
1072 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1073 let tg = a.telegram().expect("telegram parsed");
1074 assert_eq!(tg.bot_token_env, "T");
1075 assert_eq!(tg.chat_ids_env, "C");
1076 assert!(tg.speech_to_text.is_none());
1077 }
1078
1079 #[test]
1080 fn agent_telegram_block_parses_speech_to_text() {
1081 let yaml = "\
1082interfaces:
1083 telegram:
1084 bot_token_env: T
1085 chat_ids_env: C
1086 speech_to_text:
1087 provider: groq
1088 api_key_env: GROQ_API_KEY
1089 model: whisper-large-v3
1090 language: en
1091";
1092 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1093 let stt = a
1094 .telegram()
1095 .and_then(|t| t.speech_to_text.as_ref())
1096 .expect("speech_to_text parsed");
1097 assert_eq!(stt.provider, "groq");
1098 assert_eq!(stt.api_key_env, "GROQ_API_KEY");
1099 assert_eq!(stt.model, "whisper-large-v3");
1100 assert_eq!(stt.language.as_deref(), Some("en"));
1101 }
1102
1103 #[test]
1104 fn agent_telegram_block_parses_speech_to_text_without_language() {
1105 let yaml = "\
1106interfaces:
1107 telegram:
1108 bot_token_env: T
1109 chat_ids_env: C
1110 speech_to_text:
1111 provider: groq
1112 api_key_env: K
1113 model: whisper-large-v3
1114";
1115 let a: Agent = serde_yaml::from_str(yaml).unwrap();
1116 let stt = a
1117 .telegram()
1118 .and_then(|t| t.speech_to_text.as_ref())
1119 .expect("speech_to_text parsed");
1120 assert!(stt.language.is_none());
1121 }
1122
1123 #[test]
1124 fn effort_parses_all_five_levels() {
1125 for (yaml, expected) in [
1126 ("effort: low\n", EffortLevel::Low),
1127 ("effort: medium\n", EffortLevel::Medium),
1128 ("effort: high\n", EffortLevel::High),
1129 ("effort: xhigh\n", EffortLevel::Xhigh),
1130 ("effort: max\n", EffortLevel::Max),
1131 ] {
1132 let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
1133 assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
1134 }
1135 }
1136
1137 #[test]
1138 fn ultracode_parses_from_yaml_and_defaults_false() {
1139 let on: Agent = serde_yaml::from_str("ultracode: true\n").expect("ultracode: true");
1143 assert!(on.ultracode, "ultracode: true must parse to true");
1144 let off: Agent = serde_yaml::from_str("ultracode: false\n").expect("ultracode: false");
1145 assert!(!off.ultracode, "ultracode: false must parse to false");
1146 let absent: Agent = serde_yaml::from_str("effort: low\n").expect("no ultracode key");
1148 assert!(!absent.ultracode, "omitted ultracode must default to false");
1149 }
1150
1151 #[test]
1152 fn effort_unknown_value_is_rejected() {
1153 let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
1154 .expect_err("typo'd effort value must fail to parse");
1155 let msg = err.to_string();
1156 assert!(
1157 msg.contains("low") && msg.contains("max"),
1158 "error should enumerate valid variants; got: {msg}"
1159 );
1160 }
1161
1162 #[test]
1163 fn effort_renders_to_lowercase_string() {
1164 assert_eq!(EffortLevel::Low.as_str(), "low");
1165 assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
1166 assert_eq!(EffortLevel::Max.as_str(), "max");
1167 }
1168
1169 #[test]
1170 fn discover_prefers_dot_team() {
1171 let tmp = tempfile::tempdir().unwrap();
1172 let repo = tmp.path();
1173 std::fs::create_dir_all(repo.join(".team")).unwrap();
1174 std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1175 std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
1177
1178 let sub = repo.join("src/deep/nested");
1180 std::fs::create_dir_all(&sub).unwrap();
1181 let found = Compose::discover(&sub).unwrap();
1182 assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
1183 }
1184
1185 #[test]
1186 fn discover_no_longer_falls_back_to_flat_layout() {
1187 let tmp = tempfile::tempdir().unwrap();
1191 std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
1192 let err = Compose::discover(tmp.path()).unwrap_err();
1193 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1194 }
1195
1196 #[test]
1197 fn discover_returns_first_dot_team_walking_up() {
1198 let tmp = tempfile::tempdir().unwrap();
1201 let outer = tmp.path();
1202 let inner = outer.join("packages/inner");
1203 std::fs::create_dir_all(outer.join(".team")).unwrap();
1204 std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1205 std::fs::create_dir_all(inner.join(".team")).unwrap();
1206 std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
1207
1208 let from_inner = inner.join("src/deep");
1209 std::fs::create_dir_all(&from_inner).unwrap();
1210 let found = Compose::discover(&from_inner).unwrap();
1211 assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
1212 }
1213
1214 #[test]
1215 fn discover_errors_when_nothing_found() {
1216 let tmp = tempfile::tempdir().unwrap();
1217 let err = Compose::discover(tmp.path()).unwrap_err();
1218 assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
1219 }
1220
1221 #[test]
1222 fn role_prompt_parses_single_string_form() {
1223 let yaml = "role_prompt: roles/mgr.md\n";
1224 let agent: Agent = serde_yaml::from_str(&format!(
1225 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1226 ))
1227 .unwrap();
1228 match agent.role_prompt.unwrap() {
1229 RolePrompt::Single(p) => assert_eq!(p, PathBuf::from("roles/mgr.md")),
1230 other => panic!("expected Single, got {other:?}"),
1231 }
1232 }
1233
1234 #[test]
1235 fn role_prompt_parses_list_form() {
1236 let yaml = "role_prompt:\n - roles/_base.md\n - roles/mgr.md\n";
1237 let agent: Agent = serde_yaml::from_str(&format!(
1238 "runtime: claude-code\nautonomy: low_risk_only\n{yaml}"
1239 ))
1240 .unwrap();
1241 match agent.role_prompt.unwrap() {
1242 RolePrompt::Multiple(v) => assert_eq!(
1243 v,
1244 vec![
1245 PathBuf::from("roles/_base.md"),
1246 PathBuf::from("roles/mgr.md"),
1247 ]
1248 ),
1249 other => panic!("expected Multiple, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn role_prompt_paths_returns_declared_order() {
1255 let rp = RolePrompt::Multiple(vec![
1256 PathBuf::from("a.md"),
1257 PathBuf::from("b.md"),
1258 PathBuf::from("c.md"),
1259 ]);
1260 let got: Vec<&Path> = rp.paths();
1261 assert_eq!(
1262 got,
1263 vec![Path::new("a.md"), Path::new("b.md"), Path::new("c.md")]
1264 );
1265 }
1266
1267 #[test]
1275 fn schema_version_accepts_semver_string() {
1276 let v: SchemaVersion = serde_yaml::from_str("\"2.0.0\"").unwrap();
1277 assert_eq!(v.value, "2.0.0");
1278 assert!(!v.from_legacy_int, "string form is NOT the legacy path");
1279 }
1280
1281 #[test]
1282 fn schema_version_accepts_arbitrary_semver_string_for_later_validation() {
1283 let v: SchemaVersion = serde_yaml::from_str("\"abc\"").unwrap();
1288 assert_eq!(v.value, "abc");
1289 }
1290
1291 #[test]
1292 fn schema_version_coerces_legacy_integer_two() {
1293 let v: SchemaVersion = serde_yaml::from_str("2").unwrap();
1294 assert_eq!(v.value, "2.0.0");
1295 assert!(v.from_legacy_int, "integer-2 must be flagged for rewrite");
1296 }
1297
1298 #[test]
1299 fn schema_version_rejects_other_integers() {
1300 for n in [0u64, 1, 3, 99] {
1304 let err = serde_yaml::from_str::<SchemaVersion>(&n.to_string())
1305 .expect_err("non-2 integer must fail");
1306 let msg = err.to_string();
1307 assert!(
1308 msg.contains("only legacy `2` is auto-coerced"),
1309 "error must name the constraint; got: {msg}"
1310 );
1311 }
1312 }
1313
1314 #[test]
1315 fn schema_version_rejects_non_string_non_int_shapes() {
1316 for yaml in ["true", "[1,2,3]", "{a: b}"] {
1318 let res = serde_yaml::from_str::<SchemaVersion>(yaml);
1319 assert!(res.is_err(), "yaml `{yaml}` must fail to deserialize");
1320 }
1321 }
1322
1323 #[test]
1330 fn load_rewrites_legacy_version_two_in_file_and_in_memory() {
1331 let tmp = tempfile::tempdir().unwrap();
1332 let root = tmp.path().join(".team");
1333 std::fs::create_dir_all(&root).unwrap();
1334 let yaml = "\
1335# T-265 fixture — legacy version
1336version: 2
1337broker:
1338 type: sqlite
1339 path: state/mailbox.db
1340";
1341 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1342 let compose = Compose::load(&root).expect("load succeeds on legacy file");
1343 assert_eq!(compose.global.version.value, "2.0.0");
1345 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1347 assert!(
1348 after.contains("version: \"2.0.0\""),
1349 "file must be rewritten;\n{after}"
1350 );
1351 assert!(
1352 !after.contains("\nversion: 2\n"),
1353 "no legacy literal must survive;\n{after}"
1354 );
1355 assert!(
1357 after.contains("# T-265 fixture"),
1358 "comment must survive the rewrite;\n{after}"
1359 );
1360 assert!(after.contains("type: sqlite"));
1362 }
1363
1364 #[test]
1365 fn load_leaves_canonical_semver_file_untouched() {
1366 let tmp = tempfile::tempdir().unwrap();
1367 let root = tmp.path().join(".team");
1368 std::fs::create_dir_all(&root).unwrap();
1369 let yaml = "\
1370version: \"2.0.0\"
1371broker:
1372 type: sqlite
1373";
1374 std::fs::write(root.join("team-compose.yaml"), yaml).unwrap();
1375 let compose = Compose::load(&root).expect("load succeeds");
1376 assert_eq!(compose.global.version.value, "2.0.0");
1377 assert!(
1378 !compose.global.version.from_legacy_int,
1379 "canonical file must NOT be flagged for rewrite"
1380 );
1381 let after = std::fs::read_to_string(root.join("team-compose.yaml")).unwrap();
1383 assert_eq!(after, yaml, "canonical file must NOT be mutated on load");
1384 }
1385
1386 #[test]
1387 fn load_hard_errors_on_non_two_integer_version() {
1388 let tmp = tempfile::tempdir().unwrap();
1389 let root = tmp.path().join(".team");
1390 std::fs::create_dir_all(&root).unwrap();
1391 std::fs::write(root.join("team-compose.yaml"), "version: 3\n").unwrap();
1392 let err = Compose::load(&root).expect_err("must reject integer-3 at parse");
1393 assert!(
1394 err.to_string().contains("only legacy `2` is auto-coerced"),
1395 "error must name the constraint; got: {err}"
1396 );
1397 }
1398
1399 #[cfg(unix)]
1416 #[test]
1417 fn load_degrades_to_in_memory_when_legacy_rewrite_cannot_write() {
1418 use std::io::Write;
1419 use std::os::unix::fs::PermissionsExt;
1420 use std::sync::{Arc, Mutex};
1421
1422 struct CaptureWriter(Arc<Mutex<Vec<u8>>>);
1425 impl Write for CaptureWriter {
1426 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1427 self.0.lock().unwrap().extend_from_slice(buf);
1428 Ok(buf.len())
1429 }
1430 fn flush(&mut self) -> std::io::Result<()> {
1431 Ok(())
1432 }
1433 }
1434
1435 let tmp = tempfile::tempdir().unwrap();
1436 let root = tmp.path().join(".team");
1437 std::fs::create_dir_all(&root).unwrap();
1438 let compose_path = root.join("team-compose.yaml");
1439 std::fs::write(
1440 &compose_path,
1441 "version: 2\nbroker:\n type: sqlite\n path: state/mailbox.db\n",
1442 )
1443 .unwrap();
1444
1445 std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o444)).unwrap();
1448
1449 let logs = Arc::new(Mutex::new(Vec::<u8>::new()));
1452 let sink = logs.clone();
1453 let subscriber = tracing_subscriber::fmt()
1454 .with_max_level(tracing::Level::WARN)
1455 .with_writer(move || CaptureWriter(sink.clone()))
1456 .finish();
1457 let result = tracing::subscriber::with_default(subscriber, || Compose::load(&root));
1458
1459 std::fs::set_permissions(&compose_path, std::fs::Permissions::from_mode(0o644)).unwrap();
1462
1463 let compose = result.expect("a read-only compose file must degrade, not error");
1465 assert_eq!(compose.global.version.value, "2.0.0");
1467 assert!(
1468 compose.global.version.from_legacy_int,
1469 "legacy-int normalization must still be flagged in memory"
1470 );
1471 let captured = String::from_utf8(logs.lock().unwrap().clone()).unwrap();
1473 assert!(
1474 captured.contains("could not rewrite legacy"),
1475 "the read-only degrade must emit its tracing::warn; captured:\n{captured}"
1476 );
1477 let after = std::fs::read_to_string(&compose_path).unwrap();
1480 assert!(
1481 after.contains("version: 2"),
1482 "the file must be left un-rewritten on the degrade path;\n{after}"
1483 );
1484 }
1485
1486 const PROJECT_YAML_HEAD: &str = "\
1493version: 2
1494project:
1495 id: p
1496 name: P
1497 cwd: .
1498";
1499
1500 #[test]
1501 fn project_without_interfaces_block_parses_unchanged() {
1502 let p: Project = serde_yaml::from_str(PROJECT_YAML_HEAD).unwrap();
1505 assert!(p.interfaces.is_none());
1506 assert!(p.telegram().is_none());
1507 }
1508
1509 #[test]
1510 fn project_telegram_block_parses_under_interfaces() {
1511 let yaml = format!(
1515 "{PROJECT_YAML_HEAD}\
1516interfaces:
1517 telegram:
1518 manager_bot:
1519 token_env: TEAMCTL_TG_MANAGER_TOKEN
1520 profile_picture:
1521 image_model:
1522 provider: openai
1523 api_key_env: OPENAI_API_KEY
1524 model: gpt-image-2
1525 fallback: initials
1526"
1527 );
1528 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1529 let tg = p.telegram().expect("project telegram parsed");
1530 let mb = tg.manager_bot.as_ref().expect("manager_bot parsed");
1531 assert_eq!(mb.token_env, "TEAMCTL_TG_MANAGER_TOKEN");
1532 let pp = tg.profile_picture.as_ref().expect("profile_picture parsed");
1533 let im = pp.image_model.as_ref().expect("image_model parsed");
1534 assert_eq!(im.provider, "openai");
1535 assert_eq!(im.api_key_env, "OPENAI_API_KEY");
1536 assert_eq!(im.model, "gpt-image-2");
1537 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1538 }
1539
1540 #[test]
1541 fn project_telegram_block_parses_manager_bot_only() {
1542 let yaml = format!(
1547 "{PROJECT_YAML_HEAD}\
1548interfaces:
1549 telegram:
1550 manager_bot:
1551 token_env: TEAMCTL_TG_MANAGER_TOKEN
1552"
1553 );
1554 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1555 let tg = p.telegram().expect("project telegram parsed");
1556 assert_eq!(
1557 tg.manager_bot.as_ref().unwrap().token_env,
1558 "TEAMCTL_TG_MANAGER_TOKEN"
1559 );
1560 assert!(tg.profile_picture.is_none());
1561 }
1562
1563 #[test]
1564 fn profile_picture_fallback_defaults_to_initials_when_omitted() {
1565 let yaml = format!(
1569 "{PROJECT_YAML_HEAD}\
1570interfaces:
1571 telegram:
1572 profile_picture:
1573 image_model:
1574 provider: openai
1575 api_key_env: OPENAI_API_KEY
1576 model: gpt-image-2
1577"
1578 );
1579 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1580 let pp = p
1581 .telegram()
1582 .and_then(|t| t.profile_picture.as_ref())
1583 .expect("profile_picture parsed");
1584 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1585 }
1586
1587 #[test]
1588 fn profile_picture_image_model_optional_with_initials_fallback() {
1589 let yaml = format!(
1593 "{PROJECT_YAML_HEAD}\
1594interfaces:
1595 telegram:
1596 profile_picture:
1597 fallback: initials
1598"
1599 );
1600 let p: Project = serde_yaml::from_str(&yaml).unwrap();
1601 let pp = p
1602 .telegram()
1603 .and_then(|t| t.profile_picture.as_ref())
1604 .expect("profile_picture parsed");
1605 assert!(pp.image_model.is_none());
1606 assert_eq!(pp.fallback, ProfilePictureFallback::Initials);
1607 }
1608
1609 #[test]
1610 fn manager_bot_missing_token_env_rejected() {
1611 let yaml = format!(
1615 "{PROJECT_YAML_HEAD}\
1616interfaces:
1617 telegram:
1618 manager_bot: {{}}
1619"
1620 );
1621 let err =
1622 serde_yaml::from_str::<Project>(&yaml).expect_err("malformed manager_bot must reject");
1623 assert!(
1624 err.to_string().contains("token_env"),
1625 "error must name the missing field: {err}"
1626 );
1627 }
1628
1629 #[test]
1630 fn image_model_missing_required_fields_rejected() {
1631 for (label, yaml_fragment) in [
1635 ("missing provider", "api_key_env: K\n model: M"),
1636 ("missing api_key_env", "provider: openai\n model: M"),
1637 ("missing model", "provider: openai\n api_key_env: K"),
1638 ] {
1639 let yaml = format!(
1640 "{PROJECT_YAML_HEAD}\
1641interfaces:
1642 telegram:
1643 profile_picture:
1644 image_model:
1645 {yaml_fragment}
1646"
1647 );
1648 let result = serde_yaml::from_str::<Project>(&yaml);
1649 assert!(
1650 result.is_err(),
1651 "malformed image_model ({label}) must reject, got: {result:?}"
1652 );
1653 }
1654 }
1655
1656 #[test]
1657 fn profile_picture_fallback_unknown_value_rejected() {
1658 let yaml = format!(
1662 "{PROJECT_YAML_HEAD}\
1663interfaces:
1664 telegram:
1665 profile_picture:
1666 fallback: emoji
1667"
1668 );
1669 let err = serde_yaml::from_str::<Project>(&yaml)
1670 .expect_err("unknown fallback variant must reject");
1671 assert!(
1672 err.to_string().contains("emoji") || err.to_string().contains("variant"),
1673 "error must explain the unknown variant: {err}"
1674 );
1675 }
1676}